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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +37 -8
- package/README.md +32 -2
- package/bin/slidemind.mjs +535 -122
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +30 -6
- package/skills/search/SKILL.md +3 -1
- package/src/bridge/build-and-push.mjs +418 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +344 -26
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/graph-auth.mjs +865 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +69 -1
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
|
@@ -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
|
|
18
|
-
//
|
|
19
|
-
// button, whose sub-label says the agent
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// -
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
"
|
|
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-
|
|
351
|
-
".sm-
|
|
352
|
-
".sm-
|
|
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
|
-
|
|
465
|
-
|
|
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 (
|
|
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
|
|
622
|
-
|
|
623
|
-
|
|
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
|
|
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
|
|
1700
|
+
if (!composerEl) return;
|
|
1405
1701
|
composerEl.innerHTML = "";
|
|
1406
1702
|
|
|
1407
|
-
if (
|
|
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 (
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
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 =
|
|
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",
|
|
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
|
-
// ----------
|
|
1491
|
-
// The tab
|
|
1492
|
-
//
|
|
1493
|
-
//
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1603
|
-
|
|
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 (!
|
|
1607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1652
|
-
//
|
|
1653
|
-
//
|
|
1654
|
-
//
|
|
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
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
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
|
-
|
|
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") {
|