privateboard 0.1.8 → 0.1.10
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/dist/cli.js +879 -180
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +2 -1
- package/public/agent-profile.css +525 -0
- package/public/agent-profile.js +278 -1
- package/public/app.js +279 -120
- package/public/index.html +312 -164
- package/public/magazine.html +1257 -838
- package/public/newspaper.html +1389 -1267
- package/public/ppt.html +2623 -0
- package/public/room-settings.css +40 -4
- package/public/room-settings.js +44 -2
- package/public/user-settings.css +23 -21
- package/public/user-settings.js +1 -4
- package/public/bento.html +0 -1396
package/public/app.js
CHANGED
|
@@ -422,24 +422,14 @@
|
|
|
422
422
|
// No explicit room in the hash — land on the new-room composer
|
|
423
423
|
// (ChatGPT / Claude style: default = fresh, existing rooms live
|
|
424
424
|
// in the sidebar). The composer renders inside closeRoom →
|
|
425
|
-
// renderEmptyState.
|
|
426
|
-
//
|
|
427
|
-
//
|
|
425
|
+
// renderEmptyState. The sidebar persistence layer in index.html
|
|
426
|
+
// (boardroom.sidebar.* keys + restore() on DOMContentLoaded)
|
|
427
|
+
// takes over from here · if the user's last sidebar selection
|
|
428
|
+
// was a specific room / reports / notes, that fires AFTER
|
|
429
|
+
// app.init and overrides this composer landing.
|
|
428
430
|
this.closeRoom();
|
|
429
431
|
},
|
|
430
432
|
|
|
431
|
-
/** Choose a sensible default room when none is in the URL hash. */
|
|
432
|
-
firstSelectableRoom() {
|
|
433
|
-
if (!this.rooms || !this.rooms.length) return null;
|
|
434
|
-
const rank = (status) => (status === "live" ? 0 : status === "paused" ? 1 : 2);
|
|
435
|
-
const sorted = this.rooms.slice().sort((a, b) => {
|
|
436
|
-
const dr = rank(a.status) - rank(b.status);
|
|
437
|
-
if (dr !== 0) return dr;
|
|
438
|
-
return (b.createdAt || 0) - (a.createdAt || 0);
|
|
439
|
-
});
|
|
440
|
-
return sorted[0] || null;
|
|
441
|
-
},
|
|
442
|
-
|
|
443
433
|
navigateToRoom(roomId) {
|
|
444
434
|
location.hash = "#/r/" + roomId;
|
|
445
435
|
},
|
|
@@ -648,7 +638,15 @@
|
|
|
648
638
|
this.currentRoom = null;
|
|
649
639
|
this.currentMessages = [];
|
|
650
640
|
this.currentMembers = [];
|
|
651
|
-
|
|
641
|
+
// `currentChair` is NOT reset · the chair is a structural
|
|
642
|
+
// singleton (one moderator agent in the catalog, same across
|
|
643
|
+
// every room), and the sidebar's Chair section keys off it
|
|
644
|
+
// whether or not a room is loaded. Earlier this line read
|
|
645
|
+
// `this.currentChair = null;`, which dropped the Chair row
|
|
646
|
+
// from the Agents tab any time the user landed on the no-
|
|
647
|
+
// room state — the chair would re-appear the moment a room
|
|
648
|
+
// re-opened (since `openRoom` re-sets it from data.chair),
|
|
649
|
+
// but the empty + composer states looked broken.
|
|
652
650
|
this.currentQueue = [];
|
|
653
651
|
this.currentRound = { spoken: 0, total: 0 };
|
|
654
652
|
this.currentKeyPoints = [];
|
|
@@ -1519,21 +1517,21 @@
|
|
|
1519
1517
|
* card thought it was still generating because bodyMd was empty
|
|
1520
1518
|
* — which is the steady-state shape for bento, not an
|
|
1521
1519
|
* in-flight signal). */
|
|
1522
|
-
/** True for any structured-output mode —
|
|
1523
|
-
*
|
|
1524
|
-
* runs the single-pass chair-LLM pipeline (extract + write
|
|
1525
|
-
* no Stage 2/3 scaffold/write). Centralised so future
|
|
1526
|
-
* touch one place. */
|
|
1520
|
+
/** True for any structured-output mode — magazine, newspaper,
|
|
1521
|
+
* ppt — that persists to body_json instead of body_md and
|
|
1522
|
+
* runs the single-pass chair-LLM pipeline (extract + write
|
|
1523
|
+
* only, no Stage 2/3 scaffold/write). Centralised so future
|
|
1524
|
+
* modes touch one place. */
|
|
1527
1525
|
isStructuredBriefMode(mode) {
|
|
1528
|
-
return mode === "
|
|
1526
|
+
return mode === "magazine" || mode === "newspaper" || mode === "ppt";
|
|
1529
1527
|
},
|
|
1530
1528
|
|
|
1531
1529
|
briefHasBody(b) {
|
|
1532
1530
|
if (!b) return false;
|
|
1533
1531
|
if (this.isStructuredBriefMode(b.mode)) {
|
|
1534
|
-
//
|
|
1535
|
-
// An empty object isn't a body; check
|
|
1536
|
-
// field.
|
|
1532
|
+
// Magazine, newspaper + ppt all populate bodyJson with the
|
|
1533
|
+
// structured scaffold. An empty object isn't a body; check
|
|
1534
|
+
// for at least a title field.
|
|
1537
1535
|
const j = b.bodyJson;
|
|
1538
1536
|
return !!(j && typeof j === "object" && (j.title || j.milestones));
|
|
1539
1537
|
}
|
|
@@ -1543,9 +1541,9 @@
|
|
|
1543
1541
|
|
|
1544
1542
|
/** Build the right viewer URL for a brief based on its mode.
|
|
1545
1543
|
* · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
|
|
1546
|
-
* · 'bento' → `/bento.html?b=B`
|
|
1547
1544
|
* · 'magazine' → `/magazine.html?b=B`
|
|
1548
1545
|
* · 'newspaper' → `/newspaper.html?b=B`
|
|
1546
|
+
* · 'ppt' → `/ppt.html?b=B`
|
|
1549
1547
|
*
|
|
1550
1548
|
* Structured renderers don't need the room context · the brief
|
|
1551
1549
|
* is self-contained. Used by the View Report button, the
|
|
@@ -1555,9 +1553,9 @@
|
|
|
1555
1553
|
briefViewerHref(b, roomId) {
|
|
1556
1554
|
if (!b || !b.id) return null;
|
|
1557
1555
|
const id = encodeURIComponent(b.id);
|
|
1558
|
-
if (b.mode === "bento") return `/bento.html?b=${id}`;
|
|
1559
1556
|
if (b.mode === "magazine") return `/magazine.html?b=${id}`;
|
|
1560
1557
|
if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
|
|
1558
|
+
if (b.mode === "ppt") return `/ppt.html?b=${id}`;
|
|
1561
1559
|
const r = roomId ? encodeURIComponent(roomId) : "";
|
|
1562
1560
|
return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
|
|
1563
1561
|
},
|
|
@@ -1580,21 +1578,21 @@
|
|
|
1580
1578
|
briefModeLabel(b) {
|
|
1581
1579
|
const mode = (b && b.mode) || "research-note";
|
|
1582
1580
|
switch (mode) {
|
|
1583
|
-
case "bento": return "Bento";
|
|
1584
1581
|
case "magazine": return "Magazine";
|
|
1585
1582
|
case "newspaper": return "Newspaper";
|
|
1583
|
+
case "ppt": return "Slides";
|
|
1586
1584
|
default: return "Report";
|
|
1587
1585
|
}
|
|
1588
1586
|
},
|
|
1589
1587
|
|
|
1590
1588
|
/** Render the report-mode picker · four icon-tile cards that let
|
|
1591
|
-
* the user pick between research-note,
|
|
1592
|
-
*
|
|
1593
|
-
* visually mirrors that mode's layout — research-note as a
|
|
1594
|
-
*
|
|
1595
|
-
*
|
|
1596
|
-
*
|
|
1597
|
-
* DOM for a11y / keyboard nav).
|
|
1589
|
+
* the user pick between research-note, magazine, newspaper, and
|
|
1590
|
+
* ppt (slides). Each tile shows a custom monoline SVG that
|
|
1591
|
+
* visually mirrors that mode's layout — research-note as a memo,
|
|
1592
|
+
* magazine as a masthead + numeral block + small cards,
|
|
1593
|
+
* newspaper as a banner + 3-column text grid, ppt as a slide
|
|
1594
|
+
* rectangle with bullet lines. The native radio is visually
|
|
1595
|
+
* hidden (kept in the DOM for a11y / keyboard nav).
|
|
1598
1596
|
*
|
|
1599
1597
|
* Used by both the adjourn overlay (filing the report at
|
|
1600
1598
|
* adjourn-time / generate-brief flow) AND the supplement
|
|
@@ -1622,13 +1620,6 @@
|
|
|
1622
1620
|
<line x1="9" y1="15" x2="16" y2="15"/>
|
|
1623
1621
|
<line x1="9" y1="18" x2="14" y2="18"/>
|
|
1624
1622
|
</svg>`,
|
|
1625
|
-
"bento": `
|
|
1626
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1627
|
-
<rect x="3" y="3" width="9" height="13" rx="1.5"/>
|
|
1628
|
-
<rect x="13" y="3" width="8" height="6" rx="1.5"/>
|
|
1629
|
-
<rect x="13" y="10" width="8" height="6" rx="1.5"/>
|
|
1630
|
-
<rect x="3" y="17" width="18" height="4" rx="1.5"/>
|
|
1631
|
-
</svg>`,
|
|
1632
1623
|
"magazine": `
|
|
1633
1624
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1634
1625
|
<line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
|
|
@@ -1654,6 +1645,17 @@
|
|
|
1654
1645
|
<line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
|
|
1655
1646
|
<line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
|
|
1656
1647
|
</svg>`,
|
|
1648
|
+
"ppt": `
|
|
1649
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1650
|
+
<rect x="3" y="4" width="18" height="13" rx="1"/>
|
|
1651
|
+
<line x1="7" y1="9" x2="14" y2="9" stroke-width="1.2"/>
|
|
1652
|
+
<line x1="7" y1="12" x2="17" y2="12" stroke-width="0.8"/>
|
|
1653
|
+
<line x1="7" y1="14" x2="15" y2="14" stroke-width="0.8"/>
|
|
1654
|
+
<circle cx="5.5" cy="12" r="0.6" fill="currentColor" stroke="none"/>
|
|
1655
|
+
<circle cx="5.5" cy="14" r="0.6" fill="currentColor" stroke="none"/>
|
|
1656
|
+
<line x1="10" y1="20" x2="14" y2="20" stroke-width="1"/>
|
|
1657
|
+
<line x1="12" y1="17" x2="12" y2="20" stroke-width="0.6"/>
|
|
1658
|
+
</svg>`,
|
|
1657
1659
|
};
|
|
1658
1660
|
const opt = (value, title, deck, primary) => `
|
|
1659
1661
|
<label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
|
|
@@ -1669,9 +1671,9 @@
|
|
|
1669
1671
|
<div class="adjourn-mode-label">// report format</div>
|
|
1670
1672
|
<div class="adjourn-mode-options adjourn-mode-options-4">
|
|
1671
1673
|
${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
|
|
1672
|
-
${opt("bento", "Bento", "Single-page poster · 3 milestones + sidebar cards.")}
|
|
1673
1674
|
${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
|
|
1674
1675
|
${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
|
|
1676
|
+
${opt("ppt", "Slides", "Slide deck · 7-9 slides, arrow-key navigation, present mode.")}
|
|
1675
1677
|
</div>
|
|
1676
1678
|
</div>`;
|
|
1677
1679
|
},
|
|
@@ -2021,14 +2023,12 @@
|
|
|
2021
2023
|
confirm: "[ Regenerate ]",
|
|
2022
2024
|
confirmBusy: "[ Regenerating… ]",
|
|
2023
2025
|
};
|
|
2024
|
-
// Default the picker to the
|
|
2025
|
-
//
|
|
2026
|
-
//
|
|
2027
|
-
//
|
|
2028
|
-
//
|
|
2029
|
-
const defaultMode = this.
|
|
2030
|
-
? this.currentBrief.mode
|
|
2031
|
-
: this.lastBriefMode();
|
|
2026
|
+
// Default the picker to the user's last picked mode — same
|
|
2027
|
+
// behaviour as the adjourn (Generate) flow, so the picker reads
|
|
2028
|
+
// consistently across both surfaces. The parent brief's mode is
|
|
2029
|
+
// available if needed, but the user's most recent explicit
|
|
2030
|
+
// choice wins.
|
|
2031
|
+
const defaultMode = this.lastBriefMode();
|
|
2032
2032
|
const html = `
|
|
2033
2033
|
<div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
|
|
2034
2034
|
<div class="supplement-backdrop" data-supplement-close></div>
|
|
@@ -2261,7 +2261,7 @@
|
|
|
2261
2261
|
const adjournedLine = room.adjournedAt
|
|
2262
2262
|
? `${t.adjournedAtPrefix} ${this.timeFmt(room.adjournedAt)}`
|
|
2263
2263
|
: "";
|
|
2264
|
-
const
|
|
2264
|
+
const subjectFull = room.subject || "(no subject)";
|
|
2265
2265
|
|
|
2266
2266
|
// Tone + intensity inherit from parent. Trigger uses the same
|
|
2267
2267
|
// `.cmp-dd` markup as the new-room composer's toolbar buttons —
|
|
@@ -2311,8 +2311,11 @@
|
|
|
2311
2311
|
</header>
|
|
2312
2312
|
<div class="supplement-body">
|
|
2313
2313
|
<div class="followup-parent-card">
|
|
2314
|
-
<div class="followup-parent-subject">${this.escape(
|
|
2315
|
-
<div class="followup-parent-meta"
|
|
2314
|
+
<div class="followup-parent-subject is-clamped" data-followup-subject-text>${this.escape(subjectFull)}</div>
|
|
2315
|
+
<div class="followup-parent-meta-row">
|
|
2316
|
+
<button type="button" class="followup-parent-subject-toggle" data-followup-subject-toggle hidden>Show more</button>
|
|
2317
|
+
<div class="followup-parent-meta">${this.escape(adjournedLine)}${adjournedLine && briefLine ? " · " : ""}${this.escape(briefLine)}</div>
|
|
2318
|
+
</div>
|
|
2316
2319
|
<div class="followup-parent-note">${this.escape(t.contextNote)}</div>
|
|
2317
2320
|
</div>
|
|
2318
2321
|
|
|
@@ -2386,6 +2389,24 @@
|
|
|
2386
2389
|
// so listeners are GC'd when the modal is removed.
|
|
2387
2390
|
const overlayEl = document.getElementById("followup-overlay");
|
|
2388
2391
|
if (overlayEl) {
|
|
2392
|
+
// Subject clamp · long parent-room subjects clamp to 2 lines
|
|
2393
|
+
// by default. Post-mount measurement reveals the Show more /
|
|
2394
|
+
// less toggle only when the text actually overflows. rAF
|
|
2395
|
+
// settles initial layout so scrollHeight is meaningful.
|
|
2396
|
+
const subjEl = overlayEl.querySelector("[data-followup-subject-text]");
|
|
2397
|
+
const subjBtn = overlayEl.querySelector("[data-followup-subject-toggle]");
|
|
2398
|
+
if (subjEl && subjBtn) {
|
|
2399
|
+
requestAnimationFrame(() => {
|
|
2400
|
+
if (subjEl.scrollHeight > subjEl.clientHeight + 1) {
|
|
2401
|
+
subjBtn.hidden = false;
|
|
2402
|
+
}
|
|
2403
|
+
});
|
|
2404
|
+
subjBtn.addEventListener("click", (ev) => {
|
|
2405
|
+
ev.preventDefault();
|
|
2406
|
+
const expanded = subjEl.classList.toggle("is-clamped") === false;
|
|
2407
|
+
subjBtn.textContent = expanded ? "Show less" : "Show more";
|
|
2408
|
+
});
|
|
2409
|
+
}
|
|
2389
2410
|
const sameCheckbox = overlayEl.querySelector("[data-followup-same-cast]");
|
|
2390
2411
|
const castBtn = overlayEl.querySelector("[data-followup-cast-btn]");
|
|
2391
2412
|
if (sameCheckbox && castBtn) {
|
|
@@ -3778,7 +3799,6 @@
|
|
|
3778
3799
|
<div class="row-content">
|
|
3779
3800
|
<div class="row-top-line">
|
|
3780
3801
|
<span class="row-title">${this.escape(fullTitle)}</span>
|
|
3781
|
-
<span class="row-time">${this.escape(time)}</span>
|
|
3782
3802
|
</div>
|
|
3783
3803
|
<div class="row-subtitle">${status}${this.escape(r.subject || "")}</div>
|
|
3784
3804
|
</div>
|
|
@@ -3887,6 +3907,47 @@
|
|
|
3887
3907
|
this.composerMode = "room";
|
|
3888
3908
|
},
|
|
3889
3909
|
|
|
3910
|
+
/** Toggle the `isPinned` flag for an agent · the sidebar pin
|
|
3911
|
+
* button calls this. Persists to the server (PATCH /api/agents/:id
|
|
3912
|
+
* { isPinned }), updates local state in place, then re-renders
|
|
3913
|
+
* the sidebar so the row moves between the Pinned / Custom /
|
|
3914
|
+
* Core buckets. Optimistic with rollback on failure: we flip the
|
|
3915
|
+
* flag locally + repaint immediately, then revert if the PATCH
|
|
3916
|
+
* fails — no waiting on the round-trip for visible feedback. */
|
|
3917
|
+
async togglePinAgent(agentId) {
|
|
3918
|
+
if (!agentId) return;
|
|
3919
|
+
const idx = (this.agents || []).findIndex((a) => a && a.id === agentId);
|
|
3920
|
+
if (idx < 0) return;
|
|
3921
|
+
const agent = this.agents[idx];
|
|
3922
|
+
const prev = !!agent.isPinned;
|
|
3923
|
+
const next = !prev;
|
|
3924
|
+
// Optimistic flip · render immediately so the click feels instant.
|
|
3925
|
+
agent.isPinned = next;
|
|
3926
|
+
this.agentsById[agent.id] = agent;
|
|
3927
|
+
this.renderSidebarAgents();
|
|
3928
|
+
try {
|
|
3929
|
+
const r = await fetch("/api/agents/" + encodeURIComponent(agentId), {
|
|
3930
|
+
method: "PATCH",
|
|
3931
|
+
headers: { "content-type": "application/json" },
|
|
3932
|
+
body: JSON.stringify({ isPinned: next }),
|
|
3933
|
+
});
|
|
3934
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
3935
|
+
// Server returns the updated row · merge the canonical fields
|
|
3936
|
+
// back in so any side-effect updates (updated_at, etc.) land.
|
|
3937
|
+
const updated = await r.json();
|
|
3938
|
+
if (updated && updated.id) {
|
|
3939
|
+
this.agents[idx] = updated;
|
|
3940
|
+
this.agentsById[updated.id] = updated;
|
|
3941
|
+
}
|
|
3942
|
+
} catch (e) {
|
|
3943
|
+
// Rollback · keep the UI honest about persistence failure.
|
|
3944
|
+
agent.isPinned = prev;
|
|
3945
|
+
this.agentsById[agent.id] = agent;
|
|
3946
|
+
this.renderSidebarAgents();
|
|
3947
|
+
alert((e && e.message ? e.message : "pin failed") + " — try again.");
|
|
3948
|
+
}
|
|
3949
|
+
},
|
|
3950
|
+
|
|
3890
3951
|
/** Render the sidebar's Agents panel from the live agent catalog.
|
|
3891
3952
|
* Three buckets so the seeded directors keep their familiar
|
|
3892
3953
|
* groupings while user-created ones stack into a Custom section
|
|
@@ -3919,7 +3980,6 @@
|
|
|
3919
3980
|
<div class="agent-row-content">
|
|
3920
3981
|
<div class="agent-row-top-line">
|
|
3921
3982
|
<span class="agent-row-title">${this.escape(a.name)}</span>
|
|
3922
|
-
<span class="agent-row-time">${this.escape(time)}</span>
|
|
3923
3983
|
${pinBtn}
|
|
3924
3984
|
</div>
|
|
3925
3985
|
<div class="agent-row-subtitle">
|
|
@@ -4606,7 +4666,44 @@
|
|
|
4606
4666
|
if (brief) brief.innerHTML = "";
|
|
4607
4667
|
|
|
4608
4668
|
const chatScroller = document.querySelector(".chat");
|
|
4609
|
-
if (chatScroller)
|
|
4669
|
+
if (chatScroller) {
|
|
4670
|
+
chatScroller.scrollTop = 0;
|
|
4671
|
+
// Mark the chat as hosting a composer so the CSS rule
|
|
4672
|
+
// `.chat.chat--composer` flips it to a grid with
|
|
4673
|
+
// `align-content: center` — vertically centring the
|
|
4674
|
+
// composer when it fits the viewport. When .cmp's natural
|
|
4675
|
+
// height > .chat height we also add `.chat--composer-overflow`
|
|
4676
|
+
// (via updateComposerOverflow) which switches to a layout
|
|
4677
|
+
// where the .cmp-fold (hero + input) is min-height: 100% of
|
|
4678
|
+
// the chat with content centred inside, and .cmp-starters
|
|
4679
|
+
// flow below as scrollable extras. Removed in renderChat()
|
|
4680
|
+
// when real chat messages take over.
|
|
4681
|
+
chatScroller.classList.add("chat--composer");
|
|
4682
|
+
this.updateComposerOverflow();
|
|
4683
|
+
}
|
|
4684
|
+
},
|
|
4685
|
+
|
|
4686
|
+
/** Toggle `.chat--composer-overflow` on the chat scroller based on
|
|
4687
|
+
* whether the composer's natural height exceeds the visible chat
|
|
4688
|
+
* area. In overflow mode the hero (`.cmp-fold`) anchors at the
|
|
4689
|
+
* viewport centre and `.cmp-starters` flow below the fold;
|
|
4690
|
+
* without overflow, the parent's `align-content: center` keeps
|
|
4691
|
+
* the whole composer block centred. Called after composer render
|
|
4692
|
+
* and on window resize. */
|
|
4693
|
+
updateComposerOverflow() {
|
|
4694
|
+
const chat = document.querySelector(".chat.chat--composer");
|
|
4695
|
+
if (!chat) return;
|
|
4696
|
+
const cmp = chat.querySelector(".cmp");
|
|
4697
|
+
if (!cmp) return;
|
|
4698
|
+
requestAnimationFrame(() => {
|
|
4699
|
+
chat.classList.remove("chat--composer-overflow");
|
|
4700
|
+
const overflows = cmp.scrollHeight > chat.clientHeight + 1;
|
|
4701
|
+
chat.classList.toggle("chat--composer-overflow", overflows);
|
|
4702
|
+
});
|
|
4703
|
+
if (!this._composerResizeAttached) {
|
|
4704
|
+
this._composerResizeAttached = true;
|
|
4705
|
+
window.addEventListener("resize", () => this.updateComposerOverflow());
|
|
4706
|
+
}
|
|
4610
4707
|
},
|
|
4611
4708
|
|
|
4612
4709
|
/** Open the All Reports page · cross-room brief index in a card
|
|
@@ -4951,11 +5048,16 @@
|
|
|
4951
5048
|
}
|
|
4952
5049
|
|
|
4953
5050
|
// Scroll path · IntersectionObserver fires when the sentinel
|
|
4954
|
-
// crosses the viewport.
|
|
4955
|
-
//
|
|
4956
|
-
//
|
|
4957
|
-
//
|
|
5051
|
+
// crosses the viewport. The All Reports view uses an inner
|
|
5052
|
+
// scroll container (`.main-view[data-main-view="reports"]` has
|
|
5053
|
+
// `overflow-y: auto`), so the document viewport itself never
|
|
5054
|
+
// scrolls. The observer's `root` MUST point at the inner
|
|
5055
|
+
// scroller — otherwise the sentinel never intersects and
|
|
5056
|
+
// infinite scroll silently does nothing. `rootMargin: 200px`
|
|
5057
|
+
// triggers slightly before the actual edge so the next batch
|
|
5058
|
+
// is rendered before the user reaches the bottom.
|
|
4958
5059
|
try {
|
|
5060
|
+
const scrollRoot = document.querySelector('.main-view[data-main-view="reports"]') || null;
|
|
4959
5061
|
this._reportsLoadObserver = new IntersectionObserver(
|
|
4960
5062
|
(entries) => {
|
|
4961
5063
|
for (const entry of entries) {
|
|
@@ -4965,7 +5067,7 @@
|
|
|
4965
5067
|
}
|
|
4966
5068
|
}
|
|
4967
5069
|
},
|
|
4968
|
-
{ rootMargin: "200px 0px 200px 0px", threshold: 0.01 },
|
|
5070
|
+
{ root: scrollRoot, rootMargin: "200px 0px 200px 0px", threshold: 0.01 },
|
|
4969
5071
|
);
|
|
4970
5072
|
this._reportsLoadObserver.observe(sentinel);
|
|
4971
5073
|
} catch { /* IntersectionObserver unavailable · click path remains */ }
|
|
@@ -4983,16 +5085,34 @@
|
|
|
4983
5085
|
}
|
|
4984
5086
|
},
|
|
4985
5087
|
|
|
4986
|
-
/** Single reading-list row · room kicker, title,
|
|
5088
|
+
/** Single reading-list row · room kicker, title, subtitle excerpt,
|
|
4987
5089
|
* meta tail. No card chrome — entries are separated by a hairline
|
|
4988
|
-
* inside the list.
|
|
5090
|
+
* inside the list.
|
|
5091
|
+
*
|
|
5092
|
+
* Subtitle source by mode:
|
|
5093
|
+
* · Report → bodyJson.bottomLine.judgement
|
|
5094
|
+
* · Magazine → bodyJson.kicker
|
|
5095
|
+
* · Newspaper → bodyJson.kicker
|
|
5096
|
+
* All three fall back to a cleaned-up first content paragraph
|
|
5097
|
+
* from bodyMd if the structured field is missing. */
|
|
4989
5098
|
renderReportItemHtml(b) {
|
|
4990
5099
|
const json = b.bodyJson || {};
|
|
4991
5100
|
const bottomLine = json.bottomLine || {};
|
|
4992
|
-
|
|
5101
|
+
// Pull the right subtitle for this mode. Magazine / newspaper
|
|
5102
|
+
// carry their deck/lede text in `kicker`; Report carries it
|
|
5103
|
+
// in `bottomLine.judgement`. Either may be missing on legacy
|
|
5104
|
+
// / partial briefs — fall back to the first prose paragraph
|
|
5105
|
+
// from bodyMd.
|
|
5106
|
+
const subtitle = (
|
|
5107
|
+
(bottomLine.judgement || "").trim() ||
|
|
5108
|
+
(json.kicker || "").trim() ||
|
|
5109
|
+
this._extractBriefExcerpt(b.bodyMd) ||
|
|
5110
|
+
""
|
|
5111
|
+
);
|
|
4993
5112
|
const time = this.relTime(b.createdAt) || "";
|
|
4994
5113
|
const roomLabel = b.roomName || b.roomSubject || "—";
|
|
4995
5114
|
const roomNumLabel = b.roomNumber != null ? `#${String(b.roomNumber).padStart(3, "0")}` : "";
|
|
5115
|
+
const typeLabel = this.briefModeLabel(b);
|
|
4996
5116
|
const findingsCount = Array.isArray(json.headlineFindings) ? json.headlineFindings.length : 0;
|
|
4997
5117
|
const positionsCount = Array.isArray(json.positions) ? json.positions.length : 0;
|
|
4998
5118
|
const metaParts = [];
|
|
@@ -5009,16 +5129,45 @@
|
|
|
5009
5129
|
<span class="reports-item-num">${this.escape(roomNumLabel)}</span>
|
|
5010
5130
|
<span class="reports-item-sep">·</span>
|
|
5011
5131
|
<span class="reports-item-room">${this.escape(roomLabel)}</span>
|
|
5132
|
+
<span class="reports-item-sep">·</span>
|
|
5133
|
+
<span class="reports-item-type" data-mode="${this.escape(((b && b.mode) || "research-note").toLowerCase())}">${this.escape(typeLabel)}</span>
|
|
5012
5134
|
<span class="reports-item-time">${this.escape(time)}</span>
|
|
5013
5135
|
</div>
|
|
5014
5136
|
<h3 class="reports-item-title">${this.escape(b.title || "Untitled brief")}</h3>
|
|
5015
|
-
${
|
|
5137
|
+
${subtitle ? `<p class="reports-item-judgement">${this.escape(subtitle)}</p>` : ""}
|
|
5016
5138
|
${metaHtml}
|
|
5017
5139
|
</a>
|
|
5018
5140
|
</li>
|
|
5019
5141
|
`;
|
|
5020
5142
|
},
|
|
5021
5143
|
|
|
5144
|
+
/** Pull the first prose paragraph from a brief's bodyMd as a
|
|
5145
|
+
* subtitle fallback. Skips markdown headers, code fences, table
|
|
5146
|
+
* rows, list markers, blockquotes — finds the first content line
|
|
5147
|
+
* that reads as prose. Strips inline markdown (bold/italic/code/
|
|
5148
|
+
* link syntax) so the excerpt reads as plain text. */
|
|
5149
|
+
_extractBriefExcerpt(md) {
|
|
5150
|
+
if (!md || typeof md !== "string") return "";
|
|
5151
|
+
const lines = md.split("\n");
|
|
5152
|
+
for (const line of lines) {
|
|
5153
|
+
const t = line.trim();
|
|
5154
|
+
if (!t) continue;
|
|
5155
|
+
if (t.startsWith("#")) continue; // markdown heading
|
|
5156
|
+
if (t.startsWith("```")) continue; // code fence
|
|
5157
|
+
if (t.startsWith("|") || /^[-=]{3,}$/.test(t)) continue; // table / hr
|
|
5158
|
+
if (t.startsWith("> ")) continue; // blockquote
|
|
5159
|
+
if (/^[-*+]\s/.test(t)) continue; // list item
|
|
5160
|
+
if (/^\d+\.\s/.test(t)) continue; // ordered list item
|
|
5161
|
+
const cleaned = t
|
|
5162
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
5163
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
5164
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
5165
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
5166
|
+
if (cleaned.length >= 12) return cleaned.slice(0, 240);
|
|
5167
|
+
}
|
|
5168
|
+
return md.slice(0, 240).replace(/\s+/g, " ").trim();
|
|
5169
|
+
},
|
|
5170
|
+
|
|
5022
5171
|
// ── All Notes view · chairman's notes index ───────────────
|
|
5023
5172
|
/** Open the All Notes page · cross-room saved-excerpt index in
|
|
5024
5173
|
* the same main-view-replacement pattern as openAllReports.
|
|
@@ -5697,10 +5846,12 @@
|
|
|
5697
5846
|
// of which composer we're switching to.
|
|
5698
5847
|
document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
5699
5848
|
document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
5700
|
-
// If the URL still carries
|
|
5701
|
-
// navigation, drop it — otherwise refresh would bounce
|
|
5702
|
-
// back to the
|
|
5703
|
-
|
|
5849
|
+
// If the URL still carries an All-Reports / All-Notes hash from
|
|
5850
|
+
// a prior navigation, drop it — otherwise refresh would bounce
|
|
5851
|
+
// the user back to that view (handleRoute matches the hash on
|
|
5852
|
+
// boot and beats the sidebar-restore path that would otherwise
|
|
5853
|
+
// honour ROOMS_KEY="new").
|
|
5854
|
+
if (/^#\/(reports|notes)$/i.test(location.hash || "")) {
|
|
5704
5855
|
try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
|
|
5705
5856
|
}
|
|
5706
5857
|
|
|
@@ -5741,7 +5892,7 @@
|
|
|
5741
5892
|
const t = {
|
|
5742
5893
|
greet: greeting,
|
|
5743
5894
|
prompt: "What's on your mind today?",
|
|
5744
|
-
placeholder: "
|
|
5895
|
+
placeholder: "An idea you're not sure about, a decision you keep avoiding, or a thesis you want stress-tested. e.g. Should we lean into mid-market or stay enterprise-only? Or: What's the strongest argument against shipping the self-serve tier next quarter?",
|
|
5745
5896
|
convene: "Convene",
|
|
5746
5897
|
tuneLabel: "tune",
|
|
5747
5898
|
starterLabel: "starter",
|
|
@@ -7486,18 +7637,16 @@
|
|
|
7486
7637
|
|
|
7487
7638
|
const tone = r.mode || "constructive";
|
|
7488
7639
|
const intensity = r.intensity || "sharp";
|
|
7489
|
-
const style = r.briefStyle || "auto";
|
|
7490
7640
|
|
|
7491
|
-
// Status
|
|
7492
|
-
//
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
}
|
|
7641
|
+
// Status word for the kicker · the coloured glyph (●/❚❚/▣) is
|
|
7642
|
+
// drawn via CSS `::before` on `.kicker-status.status-{state}`
|
|
7643
|
+
// so all the styling lives next to the colour rule.
|
|
7644
|
+
const statusWord = (
|
|
7645
|
+
r.status === "paused" ? "PAUSED" :
|
|
7646
|
+
r.status === "adjourned" ? "ADJOURNED" :
|
|
7647
|
+
r.status === "live" ? "LIVE" :
|
|
7648
|
+
""
|
|
7649
|
+
);
|
|
7501
7650
|
|
|
7502
7651
|
// Three primary actions, one per state — CSS hides the wrong ones based
|
|
7503
7652
|
// on html[data-status]. Per PRD §5.2.3:
|
|
@@ -7510,20 +7659,24 @@
|
|
|
7510
7659
|
// the DOM stays stable; the collapsed state flips room-head's
|
|
7511
7660
|
// grid template to a 3-track layout so this button takes the
|
|
7512
7661
|
// leading auto-track slot.
|
|
7662
|
+
//
|
|
7663
|
+
// Compact two-row layout: kicker (mono, all the meta) + subject.
|
|
7664
|
+
// Replaces the prior three-row stack (badge+id / subject /
|
|
7665
|
+
// tagged-meta-pills) — net height reduction ~150 → ~85px.
|
|
7666
|
+
// Tone / intensity / brief-style remain editable in the room
|
|
7667
|
+
// settings overlay; only their on-header surface is collapsed.
|
|
7513
7668
|
head.innerHTML = `
|
|
7514
7669
|
<button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
|
|
7515
7670
|
<div class="room-info">
|
|
7516
|
-
<div class="room-
|
|
7517
|
-
<span class="
|
|
7518
|
-
<span class="
|
|
7671
|
+
<div class="room-kicker">
|
|
7672
|
+
<span class="kicker-num">// ROOM #${r.number}</span>
|
|
7673
|
+
<span class="kicker-sep">·</span>
|
|
7674
|
+
<span class="kicker-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}">${this.escape(tone.toUpperCase())}</span>
|
|
7675
|
+
<span class="kicker-sep">·</span>
|
|
7676
|
+
<span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
|
|
7677
|
+
${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
|
|
7519
7678
|
</div>
|
|
7520
7679
|
<h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(r.subject)}</h1>
|
|
7521
|
-
<div class="room-meta" data-room-meta>
|
|
7522
|
-
<span class="meta-tag tag-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}"><span class="k">tone</span><span class="v">${this.escape(tone)}</span></span>
|
|
7523
|
-
<span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${this.escape(intensity)}</span></span>
|
|
7524
|
-
<span class="meta-tag tag-report"><span class="k">report</span><span class="v">${this.escape(style)}</span></span>
|
|
7525
|
-
${stamp ? `<span class="meta-stamp">${this.escape(stamp)}</span>` : ""}
|
|
7526
|
-
</div>
|
|
7527
7680
|
</div>
|
|
7528
7681
|
<div class="head-actions">
|
|
7529
7682
|
<div class="head-cast">${castHtml}</div>
|
|
@@ -7556,8 +7709,9 @@
|
|
|
7556
7709
|
// overflow:hidden chain (.main / .main-view both clip absolutely-
|
|
7557
7710
|
// positioned descendants and CAN'T be relaxed without breaking
|
|
7558
7711
|
// chat scroll). Body-attached fixed-position tooltip is the only
|
|
7559
|
-
// reliable approach.
|
|
7560
|
-
|
|
7712
|
+
// reliable approach. Anchor moved from the old .meta-tag pill to
|
|
7713
|
+
// .kicker-tone in the compact two-row header.
|
|
7714
|
+
const toneTag = head.querySelector(".kicker-tone[data-tone-tip]");
|
|
7561
7715
|
if (toneTag) {
|
|
7562
7716
|
toneTag.addEventListener("mouseenter", () => this.showToneTip(toneTag));
|
|
7563
7717
|
toneTag.addEventListener("mouseleave", () => this.hideToneTip());
|
|
@@ -7602,6 +7756,14 @@
|
|
|
7602
7756
|
renderChat() {
|
|
7603
7757
|
const chat = document.querySelector("[data-chat-messages]");
|
|
7604
7758
|
if (!chat) return;
|
|
7759
|
+
// Strip the composer-centring class · real chat messages take
|
|
7760
|
+
// over `[data-chat-messages]` here, and chat-message mode wants
|
|
7761
|
+
// the default top-flow scroll (messages stack from the top).
|
|
7762
|
+
const chatScroller = chat.closest(".chat");
|
|
7763
|
+
if (chatScroller) {
|
|
7764
|
+
chatScroller.classList.remove("chat--composer");
|
|
7765
|
+
chatScroller.classList.remove("chat--composer-overflow");
|
|
7766
|
+
}
|
|
7605
7767
|
const messages = this.currentMessages.slice();
|
|
7606
7768
|
const r = this.currentRoom;
|
|
7607
7769
|
const banner = r
|
|
@@ -8132,7 +8294,7 @@
|
|
|
8132
8294
|
// tight (~4 lines of body text) so even mid-length openers
|
|
8133
8295
|
// collapse — the user can always one-click to read the full
|
|
8134
8296
|
// text. Toggle handler lives at doc level (see init).
|
|
8135
|
-
const isLongOpener = (m.body || "").length >
|
|
8297
|
+
const isLongOpener = (m.body || "").length > 80;
|
|
8136
8298
|
// Follow-up rooms get an "origin" row above the question that
|
|
8137
8299
|
// names the parent room (number + subject) and links back to
|
|
8138
8300
|
// it via the hash route. Without this, a follow-up looks
|
|
@@ -9074,10 +9236,11 @@
|
|
|
9074
9236
|
? "GENERATING…"
|
|
9075
9237
|
: "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
|
|
9076
9238
|
|
|
9077
|
-
// Open Report URL · routes to /
|
|
9078
|
-
// /report.html for research-note
|
|
9079
|
-
//
|
|
9080
|
-
//
|
|
9239
|
+
// Open Report URL · routes to /magazine.html or /newspaper.html
|
|
9240
|
+
// for the structured modes and /report.html for research-note
|
|
9241
|
+
// briefs (default). The structured renderers don't need the
|
|
9242
|
+
// room id so the URL is shorter for those modes. See
|
|
9243
|
+
// briefViewerHref for the routing table.
|
|
9081
9244
|
const reportHref = this.briefViewerHref(b, this.currentRoomId)
|
|
9082
9245
|
|| (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
|
|
9083
9246
|
|
|
@@ -9221,19 +9384,6 @@
|
|
|
9221
9384
|
"Drafting the Strategic Planning Assumption",
|
|
9222
9385
|
"Polishing the final pass",
|
|
9223
9386
|
],
|
|
9224
|
-
// Bento's `write` stage does different work · one chair-LLM
|
|
9225
|
-
// call that compresses the room into the 8-slot bento layout.
|
|
9226
|
-
// Keyed under `bento-write` and selected at render time when
|
|
9227
|
-
// the brief's mode is bento.
|
|
9228
|
-
"bento-write": [
|
|
9229
|
-
"Compressing to the takeaway · the 1-line title",
|
|
9230
|
-
"Picking the 3 milestones",
|
|
9231
|
-
"Sizing the big-number callouts",
|
|
9232
|
-
"Mapping ranked bars from the room's data",
|
|
9233
|
-
"Distilling the verification signals",
|
|
9234
|
-
"Compressing recommendations into elevator-pitch lines",
|
|
9235
|
-
"Naming the closing flow · before → after",
|
|
9236
|
-
],
|
|
9237
9387
|
// Magazine mode · same single-pass chair-LLM call but the
|
|
9238
9388
|
// emphasis is on editorial cover-line composition rather
|
|
9239
9389
|
// than infographic compression. Keyed under `magazine-write`
|
|
@@ -9259,6 +9409,18 @@
|
|
|
9259
9409
|
"Stacking the more-headings sidebar",
|
|
9260
9410
|
"Stamping the masthead date",
|
|
9261
9411
|
],
|
|
9412
|
+
// PPT mode · same single-pass chair-LLM call · biases toward
|
|
9413
|
+
// slide-friendly short claims and one-idea-per-slide content.
|
|
9414
|
+
// Keyed under `ppt-write` and selected at render time when
|
|
9415
|
+
// the brief's mode is ppt.
|
|
9416
|
+
"ppt-write": [
|
|
9417
|
+
"Drafting the cover slide",
|
|
9418
|
+
"Sketching the agenda",
|
|
9419
|
+
"Writing milestone slides",
|
|
9420
|
+
"Compressing recommendations to bullets",
|
|
9421
|
+
"Sizing the data callouts",
|
|
9422
|
+
"Stamping the closing takeaway",
|
|
9423
|
+
],
|
|
9262
9424
|
},
|
|
9263
9425
|
// System UI · always English. The `zh` key is kept as an alias
|
|
9264
9426
|
// of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
|
|
@@ -9430,9 +9592,9 @@
|
|
|
9430
9592
|
// pipeline labels are the app's voice (chrome around the
|
|
9431
9593
|
// generation), not the report content itself.
|
|
9432
9594
|
const STRUCTURED_WRITE_LABELS = {
|
|
9433
|
-
bento: "Composing the bento",
|
|
9434
9595
|
magazine: "Composing the magazine",
|
|
9435
9596
|
newspaper: "Composing the newspaper",
|
|
9597
|
+
ppt: "Composing the deck",
|
|
9436
9598
|
};
|
|
9437
9599
|
const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
|
|
9438
9600
|
? [
|
|
@@ -9499,15 +9661,12 @@
|
|
|
9499
9661
|
}
|
|
9500
9662
|
|
|
9501
9663
|
// Rotating sub-line · advances every 3s within the active stage.
|
|
9502
|
-
// Bento mode's `write` stage does different work than the
|
|
9503
|
-
// research-note `write` stage — pick its dedicated rotator
|
|
9504
|
-
// (`bento-write`) when active. Falls through to the regular
|
|
9505
|
-
// key for every other stage / mode.
|
|
9506
|
-
let substageText = "";
|
|
9507
9664
|
// Structured-mode write stages get their own rotator copy
|
|
9508
|
-
// (
|
|
9509
|
-
//
|
|
9510
|
-
//
|
|
9665
|
+
// (`magazine-write` / `newspaper-write`) since the work is
|
|
9666
|
+
// different from research-note's chapter-by-chapter write.
|
|
9667
|
+
// Falls through to the regular stage key for every other
|
|
9668
|
+
// stage / mode.
|
|
9669
|
+
let substageText = "";
|
|
9511
9670
|
let subKey = activeDef.key;
|
|
9512
9671
|
if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
|
|
9513
9672
|
subKey = `${b.mode}-write`;
|