@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}
@@ -1086,13 +1095,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1086
1095
  .rk-ip-tab svg{flex:none}
1087
1096
 
1088
1097
  .rk-inpaint-page{padding-left:var(--rk-gutter);padding-right:var(--rk-gutter)}
1089
- .rk-inpaint-workbench{display:grid;grid-template-columns:320px minmax(0,1fr) 320px;gap:20px;align-items:start}
1090
- @media(max-width:1200px){.rk-inpaint-workbench{grid-template-columns:320px minmax(0,1fr)}.rk-inpaint-output{grid-column:1 / -1;grid-row:2}}
1091
- @media(max-width:820px){.rk-inpaint-workbench{grid-template-columns:1fr}.rk-inpaint-output,.rk-inpaint-stage,.rk-inpaint-panel{grid-column:auto}.rk-inpaint-stage{order:-1}}
1098
+ .rk-inpaint-workbench{display:grid;grid-template-columns:300px minmax(0,1fr) 300px;gap:20px;align-items:start}
1099
+ @media(max-width:1200px){.rk-inpaint-workbench{grid-template-columns:300px minmax(0,1fr)}.rk-inpaint-output{grid-column:1 / -1;grid-row:2}}
1100
+ @media(max-width:820px){.rk-inpaint-workbench{grid-template-columns:1fr}.rk-inpaint-output,.rk-ip-chat,.rk-inpaint-panel{grid-column:auto}.rk-ip-chat{order:-1}}
1092
1101
 
1093
1102
  /* panels (no side borders — uniform hairline) */
1094
- .rk-inpaint-panel,.rk-inpaint-stage,.rk-inpaint-output{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card)}
1103
+ .rk-inpaint-panel,.rk-ip-chat,.rk-inpaint-output{background:var(--rk-surface);border:1px solid var(--rk-border);border-radius:var(--rk-r-3xl);box-shadow:var(--rk-shadow-card)}
1095
1104
  .rk-inpaint-panel{padding:18px;display:grid;gap:16px;align-content:start}
1105
+ /* fields the chat drives programmatically — present in the DOM, never shown */
1106
+ .rk-ip-legacy{display:none!important}
1107
+ .rk-ip-maskblock[hidden]{display:none}
1096
1108
  .rk-inpaint-block{display:grid;gap:9px}
1097
1109
  .rk-inpaint-h2{font-family:var(--rk-font-display);font-size:var(--rk-text-lg);font-weight:800;color:var(--rk-ink);letter-spacing:-.01em}
1098
1110
 
@@ -1173,9 +1185,33 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1173
1185
  .rk-inpaint-workbench.is-locked .rk-inpaint-generate{opacity:1;cursor:default;background:var(--rk-n-50);color:var(--rk-text-muted);border-color:var(--rk-border);box-shadow:none;font-size:12.5px}
1174
1186
  .rk-inpaint-workbench.is-locked .rk-inpaint-generate svg{display:none}
1175
1187
 
1176
- /* center stage + canvas */
1177
- .rk-inpaint-stage{min-height:420px;display:grid;place-items:center;padding:20px;overflow:hidden}
1178
- .rk-inpaint-empty{display:grid;justify-items:center;gap:8px;text-align:center;color:var(--rk-text-muted);padding:34px 20px}
1188
+ /* center: chat feed + composer */
1189
+ .rk-ip-chat{display:grid;grid-template-rows:minmax(0,1fr) auto;height:min(76vh,860px);min-height:520px;overflow:hidden}
1190
+ .rk-ip-feed{min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:18px 18px 6px;display:flex;flex-direction:column;gap:14px}
1191
+ .rk-ip-canvasholder{display:none}
1192
+ .rk-ip-turn{display:flex;flex-direction:column;gap:6px;max-width:100%}
1193
+ .rk-ip-turn[data-role="user"]{align-items:flex-end}
1194
+ .rk-ip-turn[data-role="assistant"]{align-items:flex-start}
1195
+ .rk-ip-bubble{max-width:min(88%,640px);padding:10px 13px;border-radius:var(--rk-r-2xl);font-size:13.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
1196
+ .rk-ip-turn[data-role="user"] .rk-ip-bubble{background:var(--rk-gold-tint);border:1px solid var(--rk-gold-600);color:var(--rk-ink);border-bottom-right-radius:var(--rk-r-md,8px)}
1197
+ .rk-ip-turn[data-role="assistant"] .rk-ip-bubble{background:var(--rk-n-50);border:1px solid var(--rk-border);color:var(--rk-text-muted);border-bottom-left-radius:var(--rk-r-md,8px)}
1198
+ .rk-ip-turn-tag{font-size:10.5px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;color:var(--rk-text-faint)}
1199
+ .rk-ip-turn-refs{display:flex;flex-wrap:wrap;gap:5px;justify-content:flex-end}
1200
+ .rk-ip-turn-refs img{width:34px;height:34px;border-radius:var(--rk-r-md,8px);border:1px solid var(--rk-border);object-fit:cover}
1201
+ .rk-ip-media{width:fit-content;max-width:min(100%,640px);display:grid;gap:7px}
1202
+ .rk-ip-media-frame{position:relative;width:fit-content;max-width:100%;border-radius:var(--rk-r-2xl);overflow:hidden;background:var(--rk-n-100);border:1px solid var(--rk-border);line-height:0}
1203
+ .rk-ip-media-frame.is-latest{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1204
+ .rk-ip-media-frame img,.rk-ip-media-frame video{display:block;max-width:100%;max-height:min(52vh,520px);width:auto;height:auto}
1205
+ .rk-ip-media-actions{display:flex;flex-wrap:wrap;align-items:center;gap:5px}
1206
+ .rk-ip-act{display:inline-grid;place-items:center;width:27px;height:27px;padding:0;border:1px solid var(--rk-border);border-radius:50%;background:#fff;color:var(--rk-n-700);cursor:pointer;text-decoration:none;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
1207
+ .rk-ip-act:hover{background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1208
+ .rk-ip-act-note{font-size:11.5px;font-weight:700;color:var(--rk-text-faint);margin-left:2px}
1209
+ .rk-ip-pending{display:flex;align-items:center;gap:9px;padding:10px 13px;border:1px solid var(--rk-border);border-radius:var(--rk-r-2xl);background:var(--rk-n-50);font-size:12.5px;font-weight:700;color:var(--rk-text-muted)}
1210
+ .rk-ip-pending::before{content:"";flex:none;width:16px;height:16px;border-radius:50%;border:2.5px solid var(--rk-gold-tint2,rgba(252,185,0,.35));border-top-color:var(--rk-gold-700);animation:rk-ip-spin .8s linear infinite}
1211
+ .rk-ip-turn.is-flash .rk-ip-bubble,.rk-ip-turn.is-flash .rk-ip-media-frame{box-shadow:0 0 0 3px var(--rk-gold-600)}
1212
+ .rk-ip-turn[data-kind="stopped"] .rk-ip-bubble{background:var(--rk-n-100);border-color:var(--rk-border-strong);color:var(--rk-text-muted);font-weight:700}
1213
+ .rk-ip-turn[data-kind="error"] .rk-ip-bubble{background:#fdf3f2;border-color:#e6a8a0;color:#b42318;font-weight:600}
1214
+ .rk-inpaint-empty{display:grid;justify-items:center;gap:8px;text-align:center;color:var(--rk-text-muted);padding:34px 20px;margin:auto 0}
1179
1215
  .rk-inpaint-empty[hidden]{display:none}
1180
1216
  .rk-inpaint-empty-ic{color:var(--rk-n-400)}
1181
1217
  .rk-inpaint-empty-title{font-family:var(--rk-font-display);font-weight:800;font-size:var(--rk-text-lg);color:var(--rk-ink)}
@@ -1183,26 +1219,48 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1183
1219
  .editor-inpaint-canvas-wrap{position:relative;display:inline-block;line-height:0;max-width:100%;border-radius:var(--rk-r-2xl);overflow:hidden;box-shadow:var(--rk-shadow-md);
1184
1220
  --inpaint-brush-cursor-size:70px;--inpaint-brush-cursor-x:0px;--inpaint-brush-cursor-y:0px}
1185
1221
  .editor-inpaint-canvas-wrap[hidden]{display:none}
1186
- .editor-inpaint-canvas-wrap img{display:block;max-width:100%;max-height:min(64vh,620px);width:auto;height:auto;object-fit:contain;user-select:none}
1222
+ .editor-inpaint-canvas-wrap img{display:block;max-width:100%;max-height:min(52vh,520px);width:auto;height:auto;object-fit:contain;user-select:none}
1223
+ /* paint toolbar — sits directly under the image it paints, never over it */
1224
+ /* align-items:center keeps the canvas wrap at the image's intrinsic width — stretching
1225
+ it would stretch the absolutely-positioned mask canvas and skew stroke coordinates. */
1226
+ .rk-ip-paintstack{display:inline-flex;flex-direction:column;align-items:center;max-width:100%}
1227
+ .rk-ip-painttools{width:100%;box-sizing:border-box;display:flex;align-items:center;justify-content:center;gap:6px;padding:6px 8px;border-top:1px solid var(--rk-border);background:var(--rk-n-50);line-height:1;flex-wrap:wrap}
1228
+ .rk-ip-painttools-brush{display:flex;align-items:center;gap:6px}
1229
+ .rk-ip-painttools-label{font-size:11px;font-weight:800;color:var(--rk-text-muted);white-space:nowrap}
1230
+ .rk-ip-painttools .rk-inpaint-range{width:86px}
1231
+ .rk-ip-painttool{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;border:1px solid var(--rk-border);background:#fff;color:var(--rk-n-700);cursor:pointer}
1232
+ .rk-ip-painttool:hover:not(:disabled){background:var(--rk-gold-tint);color:var(--rk-gold-700);border-color:var(--rk-gold-600)}
1233
+ .rk-ip-painttool:disabled{opacity:.45;cursor:default}
1234
+ .rk-ip-painttool svg{width:13px;height:13px}
1187
1235
  .editor-inpaint-canvas-wrap canvas{position:absolute;inset:0;width:100%;height:100%;opacity:.86;cursor:none;touch-action:none}
1188
1236
  .editor-inpaint-brush-cursor{position:absolute;z-index:2;left:0;top:0;width:var(--inpaint-brush-cursor-size);height:var(--inpaint-brush-cursor-size);
1189
1237
  border:2px solid rgba(255,255,255,.95);border-radius:50%;box-shadow:0 0 0 1px rgba(28,25,23,.7),0 0 18px rgba(28,25,23,.25);opacity:0;pointer-events:none;
1190
1238
  transform:translate(var(--inpaint-brush-cursor-x),var(--inpaint-brush-cursor-y)) translate(-50%,-50%)}
1191
1239
  .editor-inpaint-brush-cursor[data-visible="true"]{opacity:1}
1192
1240
 
1193
- /* text-to-image composer (empty state) */
1194
- .rk-ip-gen{width:100%;max-width:460px;margin-top:16px;display:grid;gap:10px;justify-items:stretch}
1195
- .rk-ip-gen-or{position:relative;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--rk-text-faint)}
1196
- .rk-ip-gen-or::before,.rk-ip-gen-or::after{content:"";position:absolute;top:50%;width:calc(50% - 26px);height:1px;background:var(--rk-border)}
1197
- .rk-ip-gen-or::before{left:0}.rk-ip-gen-or::after{right:0}
1198
- .rk-ip-gen-box{background:#fff;border:1.5px solid var(--rk-border-strong);border-radius:var(--rk-r-2xl);padding:10px 10px 8px;display:grid;gap:8px;
1199
- box-shadow:var(--rk-shadow-sm);transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease);text-align:left}
1200
- .rk-ip-gen-box:focus-within{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1201
- .rk-ip-gen.is-dragref .rk-ip-gen-box{border-color:var(--rk-gold-600);border-style:dashed;background:var(--rk-gold-tint)}
1202
- .rk-ip-gen.is-busy .rk-ip-gen-box{opacity:.75}
1203
- .rk-ip-gen-input{width:100%;border:0;outline:0;resize:none;background:transparent;font:inherit;font-size:14px;line-height:1.45;color:var(--rk-ink);min-height:44px;padding:2px 4px}
1204
- .rk-ip-gen-input::placeholder{color:var(--rk-text-faint)}
1205
- .rk-ip-gen-input:disabled{cursor:not-allowed;color:var(--rk-text-muted)}
1241
+ /* chat composer */
1242
+ .rk-ip-composer{flex:none;margin:0;padding:12px 18px 16px;border-top:1px solid var(--rk-border);background:var(--rk-surface);display:grid;gap:8px;
1243
+ border-bottom-left-radius:var(--rk-r-3xl);border-bottom-right-radius:var(--rk-r-3xl)}
1244
+ .rk-ip-composer.is-dragref{background:var(--rk-gold-tint)}
1245
+ .rk-ip-composer.is-busy .rk-ip-comp-status{color:var(--rk-gold-700)}
1246
+ .rk-ip-comp-input{width:100%;border:1.5px solid var(--rk-border-strong);border-radius:var(--rk-r-2xl);background:#fff;outline:0;resize:none;font:inherit;font-size:14px;line-height:1.45;
1247
+ color:var(--rk-ink);min-height:56px;max-height:232px;overflow-y:hidden;padding:10px 12px;transition:border-color var(--rk-dur) var(--rk-ease),box-shadow var(--rk-dur) var(--rk-ease)}
1248
+ .rk-ip-comp-input:focus{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1249
+ .rk-ip-comp-input::placeholder{color:var(--rk-text-faint)}
1250
+ .rk-ip-comp-input:disabled{cursor:not-allowed;color:var(--rk-text-muted);background:var(--rk-n-50)}
1251
+ .rk-ip-comp-bar{display:flex;align-items:center;gap:8px}
1252
+ .rk-ip-comp-modeselect{appearance:none;-webkit-appearance:none;border:1px solid var(--rk-border-strong);border-radius:999px;background:var(--rk-n-50);color:var(--rk-ink);
1253
+ font:inherit;font-size:12px;font-weight:800;padding:7px 26px 7px 12px;cursor:pointer;outline:0;
1254
+ background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 20 20' fill='none' stroke='%2378716c' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E");
1255
+ background-repeat:no-repeat;background-position:right 9px center}
1256
+ .rk-ip-comp-modeselect:focus{border-color:var(--rk-gold-600);box-shadow:0 0 0 3px var(--rk-gold-tint)}
1257
+ .rk-ip-comp-status{flex:1;min-width:0;margin:0;font-size:11.5px;font-weight:600;line-height:1.35;color:var(--rk-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1258
+ .rk-ip-comp-status[data-tone="error"]{color:#b42318}
1259
+ .rk-ip-comp-status[data-tone="success"]{color:#1f7a44}
1260
+ .rk-ip-comp-mask{display:flex;align-items:center;gap:8px;padding:6px 8px 6px 11px;border:1px solid var(--rk-gold-600);border-radius:999px;background:var(--rk-gold-tint);
1261
+ font-size:11.5px;font-weight:700;color:var(--rk-gold-700);justify-self:start;max-width:100%}
1262
+ .rk-ip-comp-mask[hidden]{display:none}
1263
+ .rk-ip-comp-mask button{border:0;background:transparent;color:inherit;font:inherit;font-size:11.5px;font-weight:800;text-decoration:underline;cursor:pointer;padding:0}
1206
1264
  .rk-ip-gen-clip:disabled{opacity:.5;cursor:not-allowed}
1207
1265
  .rk-ip-gen-presets{display:flex;flex-wrap:wrap;gap:6px}
1208
1266
  .rk-ip-preset{flex:none;border:1px dashed var(--rk-border-strong,var(--rk-border));background:var(--rk-n-50);color:var(--rk-n-700);border-radius:999px;padding:4px 10px;font-size:11.5px;font-weight:600;cursor:pointer;transition:background var(--rk-dur) var(--rk-ease),color var(--rk-dur) var(--rk-ease),border-color var(--rk-dur) var(--rk-ease)}
@@ -1222,6 +1280,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1222
1280
  .rk-ip-gen-send{border:0;background:var(--rk-gold-500,var(--rk-gold-600));color:var(--rk-ink)}
1223
1281
  .rk-ip-gen-send:hover:not(:disabled){background:var(--rk-gold-600)}
1224
1282
  .rk-ip-gen-send:disabled{opacity:.5;cursor:default}
1283
+ .rk-ip-gen-send[data-mode="stop"]{background:var(--rk-ink);color:#fff}
1284
+ .rk-ip-gen-send[data-mode="stop"]:hover{background:#000}
1225
1285
  .rk-ip-gen-send.is-busy{background:var(--rk-gold-tint);pointer-events:none}
1226
1286
  .rk-ip-gen-spin,.rk-ip-gen-send .rk-ip-gen-spin{width:16px;height:16px;border-radius:50%;border:2.5px solid var(--rk-gold-tint2,rgba(252,185,0,.35));border-top-color:var(--rk-gold-700);animation:rk-ip-spin .8s linear infinite}
1227
1287
  .rk-ip-gen-status{font-size:12px;font-weight:600;min-height:16px;color:var(--rk-text-muted);text-align:left;padding-left:2px}
@@ -1256,6 +1316,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1256
1316
  .custom-inpaint-history-item[data-kind="source"]{background:var(--rk-gold-tint);border-color:var(--rk-gold-600)}
1257
1317
  .custom-inpaint-history-thumb-button{width:54px;height:54px;display:grid;padding:0;border:0;border-radius:var(--rk-r-lg);background:transparent;cursor:zoom-in}
1258
1318
  .custom-inpaint-history-thumb{width:54px;height:54px;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);object-fit:cover;background:var(--rk-n-200)}
1319
+ .custom-inpaint-history-videothumb{display:grid;place-items:center;font-size:16px;color:var(--rk-n-600);background:var(--rk-n-100)}
1320
+ .custom-inpaint-history-thumb-button{cursor:pointer}
1259
1321
  .custom-inpaint-history-generating{width:54px;height:54px;display:grid;place-items:center;border:1px solid var(--rk-border);border-radius:var(--rk-r-lg);background:var(--rk-n-100)}
1260
1322
  .custom-inpaint-history-generating::before{content:"";width:22px;height:22px;border-radius:50%;border:2.5px solid var(--rk-gold-tint2,rgba(252,185,0,.35));border-top-color:var(--rk-gold-700);animation:rk-ip-spin .8s linear infinite}
1261
1323
  .custom-inpaint-history-meta{min-width:0;display:grid;gap:5px}
@@ -1283,7 +1345,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1283
1345
  .custom-inpaint-lightbox-title{min-width:0;margin:0;color:var(--rk-ink);font-size:14px;font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1284
1346
  .custom-inpaint-lightbox-actions{display:flex;align-items:center;gap:8px}
1285
1347
  .custom-inpaint-lightbox-stage{min-height:0;display:grid;place-items:center;overflow:auto}
1286
- .custom-inpaint-lightbox-stage img{max-width:100%;max-height:calc(94svh - 92px);object-fit:contain;border-radius:var(--rk-r-xl);background:var(--rk-n-100)}
1348
+ .custom-inpaint-lightbox-stage img,.custom-inpaint-lightbox-stage video{max-width:100%;max-height:calc(94svh - 92px);object-fit:contain;border-radius:var(--rk-r-xl);background:var(--rk-n-100)}
1349
+ .custom-inpaint-lightbox-stage img[hidden],.custom-inpaint-lightbox-stage video[hidden]{display:none}
1287
1350
 
1288
1351
  @keyframes rk-ip-spin{to{transform:rotate(360deg)}}
1289
1352
 
@@ -1394,17 +1457,18 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1394
1457
  <div class="rk-inpaint-head-text">
1395
1458
  <div class="rk-ip-tabs" role="tablist" aria-label="Editor mode"><a class="rk-ip-tab is-active" href="/tools/image" role="tab" aria-selected="true"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9" r="1.5"/><path d="m4 17 4.5-4.5 3 3L16 11l4 4"/></svg><span>Image</span></a><a class="rk-ip-tab" href="/tools/video" role="tab" aria-selected="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="14" height="14" rx="2"/><path d="m17 9 4-2v10l-4-2"/></svg><span>Video</span></a><a class="rk-ip-tab" href="/tools/clipper" role="tab" aria-selected="false"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M8.1 8.1 20 18M8.1 15.9 20 6"/></svg><span>Clipper</span></a></div>
1396
1459
  <h1 class="rk-h1">Create Image</h1>
1397
- <p class="rk-sub">Upload an image (or generate one), paint mask regions in different colors, give each color its own instruction, add an optional whole-image prompt, and generate everything outside your mask stays untouched.</p>
1460
+ <p class="rk-sub">Chat to create an image, then keep chatting to refine it every message edits the newest image, and only that image, so instructions stay sharp. Paint a mask on it to change only that region, or switch the composer to Video to animate it.</p>
1398
1461
  </div>
1399
1462
  <div class="rk-inpaint-toolbar">
1400
1463
  <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm rk-inpaint-startnew" data-custom-inpaint-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>
1464
+ <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-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>
1401
1465
  <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-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>
1402
1466
  <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-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>
1403
1467
  </div>
1404
1468
  </div>
1405
1469
 
1406
1470
  <div class="rk-inpaint-workbench">
1407
- <!-- LEFT: source + prompts + brush/color controls -->
1471
+ <!-- LEFT: source + per-color mask instructions (mask block appears once you paint) -->
1408
1472
  <section class="rk-inpaint-panel" aria-label="Controls">
1409
1473
  <div class="rk-inpaint-block">
1410
1474
  <label class="rk-label">Source image</label>
@@ -1414,98 +1478,99 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1414
1478
  <span class="rk-inpaint-drop-name" data-custom-inpaint-source-name>No image selected</span>
1415
1479
  </button>
1416
1480
  <input data-custom-inpaint-file type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" hidden />
1481
+ <span class="rk-hint">Optional — just type in the chat and the first generated image becomes your working image.</span>
1417
1482
  </div>
1418
1483
 
1419
- <div class="rk-inpaint-block">
1420
- <label class="rk-label">Whole image prompt</label>
1421
- <div class="rk-ip-promptfield">
1422
- <textarea class="rk-textarea rk-ip-global" data-editor-inpaint-global-prompt placeholder="Describe the overall edit, style, constraints, or preservation notes (optional)."></textarea>
1423
- <button type="button" class="rk-ip-attach" data-editor-inpaint-attach-scope="global" aria-label="Attach reference image" title="Attach reference image"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1424
- </div>
1425
- <span class="editor-inpaint-attachment-chips rk-ip-attach-chips" data-editor-inpaint-attachments="global"></span>
1426
- <input data-editor-inpaint-attachment-input type="file" accept="image/jpeg,image/png,image/webp" multiple hidden />
1427
- <span class="rk-hint">Guidance for the masked edit and overall consistency. It never permits changing unmasked pixels.</span>
1428
- </div>
1429
-
1430
- <div class="rk-inpaint-block">
1484
+ <div class="rk-inpaint-block rk-ip-maskblock" data-ip-mask-block hidden>
1431
1485
  <div class="rk-inpaint-brushrow">
1432
- <label class="rk-label">Brush <span data-editor-inpaint-brush-label>70</span>&nbsp;px</label>
1433
- <div class="rk-ip-toolbtns">
1434
- <button type="button" class="rk-ip-iconbtn" data-editor-inpaint-undo aria-label="Undo stroke" title="Undo"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7.2 7H3.8V3.6"/><path d="M4 7a7 7 0 1 1 1.9 6.6"/></svg></button>
1435
- <button type="button" class="rk-ip-iconbtn" data-editor-inpaint-redo aria-label="Redo stroke" title="Redo"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.8 7h3.4V3.6"/><path d="M16 7a7 7 0 1 0-1.9 6.6"/></svg></button>
1436
- <button type="button" class="rk-ip-iconbtn" data-editor-inpaint-clear aria-label="Clear mask" title="Clear mask"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l10 10M15 5 5 15"/></svg></button>
1437
- </div>
1438
- </div>
1439
- <input data-editor-inpaint-brush class="rk-inpaint-range" type="range" min="6" max="180" step="1" value="70" />
1440
- </div>
1441
-
1442
- <div class="rk-inpaint-block">
1443
- <div class="rk-inpaint-brushrow">
1444
- <label class="rk-label">Mask colors</label>
1486
+ <label class="rk-label">Mask regions</label>
1445
1487
  <input class="rk-ip-colorpicker" data-editor-inpaint-color-picker type="color" value="#ff2d55" aria-label="Mask color" />
1446
1488
  </div>
1447
- <span class="rk-hint">Paint regions in different colors, then give each color its own replacement instruction below.</span>
1489
+ <span class="rk-hint">Paint on the image in the chat. Give each color its own replacement instruction — the chat box stays the whole-image prompt.</span>
1448
1490
  <div class="editor-inpaint-swatches" data-editor-inpaint-swatches></div>
1449
1491
  <div class="editor-inpaint-prompts" data-editor-inpaint-prompts></div>
1450
1492
  <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm rk-ip-addcolor" data-editor-inpaint-add-color>+ Add color</button>
1451
1493
  </div>
1452
1494
 
1453
- <div class="rk-inpaint-block">
1454
- <button type="button" class="rk-btn rk-btn-gold rk-inpaint-generate" data-editor-inpaint-submit><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9z"/></svg> <span data-editor-inpaint-submit-label>Generate edit</span></button>
1455
- <p class="custom-inpaint-status rk-inpaint-status" data-custom-inpaint-status data-tone="" aria-live="polite"></p>
1495
+ <!-- Legacy fields the chat drives behind the scenes: the composer text is written
1496
+ into the whole-image prompt, and the composer's send press clicks submit. -->
1497
+ <div class="rk-ip-legacy" aria-hidden="true">
1498
+ <textarea class="rk-textarea rk-ip-global" data-editor-inpaint-global-prompt></textarea>
1499
+ <button type="button" class="rk-ip-attach" data-editor-inpaint-attach-scope="global" aria-label="Attach reference image"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1500
+ <span class="editor-inpaint-attachment-chips rk-ip-attach-chips" data-editor-inpaint-attachments="global"></span>
1501
+ <input data-editor-inpaint-attachment-input type="file" accept="image/jpeg,image/png,image/webp" multiple hidden />
1502
+ <button type="button" data-editor-inpaint-submit><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9z"/></svg> <span data-editor-inpaint-submit-label>Generate edit</span></button>
1503
+ <div class="custom-inpaint-output" data-custom-inpaint-output hidden>
1504
+ <button type="button" data-custom-inpaint-result-open aria-label="View image edit result">
1505
+ <img data-custom-inpaint-result alt="Image edit result" />
1506
+ </button>
1507
+ <button type="button" data-custom-inpaint-save>Save to Files</button>
1508
+ </div>
1456
1509
  </div>
1457
1510
  </section>
1458
1511
 
1459
- <!-- CENTER: canvas stage (with text-to-image empty state) -->
1460
- <section class="rk-inpaint-stage" aria-label="Canvas">
1461
- <div class="rk-inpaint-empty" data-custom-inpaint-empty>
1462
- <span class="rk-inpaint-empty-ic" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg></span>
1463
- <p class="rk-inpaint-empty-title">Upload an image to start</p>
1464
- <p class="rk-inpaint-empty-sub">Then paint mask regions in one or more colors.</p>
1465
-
1466
- <!-- Or generate an image from scratch (text-to-image) -->
1467
- <div class="rk-ip-gen" id="rk-ip-gen">
1468
- <span class="rk-ip-gen-or">or</span>
1469
- <div class="rk-ip-gen-box">
1470
- <textarea id="rk-ip-genprompt" class="rk-ip-gen-input" rows="2" placeholder="Or type your image prompt"></textarea>
1471
- <div class="rk-ip-gen-presets" id="rk-ip-genpresets">
1472
- <button type="button" class="rk-ip-preset" data-preset="character-card" title="Fill the prompt with a character reference sheet (multi-angle, expressions, poses)">+ Character card</button>
1473
- <button type="button" class="rk-ip-preset" data-preset="storyboard" title="Fill the prompt with a numbered storyboard (sequential shot panels)">+ Storyboard</button>
1474
- </div>
1475
- <div class="rk-ip-gen-refs" id="rk-ip-genrefs" hidden></div>
1476
- <div class="rk-ip-gen-bar">
1477
- <button type="button" class="rk-ip-gen-clip" id="rk-ip-genclip" aria-label="Attach reference images from your files" title="Attach reference images from your files"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1478
- <span class="rk-ip-gen-hint">Generate a brand-new image, then paint to refine it.</span>
1479
- <button type="button" class="rk-ip-gen-send" id="rk-ip-gensend" aria-label="Generate image"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg></button>
1480
- </div>
1512
+ <!-- CENTER: chat feed + composer. Every turn appends to the feed; edits always
1513
+ apply to the LATEST image, which is the one you paint on in-feed. -->
1514
+ <section class="rk-ip-chat" aria-label="Create Image chat">
1515
+ <div class="rk-ip-feed" data-ip-feed>
1516
+ <div class="rk-inpaint-empty" data-custom-inpaint-empty>
1517
+ <span class="rk-inpaint-empty-ic" aria-hidden="true"><svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2.5l1.9 5.1 5.1 1.9-5.1 1.9L12 16.5l-1.9-5.1L5 9.5l5.1-1.9z"/></svg></span>
1518
+ <p class="rk-inpaint-empty-title">Describe the image you want</p>
1519
+ <p class="rk-inpaint-empty-sub">Then keep chatting to refine it — every message edits the newest image. Paint a mask on it first to edit just that region, or switch the composer to Video to animate it.</p>
1520
+ <div class="rk-ip-gen-presets" id="rk-ip-genpresets">
1521
+ <button type="button" class="rk-ip-preset" data-preset="character-card" title="Fill the prompt with a character reference sheet (multi-angle, expressions, poses)">+ Character card</button>
1522
+ <button type="button" class="rk-ip-preset" data-preset="storyboard" title="Fill the prompt with a numbered storyboard (sequential shot panels)">+ Storyboard</button>
1481
1523
  </div>
1482
- <p class="rk-ip-gen-status" id="rk-ip-genstatus" data-tone="" aria-live="polite"></p>
1483
1524
  </div>
1484
1525
  </div>
1485
- <div class="rk-inpaint-canvaswrap editor-inpaint-canvas-wrap" data-custom-inpaint-canvas-wrap hidden>
1486
- <img data-editor-inpaint-image class="rk-inpaint-img" alt="" />
1487
- <canvas data-editor-inpaint-canvas class="rk-inpaint-canvas"></canvas>
1488
- <div class="editor-inpaint-brush-cursor" data-editor-inpaint-brush-cursor aria-hidden="true"></div>
1526
+
1527
+ <!-- The paint surface. JS moves this whole stack into the newest image bubble,
1528
+ so the brush toolbar always travels with the image it paints. -->
1529
+ <div class="rk-ip-canvasholder" data-ip-canvas-holder hidden>
1530
+ <div class="rk-ip-paintstack" data-ip-paint-stack>
1531
+ <div class="rk-inpaint-canvaswrap editor-inpaint-canvas-wrap" data-custom-inpaint-canvas-wrap hidden>
1532
+ <img data-editor-inpaint-image class="rk-inpaint-img" alt="" />
1533
+ <canvas data-editor-inpaint-canvas class="rk-inpaint-canvas"></canvas>
1534
+ <div class="editor-inpaint-brush-cursor" data-editor-inpaint-brush-cursor aria-hidden="true"></div>
1535
+ </div>
1536
+ <div class="rk-ip-painttools" data-ip-painttools>
1537
+ <span class="rk-ip-painttools-brush">
1538
+ <span class="rk-ip-painttools-label">Brush <span data-editor-inpaint-brush-label>70</span></span>
1539
+ <input data-editor-inpaint-brush class="rk-inpaint-range" type="range" min="6" max="180" step="1" value="70" aria-label="Brush size" />
1540
+ </span>
1541
+ <button type="button" class="rk-ip-painttool" data-editor-inpaint-undo aria-label="Undo stroke" title="Undo stroke (⌘Z)"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M7.2 7H3.8V3.6"/><path d="M4 7a7 7 0 1 1 1.9 6.6"/></svg></button>
1542
+ <button type="button" class="rk-ip-painttool" data-editor-inpaint-redo aria-label="Redo stroke" title="Redo stroke (⇧⌘Z)"><svg width="16" height="16" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12.8 7h3.4V3.6"/><path d="M16 7a7 7 0 1 0-1.9 6.6"/></svg></button>
1543
+ <button type="button" class="rk-ip-painttool" data-editor-inpaint-clear aria-label="Clear mask" title="Clear mask"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l10 10M15 5 5 15"/></svg></button>
1544
+ </div>
1545
+ </div>
1489
1546
  </div>
1490
- </section>
1491
1547
 
1492
- <!-- RIGHT: result + tracer-grouped history -->
1493
- <section class="rk-inpaint-output" aria-label="Result and history">
1494
- <div class="custom-inpaint-output rk-inpaint-result" data-custom-inpaint-output hidden>
1495
- <div class="rk-inpaint-result-head">
1496
- <h2 class="rk-inpaint-h2">Result</h2>
1497
- <button type="button" class="rk-btn rk-btn-ghost rk-btn-sm" data-custom-inpaint-save>Save to Files</button>
1548
+ <div class="rk-ip-composer" data-ip-composer>
1549
+ <div class="rk-ip-comp-mask" data-ip-comp-mask hidden></div>
1550
+ <div class="rk-ip-gen-refs" id="rk-ip-genrefs" hidden></div>
1551
+ <textarea id="rk-ip-genprompt" class="rk-ip-comp-input" rows="2" placeholder="Describe the image you want…"></textarea>
1552
+ <div class="rk-ip-comp-bar">
1553
+ <span class="rk-ip-comp-mode" data-ip-mode-wrap>
1554
+ <select class="rk-ip-comp-modeselect" data-ip-mode aria-label="What to create">
1555
+ <option value="image">Image</option>
1556
+ <option value="video">Video</option>
1557
+ </select>
1558
+ </span>
1559
+ <button type="button" class="rk-ip-gen-clip" id="rk-ip-genclip" aria-label="Attach reference images from your files" title="Attach reference images"><svg width="18" height="18" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m8.1 10.7 4.8-4.8a2.5 2.5 0 0 1 3.5 3.5l-6.1 6.1a4 4 0 0 1-5.7-5.7l6.3-6.3"/><path d="m10 8.8-4.1 4.1a1.6 1.6 0 0 0 2.3 2.3l5.6-5.6"/></svg></button>
1560
+ <p class="custom-inpaint-status rk-ip-comp-status" data-custom-inpaint-status data-tone="" aria-live="polite"></p>
1561
+ <button type="button" class="rk-ip-gen-send" id="rk-ip-gensend" aria-label="Send"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5"/><path d="m5 12 7-7 7 7"/></svg></button>
1498
1562
  </div>
1499
- <button type="button" class="rk-inpaint-result-imgwrap custom-inpaint-preview" data-custom-inpaint-result-open aria-label="View image edit result">
1500
- <img class="rk-inpaint-result-img" data-custom-inpaint-result alt="Image edit result" />
1501
- </button>
1563
+ <p class="rk-ip-gen-status" id="rk-ip-genstatus" data-tone="" aria-live="polite" hidden></p>
1502
1564
  </div>
1565
+ </section>
1503
1566
 
1567
+ <!-- RIGHT: tracer-grouped session history -->
1568
+ <section class="rk-inpaint-output" aria-label="Session history">
1504
1569
  <div class="rk-inpaint-history">
1505
1570
  <div class="rk-inpaint-history-head">
1506
1571
  <h2 class="rk-inpaint-h2">This session</h2>
1507
1572
  <div class="rk-ip-histtools">
1508
- <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-history-refresh aria-label="Refresh history" title="Refresh history"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 10a6.5 6.5 0 1 1-1.9-4.6"/><path d="M16.5 4.2v4.2h-4.2"/></svg></button>
1573
+ <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-history-refresh aria-label="Reload this session from the server" title="Reload this session"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16.5 10a6.5 6.5 0 1 1-1.9-4.6"/><path d="M16.5 4.2v4.2h-4.2"/></svg></button>
1509
1574
  </div>
1510
1575
  </div>
1511
1576
  <div class="custom-inpaint-history-list rk-inpaint-historylist" data-custom-inpaint-history-list>
@@ -1553,13 +1618,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1553
1618
  <div class="custom-inpaint-lightbox-head">
1554
1619
  <p class="custom-inpaint-lightbox-title" id="custom-inpaint-lightbox-title" data-custom-inpaint-lightbox-title>Image edit output</p>
1555
1620
  <div class="custom-inpaint-lightbox-actions">
1556
- <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-lightbox-copy aria-label="Copy image URL" title="Copy URL"><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>
1621
+ <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-lightbox-copy aria-label="Copy file URL" title="Copy URL"><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>
1622
+ <a class="rk-ip-iconbtn" data-custom-inpaint-lightbox-link href="#" target="_blank" rel="noopener noreferrer" aria-label="Open the direct file URL" title="Open direct file URL"><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.6 11.4a3 3 0 0 0 4.3 0l2.4-2.4a3 3 0 1 0-4.3-4.3l-1 1"/><path d="M11.4 8.6a3 3 0 0 0-4.3 0l-2.4 2.4a3 3 0 1 0 4.3 4.3l1-1"/></svg></a>
1557
1623
  <a class="rk-btn rk-btn-ink rk-btn-sm" data-custom-inpaint-lightbox-download href="#" target="_blank" rel="noopener noreferrer" download><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 3v9"/><path d="m6.5 8.5 3.5 3.5 3.5-3.5"/><path d="M4 16h12"/></svg> Download</a>
1558
1624
  <button type="button" class="rk-ip-iconbtn" data-custom-inpaint-lightbox-close aria-label="Close preview" title="Close"><svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5l10 10"/><path d="M15 5 5 15"/></svg></button>
1559
1625
  </div>
1560
1626
  </div>
1561
1627
  <div class="custom-inpaint-lightbox-stage">
1562
1628
  <img data-custom-inpaint-lightbox-image alt="Image edit output preview" />
1629
+ <video data-custom-inpaint-lightbox-video controls playsinline hidden></video>
1563
1630
  </div>
1564
1631
  </div>
1565
1632
  </div>
@@ -1697,7 +1764,17 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1697
1764
  const lightboxImage = document.querySelector("[data-custom-inpaint-lightbox-image]");
1698
1765
  const lightboxCopy = document.querySelector("[data-custom-inpaint-lightbox-copy]");
1699
1766
  const lightboxDownload = document.querySelector("[data-custom-inpaint-lightbox-download]");
1767
+ const lightboxLink = document.querySelector("[data-custom-inpaint-lightbox-link]");
1768
+ const lightboxVideo = document.querySelector("[data-custom-inpaint-lightbox-video]");
1700
1769
  const lightboxClose = document.querySelector("[data-custom-inpaint-lightbox-close]");
1770
+ // chat surface
1771
+ const chatFeed = document.querySelector("[data-ip-feed]");
1772
+ const canvasHolder = document.querySelector("[data-ip-canvas-holder]");
1773
+ const composerEl = document.querySelector("[data-ip-composer]");
1774
+ const modeSelect = document.querySelector("[data-ip-mode]");
1775
+ const maskBlock = document.querySelector("[data-ip-mask-block]");
1776
+ const maskChip = document.querySelector("[data-ip-comp-mask]");
1777
+ const paintStack = document.querySelector("[data-ip-paint-stack]");
1701
1778
  if (!(uploadButton instanceof HTMLButtonElement)
1702
1779
  || !(startNewButton instanceof HTMLButtonElement)
1703
1780
  || !(fileInput instanceof HTMLInputElement)
@@ -1737,7 +1814,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1737
1814
  || !(lightboxImage instanceof HTMLImageElement)
1738
1815
  || !(lightboxCopy instanceof HTMLButtonElement)
1739
1816
  || !(lightboxDownload instanceof HTMLAnchorElement)
1740
- || !(lightboxClose instanceof HTMLButtonElement)) {
1817
+ || !(lightboxClose instanceof HTMLButtonElement)
1818
+ || !(chatFeed instanceof HTMLElement)
1819
+ || !(canvasHolder instanceof HTMLElement)
1820
+ || !(composerEl instanceof HTMLElement)
1821
+ || !(modeSelect instanceof HTMLSelectElement)
1822
+ || !(maskBlock instanceof HTMLElement)
1823
+ || !(maskChip instanceof HTMLElement)
1824
+ || !(paintStack instanceof HTMLElement)) {
1741
1825
  return;
1742
1826
  }
1743
1827
 
@@ -1752,13 +1836,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1752
1836
  let inpaintAttachments = [];
1753
1837
  let pendingInpaintAttachmentScope = null;
1754
1838
  let inpaintHistoryItems = [];
1839
+ // Chat state. Declared up here because the legacy helpers below (loadSourceUrl,
1840
+ // applyCompletedInpaintJob, resetWorkspace) push turns into it.
1841
+ let chatTurns = [];
1842
+ let activeTurnId = "";
1755
1843
  // Local "starting image" history entries (uploaded / generated / deep-linked
1756
1844
  // sources). They are not job runs, so they survive server-history reloads by
1757
1845
  // being re-injected after each rebuild — this lets users see where a session
1758
1846
  // started from.
1759
- let sourceHistoryItems = [];
1760
1847
  let historyTracerRefreshTimer = 0;
1761
- const hiddenInpaintHistoryKeys = new Set();
1762
1848
  const maxInpaintAttachments = 8;
1763
1849
  const allowedInpaintAttachmentTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
1764
1850
 
@@ -1831,12 +1917,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1831
1917
  : (sessionLocked ? "Edit generated \u2014 Start New to edit again" : "Generate edit");
1832
1918
  }
1833
1919
 
1834
- // Once an edit is generated the inputs that produced it are frozen (prompts,
1835
- // masks, brush, colors, references, source) so the record stays truthful —
1836
- // to make another edit the user hits Start New (or opens/loads another image).
1920
+ // The chat surface iterates forever on the newest image, so the old
1921
+ // freeze-after-one-edit lock is gone: setSessionLocked() is kept as a no-op
1922
+ // so every legacy call site still type-checks and simply never locks.
1837
1923
  let sessionLocked = false;
1838
1924
  function applySessionLock() {
1839
- const locked = sessionLocked;
1925
+ const locked = false;
1840
1926
  const workbench = document.querySelector(".rk-inpaint-workbench");
1841
1927
  if (workbench instanceof HTMLElement) workbench.classList.toggle("is-locked", locked);
1842
1928
  inpaintGlobalPrompt.readOnly = locked;
@@ -1854,8 +1940,8 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1854
1940
  // Keep the submit button + label consistent with the lock while not loading.
1855
1941
  if (inpaintSubmit.getAttribute("data-loading") !== "true") setSubmitLoading(false);
1856
1942
  }
1857
- function setSessionLocked(locked) {
1858
- sessionLocked = Boolean(locked);
1943
+ function setSessionLocked() {
1944
+ sessionLocked = false;
1859
1945
  applySessionLock();
1860
1946
  }
1861
1947
 
@@ -1873,15 +1959,31 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1873
1959
  return prefix + "-" + stamp + ".png";
1874
1960
  }
1875
1961
 
1876
- function openImagePreview(url, label, downloadName) {
1962
+ // Fullscreen preview for any feed item — images and generated videos alike.
1963
+ function openImagePreview(url, label, downloadName, mediaKind) {
1877
1964
  if (!url) return;
1878
- lightboxImage.src = url;
1879
- lightboxImage.alt = label || "Image edit output preview";
1965
+ const isVideo = mediaKind === "video";
1966
+ if (isVideo && lightboxVideo instanceof HTMLVideoElement) {
1967
+ lightboxVideo.src = url;
1968
+ lightboxVideo.hidden = false;
1969
+ lightboxImage.hidden = true;
1970
+ lightboxImage.removeAttribute("src");
1971
+ } else {
1972
+ if (lightboxVideo instanceof HTMLVideoElement) {
1973
+ lightboxVideo.pause();
1974
+ lightboxVideo.removeAttribute("src");
1975
+ lightboxVideo.hidden = true;
1976
+ }
1977
+ lightboxImage.hidden = false;
1978
+ lightboxImage.src = url;
1979
+ lightboxImage.alt = label || "Image edit output preview";
1980
+ }
1880
1981
  lightboxTitle.textContent = label || "Image edit output";
1881
1982
  lightboxCopy.dataset.copyUrl = url;
1882
1983
  lightboxCopy.setAttribute("title", "Copy URL");
1883
1984
  lightboxDownload.href = url;
1884
- lightboxDownload.setAttribute("download", downloadName || imageDownloadName("inpaint-output"));
1985
+ lightboxDownload.setAttribute("download", downloadName || imageDownloadName(isVideo ? "video-output" : "inpaint-output"));
1986
+ if (lightboxLink instanceof HTMLAnchorElement) lightboxLink.href = url;
1885
1987
  lightbox.hidden = false;
1886
1988
  lightbox.setAttribute("data-open", "true");
1887
1989
  document.body.style.overflow = "hidden";
@@ -1891,9 +1993,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
1891
1993
  lightbox.setAttribute("data-open", "false");
1892
1994
  lightbox.hidden = true;
1893
1995
  lightboxImage.removeAttribute("src");
1996
+ if (lightboxVideo instanceof HTMLVideoElement) {
1997
+ lightboxVideo.pause();
1998
+ lightboxVideo.removeAttribute("src");
1999
+ lightboxVideo.hidden = true;
2000
+ }
1894
2001
  lightboxCopy.dataset.copyUrl = "";
1895
2002
  lightboxCopy.setAttribute("title", "Copy URL");
1896
2003
  lightboxDownload.href = "#";
2004
+ if (lightboxLink instanceof HTMLAnchorElement) lightboxLink.href = "#";
1897
2005
  document.body.style.overflow = "";
1898
2006
  }
1899
2007
 
@@ -2104,6 +2212,31 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2104
2212
  }
2105
2213
  renderAllAttachmentChips();
2106
2214
  applySessionLock();
2215
+ syncMaskUi();
2216
+ }
2217
+
2218
+ // The mask controls only exist while a mask exists: the left block holds the
2219
+ // per-color instructions, and the composer grows a chip so it is obvious the
2220
+ // next message is a MASKED edit rather than a whole-image one.
2221
+ function syncMaskUi() {
2222
+ const painted = inpaintStrokes.length > 0;
2223
+ const showBlock = Boolean(sourceImageUrl) && (painted || redoStrokes.length > 0);
2224
+ maskBlock.hidden = !showBlock;
2225
+ const usedColors = new Set(inpaintStrokes.map((stroke) => normalizeHexColor(stroke.color)));
2226
+ maskChip.hidden = !painted;
2227
+ if (painted) {
2228
+ maskChip.innerHTML = "";
2229
+ const label = document.createElement("span");
2230
+ label.textContent = "Masked edit \u00b7 " + usedColors.size + " region" + (usedColors.size === 1 ? "" : "s") + " painted";
2231
+ const clear = document.createElement("button");
2232
+ clear.type = "button";
2233
+ clear.dataset.ipClearMask = "true";
2234
+ clear.textContent = "Clear mask";
2235
+ maskChip.append(label, clear);
2236
+ }
2237
+ inpaintUndo.disabled = !inpaintStrokes.length;
2238
+ inpaintRedo.disabled = !redoStrokes.length;
2239
+ inpaintClear.disabled = !painted;
2107
2240
  }
2108
2241
 
2109
2242
  function redrawInpaintMask() {
@@ -2161,6 +2294,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2161
2294
  inpaintStrokes.push(currentInpaintStroke);
2162
2295
  redoStrokes = [];
2163
2296
  redrawInpaintMask();
2297
+ syncMaskUi();
2164
2298
  }
2165
2299
 
2166
2300
  function continueInpaintStroke(event) {
@@ -2451,22 +2585,17 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2451
2585
  setStatus("The source image URL is invalid.", "error");
2452
2586
  return;
2453
2587
  }
2454
- setSessionLocked(false);
2455
- sourceImageUrl = url;
2456
2588
  sourceImageName = firstSearchParam(["source_image_name", "image_name", "file_name"]) || sourceNameFromUrl(url);
2457
- sourceName.textContent = sourceImageName;
2458
- emptyState.hidden = true;
2459
- canvasWrap.hidden = false;
2460
- outputPanel.hidden = true;
2461
- resultImage.removeAttribute("src");
2462
- resetMask();
2463
- inpaintImage.onload = () => {
2464
- syncInpaintCanvasSize();
2465
- resetMask();
2466
- };
2467
- inpaintImage.src = sourceImageUrl;
2468
- recordSourceHistoryItem(sourceImageUrl, origin || "url", sourceImageName);
2469
- setStatus("Source ready from URL.", "success");
2589
+ adoptLatestImage(url, sourceImageName);
2590
+ addTurn({
2591
+ role: "assistant",
2592
+ kind: "media",
2593
+ mediaKind: "image",
2594
+ url,
2595
+ note: origin === "generated" ? "generated" : "starting image"
2596
+ });
2597
+ recordSourceHistoryItem(url, origin || "url", sourceImageName);
2598
+ setStatus("Working image ready — describe an edit, or paint a mask on it first.", "success");
2470
2599
  }
2471
2600
 
2472
2601
  function clearInpaintUrlParams() {
@@ -2539,6 +2668,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2539
2668
  currentInpaintStroke = null;
2540
2669
  isInpaintPainting = false;
2541
2670
  redrawInpaintMask();
2671
+ syncMaskUi();
2542
2672
  }
2543
2673
 
2544
2674
  function resetWorkspace() {
@@ -2546,7 +2676,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2546
2676
  sourceImageUrl = "";
2547
2677
  sourceImageName = "";
2548
2678
  sourceName.textContent = "No image selected";
2549
- emptyState.hidden = false;
2550
2679
  canvasWrap.hidden = true;
2551
2680
  outputPanel.hidden = true;
2552
2681
  resultImage.removeAttribute("src");
@@ -2559,9 +2688,11 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2559
2688
  pendingInpaintAttachmentScope = null;
2560
2689
  activeInpaintColor = "#ff2d55";
2561
2690
  inpaintColorPicker.value = activeInpaintColor;
2562
- sourceHistoryItems = [];
2691
+ chatTurns = [];
2692
+ activeTurnId = "";
2563
2693
  resetMask();
2564
2694
  renderInpaintControls();
2695
+ renderChat();
2565
2696
  setStatus("", "");
2566
2697
  }
2567
2698
 
@@ -2580,26 +2711,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2580
2711
  const wasAutoPending = tracerAutoPending;
2581
2712
  if (wasAutoPending) nameTracerFromSeed(fileName.replace(/\.[a-z0-9]+$/i, ""));
2582
2713
  const uploadedUrl = await uploadTemporaryBlob(file, fileName, file.type || "image/png", "inpaints/" + getTracerValue());
2583
- setSessionLocked(false);
2584
- sourceImageUrl = absoluteUrl(uploadedUrl);
2585
- sourceImageName = fileName;
2586
- sourceName.textContent = fileName;
2587
- emptyState.hidden = true;
2588
- canvasWrap.hidden = false;
2589
- outputPanel.hidden = true;
2590
- resultImage.removeAttribute("src");
2591
- resetMask();
2592
- inpaintImage.onload = () => {
2593
- syncInpaintCanvasSize();
2594
- resetMask();
2595
- };
2596
- inpaintImage.src = sourceImageUrl;
2714
+ const uploaded = absoluteUrl(uploadedUrl);
2715
+ adoptLatestImage(uploaded, fileName);
2716
+ addTurn({ role: "assistant", kind: "media", mediaKind: "image", url: uploaded, note: "uploaded image" });
2597
2717
  if (wasAutoPending) {
2598
2718
  void loadInpaintHistory();
2599
2719
  void loadTracerSessions();
2600
2720
  }
2601
- recordSourceHistoryItem(sourceImageUrl, "upload", fileName);
2602
- setStatus("Source ready.", "success");
2721
+ recordSourceHistoryItem(uploaded, "upload", fileName);
2722
+ setStatus("Uploaded — describe an edit, or paint a mask on it first.", "success");
2603
2723
  } catch (error) {
2604
2724
  console.error(error);
2605
2725
  setStatus(error instanceof Error ? error.message : "Unable to upload source image.", "error");
@@ -2641,48 +2761,11 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2641
2761
  };
2642
2762
  }
2643
2763
 
2644
- function upsertInpaintHistoryItem(item) {
2645
- if (!item || !item.key) return;
2646
- inpaintHistoryItems = inpaintHistoryItems.filter((entry) => (
2647
- entry.key !== item.key
2648
- && (!item.jobId || entry.jobId !== item.jobId)
2649
- ));
2650
- inpaintHistoryItems.unshift(item);
2651
- inpaintHistoryItems.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0));
2652
- renderInpaintHistory();
2653
- }
2654
-
2655
- // Record the image a session started from (upload / generation / deep-link) so
2656
- // it appears in History. De-duplicated by URL; kept in sourceHistoryItems so a
2657
- // subsequent server-history reload doesn't drop it.
2658
- function recordSourceHistoryItem(url, origin, name) {
2659
- if (!url) return;
2660
- let abs = "";
2661
- try { abs = absoluteUrl(url); } catch { return; }
2662
- const key = "source:" + abs;
2663
- let item = sourceHistoryItems.find((entry) => entry.key === key);
2664
- if (!item) {
2665
- item = {
2666
- key,
2667
- kind: "source",
2668
- origin: origin || "upload",
2669
- url: abs,
2670
- name: name || sourceNameFromUrl(abs),
2671
- createdAt: new Date().toISOString()
2672
- };
2673
- sourceHistoryItems.push(item);
2674
- }
2675
- upsertInpaintHistoryItem(item);
2676
- }
2677
-
2678
- function reinjectSourceHistoryItems() {
2679
- for (const item of sourceHistoryItems) {
2680
- if (!inpaintHistoryItems.some((entry) => entry.key === item.key)) {
2681
- inpaintHistoryItems.push(item);
2682
- }
2683
- }
2684
- inpaintHistoryItems.sort((a, b) => Date.parse(b.createdAt || 0) - Date.parse(a.createdAt || 0));
2685
- }
2764
+ // The right-hand session rail is DERIVED from the chat (see syncSessionAssets),
2765
+ // so the old push-based item store is gone. These two shims stay because the
2766
+ // legacy job-polling paths still call them; the chat is the source of truth.
2767
+ function upsertInpaintHistoryItem() {}
2768
+ function recordSourceHistoryItem() {}
2686
2769
 
2687
2770
  // Mirror each completed inpaint output into /temp/inpaints/<tracer>/ so a
2688
2771
  // session's outputs are browsable as files (grouped by the same tracer that
@@ -2713,16 +2796,11 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2713
2796
  const url = absoluteUrl(outputUrl);
2714
2797
  const jobId = String(payload?.job_id || payload?.jobId || "");
2715
2798
  resultImage.src = url;
2716
- outputPanel.hidden = false;
2717
- setSessionLocked(true);
2799
+ // The finished edit becomes the newest image in the chat — and therefore
2800
+ // the thing the next message edits and the surface you paint on.
2801
+ resolveActiveTurn({ kind: "media", mediaKind: "image", url, jobId });
2802
+ adoptLatestImage(url);
2718
2803
  void persistInpaintOutputToTemp(url, jobId);
2719
- upsertInpaintHistoryItem({
2720
- key: historyItemKey({ jobId: jobId || "inpaint" }, url, 0),
2721
- kind: "output",
2722
- jobId,
2723
- url,
2724
- createdAt: String(payload?.completed_at || payload?.updated_at || new Date().toISOString())
2725
- });
2726
2804
  return url;
2727
2805
  }
2728
2806
 
@@ -2736,12 +2814,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2736
2814
  try {
2737
2815
  const parsed = new URL(value, window.location.href);
2738
2816
  const storageKey = parsed.searchParams.get("key") || decodeURIComponent(parsed.pathname).replace(/^\/+/, "");
2739
- return !/\/temporary\/inpaints\/_inputs(?:\/|$)/i.test(storageKey)
2817
+ return !/\/temporary\/inpaints\/_(?:inputs|context)(?:\/|$)/i.test(storageKey)
2740
2818
  && !/\/temporary\/inpaint(?:-|\/|$)/i.test(storageKey)
2741
2819
  && !/\/temporary\/inpaint-masks(?:\/|$)/i.test(storageKey)
2742
2820
  && !/inpaint-mask-[^/]+\.png/i.test(storageKey);
2743
2821
  } catch {
2744
- return !/\/temporary\/inpaints\/_inputs(?:\/|$)/i.test(value)
2822
+ return !/\/temporary\/inpaints\/_(?:inputs|context)(?:\/|$)/i.test(value)
2745
2823
  && !/\/temporary\/inpaint(?:-|\/|$)/i.test(value)
2746
2824
  && !/\/temporary\/inpaint-masks(?:\/|$)/i.test(value)
2747
2825
  && !/inpaint-mask-[^/]+\.png/i.test(value);
@@ -2749,16 +2827,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2749
2827
  });
2750
2828
  }
2751
2829
 
2752
- function isInpaintHistoryEntry(entry) {
2753
- const templateId = String(entry?.linkedJob?.template_id || entry?.linkedJob?.templateId || "").toLowerCase();
2830
+ // Which chat turn an api-history entry represents. These four routes ARE the
2831
+ // conversation everything else in the customer's run history is ignored here.
2832
+ function chatEntryKind(entry) {
2754
2833
  const path = String(entry?.path || "").toLowerCase();
2755
- return templateId === "primitive:image_inpaint"
2756
- || path.includes("/primitives/images/inpaint")
2757
- || path.includes("/primitives/image_inpaint");
2834
+ const templateId = String(entry?.linkedJob?.template_id || entry?.linkedJob?.templateId || "").toLowerCase();
2835
+ if (path.includes("/primitives/images/inpaint") || templateId === "primitive:image_inpaint") return "inpaint";
2836
+ if (path.includes("/primitives/images/edit") || templateId === "primitive:image_edit") return "edit";
2837
+ if (path.includes("/primitives/images/generate") || templateId === "primitive:image_generate") return "generate";
2838
+ if (path.includes("/primitives/videos/generate") || templateId === "primitive:video_generate") return "video";
2839
+ return "";
2758
2840
  }
2759
2841
 
2760
- function historyItemKey(entry, url, index) {
2761
- return String(entry?.jobId || entry?.job_id || entry?.id || "inpaint") + ":" + index + ":" + String(url || "");
2842
+ function historyEntryVideoUrls(entry) {
2843
+ const urls = Array.isArray(entry?.outputMediaUrls) ? entry.outputMediaUrls : [];
2844
+ return urls.filter((url) => /\.(mp4|mov|webm|m4v)(\?|#|$)/i.test(String(url || "")));
2762
2845
  }
2763
2846
 
2764
2847
  function setHistoryEmpty(message) {
@@ -2769,14 +2852,46 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2769
2852
  historyList.append(empty);
2770
2853
  }
2771
2854
 
2855
+ // ---- right rail: every asset this session produced, derived from the chat ----
2856
+ function syncSessionAssets() {
2857
+ const items = [];
2858
+ for (let i = chatTurns.length - 1; i >= 0; i--) {
2859
+ const turn = chatTurns[i];
2860
+ if (turn.role !== "assistant") continue;
2861
+ if (turn.kind === "media" && turn.url) {
2862
+ items.push({
2863
+ key: turn.id,
2864
+ kind: "output",
2865
+ mediaKind: turn.mediaKind === "video" ? "video" : "image",
2866
+ url: turn.url,
2867
+ jobId: turn.jobId || "",
2868
+ title: turn.prompt || turn.note || (turn.mediaKind === "video" ? "Video" : "Image"),
2869
+ createdAt: turn.at
2870
+ });
2871
+ } else if (turn.kind === "pending") {
2872
+ items.push({
2873
+ key: turn.id,
2874
+ kind: "pending",
2875
+ jobId: turn.jobId || "",
2876
+ title: turn.prompt || "Working…",
2877
+ status: turn.label || "",
2878
+ createdAt: turn.at
2879
+ });
2880
+ }
2881
+ }
2882
+ inpaintHistoryItems = items;
2883
+ renderInpaintHistory();
2884
+ }
2885
+
2772
2886
  function renderInpaintHistory() {
2773
2887
  historyList.innerHTML = "";
2774
- const visible = inpaintHistoryItems.filter((item) => !hiddenInpaintHistoryKeys.has(item.key));
2775
- if (!visible.length) {
2776
- setHistoryEmpty("No inpaint outputs yet.");
2888
+ if (!inpaintHistoryItems.length) {
2889
+ setHistoryEmpty(resolveTracer()
2890
+ ? "Nothing in this session yet. Send a message to create your first image."
2891
+ : "New session — outputs land here as you chat.");
2777
2892
  return;
2778
2893
  }
2779
- for (const item of visible) {
2894
+ for (const item of inpaintHistoryItems) {
2780
2895
  const row = document.createElement("div");
2781
2896
  row.className = "custom-inpaint-history-item";
2782
2897
  row.dataset.historyKey = item.key;
@@ -2785,158 +2900,83 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2785
2900
  if (item.kind === "pending") {
2786
2901
  thumbButton = document.createElement("div");
2787
2902
  thumbButton.className = "custom-inpaint-history-generating";
2788
- thumbButton.setAttribute("aria-label", "Image edit generating");
2903
+ thumbButton.setAttribute("aria-label", "Generating");
2789
2904
  thumbButton.setAttribute("role", "img");
2790
2905
  } else {
2791
2906
  thumbButton = document.createElement("button");
2792
2907
  thumbButton.type = "button";
2793
2908
  thumbButton.className = "custom-inpaint-history-thumb-button";
2794
2909
  thumbButton.dataset.historyPreview = item.key;
2795
- thumbButton.setAttribute("aria-label", "View inpaint output");
2796
- const thumb = document.createElement("img");
2797
- thumb.className = "custom-inpaint-history-thumb";
2798
- thumb.src = item.url;
2799
- thumb.alt = "Image edit output";
2800
- thumb.loading = "lazy";
2801
- thumbButton.append(thumb);
2910
+ thumbButton.setAttribute("aria-label", "Jump to this message");
2911
+ if (item.mediaKind === "video") {
2912
+ const badge = document.createElement("span");
2913
+ badge.className = "custom-inpaint-history-thumb custom-inpaint-history-videothumb";
2914
+ badge.textContent = "";
2915
+ thumbButton.append(badge);
2916
+ } else {
2917
+ const thumb = document.createElement("img");
2918
+ thumb.className = "custom-inpaint-history-thumb";
2919
+ thumb.src = item.url;
2920
+ thumb.alt = "";
2921
+ thumb.loading = "lazy";
2922
+ thumbButton.append(thumb);
2923
+ }
2802
2924
  }
2803
2925
  const meta = document.createElement("div");
2804
2926
  meta.className = "custom-inpaint-history-meta";
2805
2927
  const title = document.createElement("div");
2806
2928
  title.className = "custom-inpaint-history-title";
2807
- title.textContent = item.kind === "pending"
2808
- ? "Generating inpaint"
2809
- : item.kind === "source"
2810
- ? (item.origin === "generated" ? "Generated starting image"
2811
- : item.origin === "upload" ? "Uploaded starting image"
2812
- : "Starting image")
2813
- : (item.createdAt ? new Date(item.createdAt).toLocaleString() : "Image edit output");
2814
- if (item.kind === "pending") {
2815
- const subtitle = document.createElement("div");
2816
- subtitle.className = "custom-inpaint-history-subtitle";
2817
- const progress = Number(item.progress || 0);
2818
- subtitle.textContent = "Job " + (item.status || "queued") + (progress > 0 ? " · " + Math.round(progress * 100) + "%" : "");
2819
- meta.append(title, subtitle);
2820
- } else if (item.kind === "source") {
2821
- const subtitle = document.createElement("div");
2822
- subtitle.className = "custom-inpaint-history-subtitle";
2823
- subtitle.textContent = "Where you started" + (item.createdAt ? " · " + new Date(item.createdAt).toLocaleString() : "");
2824
- meta.append(title, subtitle);
2825
- } else {
2826
- meta.append(title);
2827
- }
2929
+ title.textContent = item.title;
2930
+ title.title = item.title;
2931
+ const subtitle = document.createElement("div");
2932
+ subtitle.className = "custom-inpaint-history-subtitle";
2933
+ subtitle.textContent = item.kind === "pending"
2934
+ ? (item.status || "Working…")
2935
+ : (item.mediaKind === "video" ? "Video" : "Image") + (item.createdAt ? " · " + relTimeShort(item.createdAt) : "");
2936
+ meta.append(title, subtitle);
2828
2937
  const actions = document.createElement("div");
2829
2938
  actions.className = "custom-inpaint-history-actions";
2830
2939
  if (item.kind === "pending") {
2831
- const check = document.createElement("button");
2832
- check.type = "button";
2833
- check.className = "custom-inpaint-history-check";
2834
- check.dataset.historyCheck = item.jobId || "";
2835
- check.textContent = "Check Status";
2836
- actions.append(check);
2940
+ if (item.jobId) {
2941
+ const check = document.createElement("button");
2942
+ check.type = "button";
2943
+ check.className = "custom-inpaint-history-check";
2944
+ check.dataset.historyCheck = item.jobId;
2945
+ check.textContent = "Check Status";
2946
+ actions.append(check);
2947
+ }
2837
2948
  } else {
2838
2949
  const download = document.createElement("a");
2839
2950
  download.className = "custom-inpaint-history-icon";
2840
2951
  download.href = item.url;
2841
2952
  download.target = "_blank";
2842
2953
  download.rel = "noopener noreferrer";
2843
- download.download = imageDownloadName("inpaint-history", item.createdAt);
2844
- download.setAttribute("aria-label", "Download inpaint output");
2954
+ download.download = imageDownloadName(item.mediaKind === "video" ? "vidfarm-video" : "vidfarm-image", item.createdAt);
2955
+ download.setAttribute("aria-label", "Download");
2845
2956
  download.setAttribute("title", "Download");
2846
2957
  download.innerHTML = '<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="M10 3v9"></path><path d="m6.5 8.5 3.5 3.5 3.5-3.5"></path><path d="M4 16h12"></path></svg>';
2847
2958
  const copy = document.createElement("button");
2848
2959
  copy.type = "button";
2849
2960
  copy.className = "custom-inpaint-history-icon";
2850
2961
  copy.dataset.historyCopy = item.key;
2851
- copy.setAttribute("aria-label", "Copy inpaint output URL");
2962
+ copy.setAttribute("aria-label", "Copy the file URL");
2852
2963
  copy.setAttribute("title", "Copy URL");
2853
2964
  copy.innerHTML = '<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="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><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"></path></svg>';
2854
2965
  const save = document.createElement("button");
2855
2966
  save.type = "button";
2856
2967
  save.className = "custom-inpaint-history-icon";
2857
2968
  save.dataset.historySave = item.key;
2858
- save.setAttribute("aria-label", "Save this output to My Files");
2859
- save.setAttribute("title", "Save to Files");
2969
+ save.setAttribute("aria-label", "Save to My Files");
2970
+ save.setAttribute("title", "Save to My Files");
2860
2971
  save.innerHTML = '<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="M5 4h8l2 2v10H5z"></path><path d="M8 4v4h4V4"></path><path d="M8 12h4"></path></svg>';
2861
2972
  actions.append(download, copy, save);
2862
2973
  }
2863
- const remove = document.createElement("button");
2864
- remove.type = "button";
2865
- remove.className = "custom-inpaint-history-icon";
2866
- remove.dataset.historyRemove = item.key;
2867
- remove.setAttribute("aria-label", "Remove inpaint output from history strip");
2868
- remove.setAttribute("title", "Remove");
2869
- remove.innerHTML = '<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="M5 5l10 10"></path><path d="M15 5 5 15"></path></svg>';
2870
- actions.append(remove);
2871
2974
  meta.append(actions);
2872
- const confirm = document.createElement("div");
2873
- confirm.className = "custom-inpaint-history-popconfirm";
2874
- confirm.hidden = true;
2875
- confirm.dataset.historyConfirm = item.key;
2876
- const confirmText = document.createElement("p");
2877
- confirmText.textContent = "Remove from this history strip?";
2878
- const confirmActions = document.createElement("div");
2879
- confirmActions.className = "custom-inpaint-history-popconfirm-actions";
2880
- const cancel = document.createElement("button");
2881
- cancel.type = "button";
2882
- cancel.dataset.historyCancel = item.key;
2883
- cancel.textContent = "Cancel";
2884
- const confirmRemove = document.createElement("button");
2885
- confirmRemove.type = "button";
2886
- confirmRemove.dataset.historyConfirmRemove = item.key;
2887
- confirmRemove.dataset.danger = "true";
2888
- confirmRemove.textContent = "Remove";
2889
- confirmActions.append(cancel, confirmRemove);
2890
- confirm.append(confirmText, confirmActions);
2891
- row.append(thumbButton, meta, confirm);
2975
+ row.append(thumbButton, meta);
2892
2976
  historyList.append(row);
2893
2977
  }
2894
2978
  }
2895
2979
 
2896
- function closeInpaintHistoryConfirms(exceptKey) {
2897
- historyList.querySelectorAll("[data-history-confirm]").forEach((entry) => {
2898
- if (!(entry instanceof HTMLElement)) return;
2899
- entry.hidden = exceptKey ? entry.dataset.historyConfirm !== exceptKey : true;
2900
- });
2901
- }
2902
-
2903
- function extractInpaintHistoryItems(entries) {
2904
- const items = [];
2905
- const seen = new Set();
2906
- for (const entry of Array.isArray(entries) ? entries : []) {
2907
- if (!isInpaintHistoryEntry(entry)) continue;
2908
- const linkedJob = entry?.linkedJob || null;
2909
- if (linkedJob && isPendingJobStatus(linkedJob.status)) {
2910
- const pending = makePendingInpaintHistoryItem({
2911
- job_id: linkedJob.job_id,
2912
- status: linkedJob.status,
2913
- progress: linkedJob.progress,
2914
- created_at: linkedJob.created_at || entry.createdAt,
2915
- updated_at: linkedJob.updated_at || entry.createdAt
2916
- });
2917
- if (pending && !seen.has(pending.key)) {
2918
- seen.add(pending.key);
2919
- items.push(pending);
2920
- }
2921
- continue;
2922
- }
2923
- historyEntryUrls(entry).forEach((rawUrl, index) => {
2924
- const url = absoluteUrl(rawUrl);
2925
- const key = historyItemKey(entry, url, index);
2926
- if (seen.has(key)) return;
2927
- seen.add(key);
2928
- items.push({
2929
- key,
2930
- kind: "output",
2931
- jobId: String(entry?.jobId || entry?.job_id || entry?.linkedJob?.job_id || ""),
2932
- url,
2933
- createdAt: String(entry.createdAt || new Date().toISOString())
2934
- });
2935
- });
2936
- }
2937
- return items;
2938
- }
2939
-
2940
2980
  async function copyText(value) {
2941
2981
  if (navigator.clipboard && window.isSecureContext) {
2942
2982
  try {
@@ -2960,36 +3000,121 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
2960
3000
  return copied;
2961
3001
  }
2962
3002
 
2963
- async function loadInpaintHistory() {
3003
+ // ---- session restore -----------------------------------------------------
3004
+ // A session IS its chat. Every turn was a POST to one of the four primitive
3005
+ // routes, and the api-call history stores each request body — including the
3006
+ // the chat_message field we attach purely so the transcript rebuilds verbatim
3007
+ // (the API strips unknown envelope fields, so it never reaches a provider).
3008
+ function userTextFromEntry(entry, kind) {
3009
+ const body = (entry && entry.request && entry.request.body) || {};
3010
+ if (typeof body.chat_message === "string" && body.chat_message.trim()) return body.chat_message.trim();
3011
+ // Fallback for turns made before chat_message existed: recover what we can
3012
+ // from the prompt/instruction we composed.
3013
+ const payload = body.payload || {};
3014
+ const raw = String(payload.prompt || payload.instruction || "");
3015
+ if (!raw) return kind === "video" ? "(video)" : "(edit)";
3016
+ const latest = raw.split("LATEST INSTRUCTION: ").pop();
3017
+ if (latest && latest !== raw) return latest.trim();
3018
+ const edited = raw.split("Edit the SOURCE IMAGE as follows: ")[1];
3019
+ if (edited) return edited.split(" Keep everything else about the source image")[0].trim();
3020
+ const whole = raw.split("Whole image prompt: ")[1];
3021
+ if (whole) return whole.split(". Apply this only as guidance")[0].trim();
3022
+ return raw.length > 240 ? raw.slice(0, 240) + "…" : raw;
3023
+ }
3024
+
3025
+ function turnsFromHistoryEntries(entries) {
3026
+ const rows = (Array.isArray(entries) ? entries : [])
3027
+ .map((entry) => ({ entry, kind: chatEntryKind(entry) }))
3028
+ .filter((row) => row.kind)
3029
+ .sort((a, b) => Date.parse(a.entry.createdAt || 0) - Date.parse(b.entry.createdAt || 0));
3030
+ const turns = [];
3031
+ const seenJobs = new Set();
3032
+ for (const row of rows) {
3033
+ const entry = row.entry;
3034
+ const jobId = String(entry.jobId || (entry.linkedJob && entry.linkedJob.job_id) || "");
3035
+ if (jobId && seenJobs.has(jobId)) continue;
3036
+ if (jobId) seenJobs.add(jobId);
3037
+ const body = (entry.request && entry.request.body) || {};
3038
+ const text = userTextFromEntry(entry, row.kind);
3039
+ const at = String(entry.createdAt || new Date().toISOString());
3040
+ turns.push({
3041
+ id: nextTurnId(), at, role: "user", kind: "text", text,
3042
+ mode: row.kind === "video" ? "video" : "image",
3043
+ masked: row.kind === "inpaint" || body.chat_masked === true,
3044
+ isEdit: row.kind === "edit" || row.kind === "inpaint"
3045
+ });
3046
+ const status = String((entry.linkedJob && entry.linkedJob.status) || "");
3047
+ const urls = row.kind === "video" ? historyEntryVideoUrls(entry) : historyEntryUrls(entry);
3048
+ if (urls.length) {
3049
+ let url = "";
3050
+ try { url = absoluteUrl(urls[0]); } catch { url = String(urls[0]); }
3051
+ turns.push({
3052
+ id: nextTurnId(), at, role: "assistant", kind: "media",
3053
+ mediaKind: row.kind === "video" ? "video" : "image",
3054
+ url, jobId, prompt: text
3055
+ });
3056
+ } else if (status === "failed" || status === "cancelled") {
3057
+ turns.push({ id: nextTurnId(), at, role: "assistant", kind: "error", text: "That job " + status + "." });
3058
+ } else if (isPendingJobStatus(status)) {
3059
+ turns.push({
3060
+ id: nextTurnId(), at, role: "assistant", kind: "pending", jobId, prompt: text,
3061
+ label: "Still running (" + status + ") — use Check Status."
3062
+ });
3063
+ }
3064
+ }
3065
+ return turns;
3066
+ }
3067
+
3068
+ // Rebuild the whole conversation for the active tracer from the server.
3069
+ async function restoreSession(options) {
2964
3070
  const tracer = resolveTracer();
2965
- // Brand-new (unnamed) session: nothing on the server yet — just show any
2966
- // local starting image, without minting a random tracer.
2967
3071
  if (!tracer) {
2968
- inpaintHistoryItems = [];
2969
- reinjectSourceHistoryItems();
2970
- renderInpaintHistory();
3072
+ // Brand-new unnamed session: nothing on the server yet.
3073
+ renderChat();
2971
3074
  return;
2972
3075
  }
3076
+ // Never stomp on a turn that is mid-flight locally.
3077
+ if (genBusy || activeTurnId) { syncSessionAssets(); return; }
2973
3078
  historyRefresh.disabled = true;
2974
3079
  try {
2975
3080
  const endpoint = new URL("/job-runs/history", window.location.origin);
2976
- endpoint.searchParams.set("limit", "120");
2977
- endpoint.searchParams.set("template_id", "primitive:image_inpaint");
3081
+ endpoint.searchParams.set("limit", "250");
2978
3082
  endpoint.searchParams.set("tracer", tracer);
2979
3083
  const response = await fetch(endpoint, { credentials: "same-origin" });
2980
3084
  const payload = await parseJsonResponse(response);
2981
- if (!response.ok || (payload && payload.error)) throw new Error(payload.error || "Unable to load inpaint history.");
2982
- inpaintHistoryItems = extractInpaintHistoryItems(payload.entries);
2983
- reinjectSourceHistoryItems();
2984
- renderInpaintHistory();
3085
+ if (!response.ok || (payload && payload.error)) throw new Error(payload.error || "Unable to load this session.");
3086
+ const turns = turnsFromHistoryEntries(payload.entries);
3087
+ // A refresh must never LOSE messages: run history can lag a job by a moment,
3088
+ // and a fresh upload-only session has nothing on the server at all. Only a
3089
+ // deliberate session switch (replaceEmpty) may shrink the transcript.
3090
+ if (turns.length < chatTurns.length && !(options && options.replaceEmpty)) {
3091
+ renderChat();
3092
+ return;
3093
+ }
3094
+ chatTurns = turns;
3095
+ activeTurnId = "";
3096
+ // The newest IMAGE in the restored conversation becomes the paint surface
3097
+ // and the source of the next edit.
3098
+ let latest = "";
3099
+ for (let i = turns.length - 1; i >= 0; i--) {
3100
+ const turn = turns[i];
3101
+ if (turn.kind === "media" && turn.mediaKind === "image" && turn.url) { latest = turn.url; break; }
3102
+ }
3103
+ if (latest) adoptLatestImage(latest);
3104
+ else renderChat();
2985
3105
  } catch (error) {
2986
3106
  console.error(error);
2987
- setHistoryEmpty(error instanceof Error ? error.message : "Unable to load inpaint history.");
3107
+ setStatus(error instanceof Error ? error.message : "Unable to load this session.", "error");
2988
3108
  } finally {
2989
3109
  historyRefresh.disabled = false;
2990
3110
  }
2991
3111
  }
2992
3112
 
3113
+ // Legacy call sites (job polling, submit) refresh the session through this name.
3114
+ async function loadInpaintHistory() {
3115
+ await restoreSession();
3116
+ }
3117
+
2993
3118
  async function checkInpaintJobStatus(jobId, button) {
2994
3119
  if (!jobId) return null;
2995
3120
  if (button instanceof HTMLButtonElement) {
@@ -3035,6 +3160,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3035
3160
 
3036
3161
  async function pollInpaintJob(jobId) {
3037
3162
  for (let attempt = 0; attempt < 90; attempt += 1) {
3163
+ throwIfStopped();
3038
3164
  const response = await fetch("/api/v1/primitives/image_inpaint/jobs/" + encodeURIComponent(jobId), {
3039
3165
  credentials: "same-origin"
3040
3166
  });
@@ -3051,6 +3177,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3051
3177
  return outputUrl;
3052
3178
  }
3053
3179
  upsertInpaintHistoryItem(makePendingInpaintHistoryItem(payload));
3180
+ updatePendingLabel(activeTurnId, "Applying the masked edit (" + (payload.status || "running") + ")\u2026");
3054
3181
  setStatus("Image edit job " + jobId + " is " + (payload.status || "running") + ".", "");
3055
3182
  await new Promise((resolve) => window.setTimeout(resolve, attempt < 8 ? 1500 : 3000));
3056
3183
  }
@@ -3059,8 +3186,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3059
3186
  return null;
3060
3187
  }
3061
3188
 
3062
- async function submitInpaintJob() {
3063
- if (!sourceImageUrl || inpaintSubmit.disabled) {
3189
+ async function submitInpaintJob(options) {
3190
+ const extraRefs = (options && Array.isArray(options.extraRefs)) ? options.extraRefs : [];
3191
+ // The user's own words, stored alongside the request so the chat can be
3192
+ // rebuilt later without un-parsing the composed instruction.
3193
+ const chatMessage = (options && options.chatMessage) ? String(options.chatMessage) : inpaintGlobalPrompt.value.trim();
3194
+ if (!sourceImageUrl) {
3064
3195
  setStatus("Upload a source image first.", "error");
3065
3196
  return;
3066
3197
  }
@@ -3075,11 +3206,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3075
3206
  setStatus("Add a replacement prompt for " + missingPromptColor.color.toUpperCase() + ".", "error");
3076
3207
  return;
3077
3208
  }
3078
- const instruction = buildInpaintInstruction();
3079
- if (!instruction) {
3209
+ const builtInstruction = buildInpaintInstruction();
3210
+ if (!builtInstruction) {
3080
3211
  setStatus("Add a replacement prompt for at least one color.", "error");
3081
3212
  return;
3082
3213
  }
3214
+ const instruction = builtInstruction;
3083
3215
  setSubmitLoading(true);
3084
3216
  uploadButton.disabled = true;
3085
3217
  setStatus("Uploading masks...", "");
@@ -3095,11 +3227,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3095
3227
  headers: { "content-type": "application/json" },
3096
3228
  body: JSON.stringify({
3097
3229
  tracer,
3230
+ chat_message: chatMessage,
3231
+ chat_mode: "image",
3232
+ chat_masked: true,
3098
3233
  payload: {
3099
3234
  source_image_url: sourceImageUrl,
3100
3235
  mask_url: absoluteUrl(maskUrl),
3101
3236
  instruction,
3102
- reference_attachments: inpaintAttachments.map((attachment) => attachment.url),
3237
+ reference_attachments: inpaintAttachments.map((attachment) => attachment.url).concat(extraRefs),
3103
3238
  ...(inpaintProviderSelect.value ? { provider: inpaintProviderSelect.value } : {}),
3104
3239
  size: "auto",
3105
3240
  ...(getInpaintAspectRatio() ? { aspect_ratio: getInpaintAspectRatio() } : {}),
@@ -3110,13 +3245,19 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3110
3245
  const payload = await parseJsonResponse(response);
3111
3246
  if (!response.ok) throw new Error(payload.error || "Unable to create image edit job.");
3112
3247
  setStatus("Image edit job " + (payload.job_id || "") + " started.", "success");
3248
+ activeJob = { kind: "image_inpaint", jobId: String(payload.job_id || "") };
3113
3249
  upsertInpaintHistoryItem(makePendingInpaintHistoryItem(payload));
3114
3250
  await pollInpaintJob(payload.job_id);
3251
+ if (activeTurnId) {
3252
+ updatePendingLabel(activeTurnId, "Still running \u2014 use Check Status in this session\u2019s history.");
3253
+ activeTurnId = "";
3254
+ }
3115
3255
  await loadInpaintHistory();
3116
3256
  void loadTracerSessions();
3117
3257
  } catch (error) {
3118
3258
  console.error(error);
3119
3259
  setStatus(error instanceof Error ? error.message : "Unable to create image edit.", "error");
3260
+ throw error;
3120
3261
  } finally {
3121
3262
  setSubmitLoading(false);
3122
3263
  // Respect the post-generate lock — don't re-enable source upload if the
@@ -3133,25 +3274,23 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3133
3274
  historyRefresh.addEventListener("click", () => {
3134
3275
  void loadInpaintHistory();
3135
3276
  });
3136
- historyList.addEventListener("pointerdown", (event) => {
3137
- const target = event.target;
3138
- if (target instanceof Element && target.closest("[data-history-confirm]")) {
3139
- event.stopPropagation();
3140
- }
3141
- });
3142
3277
  historyList.addEventListener("click", (event) => {
3143
3278
  const target = event.target;
3144
3279
  if (!(target instanceof Element)) return;
3145
- const confirmPopover = target.closest("[data-history-confirm]");
3146
- if (confirmPopover instanceof HTMLElement) {
3147
- event.stopPropagation();
3148
- }
3280
+ // The rail mirrors the chat, so its thumbnail scrolls the feed to that turn
3281
+ // instead of opening a second, competing viewer.
3149
3282
  const previewButton = target.closest("[data-history-preview]");
3150
3283
  if (previewButton instanceof HTMLElement) {
3151
3284
  const key = previewButton.dataset.historyPreview || "";
3285
+ const row = chatFeed.querySelector('[data-turn-id="' + CSS.escape(key) + '"]');
3286
+ if (row instanceof HTMLElement) {
3287
+ row.scrollIntoView({ behavior: "smooth", block: "center" });
3288
+ row.classList.add("is-flash");
3289
+ window.setTimeout(() => row.classList.remove("is-flash"), 1200);
3290
+ return;
3291
+ }
3152
3292
  const item = inpaintHistoryItems.find((entry) => entry.key === key);
3153
- if (!item) return;
3154
- openImagePreview(item.url, item.createdAt ? new Date(item.createdAt).toLocaleString() : "Image edit output", imageDownloadName("image-edit-history", item.createdAt));
3293
+ if (item && item.url) openImagePreview(item.url, item.title, imageDownloadName("vidfarm-output", item.createdAt), item.mediaKind);
3155
3294
  return;
3156
3295
  }
3157
3296
  const copyButton = target.closest("[data-history-copy]");
@@ -3169,40 +3308,12 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3169
3308
  if (saveButton instanceof HTMLElement) {
3170
3309
  const key = saveButton.dataset.historySave || "";
3171
3310
  const item = inpaintHistoryItems.find((entry) => entry.key === key);
3172
- if (item && item.url) saveMediaToFiles(item.url, imageDownloadName("inpaint-output", item.createdAt));
3311
+ if (item && item.url) saveMediaToFiles(item.url, imageDownloadName("vidfarm-output", item.createdAt));
3173
3312
  return;
3174
3313
  }
3175
3314
  const checkButton = target.closest("[data-history-check]");
3176
3315
  if (checkButton instanceof HTMLButtonElement) {
3177
3316
  void checkInpaintJobStatus(checkButton.dataset.historyCheck || "", checkButton);
3178
- return;
3179
- }
3180
- const removeButton = target.closest("[data-history-remove]");
3181
- if (removeButton instanceof HTMLElement) {
3182
- const key = removeButton.dataset.historyRemove || "";
3183
- const confirm = historyList.querySelector('[data-history-confirm="' + CSS.escape(key) + '"]');
3184
- const shouldOpen = !(confirm instanceof HTMLElement) || confirm.hidden;
3185
- closeInpaintHistoryConfirms();
3186
- if (confirm instanceof HTMLElement) {
3187
- confirm.hidden = !shouldOpen;
3188
- }
3189
- removeButton.setAttribute("aria-expanded", shouldOpen ? "true" : "false");
3190
- return;
3191
- }
3192
- const cancelButton = target.closest("[data-history-cancel]");
3193
- if (cancelButton instanceof HTMLElement) {
3194
- const confirm = cancelButton.closest("[data-history-confirm]");
3195
- if (confirm instanceof HTMLElement) confirm.hidden = true;
3196
- return;
3197
- }
3198
- const confirmRemove = target.closest("[data-history-confirm-remove]");
3199
- if (confirmRemove instanceof HTMLElement) {
3200
- const key = confirmRemove.dataset.historyConfirmRemove || "";
3201
- if (key) {
3202
- hiddenInpaintHistoryKeys.add(key);
3203
- renderInpaintHistory();
3204
- }
3205
- return;
3206
3317
  }
3207
3318
  });
3208
3319
  resultOpenButton.addEventListener("click", () => {
@@ -3272,18 +3383,22 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3272
3383
  inpaintGlobalPrompt.addEventListener("paste", (event) => {
3273
3384
  handleAttachmentPaste(event, "global");
3274
3385
  });
3275
- inpaintUndo.addEventListener("click", () => {
3386
+ function undoStroke() {
3276
3387
  const stroke = inpaintStrokes.pop();
3277
3388
  if (stroke) redoStrokes.push(stroke);
3278
3389
  redrawInpaintMask();
3390
+ syncMaskUi();
3279
3391
  setStatus("", "");
3280
- });
3281
- inpaintRedo.addEventListener("click", () => {
3392
+ }
3393
+ function redoStroke() {
3282
3394
  const stroke = redoStrokes.pop();
3283
3395
  if (stroke) inpaintStrokes.push(stroke);
3284
3396
  redrawInpaintMask();
3397
+ syncMaskUi();
3285
3398
  setStatus("", "");
3286
- });
3399
+ }
3400
+ inpaintUndo.addEventListener("click", undoStroke);
3401
+ inpaintRedo.addEventListener("click", redoStroke);
3287
3402
  inpaintClear.addEventListener("click", () => {
3288
3403
  resetMask();
3289
3404
  setStatus("", "");
@@ -3349,7 +3464,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3349
3464
  hideBrushCursor();
3350
3465
  });
3351
3466
  inpaintSubmit.addEventListener("click", () => {
3352
- void submitInpaintJob();
3467
+ submitInpaintJob().catch(() => {});
3353
3468
  });
3354
3469
  function openSettingsModal() {
3355
3470
  settingsPopover.hidden = false;
@@ -3366,6 +3481,20 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3366
3481
  if (settingsPopover.hidden) openSettingsModal(); else closeSettingsModal();
3367
3482
  });
3368
3483
  if (settingsClose) settingsClose.addEventListener("click", () => closeSettingsModal());
3484
+
3485
+ // Copy chat ID — the session tracer IS this chat's thread id (it's what
3486
+ // /job-runs/history groups on, what ?tracer= reopens, and what the past-
3487
+ // sessions rail keys off). A still-unnamed session mints its id here so
3488
+ // there is always something concrete to copy/share.
3489
+ const copyChatButton = document.querySelector("[data-custom-inpaint-copy-chat]");
3490
+ if (copyChatButton instanceof HTMLButtonElement) {
3491
+ copyChatButton.addEventListener("click", () => {
3492
+ const tracer = resolveTracer() || getTracerValue();
3493
+ void copyText(tracer).then((copied) => {
3494
+ setStatus(copied ? "Chat ID copied — " + tracer : "Chat ID: " + tracer, copied ? "success" : "");
3495
+ });
3496
+ });
3497
+ }
3369
3498
  settingsPopover.addEventListener("click", (event) => {
3370
3499
  if (event.target === settingsPopover) closeSettingsModal();
3371
3500
  });
@@ -3384,16 +3513,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3384
3513
  if (sessionsBackdrop) sessionsBackdrop.hidden = true;
3385
3514
  if (settingsPopover.hidden) document.body.style.overflow = "";
3386
3515
  }
3387
- function isImageSessionEntry(entry) {
3388
- const templateId = String(entry && entry.linkedJob && (entry.linkedJob.template_id || entry.linkedJob.templateId) || "").toLowerCase();
3389
- const path = String(entry && entry.path || "").toLowerCase();
3390
- return templateId.includes("image_inpaint")
3391
- || templateId.includes("image_generate")
3392
- || path.includes("/primitives/images/inpaint")
3393
- || path.includes("/primitives/image_inpaint")
3394
- || path.includes("/primitives/images/generate")
3395
- || path.includes("/primitives/image_generate");
3396
- }
3397
3516
  function relTimeShort(value) {
3398
3517
  const then = Date.parse(value || 0);
3399
3518
  if (!then) return "";
@@ -3454,13 +3573,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3454
3573
  meta.className = "rk-ip-session-meta";
3455
3574
  const title = document.createElement("div");
3456
3575
  title.className = "rk-ip-session-title";
3457
- title.textContent = isImage ? (group.name || "Standalone image") : group.tracer;
3576
+ title.textContent = isImage ? (group.name || "Standalone image") : (group.title || group.tracer);
3577
+ title.title = isImage ? "" : (group.title ? group.title + " (" + group.tracer + ")" : group.tracer);
3458
3578
  const sub = document.createElement("div");
3459
3579
  sub.className = "rk-ip-session-sub";
3460
3580
  const when = relTimeShort(group.createdAt);
3461
3581
  sub.textContent = isImage
3462
- ? "Standalone image — tap to edit" + (when ? " · " + when : "")
3463
- : (group.count ? group.count + " image" + (group.count === 1 ? "" : "s") : "session") + (when ? " · " + when : "");
3582
+ ? "Standalone image — tap to start a chat from it" + (when ? " · " + when : "")
3583
+ : (group.count ? group.count + " message" + (group.count === 1 ? "" : "s") : "session") + (when ? " · " + when : "");
3464
3584
  meta.append(title, sub);
3465
3585
  row.append(meta);
3466
3586
  sessionsList.append(row);
@@ -3477,16 +3597,17 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3477
3597
  const payload = await parseJsonResponse(response);
3478
3598
  if (!response.ok || (payload && payload.error)) throw new Error((payload && payload.error) || "Unable to load sessions.");
3479
3599
  const groups = new Map();
3480
- // Past image gens that carry NO session tracer (e.g. one-off generations
3481
- // from other surfaces) can't be reopened as a session, so surface each as
3482
- // a standalone image the user can load fresh into a new inpaint session.
3600
+ // Past one-off image gens with NO session tracer can't be reopened as a
3601
+ // conversation, so each is offered as a standalone image to start one from.
3483
3602
  const loose = [];
3484
3603
  const looseSeen = new Set();
3485
3604
  for (const entry of Array.isArray(payload.entries) ? payload.entries : []) {
3486
- if (!isImageSessionEntry(entry)) continue;
3487
- const urls = historyEntryUrls(entry);
3605
+ const kind = chatEntryKind(entry);
3606
+ if (!kind) continue;
3607
+ const urls = kind === "video" ? historyEntryVideoUrls(entry) : historyEntryUrls(entry);
3488
3608
  const tracer = String(entry.tracer || "").trim();
3489
3609
  if (!tracer) {
3610
+ if (kind === "video") continue;
3490
3611
  for (const raw of urls) {
3491
3612
  let url = "";
3492
3613
  try { url = absoluteUrl(raw); } catch { continue; }
@@ -3497,9 +3618,16 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3497
3618
  continue;
3498
3619
  }
3499
3620
  let group = groups.get(tracer);
3500
- if (!group) { group = { kind: "session", tracer, thumb: "", count: 0, createdAt: entry.createdAt || "" }; groups.set(tracer, group); }
3501
- if (!group.thumb && urls[0]) { try { group.thumb = absoluteUrl(urls[0]); } catch {} }
3502
- group.count += urls.length;
3621
+ if (!group) { group = { kind: "session", tracer, thumb: "", title: "", count: 0, createdAt: entry.createdAt || "", firstAt: entry.createdAt || "" }; groups.set(tracer, group); }
3622
+ group.count += 1;
3623
+ // Newest image in the session is its cover; oldest message is its name.
3624
+ if (kind !== "video" && urls[0] && Date.parse(entry.createdAt || 0) >= Date.parse(group.createdAt || 0)) {
3625
+ try { group.thumb = absoluteUrl(urls[0]); } catch {}
3626
+ }
3627
+ if (!group.title || Date.parse(entry.createdAt || 0) <= Date.parse(group.firstAt || 0)) {
3628
+ const text = userTextFromEntry(entry, kind);
3629
+ if (text) { group.title = text.length > 60 ? text.slice(0, 60) + "…" : text; group.firstAt = entry.createdAt || group.firstAt; }
3630
+ }
3503
3631
  if (Date.parse(entry.createdAt || 0) > Date.parse(group.createdAt || 0)) group.createdAt = entry.createdAt;
3504
3632
  }
3505
3633
  const list = Array.from(groups.values()).concat(loose)
@@ -3512,7 +3640,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3512
3640
  sessionsLoading = false;
3513
3641
  }
3514
3642
  }
3515
- function switchToTracer(tracer, sourceUrl) {
3643
+ async function switchToTracer(tracer) {
3516
3644
  const value = String(tracer || "").trim();
3517
3645
  if (!value) return;
3518
3646
  clearInpaintUrlParams();
@@ -3521,16 +3649,14 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3521
3649
  tracerInput.value = value;
3522
3650
  tracerAutoPending = false;
3523
3651
  closeSessionsPanel();
3524
- void loadInpaintHistory();
3652
+ setStatus("Opening " + value + "…", "");
3653
+ // Rebuild the whole conversation — messages, edits, outputs — and put the
3654
+ // newest image back under the brush.
3655
+ await restoreSession({ replaceEmpty: true });
3525
3656
  void loadTracerSessions();
3526
- // Load this session's latest image straight onto the canvas so it's ready
3527
- // to paint/refinereopening a session with a blank stage is useless.
3528
- if (sourceUrl) {
3529
- loadSourceUrl(sourceUrl, "history");
3530
- setStatus("Opened session " + value + " — paint mask regions to keep editing.", "success");
3531
- } else {
3532
- setStatus("Opened session " + value + ".", "");
3533
- }
3657
+ setStatus(chatTurns.length
3658
+ ? "Session restoredkeep chatting to pick up where you left off."
3659
+ : "Opened " + value + " — no messages in it yet.", chatTurns.length ? "success" : "");
3534
3660
  }
3535
3661
  // A past image gen with no session tracer: start a brand-new session and drop
3536
3662
  // that image onto the editor as the source so it can be edited immediately.
@@ -3545,7 +3671,7 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3545
3671
  closeSessionsPanel();
3546
3672
  loadSourceUrl(value, "history");
3547
3673
  void loadTracerSessions();
3548
- setStatus("New session started from that image — paint mask regions to edit it.", "success");
3674
+ setStatus("New session started from that image — describe an edit, or paint a mask first.", "success");
3549
3675
  }
3550
3676
  if (sessionsToggle) sessionsToggle.addEventListener("click", () => {
3551
3677
  if (sessionsPanel && sessionsPanel.classList.contains("is-open")) closeSessionsPanel(); else openSessionsPanel();
@@ -3562,19 +3688,10 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3562
3688
  }
3563
3689
  const row = target.closest("[data-session-tracer]");
3564
3690
  if (row instanceof HTMLElement && row.dataset.sessionTracer) {
3565
- const thumb = row.querySelector("img.rk-ip-session-thumb");
3566
- switchToTracer(row.dataset.sessionTracer, thumb instanceof HTMLImageElement ? thumb.src : "");
3691
+ void switchToTracer(row.dataset.sessionTracer);
3567
3692
  }
3568
3693
  });
3569
3694
 
3570
- window.addEventListener("click", (event) => {
3571
- const target = event.target;
3572
- if (!(target instanceof Node)) return;
3573
- const isHistoryConfirmTarget = target instanceof Element && Boolean(target.closest("[data-history-confirm]"));
3574
- if (!historyList.contains(target) && !isHistoryConfirmTarget) {
3575
- closeInpaintHistoryConfirms();
3576
- }
3577
- });
3578
3695
  window.addEventListener("resize", () => syncInpaintCanvasSize());
3579
3696
  window.addEventListener("keydown", (event) => {
3580
3697
  if (event.key !== "Escape") return;
@@ -3583,30 +3700,93 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3583
3700
  if (sessionsPanel && sessionsPanel.classList.contains("is-open")) { closeSessionsPanel(); return; }
3584
3701
  });
3585
3702
 
3586
- // ===== text-to-image "Create Image" empty-state composer =====================
3703
+ // ===== chat composer + feed ================================================
3587
3704
  // Layered on top of the legacy inpaint logic; reuses its in-scope helpers
3588
- // (setStatus, loadSourceUrl, uploadTemporaryBlob, getTracerValue, parseJsonResponse,
3589
- // absoluteUrl, extractOutputUrl, inpaintProviderSelect). A generated image becomes
3590
- // the editable SOURCE so you can immediately paint to refine it.
3705
+ // (setStatus, uploadTemporaryBlob, getTracerValue, parseJsonResponse,
3706
+ // absoluteUrl, extractOutputUrl, inpaintProviderSelect, submitInpaintJob).
3707
+ // Every finished image becomes the newest image, which is both the paint
3708
+ // surface and the source the NEXT message edits.
3591
3709
  var genPrompt = document.getElementById('rk-ip-genprompt');
3592
3710
  var genClip = document.getElementById('rk-ip-genclip');
3593
3711
  var genSend = document.getElementById('rk-ip-gensend');
3594
3712
  var genRefsEl = document.getElementById('rk-ip-genrefs');
3595
3713
  var genStatusEl = document.getElementById('rk-ip-genstatus');
3596
- var genWrap = document.getElementById('rk-ip-gen');
3714
+ var genWrap = composerEl;
3597
3715
  var GEN_SEND_HTML = genSend ? genSend.innerHTML : '';
3598
3716
  var genRefs = [];
3599
3717
  var genBusy = false;
3600
3718
 
3601
- function setGenStatus(m, t){ if(!genStatusEl) return; genStatusEl.textContent = m || ''; genStatusEl.setAttribute('data-tone', t || ''); }
3602
- function canGenNew(){ return Boolean((genPrompt && genPrompt.value || '').trim()) && !genBusy; }
3719
+ // One status line for the whole surface the composer's.
3720
+ function setGenStatus(m, t){ setStatus(m, t); }
3721
+ function maskHasInstruction(){
3722
+ return inpaintStrokes.length > 0 && inpaintMaskColors.some(function(entry){ return String(entry.prompt || '').trim(); });
3723
+ }
3724
+ function canGenNew(){ return !genBusy && (Boolean((genPrompt && genPrompt.value || '').trim()) || maskHasInstruction()); }
3725
+ // While a turn is in flight the composer stays fully usable — you can keep
3726
+ // typing the next message, attach references, and scroll the history. The send
3727
+ // button turns into Stop, which cancels the running job.
3603
3728
  function syncGenSend(){
3604
- if(genPrompt) genPrompt.disabled = genBusy;
3605
- if(genClip) genClip.disabled = genBusy;
3606
3729
  if(!genSend) return;
3730
+ if(genBusy){
3731
+ genSend.disabled = false;
3732
+ genSend.dataset.mode = 'stop';
3733
+ genSend.setAttribute('aria-label', 'Stop generating');
3734
+ genSend.setAttribute('title', 'Stop generating');
3735
+ genSend.innerHTML = "<svg width=\"16\" height=\"16\" viewBox=\"0 0 20 20\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"5\" y=\"5\" width=\"10\" height=\"10\" rx=\"2\"/></svg>";
3736
+ return;
3737
+ }
3738
+ genSend.dataset.mode = 'send';
3739
+ genSend.setAttribute('aria-label', 'Send');
3740
+ genSend.setAttribute('title', 'Send');
3607
3741
  genSend.disabled = !canGenNew();
3608
- genSend.classList.toggle('is-busy', genBusy);
3609
- genSend.innerHTML = genBusy ? '<span class="rk-ip-gen-spin" aria-hidden="true"></span>' : GEN_SEND_HTML;
3742
+ genSend.innerHTML = GEN_SEND_HTML;
3743
+ }
3744
+ function setGenBusy(value){
3745
+ genBusy = Boolean(value);
3746
+ composerEl.classList.toggle('is-busy', genBusy);
3747
+ syncGenSend();
3748
+ }
3749
+
3750
+ // ---- stop --------------------------------------------------------------
3751
+ // Stop aborts the client poll AND best-effort cancels the job server-side so a
3752
+ // long video render stops burning provider time.
3753
+ var activeJob = { kind: '', jobId: '' };
3754
+ var stopRequested = false;
3755
+ function stopError(){ var e = new Error('__vidfarm_stopped__'); e.stopped = true; return e; }
3756
+ function throwIfStopped(){ if(stopRequested) throw stopError(); }
3757
+ function stopActiveTurn(){
3758
+ if(!genBusy || stopRequested) return;
3759
+ stopRequested = true;
3760
+ setStatus('Stopping…', '');
3761
+ if(activeTurnId) updatePendingLabel(activeTurnId, 'Stopping…');
3762
+ if(activeJob.kind && activeJob.jobId){
3763
+ fetch('/api/v1/primitives/' + activeJob.kind + '/jobs/' + encodeURIComponent(activeJob.jobId) + '/cancel', {
3764
+ method: 'POST', credentials: 'same-origin'
3765
+ }).catch(function(){});
3766
+ }
3767
+ }
3768
+ function composerPlaceholder(){
3769
+ if(!genPrompt) return;
3770
+ genPrompt.placeholder = modeSelect.value === 'video'
3771
+ ? (sourceImageUrl ? 'Describe the video \u2014 the newest image is used as its opening frame\u2026' : 'Describe the video you want\u2026')
3772
+ : (sourceImageUrl ? (inpaintStrokes.length ? 'Describe the masked edit (whole-image guidance)\u2026' : 'Describe the next edit to the newest image\u2026') : 'Describe the image you want\u2026');
3773
+ }
3774
+ // Grows with the message up to 10 rows, then scrolls inside itself.
3775
+ var COMPOSER_MAX_ROWS = 10;
3776
+ function composerMaxHeight(){
3777
+ if(!genPrompt) return 0;
3778
+ var styles = window.getComputedStyle(genPrompt);
3779
+ var line = parseFloat(styles.lineHeight) || 20;
3780
+ var pad = (parseFloat(styles.paddingTop) || 0) + (parseFloat(styles.paddingBottom) || 0)
3781
+ + (parseFloat(styles.borderTopWidth) || 0) + (parseFloat(styles.borderBottomWidth) || 0);
3782
+ return Math.round(line * COMPOSER_MAX_ROWS + pad);
3783
+ }
3784
+ function autoGrowComposer(){
3785
+ if(!genPrompt) return;
3786
+ var max = composerMaxHeight();
3787
+ genPrompt.style.height = 'auto';
3788
+ genPrompt.style.height = Math.min(max, Math.max(56, genPrompt.scrollHeight)) + 'px';
3789
+ genPrompt.style.overflowY = genPrompt.scrollHeight > max ? 'auto' : 'hidden';
3610
3790
  }
3611
3791
  function renderGenRefs(){
3612
3792
  if(!genRefsEl) return;
@@ -3710,28 +3890,6 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3710
3890
  }
3711
3891
  function dtHasFiles(e){ var t = (e.dataTransfer && e.dataTransfer.types) || []; return Array.prototype.indexOf.call(t, 'Files') !== -1; }
3712
3892
 
3713
- function pollImageGenerateJob(jobId){
3714
- var attempt = 0;
3715
- function step(){
3716
- return fetch('/api/v1/primitives/image_generate/jobs/' + encodeURIComponent(jobId), { credentials: 'same-origin' }).then(function(r){
3717
- return parseJsonResponse(r).then(function(payload){
3718
- if(!r.ok) throw new Error(payload.error || 'Unable to poll image job.');
3719
- if(payload.status === 'failed' || payload.status === 'cancelled'){ throw new Error((payload.error && payload.error.message) || ('Image job ' + payload.status + '.')); }
3720
- var out = extractOutputUrl(payload);
3721
- if(payload.status === 'succeeded' || payload.status === 'complete' || out){
3722
- if(!out) throw new Error('Image job completed without an output URL.');
3723
- return absoluteUrl(out);
3724
- }
3725
- setGenStatus('Generating image (' + (payload.status || 'running') + ')\u2026', '');
3726
- attempt++;
3727
- if(attempt >= 90){ setGenStatus('Still running \u2014 check back shortly.', ''); return ''; }
3728
- return new Promise(function(res){ setTimeout(res, attempt < 8 ? 1500 : 3000); }).then(step);
3729
- });
3730
- });
3731
- }
3732
- return step();
3733
- }
3734
-
3735
3893
  // "character card" / "storyboard" shorthand — spell out the full layout
3736
3894
  // so the image model produces a reference sheet / numbered shot board
3737
3895
  // instead of a single picture. Snippets are shared with the AI chat +
@@ -3765,36 +3923,404 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3765
3923
  btn.addEventListener('click', function(){ applyGenPreset(btn.getAttribute('data-preset') || 'character-card'); });
3766
3924
  });
3767
3925
 
3768
- function submitGenerate(){
3769
- var p = expandShorthand((genPrompt && genPrompt.value || '').trim());
3770
- if(!p){ setGenStatus('Type a prompt to generate an image.', 'error'); if(genPrompt) genPrompt.focus(); return; }
3926
+ // ---- chat state ----------------------------------------------------------
3927
+ // chatTurns/activeTurnId/renderChat/addTurn/adoptLatestImage are declared with
3928
+ // var + function declarations so the earlier legacy helpers (loadSourceUrl,
3929
+ // applyCompletedInpaintJob, resetWorkspace) can call them safely.
3930
+ var turnSeq = 0;
3931
+ function nextTurnId(){ turnSeq += 1; return 't' + turnSeq; }
3932
+ function addTurn(turn){
3933
+ var item = Object.assign({ id: nextTurnId(), at: new Date().toISOString(), role: 'assistant' }, turn);
3934
+ chatTurns.push(item);
3935
+ renderChat();
3936
+ return item;
3937
+ }
3938
+ function updateTurn(id, patch){
3939
+ if(!id) return;
3940
+ chatTurns = chatTurns.map(function(t){ return t.id === id ? Object.assign({}, t, patch) : t; });
3941
+ renderChat();
3942
+ }
3943
+ // Pending labels tick every poll; patch the node in place so a full re-render
3944
+ // never restarts a video that is playing further up the feed.
3945
+ function updatePendingLabel(id, label){
3946
+ if(!id) return;
3947
+ chatTurns = chatTurns.map(function(t){ return t.id === id ? Object.assign({}, t, { label: label }) : t; });
3948
+ var node = chatFeed.querySelector('[data-pending-id="' + id + '"]');
3949
+ if(node) node.textContent = label;
3950
+ }
3951
+ function resolveActiveTurn(patch){
3952
+ if(activeTurnId){ updateTurn(activeTurnId, patch); activeTurnId = ''; return; }
3953
+ addTurn(Object.assign({ role: 'assistant' }, patch));
3954
+ }
3955
+
3956
+ // Make the given url the newest image: the paint surface AND the source of the next edit.
3957
+ function adoptLatestImage(url, name){
3958
+ if(!url) return;
3959
+ sourceImageUrl = url;
3960
+ sourceImageName = name || sourceNameFromUrl(url);
3961
+ sourceName.textContent = sourceImageName;
3962
+ canvasWrap.hidden = false;
3963
+ outputPanel.hidden = true;
3964
+ inpaintImage.onload = function(){ syncInpaintCanvasSize(); resetMask(); };
3965
+ inpaintImage.src = url;
3966
+ resetMask();
3967
+ renderChat();
3968
+ composerPlaceholder();
3969
+ }
3970
+
3971
+ // ---- feed rendering ------------------------------------------------------
3972
+ function mediaActionsRow(turn){
3973
+ var row = document.createElement('div');
3974
+ row.className = 'rk-ip-media-actions';
3975
+ var full = document.createElement('button');
3976
+ full.type = 'button'; full.className = 'rk-ip-act'; full.dataset.ipOpen = turn.id;
3977
+ full.setAttribute('aria-label', 'Open fullscreen'); full.title = 'Fullscreen';
3978
+ full.innerHTML = "<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=\"M12 3h5v5\"/><path d=\"M8 17H3v-5\"/><path d=\"M17 3l-6 6\"/><path d=\"M3 17l6-6\"/></svg>";
3979
+ var download = document.createElement('a');
3980
+ download.className = 'rk-ip-act'; download.href = turn.url; download.target = '_blank'; download.rel = 'noopener noreferrer';
3981
+ download.setAttribute('download', imageDownloadName(turn.mediaKind === 'video' ? 'vidfarm-video' : 'vidfarm-image', turn.at));
3982
+ download.setAttribute('aria-label', 'Download this file'); download.title = 'Download';
3983
+ download.innerHTML = "<svg width=\"15\" height=\"15\" viewBox=\"0 0 20 20\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M10 3v9\"/><path d=\"m6.5 8.5 3.5 3.5 3.5-3.5\"/><path d=\"M4 16h12\"/></svg>";
3984
+ var link = document.createElement('a');
3985
+ link.className = 'rk-ip-act'; link.href = turn.url; link.target = '_blank'; link.rel = 'noopener noreferrer';
3986
+ link.setAttribute('aria-label', 'Open the direct file URL'); link.title = 'Open direct file URL';
3987
+ link.innerHTML = "<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.6 11.4a3 3 0 0 0 4.3 0l2.4-2.4a3 3 0 1 0-4.3-4.3l-1 1\"/><path d=\"M11.4 8.6a3 3 0 0 0-4.3 0l-2.4 2.4a3 3 0 1 0 4.3 4.3l1-1\"/></svg>";
3988
+ var copy = document.createElement('button');
3989
+ copy.type = 'button'; copy.className = 'rk-ip-act'; copy.dataset.ipCopy = turn.id;
3990
+ copy.setAttribute('aria-label', 'Copy the file URL'); copy.title = 'Copy URL';
3991
+ copy.innerHTML = "<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>";
3992
+ var save = document.createElement('button');
3993
+ save.type = 'button'; save.className = 'rk-ip-act'; save.dataset.ipSave = turn.id;
3994
+ save.setAttribute('aria-label', 'Save to My Files'); save.title = 'Save to My Files';
3995
+ save.innerHTML = "<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=\"M5 4h8l2 2v10H5z\"/><path d=\"M8 4v4h4V4\"/><path d=\"M8 12h4\"/></svg>";
3996
+ row.append(full, download, link, copy, save);
3997
+ return row;
3998
+ }
3999
+
4000
+ function renderChat(){
4001
+ // Detach the paint surface first — innerHTML rebuild would otherwise drop it.
4002
+ if(paintStack.parentElement !== canvasHolder) canvasHolder.appendChild(paintStack);
4003
+ var wasNearBottom = (chatFeed.scrollHeight - chatFeed.scrollTop - chatFeed.clientHeight) < 120;
4004
+ chatFeed.innerHTML = '';
4005
+ if(!chatTurns.length){
4006
+ emptyState.hidden = false;
4007
+ chatFeed.appendChild(emptyState);
4008
+ return;
4009
+ }
4010
+ emptyState.hidden = true;
4011
+ var latestPaintableId = '';
4012
+ for(var i = chatTurns.length - 1; i >= 0; i--){
4013
+ var candidate = chatTurns[i];
4014
+ if(candidate.kind === 'media' && candidate.mediaKind !== 'video' && candidate.url && candidate.url === sourceImageUrl){ latestPaintableId = candidate.id; break; }
4015
+ }
4016
+ chatTurns.forEach(function(turn){
4017
+ var row = document.createElement('div');
4018
+ row.className = 'rk-ip-turn';
4019
+ row.dataset.role = turn.role;
4020
+ row.dataset.kind = turn.kind;
4021
+ row.dataset.turnId = turn.id;
4022
+ if(turn.role === 'user'){
4023
+ if(turn.refs && turn.refs.length){
4024
+ var refs = document.createElement('div');
4025
+ refs.className = 'rk-ip-turn-refs';
4026
+ turn.refs.forEach(function(url){ var im = document.createElement('img'); im.src = url; im.alt = ''; im.loading = 'lazy'; refs.appendChild(im); });
4027
+ row.appendChild(refs);
4028
+ }
4029
+ var tag = document.createElement('span');
4030
+ tag.className = 'rk-ip-turn-tag';
4031
+ tag.textContent = turn.mode === 'video' ? 'Video' : (turn.masked ? 'Masked edit' : (turn.isEdit ? 'Edit' : 'New image'));
4032
+ row.appendChild(tag);
4033
+ var bubble = document.createElement('div');
4034
+ bubble.className = 'rk-ip-bubble';
4035
+ bubble.textContent = turn.text || '';
4036
+ row.appendChild(bubble);
4037
+ } else if(turn.kind === 'pending'){
4038
+ var pending = document.createElement('div');
4039
+ pending.className = 'rk-ip-pending';
4040
+ pending.dataset.pendingId = turn.id;
4041
+ pending.textContent = turn.label || 'Working\u2026';
4042
+ row.appendChild(pending);
4043
+ } else if(turn.kind === 'error' || turn.kind === 'stopped'){
4044
+ var err = document.createElement('div');
4045
+ err.className = 'rk-ip-bubble';
4046
+ err.textContent = turn.text || 'Something went wrong.';
4047
+ row.appendChild(err);
4048
+ } else if(turn.kind === 'media' && turn.url){
4049
+ var media = document.createElement('div');
4050
+ media.className = 'rk-ip-media';
4051
+ var isLatest = turn.id === latestPaintableId;
4052
+ var frame = document.createElement('div');
4053
+ frame.className = 'rk-ip-media-frame' + (isLatest ? ' is-latest' : '');
4054
+ if(isLatest){
4055
+ canvasWrap.hidden = false;
4056
+ frame.appendChild(paintStack);
4057
+ } else if(turn.mediaKind === 'video'){
4058
+ var video = document.createElement('video');
4059
+ video.src = turn.url; video.controls = true; video.playsInline = true; video.preload = 'metadata';
4060
+ frame.appendChild(video);
4061
+ } else {
4062
+ var img = document.createElement('img');
4063
+ img.src = turn.url; img.alt = turn.note || 'Generated image'; img.loading = 'lazy';
4064
+ frame.appendChild(img);
4065
+ }
4066
+ media.appendChild(frame);
4067
+ var actions = mediaActionsRow(turn);
4068
+ if(isLatest){
4069
+ var note = document.createElement('span');
4070
+ note.className = 'rk-ip-act-note';
4071
+ note.textContent = inpaintStrokes.length ? 'Newest \u00b7 masked' : 'Newest \u00b7 paint to mask a region';
4072
+ actions.appendChild(note);
4073
+ } else if(turn.note){
4074
+ var tagNote = document.createElement('span');
4075
+ tagNote.className = 'rk-ip-act-note';
4076
+ tagNote.textContent = turn.note;
4077
+ actions.appendChild(tagNote);
4078
+ }
4079
+ media.appendChild(actions);
4080
+ row.appendChild(media);
4081
+ } else {
4082
+ var text = document.createElement('div');
4083
+ text.className = 'rk-ip-bubble';
4084
+ text.textContent = turn.text || '';
4085
+ row.appendChild(text);
4086
+ }
4087
+ chatFeed.appendChild(row);
4088
+ });
4089
+ if(wasNearBottom) chatFeed.scrollTop = chatFeed.scrollHeight;
4090
+ updateBrushCursorSize();
4091
+ syncSessionAssets();
4092
+ }
4093
+
4094
+ function turnById(id){ return chatTurns.filter(function(t){ return t.id === id; })[0] || null; }
4095
+
4096
+ chatFeed.addEventListener('click', function(event){
4097
+ var target = event.target;
4098
+ if(!(target instanceof Element)) return;
4099
+ var open = target.closest('[data-ip-open]');
4100
+ if(open instanceof HTMLElement){
4101
+ var openTurn = turnById(open.dataset.ipOpen || '');
4102
+ if(openTurn) openImagePreview(openTurn.url, openTurn.mediaKind === 'video' ? 'Generated video' : 'Generated image', imageDownloadName(openTurn.mediaKind === 'video' ? 'vidfarm-video' : 'vidfarm-image', openTurn.at), openTurn.mediaKind);
4103
+ return;
4104
+ }
4105
+ var copy = target.closest('[data-ip-copy]');
4106
+ if(copy instanceof HTMLElement){
4107
+ var copyTurn = turnById(copy.dataset.ipCopy || '');
4108
+ if(!copyTurn) return;
4109
+ void copyText(copyTurn.url).then(function(copied){
4110
+ copy.title = copied ? 'Copied' : 'Copy failed';
4111
+ setStatus(copied ? 'File URL copied.' : 'Copy failed.', copied ? 'success' : 'error');
4112
+ window.setTimeout(function(){ copy.title = 'Copy URL'; }, 1200);
4113
+ });
4114
+ return;
4115
+ }
4116
+ var save = target.closest('[data-ip-save]');
4117
+ if(save instanceof HTMLElement){
4118
+ var saveTurn = turnById(save.dataset.ipSave || '');
4119
+ if(saveTurn) saveMediaToFiles(saveTurn.url, imageDownloadName(saveTurn.mediaKind === 'video' ? 'vidfarm-video' : 'vidfarm-image', saveTurn.at));
4120
+ }
4121
+ });
4122
+
4123
+ maskChip.addEventListener('click', function(event){
4124
+ var target = event.target;
4125
+ if(target instanceof Element && target.closest('[data-ip-clear-mask]')){
4126
+ resetMask();
4127
+ renderChat();
4128
+ composerPlaceholder();
4129
+ }
4130
+ });
4131
+
4132
+ // ---- what each request carries -------------------------------------------
4133
+ // DELIBERATELY no conversation context. Each turn sends ONLY the user's latest
4134
+ // message and (for edits) the current image. Feeding back a transcript plus
4135
+ // earlier versions of the same picture made the models blend instructions
4136
+ // across turns and drift back toward superseded versions. The newest image
4137
+ // already encodes every edit before it, so it is the only history worth
4138
+ // sending. References the user attaches by hand still go through untouched.
4139
+ // ---- job polling ---------------------------------------------------------
4140
+ function extractVideoOutputUrl(payload){
4141
+ var result = (payload && payload.result) || {};
4142
+ var out = result.output || {};
4143
+ return (out.video && out.video.file_url) || out.primary_file_url || (result.video && result.video.file_url) || result.primary_file_url
4144
+ || (Array.isArray(out.files) ? out.files[0] : '') || (Array.isArray(result.files) ? result.files[0] : '') || '';
4145
+ }
4146
+ function pollPrimitiveJob(kind, jobId, label, isVideo){
4147
+ var attempt = 0;
4148
+ var maxAttempts = isVideo ? 150 : 90;
4149
+ function step(){
4150
+ throwIfStopped();
4151
+ return fetch('/api/v1/primitives/' + kind + '/jobs/' + encodeURIComponent(jobId), { credentials: 'same-origin' }).then(function(r){
4152
+ return parseJsonResponse(r).then(function(payload){
4153
+ if(!r.ok) throw new Error(payload.error || ('Unable to poll ' + label + '.'));
4154
+ if(payload.status === 'failed' || payload.status === 'cancelled'){ throw new Error((payload.error && payload.error.message) || (label + ' ' + payload.status + '.')); }
4155
+ var out = isVideo ? extractVideoOutputUrl(payload) : extractOutputUrl(payload);
4156
+ if(payload.status === 'succeeded' || payload.status === 'complete' || out){
4157
+ if(!out) throw new Error(label + ' finished without an output URL.');
4158
+ return absoluteUrl(out);
4159
+ }
4160
+ var note = label + ' (' + (payload.status || 'running') + ')\u2026';
4161
+ updatePendingLabel(activeTurnId, note);
4162
+ setStatus(note, '');
4163
+ attempt++;
4164
+ if(attempt >= maxAttempts){ return ''; }
4165
+ return new Promise(function(res){ setTimeout(res, attempt < 8 ? 1500 : (isVideo ? 4000 : 3000)); }).then(step);
4166
+ });
4167
+ });
4168
+ }
4169
+ return step();
4170
+ }
4171
+
4172
+ // ---- the four kinds of turn ---------------------------------------------
4173
+ function runGenerateTurn(text, refs){
4174
+ return Promise.resolve().then(function(){
4175
+ var pending = addTurn({ kind: 'pending', label: 'Generating image\u2026', prompt: text });
4176
+ activeTurnId = pending.id;
4177
+ var payload = { prompt: text, output_format: 'png' };
4178
+ if(refs.length) payload.prompt_attachments = refs;
4179
+ if(inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
4180
+ var tracer = tracerAutoPending ? nameTracerFromSeed(text) : getTracerValue();
4181
+ return fetch('/api/v1/primitives/images/generate', {
4182
+ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
4183
+ body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: text, chat_mode: 'image' })
4184
+ }).then(parseJsonResponse).then(function(j){
4185
+ if(!j || !j.job_id) throw new Error((j && j.error) || 'Unable to start image generation.');
4186
+ activeJob = { kind: 'image_generate', jobId: j.job_id };
4187
+ return pollPrimitiveJob('image_generate', j.job_id, 'Generating image', false).then(function(out){
4188
+ if(!out){ updatePendingLabel(activeTurnId, 'Still generating \u2014 it will appear in this session\u2019s history when it lands.'); activeTurnId = ''; return; }
4189
+ resolveActiveTurn({ kind: 'media', mediaKind: 'image', url: out, jobId: j.job_id });
4190
+ adoptLatestImage(out);
4191
+ recordSourceHistoryItem(out, 'generated', 'Generated image');
4192
+ setStatus('Image ready \u2014 keep chatting to refine it.', 'success');
4193
+ void loadTracerSessions();
4194
+ });
4195
+ });
4196
+ });
4197
+ }
4198
+
4199
+ function runEditTurn(text, refs){
4200
+ return Promise.resolve().then(function(){
4201
+ var pending = addTurn({ kind: 'pending', label: 'Editing the newest image\u2026', prompt: text });
4202
+ activeTurnId = pending.id;
4203
+ var instruction = 'Edit the SOURCE IMAGE as follows: ' + text
4204
+ + ' Keep everything else about the source image \u2014 subject identity, composition, framing, style, and unrelated details \u2014 unchanged unless the instruction asks otherwise.';
4205
+ var payload = { source_image_url: sourceImageUrl, instruction: instruction, output_format: 'png' };
4206
+ if(refs.length) payload.reference_attachments = refs;
4207
+ if(inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
4208
+ var tracer = tracerAutoPending ? nameTracerFromSeed(text) : getTracerValue();
4209
+ return fetch('/api/v1/primitives/images/edit', {
4210
+ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
4211
+ body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: text, chat_mode: 'image' })
4212
+ }).then(parseJsonResponse).then(function(j){
4213
+ if(!j || !j.job_id) throw new Error((j && j.error) || 'Unable to start the edit.');
4214
+ activeJob = { kind: 'image_edit', jobId: j.job_id };
4215
+ return pollPrimitiveJob('image_edit', j.job_id, 'Editing image', false).then(function(out){
4216
+ if(!out){ updatePendingLabel(activeTurnId, 'Still editing \u2014 it will appear in this session\u2019s history when it lands.'); activeTurnId = ''; return; }
4217
+ resolveActiveTurn({ kind: 'media', mediaKind: 'image', url: out, jobId: j.job_id });
4218
+ adoptLatestImage(out);
4219
+ void persistInpaintOutputToTemp(out, j.job_id);
4220
+ setStatus('Edit applied \u2014 the newest image is now the one you are editing.', 'success');
4221
+ void loadTracerSessions();
4222
+ });
4223
+ });
4224
+ });
4225
+ }
4226
+
4227
+ function runMaskedTurn(text, refs){
4228
+ return Promise.resolve().then(function(){
4229
+ // The composer text is the legacy "whole image prompt"; per-color prompts
4230
+ // stay in the left panel and are merged by buildInpaintInstruction().
4231
+ inpaintGlobalPrompt.value = text;
4232
+ var pending = addTurn({ kind: 'pending', label: 'Applying the masked edit\u2026', prompt: text });
4233
+ activeTurnId = pending.id;
4234
+ return submitInpaintJob({ extraRefs: refs, chatMessage: text });
4235
+ });
4236
+ }
4237
+
4238
+ function runVideoTurn(text){
4239
+ return Promise.resolve().then(function(){
4240
+ var pending = addTurn({ kind: 'pending', label: 'Generating video \u2014 this can take a few minutes\u2026', prompt: text });
4241
+ activeTurnId = pending.id;
4242
+ var payload = { prompt: text, output_format: 'mp4' };
4243
+ // The newest image is the only visual reference — it is the opening frame.
4244
+ if(sourceImageUrl) payload.input_references = [sourceImageUrl];
4245
+ if(inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
4246
+ var tracer = tracerAutoPending ? nameTracerFromSeed(text) : getTracerValue();
4247
+ return fetch('/api/v1/primitives/videos/generate', {
4248
+ method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
4249
+ body: JSON.stringify({ tracer: tracer, payload: payload, chat_message: text, chat_mode: 'video' })
4250
+ }).then(parseJsonResponse).then(function(j){
4251
+ if(!j || !j.job_id) throw new Error((j && j.error) || 'Unable to start video generation.');
4252
+ activeJob = { kind: 'video_generate', jobId: j.job_id };
4253
+ return pollPrimitiveJob('video_generate', j.job_id, 'Generating video', true).then(function(out){
4254
+ if(!out){ updatePendingLabel(activeTurnId, 'Still rendering \u2014 check the Video tool\u2019s history shortly.'); activeTurnId = ''; return; }
4255
+ resolveActiveTurn({ kind: 'media', mediaKind: 'video', url: out, jobId: j.job_id, note: 'video' });
4256
+ setStatus('Video ready.', 'success');
4257
+ void loadTracerSessions();
4258
+ });
4259
+ });
4260
+ });
4261
+ }
4262
+
4263
+ // ---- send ----------------------------------------------------------------
4264
+ function sendChatMessage(){
3771
4265
  if(genBusy) return;
3772
- genBusy = true; syncGenSend(); if(genWrap) genWrap.classList.add('is-busy');
3773
- setGenStatus('Creating image\u2026', '');
3774
- var payload = { prompt: p, output_format: 'png' };
3775
- if(genRefs.length) payload.prompt_attachments = genRefs.slice();
3776
- if(inpaintProviderSelect && inpaintProviderSelect.value) payload.provider = inpaintProviderSelect.value;
3777
- var genTracer = tracerAutoPending ? nameTracerFromSeed(p) : getTracerValue();
3778
- if(tracerAutoPending === false){ void loadTracerSessions(); }
3779
- fetch('/api/v1/primitives/images/generate', {
3780
- method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
3781
- body: JSON.stringify({ tracer: genTracer, payload: payload })
3782
- }).then(parseJsonResponse).then(function(j){
3783
- if(!j || !j.job_id){ throw new Error((j && j.error) || 'Unable to start image generation.'); }
3784
- setGenStatus('Generating image\u2026', '');
3785
- return pollImageGenerateJob(j.job_id);
3786
- }).then(function(out){
3787
- if(out){ loadSourceUrl(out, 'generated'); setStatus('Generated image ready. Paint mask regions to refine, or download it.', 'success'); }
3788
- }).catch(function(err){
3789
- var msg = (err && err.message) ? err.message : 'Unable to generate image.';
3790
- if(/401|403|sign in|unauth/i.test(msg)) msg = 'Sign in to generate images.';
3791
- setGenStatus(msg, 'error');
3792
- }).then(function(){ genBusy = false; if(genWrap) genWrap.classList.remove('is-busy'); syncGenSend(); });
4266
+ var mode = modeSelect.value === 'video' ? 'video' : 'image';
4267
+ var raw = (genPrompt && genPrompt.value || '').trim();
4268
+ var text = mode === 'image' ? expandShorthand(raw) : raw;
4269
+ var masked = mode === 'image' && inpaintStrokes.length > 0;
4270
+ if(!text && !(masked && maskHasInstruction())){
4271
+ setStatus(mode === 'video' ? 'Describe the video you want.' : 'Type what you want to create or change.', 'error');
4272
+ if(genPrompt) genPrompt.focus();
4273
+ return;
4274
+ }
4275
+ if(mode === 'video' && !text){ setStatus('Describe the video you want.', 'error'); return; }
4276
+ var refs = genRefs.slice();
4277
+ addTurn({
4278
+ role: 'user', kind: 'text',
4279
+ text: text || 'Apply the painted mask instructions.',
4280
+ refs: refs, mode: mode, masked: masked,
4281
+ isEdit: mode === 'image' && Boolean(sourceImageUrl)
4282
+ });
4283
+ if(genPrompt){ genPrompt.value = ''; autoGrowComposer(); }
4284
+ genRefs = [];
4285
+ renderGenRefs();
4286
+ stopRequested = false;
4287
+ activeJob = { kind: '', jobId: '' };
4288
+ setGenBusy(true);
4289
+ setStatus(mode === 'video' ? 'Creating video\u2026' : 'Working\u2026', '');
4290
+ var work;
4291
+ if(mode === 'video') work = runVideoTurn(text);
4292
+ else if(!sourceImageUrl) work = runGenerateTurn(text, refs);
4293
+ else if(masked) work = runMaskedTurn(text, refs);
4294
+ else work = runEditTurn(text, refs);
4295
+ work.catch(function(err){
4296
+ if(err && (err.stopped || err.message === '__vidfarm_stopped__')){
4297
+ resolveActiveTurn({ kind: 'stopped', text: 'Stopped.' });
4298
+ setStatus('Stopped.', '');
4299
+ return;
4300
+ }
4301
+ var msg = (err && err.message) ? err.message : 'That request failed.';
4302
+ if(/401|403|sign in|unauth/i.test(msg)) msg = 'Sign in to generate media.';
4303
+ resolveActiveTurn({ kind: 'error', text: msg });
4304
+ setStatus(msg, 'error');
4305
+ }).then(function(){
4306
+ stopRequested = false;
4307
+ activeJob = { kind: '', jobId: '' };
4308
+ setGenBusy(false);
4309
+ composerPlaceholder();
4310
+ syncGenSend();
4311
+ void loadInpaintHistory();
4312
+ });
3793
4313
  }
3794
4314
 
3795
4315
  if(genPrompt){
3796
- genPrompt.addEventListener('input', syncGenSend);
3797
- genPrompt.addEventListener('keydown', function(e){ if(e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); submitGenerate(); } });
4316
+ genPrompt.addEventListener('input', function(){ syncGenSend(); autoGrowComposer(); });
4317
+ genPrompt.addEventListener('keydown', function(e){
4318
+ if(e.key !== 'Enter' || e.shiftKey) return;
4319
+ e.preventDefault();
4320
+ // Keep typing freely while a turn runs; Enter just won't fire a second one.
4321
+ if(genBusy){ setStatus('Still generating — press Stop to cancel, or wait and send then.', ''); return; }
4322
+ sendChatMessage();
4323
+ });
3798
4324
  genPrompt.addEventListener('paste', function(e){ var imgs = imagesFromClipboard(e); if(imgs.length){ e.preventDefault(); addGenReferenceFiles(imgs); } });
3799
4325
  }
3800
4326
  if(genWrap){
@@ -3803,9 +4329,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
3803
4329
  genWrap.addEventListener('drop', function(e){ if(dtHasFiles(e)){ e.preventDefault(); e.stopPropagation(); genWrap.classList.remove('is-dragref'); addGenReferenceFiles(e.dataTransfer.files); } });
3804
4330
  }
3805
4331
  if(genClip) genClip.addEventListener('click', openGenFilePanel);
3806
- if(genSend) genSend.addEventListener('click', submitGenerate);
4332
+ if(genSend) genSend.addEventListener('click', function(){ if(genBusy) stopActiveTurn(); else sendChatMessage(); });
4333
+ modeSelect.addEventListener('change', function(){ composerPlaceholder(); syncGenSend(); });
4334
+ // ⌘Z / ⇧⌘Z anywhere outside a text field undoes and redoes the paint.
4335
+ window.addEventListener('keydown', function(e){
4336
+ if(!(e.metaKey || e.ctrlKey) || String(e.key).toLowerCase() !== 'z') return;
4337
+ var el = document.activeElement;
4338
+ if(el && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) return;
4339
+ if(!inpaintStrokes.length && !redoStrokes.length) return;
4340
+ e.preventDefault();
4341
+ if(e.shiftKey) redoStroke(); else undoStroke();
4342
+ });
3807
4343
  renderGenRefs();
3808
4344
  syncGenSend();
4345
+ composerPlaceholder();
4346
+ renderChat();
3809
4347
 
3810
4348
  // Drag-and-drop onto the upload dropzone (legacy only wired click).
3811
4349
  var dropZone = document.querySelector('[data-custom-inpaint-upload]');
@@ -4365,6 +4903,34 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4365
4903
  }
4366
4904
  function fileIcon(kind) { return kind === 'pdf' ? '📕' : kind === 'json' ? '{ }' : kind === 'markdown' ? '📝' : kind === 'audio' ? '🎵' : kind === 'text' ? '📄' : '📄'; }
4367
4905
 
4906
+ // ── "edit this media" hand-off: dock chat → the matching creative tool ──
4907
+ // Images open /tools/image, videos /tools/video, each with the file preloaded as
4908
+ // that tool's working file (its chat then edits THAT media). Deliberately a fresh
4909
+ // tool session — no ?tracer — so the tool names its own chat from the first prompt.
4910
+ 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>';
4911
+ function toolEditUrl(file) {
4912
+ if (!file || !file.viewUrl) return '';
4913
+ var k = fileKind(file.name, file.contentType);
4914
+ var name = file.name || '';
4915
+ if (k === 'image') return '/tools/image?source_image_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_image_name=' + encodeURIComponent(name) : '');
4916
+ if (k === 'video') return '/tools/video?source_video_url=' + encodeURIComponent(file.viewUrl) + (name ? '&source_video_name=' + encodeURIComponent(name) : '');
4917
+ return '';
4918
+ }
4919
+ function toolEditLink(file, className) {
4920
+ var href = toolEditUrl(file);
4921
+ if (!href) return null;
4922
+ var a = document.createElement('a');
4923
+ a.className = className;
4924
+ a.href = href;
4925
+ a.target = '_blank';
4926
+ a.rel = 'noopener noreferrer';
4927
+ a.title = 'Edit in the ' + (fileKind(file.name, file.contentType) === 'video' ? 'video' : 'image') + ' tool';
4928
+ a.setAttribute('aria-label', a.title);
4929
+ // The card itself opens the lightbox; the pencil must not also trigger it.
4930
+ a.addEventListener('click', function (e) { e.stopPropagation(); });
4931
+ return a;
4932
+ }
4933
+
4368
4934
  // Compact markdown → HTML for .md previews (headings, lists, quotes, rules,
4369
4935
  // fenced + inline code, bold/italic/links). Escapes first, then injects tags
4370
4936
  // (same order as linkify). Backticks are built via char codes to avoid
@@ -4425,7 +4991,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4425
4991
  var dlBtn = document.createElement('a'); dlBtn.className = 'rk-aichat-preview-act'; dlBtn.target = '_blank'; dlBtn.rel = 'noopener noreferrer'; dlBtn.textContent = 'Download';
4426
4992
  var linkBtn = document.createElement('button'); linkBtn.type = 'button'; linkBtn.className = 'rk-aichat-preview-act'; linkBtn.textContent = 'Copy link';
4427
4993
  var xBtn = document.createElement('button'); xBtn.type = 'button'; xBtn.className = 'rk-aichat-modal-x'; xBtn.setAttribute('aria-label', 'Close'); xBtn.innerHTML = '&times;';
4428
- head.appendChild(nameEl); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
4994
+ // Same hand-off as the card pencil, spelled out as a labelled action here.
4995
+ var editBtn = document.createElement('a'); editBtn.className = 'rk-aichat-preview-act'; editBtn.target = '_blank'; editBtn.rel = 'noopener noreferrer'; editBtn.textContent = 'Edit';
4996
+ head.appendChild(nameEl); head.appendChild(editBtn); head.appendChild(copyBtn); head.appendChild(dlBtn); head.appendChild(linkBtn); head.appendChild(xBtn);
4429
4997
  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); }
4430
4998
  // Copy = file text (json/md/txt) or its URL for binaries. Copy link = the
4431
4999
  // durable public S3 URL. Download opens in a new tab when the browser can't
@@ -4474,6 +5042,9 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4474
5042
  var it = items[idx];
4475
5043
  nameEl.textContent = it.name || 'file'; nameEl.title = it.name || '';
4476
5044
  dlBtn.href = it.viewUrl; dlBtn.setAttribute('download', it.name || '');
5045
+ var editHref = toolEditUrl(it);
5046
+ editBtn.hidden = !editHref;
5047
+ if (editHref) { editBtn.href = editHref; editBtn.title = 'Edit in the ' + (fileKind(it.name, it.contentType) === 'video' ? 'video' : 'image') + ' tool'; }
4477
5048
  linkBtn.style.display = it.publicUrl ? '' : 'none';
4478
5049
  body.innerHTML = '';
4479
5050
  var kind = fileKind(it.name, it.contentType);
@@ -4528,7 +5099,15 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4528
5099
  var files = [], seen = {};
4529
5100
  var arts = (job && job.artifacts) || [];
4530
5101
  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); } }
4531
- var mu = rkResultMediaUrl(job); if (mu && !seen[mu]) { seen[mu] = true; files.push({ name: (job.tracer || 'output'), contentType: '', viewUrl: mu, publicUrl: mu }); }
5102
+ // Name the direct result URL after the FILE, not the tracer fileKind() reads the
5103
+ // extension, and a tracer-named entry ("my-poster") classifies as 'other', losing
5104
+ // the image/video thumbnail and the edit-in-tool hand-off.
5105
+ var mu = rkResultMediaUrl(job);
5106
+ if (mu && !seen[mu]) {
5107
+ seen[mu] = true;
5108
+ var seg = String(mu).split('?')[0].split('/').pop() || '';
5109
+ files.push({ name: (/.[a-z0-9]{2,5}$/i.test(seg) ? seg : (job.tracer || 'output')), contentType: '', viewUrl: mu, publicUrl: mu });
5110
+ }
4532
5111
  var res = job && job.result;
4533
5112
  if (res && typeof res === 'object' && !Array.isArray(res) && Object.keys(res).length) {
4534
5113
  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) {}
@@ -4581,14 +5160,21 @@ body.vf-has-topbanner.rk-has-sidebar .rk-content{padding-top:var(--vf-topbanner-
4581
5160
  var grid = document.createElement('div'); grid.className = 'rk-aichat-files-grid';
4582
5161
  list.forEach(function (f, ix) {
4583
5162
  var k = fileKind(f.name, f.contentType);
4584
- var card = document.createElement('button'); card.type = 'button'; card.className = 'rk-aichat-file';
5163
+ // A div, not a button: media cards carry a nested "edit in tool" link, and
5164
+ // an anchor inside a button is invalid (and unclickable in some browsers).
5165
+ var card = document.createElement('div'); card.className = 'rk-aichat-file'; card.tabIndex = 0; card.setAttribute('role', 'button');
4585
5166
  var thumb = document.createElement('div'); thumb.className = 'rk-aichat-file-thumb rk-aichat-file-thumb--' + k;
4586
5167
  if (k === 'image') { var im = document.createElement('img'); im.src = f.viewUrl; im.alt = ''; im.loading = 'lazy'; thumb.appendChild(im); }
4587
5168
  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); }
4588
5169
  else { var ic = document.createElement('span'); ic.className = 'rk-aichat-file-ic'; ic.textContent = fileIcon(k); thumb.appendChild(ic); }
5170
+ var edit = toolEditLink(f, 'rk-aichat-file-open');
5171
+ if (edit) { edit.innerHTML = PENCIL_SVG; thumb.appendChild(edit); }
4589
5172
  var nm = document.createElement('div'); nm.className = 'rk-aichat-file-name'; nm.textContent = f.name || 'file'; nm.title = f.name || '';
4590
5173
  card.appendChild(thumb); card.appendChild(nm);
4591
5174
  card.addEventListener('click', function () { openFilePreview(list, ix); });
5175
+ card.addEventListener('keydown', function (e) {
5176
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFilePreview(list, ix); }
5177
+ });
4592
5178
  grid.appendChild(card);
4593
5179
  });
4594
5180
  wrap.appendChild(grid); turn.appendChild(wrap); logBottom();