slidemind 0.2.4 → 0.3.1

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.
@@ -14,23 +14,44 @@
14
14
  // alongside window.__SLIDEMIND_RENDER_MD__ by plan.mjs).
15
15
  //
16
16
  // The decision lives in ONE always-visible chat-style composer bar pinned at
17
- // the bottom: a feedback input, a round send button (a "story iteration" pill
18
- // while storyline edits are staged) and — fused to its right — the Approve
19
- // button, whose sub-label says the agent builds the slides next. The previous
20
- // round's feedback shows as a read-only note above the input.
21
- // - send → decision "comments" with the overall comment (plus, when the
22
- // red thread was restructured, a generated story-iteration
23
- // instruction + the edited plan markdown). The tab does NOT
24
- // veil over: the surface locks read-only and the composer bar
25
- // becomes a spinner + wait note while heartbeat polling waits
26
- // for the agent's next `slidemind plan` round, which reloads
27
- // this same tab.
28
- // - Approve → decision "approve" (unsent feedback arm-confirms first),
29
- // then the full-screen brand animation — logo + drifting
30
- // wordmark + "the agent is building your slides" — before the
31
- // tab closes itself.
32
- // Both POST {decision, comment, editedPlan} to the local server so the
33
- // `slidemind plan` command unblocks.
17
+ // the bottom: a scope control (This slide / Whole story), a comment input, a
18
+ // round send button (a "story iteration" pill while storyline edits are staged)
19
+ // and — fused to its right — the Approve button, whose sub-label says the agent
20
+ // builds the slides next. Above it, the LEDGER rail lists every comment of this
21
+ // session with its anchor and status (queued → sent → addressed).
22
+ //
23
+ // TWO MODES, not "reviewing vs. locked out":
24
+ // - PARKED (the command is blocking on this tab): everything is live. Send
25
+ // resolves the gate at once (decision "comments", carrying the anchored
26
+ // comment plus any queued stragglers); Approve resolves it as "approve";
27
+ // direct edits and story iteration are allowed.
28
+ // - WORKING (the agent is revising): the plan stays readable and the strip
29
+ // stays navigable, and the composer stays LIVE — sent comments QUEUE in
30
+ // sessionStorage (ledger status "queued") instead of hitting the server.
31
+ // Direct edits, story iteration and Approve are off (the displayed round is
32
+ // already superseded). A status strip says so.
33
+ //
34
+ // Queued comments are never lost: the tab polls the heartbeat, and when the
35
+ // agent's next `slidemind plan` run claims it the reload's boot POSTs the whole
36
+ // backlog to /plan-review/backlog — which resolves that run immediately as a
37
+ // drain round, so the tab simply stays in working mode with the ledger flipped
38
+ // to "sent". A tab that is superseded by a fresh window flushes its backlog
39
+ // before retiring, for the same reason.
40
+ //
41
+ // A round transition is detected from the SERVER's per-run nonce (CFG.round):
42
+ // a different nonce means a newer `slidemind plan` run served this page. It
43
+ // flips "sent" ledger rows to "addressed", restores the selected tab + the
44
+ // unsent draft, shows a transient banner, and diffs the cached previous plan
45
+ // body per slide to badge the changed tabs "updated" — the body diff is
46
+ // COSMETIC only; it never decides whether a round arrived. The web notification
47
+ // fires ONLY when the arriving round PARKS — drain rounds land silently.
48
+ //
49
+ // A fresh window (no session of its own) seeds its ledger from CFG.ledgerSeed,
50
+ // the deck's persisted plan-review.json round, so the reviewer still sees the
51
+ // comments the agent is currently working.
52
+ //
53
+ // The decision POSTs {decision, comments[], editedPlan} to the local server so
54
+ // the `slidemind plan` command unblocks.
34
55
  //
35
56
  // Branding is lifted verbatim from review-overlay.js: the embossed "raised
36
57
  // slide" logo (inline-SVG fallback when no per-brand orb PNG is bundled), the
@@ -49,17 +70,48 @@
49
70
  var PLAN = window.__SLIDEMIND_PLAN__ || {};
50
71
 
51
72
  var APPROVE_CLOSE_DELAY_MS = 2400; // long enough to read the build animation
52
- var ROUND_READY_FLAG = "slidemind-plan-round-ready"; // sessionStorage marker across the reload
53
- var ATTENTION_TITLE = "● Plan ready — slidemind";
54
73
  var WAITING_TITLE = "⏳ Agent revising — slidemind";
74
+ // The plan body of the round that is on screen, cached for the NEXT round's
75
+ // per-slide diff. Its own key on purpose: it is ~60 KB and never changes
76
+ // during a page's life, so it is written ONCE at boot — the round context
77
+ // (selection, unsent draft) is the small blob a keystroke may rewrite.
78
+ var BODY_KEY = "slidemind-plan-body:" + (CFG.deck || "");
79
+
80
+ // The shared gate session (src/builders/gate-session.js, inlined into the page
81
+ // as window.__SLIDEMIND_GATE__): the deck-keyed sessionStorage ledger, the
82
+ // backlog flush, the server-nonce round transition, the round-ready announce
83
+ // and the heartbeat poll — one implementation, shared with the preview gate.
84
+ var SES = window.__SLIDEMIND_GATE__.createGateSession({
85
+ name: "plan",
86
+ deck: CFG.deck || "",
87
+ storage: sessionStorage,
88
+ fetch: function (u, o) {
89
+ return fetch(u, o);
90
+ },
91
+ round: CFG.round || "",
92
+ service: "slidemind-plan",
93
+ heartbeat: CFG.heartbeat || "/__plan/heartbeat",
94
+ backlogEndpoint: CFG.backlogEndpoint || "/plan-review/backlog",
95
+ pollMs: CFG.pollMs,
96
+ pollTtlMs: CFG.pollTtlMs,
97
+ attentionTitle: "● Plan ready — slidemind",
98
+ notifyTitle: "slidemind — plan ready",
99
+ notifyBody: "The revised plan is ready for your review. Click to jump back to the tab.",
100
+ });
101
+ var BANNER_MS = 7000; // how long the "Updated · Round N" banner stays
55
102
  var BEAT_DRAG_THRESHOLD = 4; // px before a tab press becomes a drag
56
103
  var BLOCK_DRAG_THRESHOLD = 3; // px before a block press becomes a drag
57
104
 
58
105
  // ---------- state ----------
59
106
  var submitted = false;
60
107
  var approveArmed = false; // inline "discard unsent feedback?" confirm state
61
- var waiting = false; // comments round sent → the surface locks read-only
108
+ var working = false; // the agent is revising → edits off, comments queue
62
109
  var baseline = CFG.planBody || ""; // the served plan body; edits diff against it
110
+ var ledger = []; // every comment of this session: {…, status:queued|sent|addressed}
111
+ var roundNo = 1; // this tab's round counter (sessionStorage, not server state)
112
+ var changedSlides = {}; // 1-based tab position → true ("updated" badges this round)
113
+ var bannerText = null; // transient round-arrival banner, null once dismissed
114
+ var commentScope = "slide"; // composer scope control: "slide" | "story"
63
115
 
64
116
  // The single source of truth the whole overlay renders from. `value` is the
65
117
  // frontmatter-less plan body (edits index into it); `selectedIndex` is the
@@ -68,19 +120,21 @@
68
120
  var S = { value: baseline, selectedIndex: 0, storyDraft: null };
69
121
 
70
122
  var feedbackText = ""; // the composer input, kept across composer rebuilds
71
- var pendingComment = null; // resolved comment/editedPlan for the next submit
72
- var pendingEdited = null;
73
123
  var editingKey = null; // the beat whose move text is in rename mode
74
124
  var newSlideCounter = 0; // unique keys for inserted slides
75
125
  var focusAfterRender = null; // an element to focus once a render settles
76
126
 
77
127
  var bar, wsEl, surfaceEl, composerEl, commentEl;
78
- var editedBadge, approveBtn, sendBtn;
79
- var waitTextEl = null;
128
+ var editedBadge, approveBtn, sendBtn, hintEl;
129
+ var workingNote = null; // headline of the working status strip
130
+ var pendingComments = []; // the batch the next decision POST carries
131
+ var pendingEdited = null; // the edited plan markdown riding with it
80
132
  var notifyPermPromise = null; // resolved Notification permission, asked at the send gesture
81
133
 
134
+ // Read-only surface: working mode is the ONLY thing that locks the plan text.
135
+ // Navigation (tab selection) and the composer stay live regardless.
82
136
  function locked() {
83
- return waiting;
137
+ return working;
84
138
  }
85
139
 
86
140
  // ---------- brand assets (identical to review-overlay.js) ----------
@@ -129,8 +183,6 @@
129
183
  var ICONS = {
130
184
  check:
131
185
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>',
132
- ghost:
133
- '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11a9 9 0 0 1 18 0v10l-3-2-3 2-3-2-3 2-3-2-3 2Z"/><path d="M9 11h.01"/><path d="M15 11h.01"/></svg>',
134
186
  send:
135
187
  '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>',
136
188
  sparkles:
@@ -196,7 +248,16 @@
196
248
  ".sm-ws{position:fixed;left:0;right:0;top:var(--deck-chrome-h,62px);bottom:0;z-index:2147483540;" +
197
249
  "display:flex;flex-direction:column;background:var(--sm-bg);}" +
198
250
  ".sm-ws-surface{flex:1;min-height:0;overflow-y:auto;background:var(--sm-bg);padding:22px 22px 30px;}" +
199
- ".sm-ws.is-waiting .sm-ws-surface{pointer-events:none;opacity:.62;}" +
251
+ /* working mode: read-only, NOT locked out — the surface stays scrollable and
252
+ the strip stays clickable; the dashed frame is the read-only signal */
253
+ ".sm-ws.is-waiting .plan-dossier{border-style:dashed;}" +
254
+ /* round-arrival banner */
255
+ ".sm-round-banner{display:flex;align-items:center;gap:9px;max-width:1120px;margin:0 auto 14px;padding:9px 13px;" +
256
+ "border:1px solid color-mix(in srgb,var(--sm-accent) 35%,transparent);border-radius:10px;" +
257
+ "background:color-mix(in srgb,var(--sm-accent) 9%,transparent);color:var(--sm-dim);font-size:12.5px;line-height:1.5;" +
258
+ "animation:sm-rise .45s cubic-bezier(.2,.7,.2,1) both;}" +
259
+ ".sm-round-banner svg{width:15px;height:15px;flex-shrink:0;color:var(--sm-accent);}" +
260
+ ".sm-round-banner b{color:var(--sm-fg);font-weight:600;}" +
200
261
  /* markdown document (Mode A + the storyboard headings share this scale) */
201
262
  ".plan-md-root{max-width:min(1120px,100%);margin:0 auto;line-height:1.65;font-size:15.5px;color:var(--sm-fg);}" +
202
263
  ".plan-md-root--storyboards{max-width:100%;display:flex;flex-direction:column;gap:0;}" +
@@ -254,6 +315,11 @@
254
315
  "font-weight:500;line-height:1.35;-webkit-box-orient:vertical;-webkit-line-clamp:2;}" +
255
316
  ".plan-storyline-beat--active .plan-storyline-beat-text{color:var(--sm-fg);font-weight:600;}" +
256
317
  ".plan-storyline-beat-text[contenteditable=\"true\"]{display:block;border-radius:4px;cursor:text;outline:none;-webkit-line-clamp:unset;}" +
318
+ /* "updated" badge — this slide changed in the round that just arrived */
319
+ ".plan-storyline-beat-updated{position:absolute;top:-6px;left:6px;border-radius:999px;padding:0 6px;" +
320
+ "background:color-mix(in srgb,var(--sm-accent) 22%,var(--sm-bg));color:var(--sm-accent);" +
321
+ "border:1px solid color-mix(in srgb,var(--sm-accent) 45%,transparent);" +
322
+ "font-size:8.5px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;line-height:1.6;}" +
257
323
  ".plan-storyline-gap{position:relative;display:flex;flex:none;align-items:center;justify-content:center;width:22px;}" +
258
324
  ".plan-storyline-gap[data-active=\"true\"]::before{position:absolute;top:18%;bottom:18%;left:50%;width:3px;border-radius:2px;background:var(--plan-story);content:\"\";transform:translateX(-50%);}" +
259
325
  ".plan-storyline-insert{position:relative;display:flex;align-items:center;justify-content:center;width:18px;height:18px;" +
@@ -345,11 +411,26 @@
345
411
  ".sm-story-discard{flex-shrink:0;border:0;background:transparent;color:var(--sm-dim);font-family:var(--sm-font);font-weight:500;" +
346
412
  "font-size:12px;padding:4px 8px;border-radius:6px;cursor:pointer;transition:background .12s,color .12s;}" +
347
413
  ".sm-story-discard:hover{background:var(--sm-muted);color:var(--sm-fg);}" +
348
- ".sm-prev-note{display:flex;align-items:flex-start;gap:8px;font-size:12px;color:var(--sm-dim);line-height:1.5;" +
414
+ /* comment ledger rail (supersedes the old single "previous round" note) */
415
+ ".sm-ledger{display:flex;flex-direction:column;gap:3px;max-height:118px;overflow-y:auto;" +
349
416
  "padding:7px 10px;border:1px dashed #3a3a40;border-radius:9px;background:var(--sm-muted);}" +
350
- ".sm-prev-note svg{width:14px;height:14px;flex-shrink:0;margin-top:2px;color:#71717a;}" +
351
- ".sm-prev-note .lbl{font-weight:600;color:#a1a1aa;white-space:nowrap;}" +
352
- ".sm-prev-note .txt{white-space:pre-wrap;word-break:break-word;color:#b9b9be;}" +
417
+ ".sm-ledger-row{display:flex;align-items:baseline;gap:8px;font-size:12px;line-height:1.45;color:#b9b9be;}" +
418
+ ".sm-ledger-anchor{flex:none;font-weight:600;color:#a1a1aa;white-space:nowrap;}" +
419
+ ".sm-ledger-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}" +
420
+ ".sm-ledger-status{flex:none;border-radius:999px;padding:1px 8px;font-size:10px;font-weight:600;" +
421
+ "letter-spacing:.04em;text-transform:uppercase;border:1px solid var(--sm-border);color:var(--sm-dim);}" +
422
+ ".sm-ledger-status[data-state=\"queued\"]{color:var(--sm-warn);border-color:rgba(246,196,84,.4);}" +
423
+ ".sm-ledger-status[data-state=\"sent\"]{color:var(--sm-accent);border-color:rgba(169,194,255,.4);}" +
424
+ ".sm-ledger-status[data-state=\"addressed\"]{color:var(--sm-success);border-color:rgba(46,204,113,.4);}" +
425
+ /* scope control */
426
+ ".sm-composer-meta{display:flex;align-items:center;gap:9px;font-size:11.5px;color:var(--sm-dim);}" +
427
+ ".sm-scope-label{font-weight:600;letter-spacing:.04em;text-transform:uppercase;font-size:9.5px;}" +
428
+ ".sm-scope{display:inline-flex;gap:2px;padding:2px;border:1px solid var(--sm-border);border-radius:999px;background:#0c0c0e;}" +
429
+ ".sm-scope-btn{border:0;border-radius:999px;padding:3px 11px;background:transparent;color:var(--sm-dim);" +
430
+ "font-family:var(--sm-font);font-size:11.5px;font-weight:500;cursor:pointer;max-width:220px;overflow:hidden;" +
431
+ "text-overflow:ellipsis;white-space:nowrap;transition:background .12s,color .12s;}" +
432
+ ".sm-scope-btn:hover{color:var(--sm-fg);}" +
433
+ ".sm-scope-btn.is-on{background:var(--sm-accent);color:#0f1220;font-weight:600;}" +
353
434
  ".sm-composer-row{display:flex;align-items:flex-end;gap:10px;}" +
354
435
  ".sm-chat-ta{flex:1;min-height:40px;max-height:150px;resize:none;background:#0c0c0e;color:var(--sm-fg);" +
355
436
  "border:1px solid var(--sm-border);border-radius:14px;padding:10px 15px;font:13px/1.5 var(--sm-font);outline:none;transition:border-color .15s;}" +
@@ -443,6 +524,86 @@
443
524
  }
444
525
  }
445
526
 
527
+ // ---------- session (shared with the preview gate via gate-session.js) ------
528
+ function saveLedger() {
529
+ SES.saveLedger(ledger);
530
+ }
531
+ // The small round context the NEXT round restores from: the selected tab and
532
+ // the unsent draft (the server nonce is added by the shared session). The
533
+ // plan body is NOT in here — see BODY_KEY.
534
+ function ctxFields() {
535
+ return { round: roundNo, selectedIndex: S.selectedIndex, draft: feedbackText };
536
+ }
537
+ function saveCtx() {
538
+ SES.saveCtx(ctxFields());
539
+ }
540
+ // Typing path: coalesced, so a keystroke never serializes anything.
541
+ function saveDraftSoon() {
542
+ SES.saveCtxSoon(ctxFields());
543
+ }
544
+ function queuedComments() {
545
+ return SES.queued(ledger);
546
+ }
547
+ function flushBacklog() {
548
+ return SES.flushBacklog(ledger);
549
+ }
550
+
551
+ // Per-slide segment diff between the previous round's plan body and this one,
552
+ // keyed by the slide NUMBER the `### Slide N` heading declares — never by the
553
+ // heading's position. Real decks number non-contiguously (2,3,4,7,8,9), so a
554
+ // positional key badged the wrong tab. Each chunk also STOPS at the next `## `
555
+ // section, so trailing prose (`## Design language`, `## Quellen`) can't be
556
+ // swept into the last slide and badge it for an unrelated edit.
557
+ function slideChunks(md) {
558
+ var out = {};
559
+ String(md || "")
560
+ .split(/^(?=###\s+Slide\b)/m)
561
+ .forEach(function (part) {
562
+ var m = /^###\s+Slide\s+(\d+)/.exec(part);
563
+ if (!m) return;
564
+ // Cut at the next level-2 heading (the chunk's own `###` line is at
565
+ // index 0, so only a later `## ` can match).
566
+ var stop = part.search(/\n##\s+/);
567
+ var body = stop > 0 ? part.slice(0, stop) : part;
568
+ out[Number(m[1])] = body.trim();
569
+ });
570
+ return out;
571
+ }
572
+ function diffSlides(prevBody, nextBody) {
573
+ var before = slideChunks(prevBody);
574
+ var after = slideChunks(nextBody);
575
+ var changed = {};
576
+ for (var no in after) {
577
+ if (before[no] !== after[no]) changed[no] = true;
578
+ }
579
+ return changed;
580
+ }
581
+
582
+ // Restore this tab's session and work out whether THIS load is a new round.
583
+ // Runs before the first render so the surface is built in the right mode.
584
+ function restoreSession() {
585
+ var prevBody = SES.read(BODY_KEY, null);
586
+ var st = SES.restore(CFG.ledgerSeed);
587
+ ledger = st.ledger;
588
+ roundNo = st.roundNo;
589
+ working = st.working;
590
+ if (st.newRound) {
591
+ // Cosmetics only: WHICH slides moved is a content question, so it stays a
592
+ // body diff — the round transition itself came from the server nonce.
593
+ changedSlides = diffSlides(prevBody, baseline);
594
+ bannerText = "Updated · Round " + roundNo;
595
+ }
596
+ // Reviewer context survives the round (invariant 6): selection + draft.
597
+ if (st.ctx) {
598
+ if (typeof st.ctx.selectedIndex === "number") S.selectedIndex = st.ctx.selectedIndex;
599
+ if (typeof st.ctx.draft === "string") feedbackText = st.ctx.draft;
600
+ }
601
+ saveLedger();
602
+ saveCtx();
603
+ // Once per page load — the served body never changes while this page lives.
604
+ SES.write(BODY_KEY, baseline);
605
+ }
606
+
446
607
  // ---------- chrome (top bar) ----------
447
608
  function buildChrome() {
448
609
  bar = el("div", "sm-o sm-bar");
@@ -461,13 +622,8 @@
461
622
  editedBadge.style.display = "none";
462
623
  title.appendChild(editedBadge);
463
624
  meta.appendChild(title);
464
- meta.appendChild(
465
- el(
466
- "div",
467
- "h",
468
- "Shape the red thread and storyboards below · drag to reorder, rename moves, edit blocks · decide when it reads right",
469
- ),
470
- );
625
+ hintEl = el("div", "h");
626
+ meta.appendChild(hintEl);
471
627
  bar.appendChild(meta);
472
628
  bar.appendChild(el("div", "sm-grow"));
473
629
 
@@ -475,15 +631,41 @@
475
631
  }
476
632
  function refreshChrome() {
477
633
  if (editedBadge) editedBadge.style.display = planWasEdited() ? "" : "none";
634
+ if (hintEl) {
635
+ hintEl.textContent = working
636
+ ? "The agent is revising · read on, browse the strip, keep commenting — editing resumes with the next round"
637
+ : "Shape the red thread and storyboards below · drag to reorder, rename moves, edit blocks · decide when it reads right";
638
+ }
478
639
 
479
640
  var hasFeedback = commentText().length > 0;
480
- var canSend = !!S.storyDraft || hasFeedback;
641
+ var canSend = (!working && !!S.storyDraft) || hasFeedback;
481
642
  if (sendBtn) sendBtn.classList.toggle("is-off", !canSend);
482
643
 
483
644
  if (approveBtn) {
484
645
  var main = approveBtn.querySelector(".main span");
485
646
  var sub = approveBtn.querySelector(".sub");
486
- if (S.storyDraft) {
647
+ if (working) {
648
+ // Approve applies to exactly the displayed round; while the agent is
649
+ // revising, that round is already superseded (server refuses it too).
650
+ approveArmed = false;
651
+ approveBtn.classList.remove("is-armed");
652
+ approveBtn.classList.add("is-off");
653
+ if (main) main.textContent = "Approve plan";
654
+ if (sub) {
655
+ sub.style.display = "";
656
+ sub.textContent = "The agent is revising — approve the next round";
657
+ }
658
+ } else if (queuedComments().length) {
659
+ // Only reachable when a backlog flush failed: approving would drop them.
660
+ approveArmed = false;
661
+ approveBtn.classList.remove("is-armed");
662
+ approveBtn.classList.add("is-off");
663
+ if (main) main.textContent = "Approve plan";
664
+ if (sub) {
665
+ sub.style.display = "";
666
+ sub.textContent = "Send your queued comments first";
667
+ }
668
+ } else if (S.storyDraft) {
487
669
  // Approve is blocked until the staged storyline is sent or discarded.
488
670
  approveArmed = false;
489
671
  approveBtn.classList.remove("is-armed");
@@ -583,6 +765,7 @@
583
765
  if (!surfaceEl) return;
584
766
  surfaceEl.innerHTML = "";
585
767
  focusAfterRender = null;
768
+ if (bannerText) surfaceEl.appendChild(roundBanner());
586
769
  var segments = PLAN.splitPlanMarkdownIntoSegments(S.value);
587
770
  var hasStoryboards = segments.some(function (s) {
588
771
  return s.type === "storyboard";
@@ -595,6 +778,28 @@
595
778
  }
596
779
  }
597
780
 
781
+ // Transient "Updated · Round N" banner. It states the round arrived and how
782
+ // many slides changed; the per-tab badges say WHICH. Self-dismisses so it
783
+ // can't be mistaken for a permanent state.
784
+ function roundBanner() {
785
+ var count = Object.keys(changedSlides).length;
786
+ var node = el("div", "sm-round-banner");
787
+ node.innerHTML =
788
+ ICONS.sparkles +
789
+ "<span><b>" +
790
+ bannerText +
791
+ "</b> " +
792
+ (count
793
+ ? count + (count === 1 ? " slide changed" : " slides changed") + " — marked “updated” on the strip."
794
+ : "The plan was revised.") +
795
+ "</span>";
796
+ setTimeout(function () {
797
+ bannerText = null;
798
+ if (node.parentNode) node.parentNode.removeChild(node);
799
+ }, BANNER_MS);
800
+ return node;
801
+ }
802
+
598
803
  function renderModeA(segments) {
599
804
  var root = el("div", "plan-md-root");
600
805
  segments.forEach(function (seg) {
@@ -608,6 +813,28 @@
608
813
  surfaceEl.appendChild(root);
609
814
  }
610
815
 
816
+ // The story beats on the strip: the `## Red thread` moves — but only when
817
+ // they match the boards one-to-one, so a drifted red thread (more moves than
818
+ // slides) never renders phantom slide tabs — otherwise each board's key
819
+ // message (read-only tabs then). Each beat carries the slide number its board
820
+ // declares, which is what a "This slide" comment is anchored to (so the agent
821
+ // reads the same number the reviewer saw).
822
+ function planBeats() {
823
+ var boards = PLAN.splitPlanMarkdownIntoSegments(S.value).filter(function (s) {
824
+ return s.type === "storyboard";
825
+ });
826
+ var messages = PLAN.extractRedThreadMessages(S.value);
827
+ if (messages.length !== boards.length) {
828
+ messages = boards.map(function (seg, i) {
829
+ return (seg.data && seg.data.keyMessage) || "Slide " + ((seg.data && seg.data.slide) || i + 1);
830
+ });
831
+ }
832
+ return messages.map(function (message, i) {
833
+ var data = boards[i] && boards[i].data;
834
+ return { message: message, slide: (data && data.slide) || i + 1 };
835
+ });
836
+ }
837
+
611
838
  // ---------- dossier (storyboard mode) ----------
612
839
  function renderDossier(segments) {
613
840
  var storyboardSegments = segments.filter(function (s) {
@@ -618,14 +845,9 @@
618
845
  return s.data;
619
846
  })
620
847
  .filter(Boolean);
621
- var redThreadMessages = PLAN.extractRedThreadMessages(S.value);
622
- // Tabs need a beat per board even without a `## Red thread` section — fall
623
- // back to each board's key message (read-only tabs then).
624
- var beatMessages = redThreadMessages.length
625
- ? redThreadMessages
626
- : storyboardSegments.map(function (seg, i) {
627
- return (seg.data && seg.data.keyMessage) || "Slide " + ((seg.data && seg.data.slide) || i + 1);
628
- });
848
+ var beatMessages = planBeats().map(function (b) {
849
+ return b.message;
850
+ });
629
851
  var committed = PLAN.backboneFromMessages(beatMessages);
630
852
  var slides = S.storyDraft || committed;
631
853
  var backboneEditable = PLAN.parsePlanBackbone(S.value) !== null;
@@ -828,6 +1050,17 @@
828
1050
  slideNo.textContent = numeral;
829
1051
  li.appendChild(slideNo);
830
1052
 
1053
+ // "updated" badge: this tab's slide changed in the round that just landed.
1054
+ // Keyed by the SLIDE NUMBER shown on the tab (`numeral`, straight off the
1055
+ // board data) — the same source the diff keys by, so non-contiguous decks
1056
+ // (2,3,4,7,8,9) badge the tab that actually moved. A staged storyline
1057
+ // renumbers positionally, so badges are suppressed while one is open.
1058
+ if (!S.storyDraft && changedSlides[numeral]) {
1059
+ var upd = el("span", "plan-storyline-beat-updated", "updated");
1060
+ upd.title = "This slide changed in the latest round";
1061
+ li.appendChild(upd);
1062
+ }
1063
+
831
1064
  var textEl = el("div", "plan-storyline-beat-text");
832
1065
  textEl.setAttribute("role", "textbox");
833
1066
  textEl.textContent = item.message;
@@ -929,8 +1162,12 @@
929
1162
  if (moved) {
930
1163
  if (gap !== null) reorderTo(idx, gap);
931
1164
  } else if (idx !== activeIndex) {
1165
+ // Selection stays live in working mode (reading + navigating are never
1166
+ // blocked) and is remembered for the next round.
932
1167
  S.selectedIndex = idx;
1168
+ saveCtx();
933
1169
  renderSurface();
1170
+ renderComposer(); // the scope control names the newly selected slide
934
1171
  } else if (canEdit) {
935
1172
  editingKey = key;
936
1173
  renderSurface();
@@ -1396,15 +1633,95 @@
1396
1633
  return figure;
1397
1634
  }
1398
1635
 
1636
+ // ---------- comment ledger ----------
1637
+ // The anchor a comment written right now would carry. "This slide" reads the
1638
+ // SELECTED strip tab; without a storyboard strip there is nothing to anchor
1639
+ // to, so everything is whole-story.
1640
+ function currentAnchor() {
1641
+ var beats = planBeats();
1642
+ if (!beats.length || commentScope !== "slide") {
1643
+ return { scope: "story", slideIndex: null, slideHeading: null };
1644
+ }
1645
+ var i = clamp(S.selectedIndex, 0, beats.length - 1);
1646
+ return { scope: "slide", slideIndex: beats[i].slide, slideHeading: beats[i].message };
1647
+ }
1648
+ function newId() {
1649
+ try {
1650
+ return crypto.randomUUID();
1651
+ } catch {
1652
+ return "c-" + Date.now() + "-" + Math.random().toString(16).slice(2);
1653
+ }
1654
+ }
1655
+ function newComment(text, forceScope) {
1656
+ var a = forceScope === "story" ? { scope: "story", slideIndex: null, slideHeading: null } : currentAnchor();
1657
+ return {
1658
+ id: newId(),
1659
+ round: roundNo,
1660
+ scope: a.scope,
1661
+ slideIndex: a.slideIndex,
1662
+ slideHeading: a.slideHeading,
1663
+ text: text,
1664
+ at: new Date().toISOString(),
1665
+ status: "queued",
1666
+ };
1667
+ }
1668
+ function anchorLabel(c) {
1669
+ if (c.scope !== "slide") return "Whole story";
1670
+ return "Slide " + (c.slideIndex == null ? "?" : c.slideIndex);
1671
+ }
1672
+ function statusLabel(c) {
1673
+ return SES.statusLabel(c);
1674
+ }
1675
+ // The rail: every comment of this session, newest last, with its anchor and
1676
+ // where it stands. This is what replaced the single "previous round" note —
1677
+ // it survives rounds and shows the queue the reviewer built up.
1678
+ function ledgerRail() {
1679
+ var rail = el("div", "sm-ledger");
1680
+ ledger.forEach(function (c) {
1681
+ var row = el("div", "sm-ledger-row");
1682
+ row.appendChild(el("span", "sm-ledger-anchor", anchorLabel(c)));
1683
+ var txt = el("span", "sm-ledger-text");
1684
+ txt.textContent = c.text;
1685
+ row.appendChild(txt);
1686
+ var chip = el("span", "sm-ledger-status");
1687
+ chip.setAttribute("data-state", c.status);
1688
+ chip.textContent = statusLabel(c);
1689
+ row.appendChild(chip);
1690
+ rail.appendChild(row);
1691
+ });
1692
+ return rail;
1693
+ }
1694
+
1399
1695
  // ---------- chat composer bar ----------
1400
- // Rebuilt on structural state changes (a storyline draft staged / discarded),
1401
- // NOT on every keystroke (keystrokes only call refreshChrome). The typed
1402
- // feedback survives a rebuild via feedbackText.
1696
+ // Rebuilt on structural state changes (a storyline draft staged / discarded,
1697
+ // a mode or selection change), NOT on every keystroke (keystrokes only call
1698
+ // refreshChrome). The typed feedback survives a rebuild via feedbackText.
1403
1699
  function renderComposer() {
1404
- if (!composerEl || waiting) return;
1700
+ if (!composerEl) return;
1405
1701
  composerEl.innerHTML = "";
1406
1702
 
1407
- if (S.storyDraft) {
1703
+ if (working) {
1704
+ var status = el("div", "sm-wait");
1705
+ status.appendChild(el("div", "sm-spin"));
1706
+ var note = el("div");
1707
+ note.innerHTML =
1708
+ "<b>" +
1709
+ (workingNote || "Comments sent.") +
1710
+ "</b> The agent is revising the plan — this tab updates itself when the " +
1711
+ "next round lands. Keep reading and navigating; anything you send now is " +
1712
+ "queued and delivered with that round.";
1713
+ status.appendChild(note);
1714
+ composerEl.appendChild(status);
1715
+ // The permission prompt was raised at the send click (a user gesture, the
1716
+ // only context a browser shows it in); once granted, say so.
1717
+ (notifyPermPromise || Promise.resolve(SES.notifyPermission())).then(function (perm) {
1718
+ if (perm === "granted") {
1719
+ note.innerHTML += " You'll get a notification when the next round needs you.";
1720
+ }
1721
+ });
1722
+ }
1723
+
1724
+ if (!working && S.storyDraft) {
1408
1725
  var banner = el("div", "sm-story-banner");
1409
1726
  banner.innerHTML =
1410
1727
  ICONS.sparkles +
@@ -1422,25 +1739,47 @@
1422
1739
  composerEl.appendChild(banner);
1423
1740
  }
1424
1741
 
1425
- if (CFG.previousComment) {
1426
- var prev = el("div", "sm-prev-note");
1427
- prev.innerHTML = ICONS.ghost + '<span class="lbl">Previous round</span>';
1428
- var txt = el("span", "txt");
1429
- txt.textContent = CFG.previousComment;
1430
- prev.appendChild(txt);
1431
- composerEl.appendChild(prev);
1742
+ if (ledger.length) composerEl.appendChild(ledgerRail());
1743
+
1744
+ // Scope control — "This slide" reads the selected strip tab. Hidden when
1745
+ // there is no strip to anchor to (plain-markdown plans).
1746
+ var beats = planBeats();
1747
+ if (!beats.length) commentScope = "story";
1748
+ if (beats.length) {
1749
+ var meta = el("div", "sm-composer-meta");
1750
+ meta.appendChild(el("span", "sm-scope-label", "Comment on"));
1751
+ var scopeBox = el("div", "sm-scope");
1752
+ var active = clamp(S.selectedIndex, 0, beats.length - 1);
1753
+ [
1754
+ { key: "slide", label: "Slide " + beats[active].slide },
1755
+ { key: "story", label: "Whole story" },
1756
+ ].forEach(function (opt) {
1757
+ var b = el("button", "sm-scope-btn" + (commentScope === opt.key ? " is-on" : ""), opt.label);
1758
+ b.type = "button";
1759
+ b.onclick = function () {
1760
+ commentScope = opt.key;
1761
+ renderComposer();
1762
+ if (commentEl) commentEl.focus();
1763
+ };
1764
+ scopeBox.appendChild(b);
1765
+ });
1766
+ meta.appendChild(scopeBox);
1767
+ composerEl.appendChild(meta);
1432
1768
  }
1433
1769
 
1434
1770
  var row = el("div", "sm-composer-row");
1435
1771
  commentEl = el("textarea", "sm-chat-ta");
1436
1772
  commentEl.rows = 1;
1437
- commentEl.placeholder = "What should change? Leave a note, or edit the plan directly.";
1773
+ commentEl.placeholder = working
1774
+ ? "Keep commenting — this goes to the agent with the next round."
1775
+ : "What should change? Leave a note, or edit the plan directly.";
1438
1776
  commentEl.value = feedbackText;
1439
1777
  commentEl.addEventListener("input", function () {
1440
1778
  feedbackText = commentEl.value;
1441
1779
  approveArmed = false; // typing more feedback disarms a pending approve-confirm
1442
1780
  commentEl.style.height = "auto";
1443
1781
  commentEl.style.height = Math.min(commentEl.scrollHeight, 150) + "px";
1782
+ saveDraftSoon(); // an unsent draft survives the next round's reload
1444
1783
  refreshChrome();
1445
1784
  });
1446
1785
  commentEl.addEventListener("keydown", function (e) {
@@ -1448,7 +1787,7 @@
1448
1787
  });
1449
1788
  row.appendChild(commentEl);
1450
1789
 
1451
- if (S.storyDraft) {
1790
+ if (!working && S.storyDraft) {
1452
1791
  // The round is sendable as-is — "send" grows the story-iteration label.
1453
1792
  sendBtn = el("button", "sm-send-pill", ICONS.sparkles + "<span>Iterate on the story</span>");
1454
1793
  sendBtn.type = "button";
@@ -1456,8 +1795,8 @@
1456
1795
  } else {
1457
1796
  sendBtn = el("button", "sm-send", ICONS.send);
1458
1797
  sendBtn.type = "button";
1459
- sendBtn.title = "Send feedback";
1460
- sendBtn.setAttribute("aria-label", "Send feedback");
1798
+ sendBtn.title = working ? "Queue for the next round" : "Send feedback";
1799
+ sendBtn.setAttribute("aria-label", sendBtn.title);
1461
1800
  }
1462
1801
  sendBtn.onclick = requestChanges;
1463
1802
  row.appendChild(sendBtn);
@@ -1472,6 +1811,7 @@
1472
1811
  "<span>Approve plan</span></span>" +
1473
1812
  '<span class="sub">the agent builds the slides next</span>';
1474
1813
  approveBtn.onclick = function () {
1814
+ if (working || queuedComments().length) return; // superseded round / unsent queue
1475
1815
  if (S.storyDraft) return; // blocked while a storyline draft is staged
1476
1816
  // Approving with unsent feedback in the input discards it — confirm inline.
1477
1817
  if (commentText() && !approveArmed) {
@@ -1487,137 +1827,66 @@
1487
1827
  refreshChrome();
1488
1828
  }
1489
1829
 
1490
- // ---------- waiting state (comments sent) ----------
1491
- // The tab does NOT veil over on a comments round: the plan stays readable, the
1492
- // surface locks, and the composer becomes the wait signal until the agent's
1493
- // next `slidemind plan` round reloads this tab (heartbeat polling).
1494
- function enterWaiting() {
1495
- waiting = true;
1830
+ // ---------- working mode (the agent is revising) ----------
1831
+ // The tab neither veils over nor freezes: the plan stays readable, the strip
1832
+ // stays navigable, the composer stays live (comments queue), and only the
1833
+ // editing affordances + Approve go away. Heartbeat polling waits for the
1834
+ // agent's next `slidemind plan` round, which reloads this same tab.
1835
+ function enterWorking(note) {
1836
+ working = true;
1837
+ workingNote = note || null;
1496
1838
  if (wsEl) wsEl.classList.add("is-waiting");
1497
- renderSurface(); // rebuild the surface read-only (no contenteditable attrs)
1498
1839
  document.title = WAITING_TITLE; // the tab label signals the state from the tab bar
1499
- if (composerEl) {
1500
- composerEl.innerHTML = "";
1501
- var w = el("div", "sm-wait");
1502
- w.appendChild(el("div", "sm-spin"));
1503
- waitTextEl = el(
1504
- "div",
1505
- null,
1506
- "<b>Feedback sent.</b> The agent is revising the plan — this tab refreshes " +
1507
- "automatically when the next round is ready. Feel free to keep reading, " +
1508
- "or come back later.",
1509
- );
1510
- w.appendChild(waitTextEl);
1511
- composerEl.appendChild(w);
1512
- }
1513
- // The permission prompt was raised at the send click (a user gesture, so the
1514
- // browser shows it); once granted, tell the user a notification will come.
1515
- (notifyPermPromise || Promise.resolve(notifyPermission())).then(function (perm) {
1516
- if (perm === "granted" && waitTextEl) {
1517
- waitTextEl.innerHTML += " You'll get a notification the moment it's ready.";
1518
- }
1519
- });
1520
- }
1521
-
1522
- // ---------- next-round attention ----------
1523
- function notifyPermission() {
1524
- try {
1525
- return "Notification" in window ? Notification.permission : "unsupported";
1526
- } catch {
1527
- return "unsupported";
1528
- }
1529
- }
1530
- function requestNotifyPermission() {
1531
- try {
1532
- if (!("Notification" in window)) return Promise.resolve("unsupported");
1533
- if (Notification.permission !== "default") return Promise.resolve(Notification.permission);
1534
- return Promise.resolve(Notification.requestPermission());
1535
- } catch {
1536
- return Promise.resolve("unsupported");
1537
- }
1538
- }
1539
- // The reload wipes this JS context, so the "announce the new round" wish is
1540
- // parked in sessionStorage and honoured by the NEXT page load.
1541
- function markRoundReadyAndReload() {
1542
- try {
1543
- sessionStorage.setItem(ROUND_READY_FLAG, "1");
1544
- } catch {
1545
- /* storage blocked — the reload still lands, just without the announce */
1546
- }
1547
- location.reload();
1548
- }
1549
- function announceNewRound() {
1550
- var flagged = false;
1551
- try {
1552
- flagged = sessionStorage.getItem(ROUND_READY_FLAG) === "1";
1553
- if (flagged) sessionStorage.removeItem(ROUND_READY_FLAG);
1554
- } catch {
1555
- /* no storage — nothing to announce */
1556
- }
1557
- if (!flagged) return;
1558
- try {
1559
- window.focus();
1560
- } catch {
1561
- /* ignore */
1562
- }
1563
- if (!document.hidden) return; // user is already looking at it
1564
- var origTitle = document.title;
1565
- var restore = function () {
1566
- document.title = origTitle;
1567
- document.removeEventListener("visibilitychange", onVis);
1568
- window.removeEventListener("focus", restore);
1569
- };
1570
- var onVis = function () {
1571
- if (!document.hidden) restore();
1572
- };
1573
- document.title = ATTENTION_TITLE;
1574
- document.addEventListener("visibilitychange", onVis);
1575
- window.addEventListener("focus", restore);
1576
- try {
1577
- if (notifyPermission() === "granted") {
1578
- var n = new Notification("slidemind — plan ready", {
1579
- body: "The revised plan is ready for your review. Click to jump back to the tab.",
1580
- tag: "slidemind-plan-round",
1581
- });
1582
- n.onclick = function () {
1583
- try {
1584
- window.focus();
1585
- } catch {
1586
- /* ignore */
1587
- }
1588
- n.close();
1589
- };
1590
- }
1591
- } catch {
1592
- /* notification construction can throw on some platforms — title marker stands */
1593
- }
1840
+ renderSurface(); // rebuild the surface read-only (no contenteditable attrs)
1841
+ renderComposer();
1842
+ startNextRoundPolling();
1594
1843
  }
1595
1844
 
1596
1845
  // ---------- submit ----------
1846
+ // Send. WORKING → the comment is queued locally (the agent is mid-round; it
1847
+ // rides along on the next run). PARKED → it resolves the gate right now,
1848
+ // carrying any queued stragglers from a flush that couldn't reach the server.
1597
1849
  function requestChanges() {
1598
- if (waiting) return;
1850
+ var typed = commentText();
1851
+ if (working) {
1852
+ if (!typed) return;
1853
+ ledger.push(newComment(typed));
1854
+ saveLedger();
1855
+ clearDraft();
1856
+ renderComposer();
1857
+ return;
1858
+ }
1859
+ var batch = queuedComments();
1599
1860
  if (S.storyDraft) {
1600
1861
  var staged = PLAN.stageStoryIteration(S.value, S.storyDraft);
1601
1862
  if (!staged) return;
1602
- var typed = commentText();
1603
- pendingComment = typed ? staged.feedback + "\n\n" + typed : staged.feedback;
1863
+ // A storyline restructure is a whole-story instruction by construction.
1864
+ batch = batch.concat([
1865
+ newComment(typed ? staged.feedback + "\n\n" + typed : staged.feedback, "story"),
1866
+ ]);
1604
1867
  pendingEdited = staged.editedPlanMarkdown;
1605
1868
  } else {
1606
- if (!commentText()) return;
1607
- pendingComment = commentText();
1869
+ if (!typed) return;
1870
+ batch = batch.concat([newComment(typed)]);
1608
1871
  pendingEdited = planWasEdited() ? S.value : null;
1609
1872
  }
1873
+ pendingComments = batch;
1610
1874
  // Ask for notification permission HERE — the click is a user gesture, the
1611
1875
  // only context where the browser will actually show the prompt.
1612
- notifyPermPromise = requestNotifyPermission();
1876
+ notifyPermPromise = SES.requestNotifyPermission();
1613
1877
  submit("comments");
1614
1878
  }
1879
+ function clearDraft() {
1880
+ feedbackText = "";
1881
+ if (commentEl) commentEl.value = "";
1882
+ saveCtx();
1883
+ }
1615
1884
  function submit(decision) {
1616
1885
  if (submitted) return;
1617
1886
  submitted = true;
1618
1887
  var payload = {
1619
1888
  decision: decision,
1620
- comment: decision === "comments" ? pendingComment : null,
1889
+ comments: decision === "comments" ? pendingComments.map(SES.wire) : [],
1621
1890
  editedPlan: decision === "comments" ? pendingEdited : planWasEdited() ? S.value : null,
1622
1891
  };
1623
1892
  fetch(CFG.endpoint, {
@@ -1625,13 +1894,44 @@
1625
1894
  headers: { "Content-Type": "application/json" },
1626
1895
  body: JSON.stringify(payload),
1627
1896
  })
1628
- .then(function () {
1897
+ .then(function (res) {
1898
+ if (res.status === 409) {
1899
+ // The gate already drained a backlog this run — this round is stale.
1900
+ // Park the batch in the ledger as QUEUED (the next claim flushes it),
1901
+ // clear the input so it isn't typed twice, and disarm the submit
1902
+ // latch — leaving it armed made every later send a silent no-op.
1903
+ // A staged story restructure survives only as its instruction TEXT:
1904
+ // its editedPlanMarkdown was computed against the superseded plan and
1905
+ // applying it to the next round would corrupt it, so it is dropped
1906
+ // deliberately (same call the add-in dialog makes on round arrival).
1907
+ if (decision === "comments") {
1908
+ pendingComments.forEach(function (c) {
1909
+ c.status = "queued";
1910
+ if (ledger.indexOf(c) < 0) ledger.push(c);
1911
+ });
1912
+ saveLedger();
1913
+ clearDraft();
1914
+ S.storyDraft = null;
1915
+ }
1916
+ submitted = false;
1917
+ enterWorking("Round superseded.");
1918
+ return;
1919
+ }
1920
+ if (!res.ok) throw new Error("gate refused the decision");
1629
1921
  if (decision === "approve") {
1630
1922
  showApproved();
1631
- } else {
1632
- enterWaiting();
1633
- startNextRoundPolling();
1923
+ return;
1634
1924
  }
1925
+ pendingComments.forEach(function (c) {
1926
+ c.status = "sent";
1927
+ if (ledger.indexOf(c) < 0) ledger.push(c);
1928
+ });
1929
+ saveLedger();
1930
+ clearDraft();
1931
+ // The staged restructure is now the agent's job — keeping it staged
1932
+ // would show the reviewer a storyline the served plan no longer claims.
1933
+ S.storyDraft = null;
1934
+ enterWorking("Comments sent.");
1635
1935
  })
1636
1936
  .catch(function () {
1637
1937
  submitted = false;
@@ -1648,53 +1948,17 @@
1648
1948
  }
1649
1949
  }, 120);
1650
1950
  }
1651
- // After a comments round the tab stays open and polls the heartbeat so the
1652
- // NEXT run (which binds the same deterministic port) reloads this tab instead
1653
- // of opening a fresh browser window. The poll reports this tab's round nonce
1654
- // and its visibility: only a VISIBLE tab may claim the next round for reuse —
1655
- // a tab buried in a background window must not swallow the round invisibly, so
1656
- // the server opens a fresh browser tab instead and answers `action: "close"`
1657
- // here, which retires this tab with a notice rather than reloading it into a
1658
- // hidden duplicate.
1951
+ // The shared heartbeat poll (gate-session.js) with this gate's two endings:
1952
+ // a retired tab hands its backlog over FIRST — the fresh window that replaces
1953
+ // it starts with an empty sessionStorage, so anything still queued here would
1954
+ // otherwise be lost (the handover also drains the new run immediately).
1659
1955
  function startNextRoundPolling() {
1660
- var startedAt = Date.now();
1661
- var pollMs = CFG.pollMs || 1000;
1662
- var ttl = CFG.pollTtlMs || 35 * 60 * 1000;
1663
- var hb = CFG.heartbeat || "/__plan/heartbeat";
1664
- var timer = setInterval(function () {
1665
- if (Date.now() - startedAt > ttl) {
1666
- clearInterval(timer);
1667
- showSessionEnded();
1668
- return;
1669
- }
1670
- var visible = document.visibilityState === "visible" ? "1" : "0";
1671
- var url =
1672
- hb +
1673
- (hb.indexOf("?") < 0 ? "?" : "&") +
1674
- "round=" +
1675
- encodeURIComponent(CFG.round || "") +
1676
- "&visible=" +
1677
- visible;
1678
- fetch(url, { cache: "no-store" })
1679
- .then(function (r) {
1680
- return r.ok ? r.json() : null;
1681
- })
1682
- .then(function (data) {
1683
- if (!data || data.service !== "slidemind-plan" || !data.round) return;
1684
- if (data.round === CFG.round) return;
1685
- if (data.action === "close") {
1686
- clearInterval(timer);
1687
- showSuperseded();
1688
- return;
1689
- }
1690
- if (data.action === "wait") return; // grace window still deciding — keep polling
1691
- clearInterval(timer);
1692
- markRoundReadyAndReload();
1693
- })
1694
- .catch(function () {
1695
- /* server down between rounds — keep polling */
1696
- });
1697
- }, pollMs);
1956
+ SES.startNextRoundPolling({
1957
+ onEnded: showSessionEnded,
1958
+ onSuperseded: function () {
1959
+ flushBacklog().then(showSuperseded, showSuperseded);
1960
+ },
1961
+ });
1698
1962
  }
1699
1963
 
1700
1964
  // ---------- done veil ----------
@@ -1764,10 +2028,28 @@
1764
2028
  var stale = document.getElementById("plan-root");
1765
2029
  if (stale && stale.parentNode) stale.parentNode.removeChild(stale);
1766
2030
 
2031
+ // Session first: it decides the MODE this page renders in (a queued backlog
2032
+ // means the agent is still working) and the round-transition state.
2033
+ var flagged = SES.consumeRoundFlag();
2034
+ restoreSession();
2035
+
1767
2036
  injectAssets();
1768
2037
  buildChrome();
1769
2038
  buildWorkspace();
1770
- announceNewRound(); // notification + title marker when this load IS the next round
2039
+
2040
+ if (working) {
2041
+ // Drain round: hand the backlog over, stay in working mode, stay silent —
2042
+ // the reviewer already knows they're mid-round.
2043
+ if (wsEl) wsEl.classList.add("is-waiting");
2044
+ document.title = WAITING_TITLE;
2045
+ startNextRoundPolling();
2046
+ flushBacklog().then(function () {
2047
+ renderComposer(); // rows flip queued → sent
2048
+ });
2049
+ } else {
2050
+ // Parked round: the agent is waiting on the reviewer — announce it.
2051
+ SES.announceNewRound(flagged);
2052
+ }
1771
2053
  }
1772
2054
 
1773
2055
  if (document.readyState === "loading") {