bareloop 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -140,6 +140,8 @@
140
140
  .dot::before{content:"[·]";color:var(--grey-dot);}
141
141
  .dot.green::before{content:"[✓]";color:var(--green);}
142
142
  .dot.red::before{content:"[✗]";color:var(--red);}
143
+ /* stopped (hamr 2026-10-07): the person pressed Stop — its own sign, same red as [✗] */
144
+ .dot.stopped::before{content:"[■]";color:var(--red);}
143
145
  /* Panel build P3 addition (2026-09-27, "yes, go"): the part cards below the
144
146
  step map (`partResultGlyph`/`attemptGlyph`) and the chat job card render
145
147
  their result glyph as PLAIN inline text inside a larger string (never
@@ -295,10 +297,6 @@
295
297
  .wf-resumed{color:var(--amber);}
296
298
  .audit-leg-divider{text-align:center;color:var(--text-dim);font-size:12px;margin:10px 0;overflow-wrap:anywhere;}
297
299
  .audit-leg-divider-row td{text-align:center;color:var(--text-dim);font-size:12px;background:var(--panel2);}
298
- /* Resume opens the run's own Job tab: only the two caps turn into inputs, everything else stays read-only */
299
- .resume-job{border:1px dashed var(--border-strong);margin-top:12px;padding:10px;display:grid;gap:8px;}
300
- .resume-cap{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
301
- .resume-cap input{width:7em;max-width:100%;min-height:36px;background:var(--panel);color:var(--text);border:1px solid var(--border-strong);padding:4px 8px;font:inherit;}
302
300
  .summary-box.red{border-color:var(--red);}
303
301
  .summary-box.amber{border-color:var(--amber);}
304
302
  .summary-box.magenta{border-color:var(--magenta);}
@@ -313,9 +311,28 @@
313
311
  .imported-box .summary-row{flex-wrap:wrap;}
314
312
  .imported-box .sum-val{flex:1 1 180px;min-width:0;overflow-wrap:anywhere;}
315
313
 
316
- .map-box{border:1px solid var(--border-strong);padding:10px;overflow-x:auto;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
314
+ .map-box{border:1px solid var(--border-strong);padding:10px;margin-top:16px;margin-bottom:14px;background:var(--panel2);}
317
315
  .map-box::before{content:"┤ MAP ├";display:block;font-size:11px;color:var(--text-faint);letter-spacing:0.08em;margin:-2px 0 8px 0;}
318
- .map-box svg{display:block;max-width:100%;height:auto;}
316
+ /* ruling 1A: chips as wide as their own text, side by side, wrapping — a name is never truncated */
317
+ .map-chips{display:flex;flex-wrap:wrap;gap:8px 4px;align-items:flex-start;padding-left:1.4em;}
318
+ /* ruling A: a step + its trailing arrow are ONE unit (the arrow never wraps alone); the lead arrow of a wrapped line
319
+ hangs in the container's left padding (absolute, zero layout impact — toggling it can never move a chip) */
320
+ .map-item{position:relative;display:inline-flex;align-items:center;gap:4px;max-width:100%;}
321
+ .map-arrow{color:var(--text-dim);font-size:13px;flex:none;}
322
+ .map-lead{display:none;position:absolute;left:-1.3em;top:50%;transform:translateY(-50%);color:var(--text-dim);font-size:13px;}
323
+ .map-item.line-start .map-lead{display:block;}
324
+ .chip-retry{color:var(--text-dim);font-size:11px;}
325
+ .map-chip{display:inline-block;width:auto;max-width:100%;min-width:0;box-sizing:border-box;font:inherit;font-size:12px;line-height:1.4;text-align:left;padding:4px 8px;border:2px solid var(--border-strong);border-radius:0;background:var(--panel2);color:var(--text-faint);white-space:normal;overflow-wrap:anywhere;}
326
+ button.map-chip{cursor:pointer;}
327
+ .map-chip.s-done{border-color:var(--green);color:var(--text);} .map-chip.s-done .chip-sign{color:#2e7d32;}
328
+ .map-chip.s-running{border-color:var(--amber);color:var(--text);} .map-chip.s-running .chip-sign{color:#b8860b;}
329
+ .map-chip.s-stopped{border-color:var(--red);color:var(--text);} .map-chip.s-stopped .chip-sign{color:var(--red);}
330
+ .map-chip.s-died{border-color:var(--magenta);color:var(--text);} .map-chip.s-died .chip-sign{color:var(--magenta);}
331
+ .map-chip.retry{border-style:dashed;}
332
+ .map-chip.resumed-next{border-right-style:dotted;}
333
+ .map-chip .chip-blocked{color:var(--red);font-size:10px;}
334
+ .map-chip.s-running .chip-sign{animation:chip-pulse 1.2s infinite;}
335
+ @keyframes chip-pulse{0%,100%{opacity:1}50%{opacity:0.3}}
319
336
  .map-key{display:flex;gap:14px;font-size:11px;color:var(--text-dim);margin-top:6px;flex-wrap:wrap;}
320
337
 
321
338
  .step-card{position:relative;border:1px solid var(--border);padding:10px;margin-bottom:8px;background:var(--panel2);}
@@ -374,6 +391,9 @@
374
391
  .job-card{position:relative;border:1px solid var(--border-strong);padding:14px 10px 10px;background:var(--panel2);margin-top:10px;margin-bottom:10px;}
375
392
  .job-card h4{margin:0 0 8px;font-size:13px;position:absolute;top:-10px;left:10px;background:var(--panel2);padding:0 6px;}
376
393
  .job-card .card-clear{position:absolute;top:-13px;right:10px;padding:1px 8px;font-size:11px;line-height:1.4;background:var(--panel2);}
394
+ .job-card .origin-line{font-size:12px;padding:6px 8px;border-left:3px solid var(--amber);background:var(--panel);color:var(--text-dim);margin-bottom:6px;overflow-wrap:anywhere;}
395
+ .job-card .origin-line.res{border-color:var(--accent, var(--text));}
396
+ .job-card .chat-resume-box{display:grid;gap:8px;margin-top:10px;}
377
397
  .job-card h4::before{content:"┤ ";color:var(--text-faint);}
378
398
  .job-card h4::after{content:" ├";color:var(--text-faint);}
379
399
  .field{margin-bottom:10px;}
@@ -533,6 +553,7 @@
533
553
  <h4 id="job-card-title">Job card — draft</h4>
534
554
  <button class="btn card-clear" type="button" id="chat-clear-btn" data-testid="chat-clear">Clear</button>
535
555
  <div class="hint" id="chat-startfrom-note" data-testid="startfrom-note" hidden></div>
556
+ <div class="origin-line" id="chat-origin-line" data-testid="origin-line" hidden></div>
536
557
 
537
558
  <div class="field">
538
559
  <label id="jf-verdict-label">Check type</label>
@@ -598,6 +619,11 @@
598
619
  </div>
599
620
  </div>
600
621
  <div class="hint" id="chat-card-error" data-testid="chat-card-error"></div>
622
+ <div id="chat-resume-box" class="chat-resume-box" data-testid="resume-box" hidden>
623
+ <div class="hint" id="chat-resume-spent" data-testid="resume-spent"></div>
624
+ <div class="ended-actions"><button class="btn primary" type="button" id="chat-resume-sign" data-testid="btn-sign-resume">Sign &amp; resume</button> <button class="btn" type="button" id="chat-resume-cancel" data-testid="btn-resume-cancel">Cancel</button></div>
625
+ <div class="resume-err" id="chat-resume-err" data-testid="resume-err" hidden></div>
626
+ </div>
601
627
  </div>
602
628
 
603
629
  <div class="chat-thread" id="chat-thread" data-testid="chat-thread"></div>
@@ -606,7 +632,7 @@
606
632
  animated dots, then a check or a cross with its code-owned reason; chat turns stay in the thread above it, right over the ask box. -->
607
633
  <ol class="chat-steps" id="chat-progress-row" data-testid="chat-steps" aria-live="polite" hidden></ol>
608
634
 
609
- <!-- build item 1 — a failed/rejected send, revise, sign-prepare,
635
+ <!-- build item 1 — a failed/rejected send, revise (reopen), sign-prepare,
610
636
  sign or check-deps POST shows here and STAYS shown: it is set
611
637
  only by this tab's own action handlers below and is never
612
638
  touched by renderActions()/poll() (which own #chat-card-error
@@ -616,10 +642,12 @@
616
642
 
617
643
  <div class="chat-input-row">
618
644
  <label for="chat-msg" style="display:none">Message</label>
619
- <textarea id="chat-msg" rows="2" placeholder="Ask for a change, or reply to the plan…" data-testid="chat-msg"></textarea>
645
+ <textarea id="chat-msg" rows="2" placeholder="Answer here when the draft asks you a question…" data-testid="chat-msg"></textarea>
620
646
  </div>
621
647
  <button class="btn primary wide" type="button" id="chat-main-btn" data-testid="chat-main" disabled>Start drafting</button>
622
648
  <div class="hint" id="chat-main-hint" data-testid="chat-main-hint"></div>
649
+ <!-- hamr 2026-10-09: Revise = reopen the boxes, filled as drafted, and draft again. Shown only while a plan waits for the OK. -->
650
+ <button class="btn wide" type="button" id="chat-reopen-btn" data-testid="chat-reopen" hidden>Revise</button>
623
651
  <div class="hint">Only your click signs. The chat can't.</div>
624
652
  </section>
625
653
 
@@ -723,8 +751,8 @@
723
751
  <div class="field"><label>Check type</label><div class="ro-value" id="details-verdict">—</div></div>
724
752
  <div class="field"><label>Model</label><div class="ro-value" id="details-model">—</div></div>
725
753
  <div class="field-row">
726
- <div class="field"><label id="details-cap-money-label">$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
727
- <div class="field"><label id="details-cap-time-label">Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
754
+ <div class="field"><label>$ cap</label><div class="ro-value" id="details-cap-money">—</div></div>
755
+ <div class="field"><label>Time cap</label><div class="ro-value" id="details-cap-time">—</div></div>
728
756
  </div>
729
757
  <div class="field" id="details-description-field"><label>Description</label><div class="ro-value" id="details-description">—</div></div>
730
758
  <div class="field"><label>Goal</label><div class="ro-value scroll" id="details-goal">—</div></div>
@@ -734,7 +762,6 @@
734
762
  <div class="field"><label>Destination</label><div class="ro-value" id="details-dest">—</div></div>
735
763
  <div class="field"><label>Tools</label><div class="ro-value" id="details-tools">—</div></div>
736
764
  <div class="hint" id="job-note"></div>
737
- <div id="resume-job" class="resume-job" data-testid="resume-job" hidden></div>
738
765
  </div>
739
766
  </section>
740
767
 
@@ -863,7 +890,6 @@
863
890
  btn.setAttribute("aria-selected", "true");
864
891
  var panel = document.getElementById(btn.getAttribute("aria-controls"));
865
892
  if(panel){ panel.hidden = false; panel.classList.add("active"); }
866
- if(btn.id === "tab-run"){ reflowMap(); }
867
893
  });
868
894
  });
869
895
  }
@@ -878,33 +904,16 @@
878
904
  });
879
905
  }
880
906
 
881
- // ---- step map renderer (verbatim algorithm from design/panel-mockup.html's
882
- // buildStepMapSVG — ONE renderer, no second hand-authored map anywhere on this page) ----
883
- function stepMapColors(state){
884
- if(state === "done") return {stroke:"var(--green)", textFill:"#2e7d32"};
885
- if(state === "running") return {stroke:"var(--amber)", textFill:"#b8860b"};
886
- if(state === "stopped") return {stroke:"var(--red)", textFill:"var(--red)"};
887
- // died (hamr's ruling B, 2026-09-25): distinct from stopped/red — no
888
- // verdict was ever reached, so this box never shares the red look.
889
- if(state === "died") return {stroke:"var(--magenta)", textFill:"var(--magenta)"};
890
- return {stroke:"var(--border-strong)", textFill:"var(--text-faint)"};
891
- }
907
+ // ---- step map renderer (buildStepMapHTML below — ONE renderer, no second hand-authored map anywhere on this page) ----
892
908
  function escapeXml(s){
893
909
  return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
894
910
  }
895
- var stepMapUidCounter = 0;
896
- var MAP_MARGIN = 10, MAP_GAP = 28;
897
- var CHAR_W = 7.2;
898
- var TITLE_HPAD = 16;
899
- var MIN_BOX_W = 100;
900
- var BOX_H = 60, BOX_H_2LINE = 78;
901
- var ROW_GAP = 70;
902
911
  // s.noNumber (F196): a synthetic placeholder box (e.g. "died during
903
912
  // planning") is a LABEL, never a real step — it renders without the
904
913
  // leading "<n> " step number a real step gets.
905
914
  // item 4 (2026-09-25): each step box shows its attempts INLINE, e.g.
906
915
  // "attempt 1 [✗] · attempt 2 [✓]" — appended to the box's own title text
907
- // (the existing wrapTitleLines 2-line wrap handles overflow the same way
916
+ // (the chip wraps long text inside itself the same way
908
917
  // it already does for a long step id; no third renderer, no new box shape).
909
918
  // item 1 (2026-09-27, panel P2 defect): an attempt's own outcome is null
910
919
  // in exactly two situations — its window is still open on a LIVE run (the
@@ -929,9 +938,9 @@
929
938
  if(g === "✗") return '<span class="glyph-red">' + g + '</span>';
930
939
  return g;
931
940
  }
932
- // PLAIN TEXT ONLY — feeds the SVG step map's title/width calculation
933
- // (`stepTitleText`, an SVG <text> node), never innerHTML; a colour span
934
- // here would render as literal "<span>" characters inside the map.
941
+ // PLAIN TEXT ONLY — feeds the step map chip label (`stepTitleText`, escaped
942
+ // before it goes into the chip); a colour span here would render as literal
943
+ // "<span>" characters inside the map.
935
944
  // {@link glyphSpan} (the P3 colour addition) is used only by the DOM
936
945
  // part-card renderer below, never by this function.
937
946
  function attemptsInlineText(attempts, boxIsLive){
@@ -1036,224 +1045,102 @@
1036
1045
  function boxStatusText(box){
1037
1046
  return box.status && box.status.word ? "[" + box.status.sign + "] " + boxStatusWord(box) : boxStatusWord(box);
1038
1047
  }
1039
- function naturalBoxWidth(steps){
1048
+ // Ruling 1A (hamr, 2026-10-09): the step map is a row of CHIPS — each box as wide as its own text, flowing
1049
+ // side by side and wrapping to the next line. Plain HTML + CSS (.map-chips / .map-chip): the browser does the
1050
+ // wrapping, so there is no width arithmetic, no resize observer, and no horizontal scroll at phone width. A name
1051
+ // is never truncated (it wraps inside its chip when it is longer than the line).
1052
+ // Marks that were line styles on the old SVG are now border styles: a retried step has a DASHED border, and a step
1053
+ // whose leg ended there (the run resumed after it) has a DOTTED right edge. The sign + word come from
1054
+ // boxStatusText (src/panel/status.js's table) — the chip shows "[sign title]" and keeps the word in its tooltip.
1055
+ function buildStepMapHTML(steps){
1040
1056
  var numIdx = stepNumberIndices(steps);
1041
- var maxChars = 0;
1042
- steps.forEach(function(s, idx){ maxChars = Math.max(maxChars, stepTitleText(numIdx[idx], s).length); });
1043
- return Math.max(MIN_BOX_W, Math.ceil(maxChars * CHAR_W) + TITLE_HPAD);
1044
- }
1045
- function computeMapLayout(n, availW, naturalW){
1046
- if(!availW) return {perRow:n, boxW:naturalW};
1047
- var usableW = availW - MAP_MARGIN * 2;
1048
- for(var perRow = n; perRow >= 2; perRow--){
1049
- var totalW = perRow * naturalW + (perRow - 1) * MAP_GAP;
1050
- if(totalW <= usableW){
1051
- var stretched = (usableW - (perRow - 1) * MAP_GAP) / perRow;
1052
- return {perRow:perRow, boxW:Math.max(naturalW, stretched)};
1053
- }
1054
- }
1055
- return {perRow:1, boxW:Math.max(MIN_BOX_W, usableW)};
1056
- }
1057
- function wrapTitleLines(text, maxWidth){
1058
- var maxChars = Math.max(1, Math.floor((maxWidth - TITLE_HPAD) / CHAR_W));
1059
- if(text.length <= maxChars) return [text];
1060
- var words = text.split(' ');
1061
- var line1 = '', i = 0;
1062
- for(; i < words.length; i++){
1063
- var cand = line1 ? line1 + ' ' + words[i] : words[i];
1064
- if(line1 && cand.length > maxChars) break;
1065
- line1 = cand;
1057
+ var last = steps.length - 1;
1058
+ var body = steps.map(function(s, idx){
1059
+ var sign = s.status && s.status.sign ? s.status.sign : "";
1060
+ var tries = boxRetryTry(s);
1061
+ var cls = "map-chip s-" + String(s.state).replace(/[^a-z-]/g, "") +
1062
+ (tries ? " retry" : "") + (s.resumedNext ? " resumed-next" : "");
1063
+ var label = escapeXml(stepTitleText(numIdx[idx], s));
1064
+ var blocked = s.blocked > 0 ? ' <span class="chip-blocked">' + s.blocked + ' blocked</span>' : '';
1065
+ var retry = tries ? '<span class="chip-retry">\u21bb' + tries + '</span> ' : '';
1066
+ var inner = '[' + retry + '<span class="chip-sign">' + escapeXml(sign) + '</span> ' + label + blocked + ']';
1067
+ var tip = ' title="' + escapeXml(boxStatusText(s)) + '"';
1068
+ var attrs = ' class="' + cls + '"' + tip + (s.resumedNext ? ' data-resumed="1"' : '') + (tries ? ' data-retry="' + tries + '"' : '');
1069
+ var chip = typeof s.partIndex === "number"
1070
+ ? '<button type="button"' + attrs + ' data-part-index="' + s.partIndex + '">' + inner + '</button>'
1071
+ : '<span' + attrs + '>' + inner + '</span>';
1072
+ return '<span class="map-item" role="listitem">' +
1073
+ (idx > 0 ? '<span class="map-lead" aria-hidden="true">\u2192</span>' : '') +
1074
+ chip + (idx < last ? '<span class="map-arrow" aria-hidden="true">\u2192</span>' : '') + '</span>';
1075
+ }).join("");
1076
+ return '<div class="map-chips" role="list" aria-label="step map">' + body + '</div>';
1077
+ }
1078
+ // Ruling A: the first chip of each WRAPPED line shows a leading arrow. CSS cannot know which chip starts a line, so
1079
+ // after render/resize this compares each item's offsetTop with the previous item's (same line = same top). The lead
1080
+ // arrow is absolutely positioned (no layout impact), so toggling the class can never reflow a chip — no loop.
1081
+ // Takes anything with offsetTop + classList (real elements, or stubs in tests).
1082
+ function markLineStarts(items){
1083
+ for(var i = 0; i < items.length; i++){
1084
+ var starts = i > 0 && items[i].offsetTop > items[i - 1].offsetTop;
1085
+ if(starts) items[i].classList.add("line-start"); else items[i].classList.remove("line-start");
1066
1086
  }
1067
- if(!line1) return [text.slice(0, maxChars), text.slice(maxChars)];
1068
- var line2 = words.slice(i).join(' ');
1069
- return line2 ? [line1, line2] : [line1];
1070
1087
  }
1071
- // ONE shared rule for "does this box draw a retry loop, and what try number" — a multi-attempt
1088
+ // ONE shared rule for "does this box draw a retry mark, and what try number" — a multi-attempt
1072
1089
  // box (a re-run step, or the fix loop; `run` on an old-shape spine falls in here too, naturally,
1073
- // never special-cased) gets a dashed self-loop labelled with its final try number; anything
1074
- // else (incl. every no-verdict kind, whose attempts is already forced to [] upstream) gets 0.
1090
+ // never special-cased) gets a dashed border; anything else (incl. every no-verdict kind, whose
1091
+ // attempts is already forced to [] upstream) gets 0.
1075
1092
  function boxRetryTry(box){
1076
1093
  var attempts = Array.isArray(box.attempts) ? box.attempts : [];
1077
1094
  return attempts.length > 1 ? attempts.length : 0;
1078
1095
  }
1079
- // item 2 (2026-09-27): the ONE place a retry loop's x-coordinate (curve
1080
- // start/control/end, and the "try N" label) is kept inside the box that
1081
- // owns it — MIN_BOX_W (100) is comfortably wider than 2*RETRY_INSET, so
1082
- // this never inverts the clamp range.
1083
- var RETRY_INSET = 4;
1084
- function clampRetryX(v, boxX, boxW){
1085
- var lo = boxX + RETRY_INSET, hi = boxX + boxW - RETRY_INSET;
1086
- return Math.min(hi, Math.max(lo, v));
1087
- }
1088
- function buildStepMapSVG(steps, availWidth){
1089
- var n = steps.length;
1090
- var naturalW = naturalBoxWidth(steps);
1091
- var layout = computeMapLayout(n, availWidth, naturalW);
1092
- var perRow = layout.perRow, boxW = layout.boxW, gap = MAP_GAP;
1093
- var rows = Math.ceil(n / perRow);
1094
- var margin = MAP_MARGIN;
1095
- var numIdx = stepNumberIndices(steps);
1096
- var titleLines = steps.map(function(s, idx){ return wrapTitleLines(stepTitleText(numIdx[idx], s), boxW); });
1097
- var maxLines = titleLines.reduce(function(m, l){ return Math.max(m, l.length); }, 1);
1098
- var boxH = maxLines > 1 ? BOX_H_2LINE : BOX_H;
1099
- var rowH = boxH + ROW_GAP;
1100
- var totalW = margin * 2 + perRow * boxW + (perRow - 1) * gap;
1101
- var totalH = rows * rowH;
1102
- var uid = "sm" + (stepMapUidCounter++);
1103
- var body =
1104
- '<defs>' +
1105
- '<marker id="' + uid + '-arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
1106
- '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-dim)"></path></marker>' +
1107
- '<marker id="' + uid + '-arrow-grey" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">' +
1108
- '<path d="M0,0 L6,3 L0,6 z" fill="var(--text-faint)"></path></marker>' +
1109
- '</defs>';
1110
- steps.forEach(function(s, idx){
1111
- var row = Math.floor(idx / perRow);
1112
- var col = idx % perRow;
1113
- var rtl = (row % 2) === 1;
1114
- var slotCol = rtl ? (perRow - 1 - col) : col;
1115
- var x = margin + slotCol * (boxW + gap);
1116
- var y = 30 + row * rowH;
1117
- var colors = stepMapColors(s.state);
1118
- var lines = titleLines[idx];
1119
- var nextRow = idx < n - 1 ? Math.floor((idx + 1) / perRow) : null;
1120
- var hasDrop = nextRow !== null && nextRow !== row;
1121
- var stateY = y + boxH - 18;
1122
- var partIdxAttr = typeof s.partIndex === "number" ? (' data-part-index="' + s.partIndex + '" style="cursor:pointer;"') : '';
1123
- body += '<rect x="' + x + '" y="' + y + '" width="' + boxW + '" height="' + boxH +
1124
- '" rx="0" fill="var(--panel2)" stroke="' + colors.stroke + '" stroke-width="2"' + partIdxAttr + '></rect>';
1125
- if(s.blocked > 0){
1126
- body += '<text x="' + (x + boxW - 6) + '" y="' + (y + 12) +
1127
- '" text-anchor="end" font-size="10" fill="var(--red)">' + s.blocked + ' blocked</text>';
1128
- }
1129
- if(lines.length === 1){
1130
- body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 25) +
1131
- '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[0]) + '</text>';
1132
- } else {
1133
- body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 17) +
1134
- '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[0]) + '</text>';
1135
- body += '<text x="' + (x + boxW / 2) + '" y="' + (y + 29) +
1136
- '" text-anchor="middle" font-size="12" fill="var(--text)">' + escapeXml(lines[1]) + '</text>';
1137
- }
1138
- body += '<text x="' + (x + boxW / 2) + '" y="' + stateY +
1139
- '" text-anchor="middle" font-size="10" fill="' + colors.textFill + '">' + escapeXml(boxStatusText(s)) + '</text>';
1140
- // P2 (2026-09-27 ruling): a running box gets the mockup's pulsing
1141
- // amber dot (design/panel-mockup.html ~line 1648) — same fixed color
1142
- // and animation, never re-derived from the state's own stroke color.
1143
- if(s.state === "running"){
1144
- body += '<circle cx="' + (x + 20) + '" cy="' + (y + 12) + '" r="4" fill="#b8860b">' +
1145
- '<animate attributeName="opacity" values="1;0.3;1" dur="1.2s" repeatCount="indefinite"></animate></circle>';
1146
- }
1147
- var retryTry = boxRetryTry(s);
1148
- if(retryTry){
1149
- // dashed self-loop under the box, same proportions as the design mockup's curve,
1150
- // scaled to this box's own width. When this same box also carries the row-to-row
1151
- // snake drop (which leaves from dead-center, x=cx), shift the loop's start away from
1152
- // center by a fixed pixel offset so the two never begin at the same point.
1153
- // item 2 (2026-09-27): at a narrow box width, subtracting that same
1154
- // fixed pixel shift from proportionally-small x-fractions (e.g. the
1155
- // curve's own endpoint at 2.8% of boxW) can push the coordinate past
1156
- // the box's own left edge — the fixed shift was sized for desktop
1157
- // widths and never scaled down with boxW. clampRetryX is the ONE
1158
- // place every retry x-coordinate (curve start/control/end AND the
1159
- // "try N" label) is kept inside [x + RETRY_INSET, x + boxW -
1160
- // RETRY_INSET] — never re-clamped ad hoc per coordinate — so the
1161
- // whole loop and its label always stay inside the box that owns it,
1162
- // at every width, while keeping the mockup's proportions untouched
1163
- // wherever they already fit (every desktop-width box already fits).
1164
- var retryShift = hasDrop ? 14 : 0;
1165
- var bottom = y + boxH;
1166
- var sx = clampRetryX(x + boxW * 0.50 - retryShift, x, boxW);
1167
- var sy = bottom + 5;
1168
- var c1x = clampRetryX(x + boxW * 0.278 - retryShift, x, boxW), c1y = bottom + 30;
1169
- var c2x = clampRetryX(x + boxW * 0.167 - retryShift, x, boxW), c2y = bottom + 30;
1170
- var ex = clampRetryX(x + boxW * 0.028 - retryShift, x, boxW), ey = bottom + 2;
1171
- var labelX = clampRetryX(x + boxW * 0.267 - retryShift, x, boxW);
1172
- body += '<path d="M ' + sx + ' ' + sy + ' C ' + c1x + ' ' + c1y + ', ' + c2x + ' ' + c2y + ', ' + ex + ' ' + ey +
1173
- '" fill="none" stroke="var(--text-faint)" stroke-width="1.5" stroke-dasharray="3,3" marker-end="url(#' + uid + '-arrow-grey)"></path>';
1174
- body += '<text x="' + labelX + '" y="' + (bottom + 32) +
1175
- '" text-anchor="middle" font-size="10" fill="var(--text-faint)">try ' + retryTry + '</text>';
1176
- }
1177
- if(idx < n - 1){
1178
- // P5-R: where a leg ended and the next leg picked up, the connector is DOTTED (and says so) —
1179
- // one run, one map; the dotted line is the only mark of the resume.
1180
- var dash = s.resumedNext ? ' stroke-dasharray="2,4" data-resumed="1"' : '';
1181
- if(nextRow === row){
1182
- var midY = y + boxH / 2;
1183
- if(!rtl){
1184
- var nx = x + boxW + gap;
1185
- body += '<line x1="' + (x + boxW) + '" y1="' + midY + '" x2="' + (nx - 2) + '" y2="' + midY +
1186
- '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1187
- } else {
1188
- var px = x - gap - boxW;
1189
- body += '<line x1="' + x + '" y1="' + midY + '" x2="' + (px + boxW + 2) + '" y2="' + midY +
1190
- '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1191
- }
1192
- } else {
1193
- var cx = x + boxW / 2;
1194
- body += '<line x1="' + cx + '" y1="' + (y + boxH) + '" x2="' + cx + '" y2="' + (y + rowH - 2) +
1195
- '" stroke="var(--text-dim)" stroke-width="2"' + dash + ' marker-end="url(#' + uid + '-arrow)"></line>';
1196
- }
1197
- }
1198
- });
1199
- return '<svg width="' + totalW + '" height="' + totalH + '" viewBox="0 0 ' + totalW + ' ' + totalH + '" role="img" aria-label="step map">' + body + '</svg>';
1200
- }
1201
- // the line-style key under the MAP (hamr 2026-10-04): only the two line styles, never a sign legend
1096
+ // the line-style key under the MAP (hamr 2026-10-04): only the two border styles, never a sign legend
1202
1097
  function stepMapKeyHTML(steps){
1203
1098
  var resumed = (steps || []).some(function(b){ return b.resumedNext; });
1204
1099
  return '<div class="map-key" data-testid="map-key">' +
1205
- '<span>⤾ dashed = retry</span>' +
1206
- (resumed ? '<span>&middot;&middot;&middot; dotted = resumed</span>' : '') +
1100
+ '<span>\u21bbN = took N tries (dashed box)</span>' +
1101
+ (resumed ? '<span>dotted edge = resumed after</span>' : '') +
1207
1102
  '</div>';
1208
1103
  }
1209
- var lastSteps = null;
1210
- var lastWidth = null;
1211
- function mapAvailWidth(wrapper){
1212
- if(!wrapper) return null;
1213
- var w = wrapper.getBoundingClientRect().width;
1214
- if(!w) return null;
1215
- return Math.max(0, w - 20);
1216
- }
1217
1104
  function renderStepMap(steps){
1218
1105
  var mount = document.getElementById("map-mount");
1219
1106
  var wrapper = document.getElementById("map-box");
1220
- lastSteps = steps;
1221
1107
  if(!steps || !steps.length){
1222
1108
  mount.innerHTML = "";
1223
1109
  wrapper.hidden = true;
1224
1110
  return;
1225
1111
  }
1226
1112
  wrapper.hidden = false;
1227
- var w = mapAvailWidth(wrapper);
1228
- lastWidth = w;
1229
- mount.innerHTML = buildStepMapSVG(steps, w) + stepMapKeyHTML(steps);
1230
- // build item B: a map box click jumps to the Audit tab, that same part
1113
+ mount.innerHTML = buildStepMapHTML(steps) + stepMapKeyHTML(steps);
1114
+ layoutMap();
1115
+ // build item B: a map chip click jumps to the Audit tab, that same part
1231
1116
  // expanded and scrolled into view — same click-through as the card list,
1232
- // sharing partIndex so the two can never disagree about which row a box
1233
- // maps to. Delegated once per render (mount.innerHTML above already
1234
- // wiped any previous listeners' targets along with the old markup).
1235
- mount.querySelectorAll("rect[data-part-index]").forEach(function(rect){
1236
- rect.addEventListener("click", function(){
1237
- var idx = Number(rect.getAttribute("data-part-index"));
1117
+ // sharing partIndex so the two can never disagree about which row a chip
1118
+ // maps to. Bound once per render (mount.innerHTML above already wiped any
1119
+ // previous listeners' targets along with the old markup).
1120
+ mount.querySelectorAll(".map-chip[data-part-index]").forEach(function(chip){
1121
+ chip.addEventListener("click", function(){
1122
+ var idx = Number(chip.getAttribute("data-part-index"));
1238
1123
  document.getElementById("tab-audit").click();
1239
1124
  openAuditPart(idx);
1240
1125
  });
1241
1126
  });
1242
1127
  }
1243
- function reflowMap(){
1244
- var wrapper = document.getElementById("map-box");
1245
- if(!wrapper || wrapper.hidden || !lastSteps) return;
1246
- var w = mapAvailWidth(wrapper);
1247
- if(w === null || w === lastWidth) return;
1248
- lastWidth = w;
1249
- document.getElementById("map-mount").innerHTML = buildStepMapSVG(lastSteps, w) + stepMapKeyHTML(lastSteps);
1128
+ function layoutMap(){
1129
+ var box = document.getElementById("map-box");
1130
+ if(!box || box.hidden) return;
1131
+ markLineStarts(Array.prototype.slice.call(box.querySelectorAll(".map-item")));
1132
+ }
1133
+ var layoutTimer = null;
1134
+ function layoutMapSoon(){
1135
+ if(layoutTimer) clearTimeout(layoutTimer);
1136
+ layoutTimer = setTimeout(function(){ layoutTimer = null; layoutMap(); }, 60);
1250
1137
  }
1251
1138
  if(typeof ResizeObserver !== "undefined"){
1252
- var ro = new ResizeObserver(function(){ reflowMap(); });
1253
- var mb = document.getElementById("map-box");
1254
- if(mb) ro.observe(mb);
1139
+ var mapRo = new ResizeObserver(layoutMapSoon);
1140
+ var mapBoxEl = document.getElementById("map-box");
1141
+ if(mapBoxEl) mapRo.observe(mapBoxEl);
1255
1142
  } else {
1256
- window.addEventListener("resize", reflowMap);
1143
+ window.addEventListener("resize", layoutMapSoon);
1257
1144
  }
1258
1145
 
1259
1146
  // F196: a `synthetic` step (e.g. the "died during planning" placeholder
@@ -1267,6 +1154,7 @@
1267
1154
  function glyphClass(g){
1268
1155
  if(g === "✓") return "green";
1269
1156
  if(g === "✗") return "red";
1157
+ if(g === "■") return "stopped";
1270
1158
  if(g === "▶") return "amber";
1271
1159
  if(g === "?") return "magenta";
1272
1160
  return "grey";
@@ -1499,6 +1387,17 @@
1499
1387
  // is a normal, expected action.
1500
1388
  var wfExpanded = {};
1501
1389
 
1390
+ // hamr 2026-10-08 (display only, nothing deleted): with NO search/filter active an expanded job shows at most its
1391
+ // latest WF_SUB_CAP other runs (g.runs is latest-first). A selected older run beyond the cap (picked from History) is
1392
+ // appended after them rather than hidden. With search/filters active every matching sub-card shows.
1393
+ var WF_SUB_CAP = 7;
1394
+ function capSubRuns(childRuns, active, selectedRunid){
1395
+ if(active || childRuns.length <= WF_SUB_CAP) return {shown: childRuns, more: false};
1396
+ var shown = childRuns.slice(0, WF_SUB_CAP);
1397
+ var sel = selectedRunid ? childRuns.slice(WF_SUB_CAP).filter(function(r){ return r.runid === selectedRunid; }) : [];
1398
+ return {shown: shown.concat(sel), more: true};
1399
+ }
1400
+
1502
1401
  function renderWorkflows(jobs){
1503
1402
  var el = document.getElementById("wf-list");
1504
1403
  el.innerHTML = "";
@@ -1585,7 +1484,15 @@
1585
1484
  if(expanded){
1586
1485
  var runsWrap = document.createElement("div");
1587
1486
  runsWrap.className = "wf-runs";
1588
- childRuns.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1487
+ var capped = capSubRuns(childRuns, active, olderSelected);
1488
+ capped.shown.forEach(function(r){ runsWrap.appendChild(buildRunRowEl(r, true)); });
1489
+ if(capped.more){
1490
+ var olderHint = document.createElement("div");
1491
+ olderHint.className = "hint";
1492
+ olderHint.setAttribute("data-testid", "wf-older-" + g.job);
1493
+ olderHint.textContent = "older: use search.";
1494
+ runsWrap.appendChild(olderHint);
1495
+ }
1589
1496
  wrap.appendChild(runsWrap);
1590
1497
  }
1591
1498
  el.appendChild(wrap);
@@ -1654,7 +1561,8 @@
1654
1561
  var q = (filters && filters.search) || "";
1655
1562
  return (runs || []).filter(function(r){
1656
1563
  if(ct.length && ct.indexOf(r.checkType) === -1) return false;
1657
- if(rs.length && rs.indexOf(r.glyph) === -1) return false;
1564
+ // a stopped run ([■]) was a [✗] before hamr's 2026-10-07 sign ruling — the fail chip still finds it
1565
+ if(rs.length && rs.indexOf(r.glyph) === -1 && !(r.glyph === "\u25A0" && rs.indexOf("\u2717") !== -1)) return false;
1658
1566
  if(!matchesSearch(r.job, r.runid, r.model, q, r.runNo)) return false;
1659
1567
  if(time !== "all"){
1660
1568
  var days = time === "7d" ? 7 : 30;
@@ -1966,7 +1874,6 @@
1966
1874
  clearSelection();
1967
1875
  currentRunid = null;
1968
1876
  runIsLive = false;
1969
- resumeMode = null;
1970
1877
  currentImportId = id;
1971
1878
  document.querySelectorAll("#import-list .wf-row").forEach(function(r){
1972
1879
  var on = r.getAttribute("data-testid") === "import-row-" + id;
@@ -2363,11 +2270,9 @@
2363
2270
  // The sentences are CODE-OWNED on the server (`endedFor`, src/panel/server.js) — this only
2364
2271
  // paints them. `detail.ended` is null while a run is live (no block while running).
2365
2272
  var lastEndedSig = null;
2366
- // P5-R (hamr 2026-10-02): Resume opens the run's OWN Job tab. There ONLY the money cap and the time cap are
2367
- // editable; every other field is the signed job, shown read-only exactly as the tab already shows it. The click
2368
- // on [Sign & resume] is the signature when a cap changed. A resume is the SAME run: no new card, no new job.
2369
- var resumeMode = null;
2370
- var lastJobShown = null;
2273
+ // Resume (P5-R, hamr 2026-10-02: opened the run's own Job tab) MOVED to the left Chat card (hamr 2026-10-07): the card
2274
+ // in RESUME mode, only the money cap and the time cap open (applyResume / signResume). The Job tab is read-only on
2275
+ // every run — values only, no inputs, no buttons. A resume is the SAME run: no new card, no new job.
2371
2276
  function renderEnded(detail){
2372
2277
  var box = document.getElementById("ended-block");
2373
2278
  var e = detail.ended;
@@ -2384,7 +2289,7 @@
2384
2289
  '<div class="ended-row"><span class="ended-label">NEXT</span><span class="ended-text" data-testid="ended-next">' + escapeXml(e.next) + '</span></div>';
2385
2290
  }
2386
2291
  // ---- P5 item 5: the run's ACTION AREA (Run tab, above the counters) — Stop while the run is live, Resume once
2387
- // it ended in a way the engine would accept (the SAME button as the Ended block's, same Job-tab resume mode).
2292
+ // it ended in a way the engine would accept (opens the Chat card's resume mode), Edit in chat on every non-green ending.
2388
2293
  // The Ended block (Audit tab) is text only and carries no buttons.
2389
2294
  var stopAsked = {};
2390
2295
  // Reuse workflow (replaces P5 item 3's Start from this) — ask the server for the prefill (the signed job), then
@@ -2397,6 +2302,33 @@
2397
2302
  document.dispatchEvent(new CustomEvent("bareloop-reuse", {detail: {runid: runid, prefill: r}}));
2398
2303
  }).catch(function(x){ window.alert("could not reach the panel: " + x.message); });
2399
2304
  }
2305
+ // Edit in chat (hamr 2026-10-07, option A) — the same prefill door as Reuse workflow (`start-from`, the ONE reader of a
2306
+ // run's signed job), but the Chat card opens with EVERY box open and the normal [Start drafting] path: it drafts a new job.
2307
+ // The origin line is code-owned fixed text; run name and status word come from the same helpers the run header uses.
2308
+ function editInChat(detail){
2309
+ var origin = "Copied from " + detail.job + " (" + runLabel(detail.runNo, detail.runid) + ") \u2014 " +
2310
+ ((detail.status && detail.status.word) || "ended") +
2311
+ ". Every box is open. Change anything and it drafts as a new job; change nothing and it is another run of the same job.";
2312
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(detail.runid)).then(function(r){
2313
+ document.getElementById("tab-chat").click();
2314
+ document.dispatchEvent(new CustomEvent("bareloop-edit", {detail: (r && r.ok) ? {runid: detail.runid, prefill: r, origin: origin} : {error: (r && r.error) || "nothing to copy"}}));
2315
+ }).catch(function(x){
2316
+ document.getElementById("tab-chat").click();
2317
+ document.dispatchEvent(new CustomEvent("bareloop-edit", {detail: {error: "could not reach the panel: " + x.message}}));
2318
+ });
2319
+ }
2320
+ // Resume (hamr 2026-10-07, option B — moved from the run's Job tab): the Chat card opens in RESUME mode, filled from the
2321
+ // SAME prefill (`start-from`), everything locked but the two caps. The card posts /api/runs/<id>/resume; a resume never
2322
+ // drafts and never makes a new card, job or run id (one run = one id).
2323
+ function resumeInChat(detail){
2324
+ authorGet("/api/author/start-from?runid=" + encodeURIComponent(detail.runid)).then(function(r){
2325
+ document.getElementById("tab-chat").click();
2326
+ document.dispatchEvent(new CustomEvent("bareloop-resume", {detail: (r && r.ok) ? {runid: detail.runid, prefill: r, resume: detail.resume || {}, title: detail.job + " (" + runLabel(detail.runNo, detail.runid) + ")"} : {error: (r && r.error) || "nothing to resume"}}));
2327
+ }).catch(function(x){
2328
+ document.getElementById("tab-chat").click();
2329
+ document.dispatchEvent(new CustomEvent("bareloop-resume", {detail: {error: "could not reach the panel: " + x.message}}));
2330
+ });
2331
+ }
2400
2332
  function renderRunActions(detail){
2401
2333
  var bar = document.getElementById("run-actions");
2402
2334
  var html = "";
@@ -2412,6 +2344,10 @@
2412
2344
  var canResume = !!detail.resume && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "resume"; });
2413
2345
  if(canResume) html = '<button class="btn primary" type="button" data-testid="btn-resume-run">Resume</button>';
2414
2346
  }
2347
+ // Edit in chat: every non-green ending (the server's Ended block says so) — a died run included
2348
+ if(!live && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "edit"; })){
2349
+ html += '<button class="btn" type="button" data-testid="btn-edit-chat">Edit in chat</button>';
2350
+ }
2415
2351
  // Reuse workflow: on a GREEN run only (the server's Ended block says so) — a new run of the same signed job
2416
2352
  if(!live && !detail.died && !!detail.ended && (detail.ended.actions || []).some(function(a){ return a.id === "reuse"; })){
2417
2353
  html += '<button class="btn" type="button" data-testid="btn-reuse">Reuse workflow</button>';
@@ -2420,6 +2356,8 @@
2420
2356
  bar.hidden = html === "";
2421
2357
  var reuseBtn = bar.querySelector('[data-testid="btn-reuse"]');
2422
2358
  if(reuseBtn) reuseBtn.addEventListener("click", function(){ reuseWorkflow(detail.runid); });
2359
+ var editBtn = bar.querySelector('[data-testid="btn-edit-chat"]');
2360
+ if(editBtn) editBtn.addEventListener("click", function(){ editInChat(detail); });
2423
2361
  var stopBtn = bar.querySelector('[data-testid="btn-stop"]');
2424
2362
  if(stopBtn){
2425
2363
  stopBtn.addEventListener("click", function(){
@@ -2431,82 +2369,7 @@
2431
2369
  });
2432
2370
  }
2433
2371
  var resBtn = bar.querySelector('[data-testid="btn-resume-run"]');
2434
- if(resBtn) resBtn.addEventListener("click", function(){ openResumeOnJobTab(detail); });
2435
- }
2436
- function openResumeOnJobTab(detail){
2437
- var r = detail.resume || {};
2438
- resumeMode = {
2439
- runid: detail.runid, spentUsd: r.spentUsd, spendComplete: r.spendComplete,
2440
- wallUsedMs: r.wallUsedMs,
2441
- budget: (r.budgetUsd === null || r.budgetUsd === undefined) ? "" : String(r.budgetUsd),
2442
- wall: typeof r.maxWallMin === "number" ? String(r.maxWallMin) : null,
2443
- painted: false,
2444
- };
2445
- document.getElementById("tab-details").click();
2446
- paintResumeMode();
2447
- }
2448
- // Turns the Job tab's two cap values into inputs (and shows the Sign box) while a resume is open on this run;
2449
- // called again after every Job re-render so a re-render never drops the typed numbers.
2450
- function paintResumeMode(){
2451
- var box = document.getElementById("resume-job");
2452
- var moneyLabel = document.getElementById("details-cap-money-label");
2453
- var timeLabel = document.getElementById("details-cap-time-label");
2454
- if(!resumeMode || resumeMode.runid !== currentRunid){
2455
- box.hidden = true; box.innerHTML = "";
2456
- moneyLabel.textContent = "$ cap"; timeLabel.textContent = "Time cap";
2457
- return;
2458
- }
2459
- var m = resumeMode;
2460
- // the unit lives in the caption, never as loose text around the input
2461
- moneyLabel.textContent = "Money cap ($)";
2462
- timeLabel.textContent = "Time cap (min)";
2463
- document.getElementById("details-cap-money").innerHTML =
2464
- '<span class="resume-cap"><input type="number" min="0" step="0.01" id="resume-budget" data-testid="resume-budget" value="' + escapeXml(m.budget) + '"></span>';
2465
- if(m.wall !== null){
2466
- document.getElementById("details-cap-time").innerHTML =
2467
- '<span class="resume-cap"><input type="number" min="1" step="1" id="resume-wall" data-testid="resume-wall" value="' + escapeXml(m.wall) + '"></span>';
2468
- }
2469
- var budgetEl = document.getElementById("resume-budget");
2470
- budgetEl.addEventListener("input", function(){ m.budget = budgetEl.value; });
2471
- var wallEl = document.getElementById("resume-wall");
2472
- if(wallEl) wallEl.addEventListener("input", function(){ m.wall = wallEl.value; });
2473
- box.hidden = false;
2474
- if(m.painted) return;
2475
- m.painted = true;
2476
- var spent = (m.spentUsd === null || m.spentUsd === undefined) ? "unknown" : ((m.spendComplete ? "" : "at least ") + panelMoney(m.spentUsd));
2477
- box.innerHTML =
2478
- '<div data-testid="resume-intro">Resume run ' + escapeXml(m.runid) + ' &mdash; the same run, not a new one. Only the money cap and the time cap can change; everything else is the signed job.</div>' +
2479
- '<div class="hint" data-testid="resume-spent">spent so far ' + escapeXml(spent) +
2480
- (typeof m.wallUsedMs === "number" && isFinite(m.wallUsedMs) ? ' &middot; time used so far ' + Math.ceil(m.wallUsedMs / 60000) + ' min' : '') + '</div>' +
2481
- '<div class="ended-actions"><button class="btn primary" type="button" data-testid="btn-sign-resume">Sign &amp; resume</button> ' +
2482
- '<button class="btn" type="button" data-testid="btn-resume-cancel">Cancel</button></div>' +
2483
- '<div class="resume-err" id="resume-err" data-testid="resume-err" hidden></div>';
2484
- var resumeSignBtn = box.querySelector('[data-testid="btn-sign-resume"]');
2485
- box.querySelector('[data-testid="btn-resume-cancel"]').addEventListener("click", function(){
2486
- resumeMode = null;
2487
- renderJob(lastJobShown);
2488
- });
2489
- resumeSignBtn.addEventListener("click", function(){
2490
- var err = document.getElementById("resume-err");
2491
- err.hidden = true;
2492
- resumeSignBtn.disabled = true;
2493
- var body = {budgetUsd: document.getElementById("resume-budget").value};
2494
- var wall = document.getElementById("resume-wall");
2495
- if(wall){ body.maxWallMin = wall.value; }
2496
- return authorPost("/api/runs/" + encodeURIComponent(m.runid) + "/resume", body).then(function(res){
2497
- if(res.body && res.body.ok){
2498
- // the SAME run goes on: back to its Run tab, which follows it until it is live again
2499
- resumeMode = null;
2500
- renderJob(lastJobShown);
2501
- document.getElementById("tab-run").click();
2502
- afterResumeRefresh(m.runid, 8);
2503
- } else {
2504
- resumeSignBtn.disabled = false;
2505
- err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
2506
- err.hidden = false;
2507
- }
2508
- }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
2509
- });
2372
+ if(resBtn) resBtn.addEventListener("click", function(){ resumeInChat(detail); });
2510
2373
  }
2511
2374
  // After a resume starts, the open run's own page follows it: re-select once the run reads live again (the spine's
2512
2375
  // new leg appears a moment after the engine starts), a few tries, never forever.
@@ -2600,7 +2463,7 @@
2600
2463
  " &middot; " + wallPhrase;
2601
2464
 
2602
2465
  var box = document.getElementById("run-summary");
2603
- box.className = "summary-box " + (detail.died ? "magenta" : (detail.glyph === "✗" ? "red" : (detail.glyph === "▶" ? "amber" : "")));
2466
+ box.className = "summary-box " + (detail.died ? "magenta" : ((detail.glyph === "✗" || detail.glyph === "■") ? "red" : (detail.glyph === "▶" ? "amber" : "")));
2604
2467
  // F196: a synthetic placeholder box (e.g. "died during planning") is a
2605
2468
  // LABEL, not a real step — excluded from this count so a run that died
2606
2469
  // before any step started reads "0 of 0 done" / "none started", never
@@ -2679,7 +2542,7 @@
2679
2542
 
2680
2543
  // ONE spelling of a step's state: its dot + the bold state word — read by the Run tab's step cards AND the Audit tab's grouped rows
2681
2544
  function stepStateHTML(box, testid){
2682
- var cls = box.state === "done" ? "green" : (box.state === "stopped" ? "red" : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2545
+ var cls = box.state === "done" ? "green" : (box.state === "stopped" ? (box.status && box.status.sign === "\u25A0" ? "stopped" : "red") : (box.state === "running" ? "amber" : (box.state === "died" ? "magenta" : "grey")));
2683
2546
  return '<span class="step-state" data-testid="' + testid + '"><span class="dot ' + cls + '"></span><b class="st-word">' + escapeXml(boxStatusWord(box)) + '</b></span>';
2684
2547
  }
2685
2548
  // build item C: Audit tab attempt row -> its rounds (server-built,
@@ -3269,7 +3132,6 @@
3269
3132
  function renderJob(job){
3270
3133
  var empty = document.getElementById("job-empty");
3271
3134
  var card = document.getElementById("job-card-readonly");
3272
- lastJobShown = job;
3273
3135
  if(!job){ empty.hidden = false; card.hidden = true; return; }
3274
3136
  empty.hidden = true;
3275
3137
  card.hidden = false;
@@ -3292,7 +3154,6 @@
3292
3154
  document.getElementById("job-note").textContent = job.note || "";
3293
3155
  lastJobToolsList = Array.isArray(job.toolsList) ? job.toolsList : null;
3294
3156
  paintOfferedRow();
3295
- paintResumeMode();
3296
3157
  }
3297
3158
 
3298
3159
  // ---- selection + loading ----
@@ -3312,7 +3173,6 @@
3312
3173
  document.querySelectorAll(siblingSelector).forEach(function(r){ r.classList.remove("selected"); r.setAttribute("aria-pressed","false"); });
3313
3174
  if(rowEl){ rowEl.classList.add("selected"); rowEl.setAttribute("aria-pressed","true"); }
3314
3175
  currentRunid = runid;
3315
- resumeMode = null;
3316
3176
  lastLegDividers = [];
3317
3177
  lastJobToolsList = null;
3318
3178
  lastRunDetailSig = null;
@@ -3696,6 +3556,7 @@
3696
3556
  var msgInput = document.getElementById("chat-msg");
3697
3557
  var mainBtn = document.getElementById("chat-main-btn");
3698
3558
  var mainHint = document.getElementById("chat-main-hint");
3559
+ var reopenBtn = document.getElementById("chat-reopen-btn");
3699
3560
  var mainAction = "none";
3700
3561
  var lastState = null;
3701
3562
  var startOk = false;
@@ -3731,32 +3592,39 @@
3731
3592
  var autoSigned = false;
3732
3593
  var sfLine = document.getElementById("chat-startfrom-line");
3733
3594
  var sfNote = document.getElementById("chat-startfrom-note");
3595
+ var originLine = document.getElementById("chat-origin-line");
3734
3596
  // the locked boxes of a reuse card: the signed workflow, greyed and not editable (the server refuses a change anyway)
3735
3597
  var LOCKED_IDS = ["jf-name", "jf-goal", "jf-success", "jf-guardrails", "jf-judge"];
3736
3598
  // hamr 2026-10-05: while a drafting session is live NOTHING on the card is editable (the session was started from
3737
3599
  // these values). The reuse rule (only Source/Destination/caps open) applies before the start. ONE function owns it.
3738
3600
  var OPEN_IDS = ["jf-source", "jf-dest", "jf-cap-money", "jf-cap-time", "jf-model"];
3739
3601
  var reuseOn = false;
3602
+ // Resume mode (hamr 2026-10-07): the card shows the signed job of one run, every box locked except the two caps.
3603
+ // `resumeRun` = {runid, noTime} while it is on, else null. Never set together with a drafting session or a reuse pick.
3604
+ var resumeRun = null;
3605
+ var CAP_IDS = ["jf-cap-money", "jf-cap-time"];
3740
3606
  function syncCardLock(){
3741
3607
  var live = sessionLive;
3608
+ var resuming = resumeRun !== null;
3742
3609
  var setLock = function(id, on){ var el = document.getElementById(id); el.readOnly = on; el.classList.toggle("locked", on); };
3743
- LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live); });
3744
- OPEN_IDS.forEach(function(id){ setLock(id, live); });
3745
- document.getElementById("jf-model").disabled = live;
3746
- // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); only a live session freezes them
3747
- document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live; });
3610
+ LOCKED_IDS.forEach(function(id){ setLock(id, reuseOn || live || resuming); });
3611
+ OPEN_IDS.forEach(function(id){ setLock(id, live || (resuming && CAP_IDS.indexOf(id) === -1)); });
3612
+ document.getElementById("jf-model").disabled = live || resuming;
3613
+ // the Check type radios stay clickable on a reuse card (Deterministic / Rubric = leave reuse); a live session or a resume freezes them
3614
+ document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.disabled = live || resuming; });
3748
3615
  document.getElementById("job-card").classList.toggle("reuse", reuseOn);
3749
- if(!reuseOn && !live){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3616
+ if(!reuseOn && !live && !resuming){ var ck = document.querySelector('input[name="jf-verdict"]:checked'); document.getElementById("jf-judge").disabled = !ck || ck.value !== "rubric"; }
3750
3617
  }
3751
3618
  function setReuseLocked(on){ reuseOn = on; syncCardLock(); }
3752
- function clearStartFrom(){
3753
- // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes
3754
- if(startFrom){
3619
+ function clearStartFrom(keepBoxes){
3620
+ // Clear after a reuse starts a genuinely new job: the reused values do not linger in the boxes — except when a Check type
3621
+ // radio click leaves reuse (keepBoxes): a radio never blanks what the person has on the card
3622
+ if(startFrom && !keepBoxes){
3755
3623
  ["jf-name", "jf-goal", "jf-source", "jf-dest", "jf-success", "jf-guardrails", "jf-judge", "jf-cap-money", "jf-cap-time"].forEach(function(id){ document.getElementById(id).value = ""; });
3756
3624
  document.querySelectorAll('input[name="jf-verdict"]').forEach(function(r){ r.checked = (r.value === "deterministic"); });
3757
3625
  }
3758
3626
  startFrom = null; reuseSession = false; autoSigned = false; reuseCheckType = "deterministic";
3759
- sfLine.hidden = true; sfNote.hidden = true;
3627
+ sfLine.hidden = true; sfNote.hidden = true; originLine.hidden = true;
3760
3628
  setReuseLocked(false);
3761
3629
  renderPicker();
3762
3630
  renderMain();
@@ -3807,6 +3675,7 @@
3807
3675
  function renderReuseList(){
3808
3676
  var q = rwQ.value.trim();
3809
3677
  var all = rwJobs || [];
3678
+ var loading = rwJobs === null && !rwFailed;
3810
3679
  rwShown = all.filter(function(j){ return j.job.toLowerCase().indexOf(q.toLowerCase()) !== -1; });
3811
3680
  if(rwHl >= rwShown.length) rwHl = 0;
3812
3681
  if(rwShown.length){
@@ -3815,7 +3684,7 @@
3815
3684
  }).join("");
3816
3685
  } else {
3817
3686
  rwList.innerHTML = '<div class="rw-empty" data-testid="rw-empty">' + (rwFailed ? "Could not load the job list."
3818
- : (all.length === 0 ? "No green jobs yet." : 'No green job matches "' + escapeXml(q) + '".')) + "</div>";
3687
+ : loading ? "Loading\u2026" : (all.length === 0 ? "No green jobs yet." : 'No green job matches "' + escapeXml(q) + '".')) + "</div>";
3819
3688
  }
3820
3689
  }
3821
3690
  function loadReuseJobs(){
@@ -3826,9 +3695,10 @@
3826
3695
  }).catch(function(){ rwFailed = true; rwJobs = null; renderReuseList(); });
3827
3696
  }
3828
3697
  // the empty search state: card emptied, nothing picked, Sign & run disabled until a job is picked
3698
+ // (the boxes are NOT cleared here: clicking the Reuse radio never blanks typing; only a pick fills the card)
3829
3699
  function openPicker(){
3830
- clearCardBoxes();
3831
3700
  rwQ.value = ""; rwHl = 0;
3701
+ rwJobs = null; rwFailed = false; // a fresh open shows "Loading…", never the previous list
3832
3702
  document.getElementById("jf-judge").disabled = true;
3833
3703
  renderPicker();
3834
3704
  renderReuseList();
@@ -3854,8 +3724,22 @@
3854
3724
  openPicker();
3855
3725
  }
3856
3726
  document.getElementById("rw-change").addEventListener("click", unpickReuse);
3727
+ // leaving resume mode: the title, the hidden drafting chrome, the time box and the resume box all come back
3728
+ function exitResumeMode(){
3729
+ resumeRun = null;
3730
+ originLine.classList.remove("res");
3731
+ document.getElementById("job-card-title").textContent = "Job card \u2014 draft";
3732
+ document.getElementById("chat-resume-box").hidden = true;
3733
+ document.getElementById("chat-resume-err").hidden = true;
3734
+ document.getElementById("jf-cap-time").parentNode.hidden = false;
3735
+ document.getElementById("chat-clear-btn").hidden = false;
3736
+ ["chat-main-btn", "chat-main-hint"].forEach(function(id){ document.getElementById(id).hidden = false; });
3737
+ document.querySelector(".chat-input-row").hidden = false;
3738
+ }
3857
3739
  function openNewCard(){
3858
3740
  card.hidden = false;
3741
+ originLine.hidden = true;
3742
+ exitResumeMode();
3859
3743
  thread.innerHTML = "";
3860
3744
  errEl.textContent = "";
3861
3745
  actionErrEl.textContent = "";
@@ -3961,12 +3845,11 @@
3961
3845
  // the limit changed in Settings: re-read the note for whatever cap is typed
3962
3846
  document.addEventListener("bareloop-limit-changed", refreshCapNote);
3963
3847
  // Check type: Deterministic / Rubric / Reuse workflow. Reuse opens the picker on an empty card; Deterministic or
3964
- // Rubric while the picker is showing (picked or not) LEAVES reuse — the empty card on that type (clearStartFrom).
3848
+ // Rubric while the picker is showing (picked or not) LEAVES reuse — boxes untouched (clearStartFrom(true)).
3965
3849
  function onVerdictChange(v){
3966
3850
  if(v === "reuse"){ if(rwBox.hidden) openPicker(); return; }
3967
3851
  if(!rwBox.hidden){
3968
- clearStartFrom();
3969
- clearCardBoxes();
3852
+ clearStartFrom(true);
3970
3853
  setVerdict(v);
3971
3854
  renderPicker();
3972
3855
  }
@@ -4126,26 +4009,20 @@
4126
4009
  }
4127
4010
 
4128
4011
  // hamr's ruling 2026-10-05: ONE wide button below the message box. Its wording, action and enabled state are ONE
4129
- // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk, revisesLeft,
4130
- // signClicked, specHash}. Actions: start | check-deps | send | revise | sign-prepare | sign | none.
4012
+ // pure function of (state, typed-text-empty, reuse); state = {session, startOk, phase, pendingAsk,
4013
+ // signClicked, specHash}. Actions: start | check-deps | send | sign-prepare | sign | none.
4131
4014
  function mainButtonFor(st, textEmpty, reuse){
4132
4015
  var ask = st.pendingAsk;
4133
4016
  var atMenu = !!ask && ask.kind === "menu";
4134
- var left = st.revisesLeft || 0;
4135
- var hint = atMenu ? left + (left === 1 ? " change left" : " changes left") : "";
4017
+ var hint = "";
4136
4018
  if(!st.session){
4137
4019
  return {text: reuse ? "Sign & run" : "Start drafting", action: "start", disabled: !st.startOk, hint: ""};
4138
4020
  }
4139
4021
  if(ask && ask.kind === "install-needed"){
4140
4022
  return {text: "Check again", action: "check-deps", disabled: false, hint: ""};
4141
4023
  }
4142
- if(ask && !textEmpty){
4143
- return atMenu
4144
- ? {text: "Send", action: "revise", disabled: left <= 0, hint: hint}
4145
- : {text: "Send", action: "send", disabled: false, hint: ""};
4146
- }
4147
4024
  if(ask && !atMenu){
4148
- return {text: "Send", action: "send", disabled: true, hint: ""};
4025
+ return {text: "Send", action: "send", disabled: textEmpty, hint: ""};
4149
4026
  }
4150
4027
  if(atMenu || st.phase === "prepared"){
4151
4028
  if(st.phase === "prepared" && st.signClicked){
@@ -4155,16 +4032,21 @@
4155
4032
  }
4156
4033
  return {text: "Sign & run", action: "none", disabled: true, hint: ""};
4157
4034
  }
4158
- // pure: the ask box takes typed text only when the session has a pending ask that accepts it (the plan menu -> a
4159
- // change request, or any question); not with no session, no ask, or while waiting on an install
4035
+ // pure: the ask box takes typed text only when the session has a pending ask that accepts it (a question the plan
4036
+ // raised); not with no session, no ask, while the plan waits for the OK (that is Sign & run or Revise, never typed
4037
+ // text), or while waiting on an install
4160
4038
  function askBoxOpenFor(st){
4161
- return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed";
4039
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind !== "install-needed" && st.pendingAsk.kind !== "menu";
4040
+ }
4041
+ // pure: Revise shows only while a plan waits for the OK
4042
+ function reviseShownFor(st){
4043
+ return !!st.session && !!st.pendingAsk && st.pendingAsk.kind === "menu";
4162
4044
  }
4163
4045
  function renderMain(){
4164
4046
  var st = {session: sessionLive, startOk: startOk, signClicked: signClickedOnce};
4165
4047
  if(sessionLive && lastState){
4166
4048
  st.phase = lastState.phase; st.pendingAsk = lastState.pendingAsk;
4167
- st.revisesLeft = lastState.revisesLeft; st.specHash = lastState.specHash;
4049
+ st.specHash = lastState.specHash;
4168
4050
  }
4169
4051
  // hamr 2026-10-05: the ask box is open only while something asks for a reply; otherwise dimmed and emptied
4170
4052
  var open = askBoxOpenFor(st);
@@ -4175,6 +4057,7 @@
4175
4057
  mainBtn.disabled = b.disabled;
4176
4058
  mainAction = b.action;
4177
4059
  mainHint.textContent = b.hint;
4060
+ reopenBtn.hidden = !reviseShownFor(st);
4178
4061
  fitBox(msgInput);
4179
4062
  fitCardBoxes();
4180
4063
  }
@@ -4314,7 +4197,7 @@
4314
4197
  poll();
4315
4198
  }
4316
4199
  function reattachLive(){
4317
- if(sessionId || sessionLive) return;
4200
+ if(sessionId || sessionLive || resumeRun !== null) return;
4318
4201
  authorGet("/api/author/live").then(function(j){
4319
4202
  if(sessionId || sessionLive || !j || !j.ok || !j.sessionId || !j.state) return;
4320
4203
  attachSession(j);
@@ -4356,13 +4239,97 @@
4356
4239
  }
4357
4240
  document.addEventListener("bareloop-reuse", function(e){ applyReuse(e.detail || {}); });
4358
4241
 
4242
+ // Edit in chat — the card opens filled from the SIGNED job with EVERY box open; the button stays the normal
4243
+ // [Start drafting] (startFrom / reuse state are never set: no borrowed track record). A live drafting session is
4244
+ // handled exactly as applyReuse does (clearStartFrom + openNewCard). A refused prefill opens nothing, error only.
4245
+ function applyEdit(d){
4246
+ if(d.error){ errEl.textContent = d.error; return; }
4247
+ var c = (d.prefill || {}).card || {};
4248
+ clearStartFrom();
4249
+ openNewCard();
4250
+ setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4251
+ if(c.model){ modelSelect.value = c.model; }
4252
+ setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4253
+ setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4254
+ setVal("jf-judge", c.judgeExamples); setVal("jf-cap-money", c.capUsd);
4255
+ setVal("jf-cap-time", typeof c.maxWallMs === "number" ? Math.round(c.maxWallMs / 60000) : "");
4256
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4257
+ originLine.textContent = d.origin || "";
4258
+ originLine.hidden = !d.origin;
4259
+ renderPicker();
4260
+ refreshModelStatus();
4261
+ refreshCapNote();
4262
+ refreshStartEnabled();
4263
+ }
4264
+ document.addEventListener("bareloop-edit", function(e){ applyEdit(e.detail || {}); });
4265
+
4266
+ // Resume — the card in RESUME mode: the signed job filled and locked, only Cap $ and Time cap open (prefilled from the
4267
+ // engine's own resume plan, the values the Job tab used to show). A live drafting session is handled exactly as
4268
+ // applyReuse does. A refused prefill opens nothing, error only.
4269
+ function applyResume(d){
4270
+ if(d.error){ errEl.textContent = d.error; return; }
4271
+ var c = (d.prefill || {}).card || {};
4272
+ var r = d.resume || {};
4273
+ clearStartFrom();
4274
+ openNewCard();
4275
+ resumeRun = {runid: d.runid, noTime: typeof r.maxWallMin !== "number"};
4276
+ document.getElementById("job-card-title").textContent = "RESUME " + (d.title || d.runid);
4277
+ setVerdict(c.checkType === "rubric" ? "rubric" : "deterministic");
4278
+ if(c.model){ modelSelect.value = c.model; }
4279
+ setVal("jf-name", c.jobName); setVal("jf-goal", c.goal); setVal("jf-source", c.source);
4280
+ setVal("jf-dest", c.destination); setVal("jf-success", c.success); setVal("jf-guardrails", c.guardrails);
4281
+ setVal("jf-judge", c.judgeExamples);
4282
+ setVal("jf-cap-money", r.budgetUsd);
4283
+ setVal("jf-cap-time", resumeRun.noTime ? "" : r.maxWallMin);
4284
+ document.getElementById("jf-cap-time").parentNode.hidden = resumeRun.noTime;
4285
+ document.getElementById("jf-judge").disabled = c.checkType !== "rubric";
4286
+ originLine.textContent = "The same run goes on, not a new one. Only the money cap and the time cap can change; everything else is the signed job.";
4287
+ originLine.classList.add("res");
4288
+ originLine.hidden = false;
4289
+ var spent = (r.spentUsd === null || r.spentUsd === undefined) ? "unknown" : ((r.spendComplete ? "" : "at least ") + panelMoney(r.spentUsd));
4290
+ document.getElementById("chat-resume-spent").textContent = "spent so far " + spent +
4291
+ (typeof r.wallUsedMs === "number" && isFinite(r.wallUsedMs) ? " \u00B7 time used so far " + Math.ceil(r.wallUsedMs / 60000) + " min" : "");
4292
+ document.getElementById("chat-resume-box").hidden = false;
4293
+ document.getElementById("chat-clear-btn").hidden = true;
4294
+ ["chat-main-btn", "chat-main-hint"].forEach(function(id){ document.getElementById(id).hidden = true; });
4295
+ document.querySelector(".chat-input-row").hidden = true;
4296
+ renderPicker();
4297
+ refreshModelStatus();
4298
+ refreshStartEnabled();
4299
+ }
4300
+ document.addEventListener("bareloop-resume", function(e){ applyResume(e.detail || {}); });
4301
+ document.getElementById("chat-resume-cancel").addEventListener("click", function(){ resetCard(); });
4302
+ var resumeSignBtn = document.getElementById("chat-resume-sign");
4303
+ function signResume(){
4304
+ var run = resumeRun;
4305
+ if(run === null) return;
4306
+ var err = document.getElementById("chat-resume-err");
4307
+ err.hidden = true;
4308
+ resumeSignBtn.disabled = true;
4309
+ var body = {budgetUsd: document.getElementById("jf-cap-money").value};
4310
+ if(!run.noTime){ body.maxWallMin = document.getElementById("jf-cap-time").value; }
4311
+ return authorPost("/api/runs/" + encodeURIComponent(run.runid) + "/resume", body).then(function(res){
4312
+ resumeSignBtn.disabled = false;
4313
+ if(res.body && res.body.ok){
4314
+ // the SAME run goes on: the card goes back to empty, the right side to its Run tab, which follows it until it is live again
4315
+ resetCard();
4316
+ document.getElementById("tab-run").click();
4317
+ afterResumeRefresh(run.runid, 8);
4318
+ } else {
4319
+ err.textContent = (res.body && res.body.error) || ("refused (" + res.status + ")");
4320
+ err.hidden = false;
4321
+ }
4322
+ }).catch(function(x){ resumeSignBtn.disabled = false; err.textContent = "could not reach the panel: " + x.message; err.hidden = false; });
4323
+ }
4324
+ resumeSignBtn.addEventListener("click", signResume);
4325
+
4359
4326
  // build item 1 — the "lost answer" fix. ROOT CAUSE: `renderActions`
4360
4327
  // (fed by the every-2s `poll()`) unconditionally overwrites
4361
4328
  // `#chat-card-error` on every tick (`errEl.textContent = state.error ||
4362
- // ""`), including the SAME tick the send/revise/sign handlers below
4329
+ // ""`), including the SAME tick the send/sign handlers below
4363
4330
  // used to call `poll()` in their own `.then` — so a refused/failed POST's
4364
4331
  // error was wiped before a person could read it (often on the very next
4365
- // line of the same callback). Compounding it, `send`/`revise` cleared
4332
+ // line of the same callback). Compounding it, `send` cleared
4366
4333
  // `msgInput.value` unconditionally, even on failure — so a rejected
4367
4334
  // answer vanished from the box with no trace at all. Fixed by: (1) a
4368
4335
  // dedicated `#chat-action-error` element only these handlers touch,
@@ -4423,20 +4390,20 @@
4423
4390
  }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
4424
4391
  }
4425
4392
 
4426
- function doRevise(){
4393
+ // hamr 2026-10-09 (as fwdloop's Revise): the draft is ended and the New job card opens with the SAME values in every
4394
+ // box, all editable; Start drafting then drafts fresh from whatever the boxes now say. No typed change request goes
4395
+ // to the model. The ended draft is an Abandon: money already spent stays booked.
4396
+ function doReopen(){
4427
4397
  if(!sessionId) return;
4428
- var text = msgInput.value;
4429
- authorPost("/api/author/" + sessionId + "/revise", {text: text}).then(function(r){
4398
+ authorPost("/api/author/" + sessionId + "/abandon", {}).then(function(r){
4430
4399
  var o = chatPostOutcome(r);
4431
- if(o.ok){
4432
- chatActionOk();
4433
- msgInput.value = "";
4434
- } else {
4435
- chatActionFailed(o.error);
4436
- }
4437
- poll();
4438
- }).catch(function(){ chatActionFailed("network error — could not reach the panel server, your text was NOT sent"); });
4400
+ if(!o.ok){ chatActionFailed(o.error); return; }
4401
+ openNewCard();
4402
+ chatActionOk();
4403
+ thread.innerHTML = '<div class="msg system">' + escapeXml("The boxes are open as you drafted them. Edit what you want, then Start drafting. The first draft's cost stays counted.") + "</div>";
4404
+ }).catch(function(){ chatActionFailed("network error — could not reach the panel server, not revised"); });
4439
4405
  }
4406
+ reopenBtn.addEventListener("click", doReopen);
4440
4407
 
4441
4408
  function doSign(){
4442
4409
  if(!sessionId) return;
@@ -4468,7 +4435,6 @@
4468
4435
  if(mainAction === "start") doStart();
4469
4436
  else if(mainAction === "check-deps") doCheckDeps();
4470
4437
  else if(mainAction === "send") doSend();
4471
- else if(mainAction === "revise") doRevise();
4472
4438
  else if(mainAction === "sign-prepare" || mainAction === "sign") doSign();
4473
4439
  });
4474
4440