privateboard 0.1.8 → 0.1.9
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 +269 -113
- package/public/index.html +304 -126
- 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) {
|
|
@@ -3887,6 +3908,47 @@
|
|
|
3887
3908
|
this.composerMode = "room";
|
|
3888
3909
|
},
|
|
3889
3910
|
|
|
3911
|
+
/** Toggle the `isPinned` flag for an agent · the sidebar pin
|
|
3912
|
+
* button calls this. Persists to the server (PATCH /api/agents/:id
|
|
3913
|
+
* { isPinned }), updates local state in place, then re-renders
|
|
3914
|
+
* the sidebar so the row moves between the Pinned / Custom /
|
|
3915
|
+
* Core buckets. Optimistic with rollback on failure: we flip the
|
|
3916
|
+
* flag locally + repaint immediately, then revert if the PATCH
|
|
3917
|
+
* fails — no waiting on the round-trip for visible feedback. */
|
|
3918
|
+
async togglePinAgent(agentId) {
|
|
3919
|
+
if (!agentId) return;
|
|
3920
|
+
const idx = (this.agents || []).findIndex((a) => a && a.id === agentId);
|
|
3921
|
+
if (idx < 0) return;
|
|
3922
|
+
const agent = this.agents[idx];
|
|
3923
|
+
const prev = !!agent.isPinned;
|
|
3924
|
+
const next = !prev;
|
|
3925
|
+
// Optimistic flip · render immediately so the click feels instant.
|
|
3926
|
+
agent.isPinned = next;
|
|
3927
|
+
this.agentsById[agent.id] = agent;
|
|
3928
|
+
this.renderSidebarAgents();
|
|
3929
|
+
try {
|
|
3930
|
+
const r = await fetch("/api/agents/" + encodeURIComponent(agentId), {
|
|
3931
|
+
method: "PATCH",
|
|
3932
|
+
headers: { "content-type": "application/json" },
|
|
3933
|
+
body: JSON.stringify({ isPinned: next }),
|
|
3934
|
+
});
|
|
3935
|
+
if (!r.ok) throw new Error("HTTP " + r.status);
|
|
3936
|
+
// Server returns the updated row · merge the canonical fields
|
|
3937
|
+
// back in so any side-effect updates (updated_at, etc.) land.
|
|
3938
|
+
const updated = await r.json();
|
|
3939
|
+
if (updated && updated.id) {
|
|
3940
|
+
this.agents[idx] = updated;
|
|
3941
|
+
this.agentsById[updated.id] = updated;
|
|
3942
|
+
}
|
|
3943
|
+
} catch (e) {
|
|
3944
|
+
// Rollback · keep the UI honest about persistence failure.
|
|
3945
|
+
agent.isPinned = prev;
|
|
3946
|
+
this.agentsById[agent.id] = agent;
|
|
3947
|
+
this.renderSidebarAgents();
|
|
3948
|
+
alert((e && e.message ? e.message : "pin failed") + " — try again.");
|
|
3949
|
+
}
|
|
3950
|
+
},
|
|
3951
|
+
|
|
3890
3952
|
/** Render the sidebar's Agents panel from the live agent catalog.
|
|
3891
3953
|
* Three buckets so the seeded directors keep their familiar
|
|
3892
3954
|
* groupings while user-created ones stack into a Custom section
|
|
@@ -4606,7 +4668,44 @@
|
|
|
4606
4668
|
if (brief) brief.innerHTML = "";
|
|
4607
4669
|
|
|
4608
4670
|
const chatScroller = document.querySelector(".chat");
|
|
4609
|
-
if (chatScroller)
|
|
4671
|
+
if (chatScroller) {
|
|
4672
|
+
chatScroller.scrollTop = 0;
|
|
4673
|
+
// Mark the chat as hosting a composer so the CSS rule
|
|
4674
|
+
// `.chat.chat--composer` flips it to a grid with
|
|
4675
|
+
// `align-content: center` — vertically centring the
|
|
4676
|
+
// composer when it fits the viewport. When .cmp's natural
|
|
4677
|
+
// height > .chat height we also add `.chat--composer-overflow`
|
|
4678
|
+
// (via updateComposerOverflow) which switches to a layout
|
|
4679
|
+
// where the .cmp-fold (hero + input) is min-height: 100% of
|
|
4680
|
+
// the chat with content centred inside, and .cmp-starters
|
|
4681
|
+
// flow below as scrollable extras. Removed in renderChat()
|
|
4682
|
+
// when real chat messages take over.
|
|
4683
|
+
chatScroller.classList.add("chat--composer");
|
|
4684
|
+
this.updateComposerOverflow();
|
|
4685
|
+
}
|
|
4686
|
+
},
|
|
4687
|
+
|
|
4688
|
+
/** Toggle `.chat--composer-overflow` on the chat scroller based on
|
|
4689
|
+
* whether the composer's natural height exceeds the visible chat
|
|
4690
|
+
* area. In overflow mode the hero (`.cmp-fold`) anchors at the
|
|
4691
|
+
* viewport centre and `.cmp-starters` flow below the fold;
|
|
4692
|
+
* without overflow, the parent's `align-content: center` keeps
|
|
4693
|
+
* the whole composer block centred. Called after composer render
|
|
4694
|
+
* and on window resize. */
|
|
4695
|
+
updateComposerOverflow() {
|
|
4696
|
+
const chat = document.querySelector(".chat.chat--composer");
|
|
4697
|
+
if (!chat) return;
|
|
4698
|
+
const cmp = chat.querySelector(".cmp");
|
|
4699
|
+
if (!cmp) return;
|
|
4700
|
+
requestAnimationFrame(() => {
|
|
4701
|
+
chat.classList.remove("chat--composer-overflow");
|
|
4702
|
+
const overflows = cmp.scrollHeight > chat.clientHeight + 1;
|
|
4703
|
+
chat.classList.toggle("chat--composer-overflow", overflows);
|
|
4704
|
+
});
|
|
4705
|
+
if (!this._composerResizeAttached) {
|
|
4706
|
+
this._composerResizeAttached = true;
|
|
4707
|
+
window.addEventListener("resize", () => this.updateComposerOverflow());
|
|
4708
|
+
}
|
|
4610
4709
|
},
|
|
4611
4710
|
|
|
4612
4711
|
/** Open the All Reports page · cross-room brief index in a card
|
|
@@ -4983,16 +5082,34 @@
|
|
|
4983
5082
|
}
|
|
4984
5083
|
},
|
|
4985
5084
|
|
|
4986
|
-
/** Single reading-list row · room kicker, title,
|
|
5085
|
+
/** Single reading-list row · room kicker, title, subtitle excerpt,
|
|
4987
5086
|
* meta tail. No card chrome — entries are separated by a hairline
|
|
4988
|
-
* inside the list.
|
|
5087
|
+
* inside the list.
|
|
5088
|
+
*
|
|
5089
|
+
* Subtitle source by mode:
|
|
5090
|
+
* · Report → bodyJson.bottomLine.judgement
|
|
5091
|
+
* · Magazine → bodyJson.kicker
|
|
5092
|
+
* · Newspaper → bodyJson.kicker
|
|
5093
|
+
* All three fall back to a cleaned-up first content paragraph
|
|
5094
|
+
* from bodyMd if the structured field is missing. */
|
|
4989
5095
|
renderReportItemHtml(b) {
|
|
4990
5096
|
const json = b.bodyJson || {};
|
|
4991
5097
|
const bottomLine = json.bottomLine || {};
|
|
4992
|
-
|
|
5098
|
+
// Pull the right subtitle for this mode. Magazine / newspaper
|
|
5099
|
+
// carry their deck/lede text in `kicker`; Report carries it
|
|
5100
|
+
// in `bottomLine.judgement`. Either may be missing on legacy
|
|
5101
|
+
// / partial briefs — fall back to the first prose paragraph
|
|
5102
|
+
// from bodyMd.
|
|
5103
|
+
const subtitle = (
|
|
5104
|
+
(bottomLine.judgement || "").trim() ||
|
|
5105
|
+
(json.kicker || "").trim() ||
|
|
5106
|
+
this._extractBriefExcerpt(b.bodyMd) ||
|
|
5107
|
+
""
|
|
5108
|
+
);
|
|
4993
5109
|
const time = this.relTime(b.createdAt) || "";
|
|
4994
5110
|
const roomLabel = b.roomName || b.roomSubject || "—";
|
|
4995
5111
|
const roomNumLabel = b.roomNumber != null ? `#${String(b.roomNumber).padStart(3, "0")}` : "";
|
|
5112
|
+
const typeLabel = this.briefModeLabel(b);
|
|
4996
5113
|
const findingsCount = Array.isArray(json.headlineFindings) ? json.headlineFindings.length : 0;
|
|
4997
5114
|
const positionsCount = Array.isArray(json.positions) ? json.positions.length : 0;
|
|
4998
5115
|
const metaParts = [];
|
|
@@ -5009,16 +5126,45 @@
|
|
|
5009
5126
|
<span class="reports-item-num">${this.escape(roomNumLabel)}</span>
|
|
5010
5127
|
<span class="reports-item-sep">·</span>
|
|
5011
5128
|
<span class="reports-item-room">${this.escape(roomLabel)}</span>
|
|
5129
|
+
<span class="reports-item-sep">·</span>
|
|
5130
|
+
<span class="reports-item-type" data-mode="${this.escape(((b && b.mode) || "research-note").toLowerCase())}">${this.escape(typeLabel)}</span>
|
|
5012
5131
|
<span class="reports-item-time">${this.escape(time)}</span>
|
|
5013
5132
|
</div>
|
|
5014
5133
|
<h3 class="reports-item-title">${this.escape(b.title || "Untitled brief")}</h3>
|
|
5015
|
-
${
|
|
5134
|
+
${subtitle ? `<p class="reports-item-judgement">${this.escape(subtitle)}</p>` : ""}
|
|
5016
5135
|
${metaHtml}
|
|
5017
5136
|
</a>
|
|
5018
5137
|
</li>
|
|
5019
5138
|
`;
|
|
5020
5139
|
},
|
|
5021
5140
|
|
|
5141
|
+
/** Pull the first prose paragraph from a brief's bodyMd as a
|
|
5142
|
+
* subtitle fallback. Skips markdown headers, code fences, table
|
|
5143
|
+
* rows, list markers, blockquotes — finds the first content line
|
|
5144
|
+
* that reads as prose. Strips inline markdown (bold/italic/code/
|
|
5145
|
+
* link syntax) so the excerpt reads as plain text. */
|
|
5146
|
+
_extractBriefExcerpt(md) {
|
|
5147
|
+
if (!md || typeof md !== "string") return "";
|
|
5148
|
+
const lines = md.split("\n");
|
|
5149
|
+
for (const line of lines) {
|
|
5150
|
+
const t = line.trim();
|
|
5151
|
+
if (!t) continue;
|
|
5152
|
+
if (t.startsWith("#")) continue; // markdown heading
|
|
5153
|
+
if (t.startsWith("```")) continue; // code fence
|
|
5154
|
+
if (t.startsWith("|") || /^[-=]{3,}$/.test(t)) continue; // table / hr
|
|
5155
|
+
if (t.startsWith("> ")) continue; // blockquote
|
|
5156
|
+
if (/^[-*+]\s/.test(t)) continue; // list item
|
|
5157
|
+
if (/^\d+\.\s/.test(t)) continue; // ordered list item
|
|
5158
|
+
const cleaned = t
|
|
5159
|
+
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
5160
|
+
.replace(/\*([^*]+)\*/g, "$1")
|
|
5161
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
5162
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
|
|
5163
|
+
if (cleaned.length >= 12) return cleaned.slice(0, 240);
|
|
5164
|
+
}
|
|
5165
|
+
return md.slice(0, 240).replace(/\s+/g, " ").trim();
|
|
5166
|
+
},
|
|
5167
|
+
|
|
5022
5168
|
// ── All Notes view · chairman's notes index ───────────────
|
|
5023
5169
|
/** Open the All Notes page · cross-room saved-excerpt index in
|
|
5024
5170
|
* the same main-view-replacement pattern as openAllReports.
|
|
@@ -5697,10 +5843,12 @@
|
|
|
5697
5843
|
// of which composer we're switching to.
|
|
5698
5844
|
document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
5699
5845
|
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
|
-
|
|
5846
|
+
// If the URL still carries an All-Reports / All-Notes hash from
|
|
5847
|
+
// a prior navigation, drop it — otherwise refresh would bounce
|
|
5848
|
+
// the user back to that view (handleRoute matches the hash on
|
|
5849
|
+
// boot and beats the sidebar-restore path that would otherwise
|
|
5850
|
+
// honour ROOMS_KEY="new").
|
|
5851
|
+
if (/^#\/(reports|notes)$/i.test(location.hash || "")) {
|
|
5704
5852
|
try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
|
|
5705
5853
|
}
|
|
5706
5854
|
|
|
@@ -5741,7 +5889,7 @@
|
|
|
5741
5889
|
const t = {
|
|
5742
5890
|
greet: greeting,
|
|
5743
5891
|
prompt: "What's on your mind today?",
|
|
5744
|
-
placeholder: "
|
|
5892
|
+
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
5893
|
convene: "Convene",
|
|
5746
5894
|
tuneLabel: "tune",
|
|
5747
5895
|
starterLabel: "starter",
|
|
@@ -7486,18 +7634,16 @@
|
|
|
7486
7634
|
|
|
7487
7635
|
const tone = r.mode || "constructive";
|
|
7488
7636
|
const intensity = r.intensity || "sharp";
|
|
7489
|
-
const style = r.briefStyle || "auto";
|
|
7490
7637
|
|
|
7491
|
-
// Status
|
|
7492
|
-
//
|
|
7493
|
-
|
|
7494
|
-
|
|
7495
|
-
|
|
7496
|
-
|
|
7497
|
-
|
|
7498
|
-
|
|
7499
|
-
|
|
7500
|
-
}
|
|
7638
|
+
// Status word for the kicker · the coloured glyph (●/❚❚/▣) is
|
|
7639
|
+
// drawn via CSS `::before` on `.kicker-status.status-{state}`
|
|
7640
|
+
// so all the styling lives next to the colour rule.
|
|
7641
|
+
const statusWord = (
|
|
7642
|
+
r.status === "paused" ? "PAUSED" :
|
|
7643
|
+
r.status === "adjourned" ? "ADJOURNED" :
|
|
7644
|
+
r.status === "live" ? "LIVE" :
|
|
7645
|
+
""
|
|
7646
|
+
);
|
|
7501
7647
|
|
|
7502
7648
|
// Three primary actions, one per state — CSS hides the wrong ones based
|
|
7503
7649
|
// on html[data-status]. Per PRD §5.2.3:
|
|
@@ -7510,20 +7656,24 @@
|
|
|
7510
7656
|
// the DOM stays stable; the collapsed state flips room-head's
|
|
7511
7657
|
// grid template to a 3-track layout so this button takes the
|
|
7512
7658
|
// leading auto-track slot.
|
|
7659
|
+
//
|
|
7660
|
+
// Compact two-row layout: kicker (mono, all the meta) + subject.
|
|
7661
|
+
// Replaces the prior three-row stack (badge+id / subject /
|
|
7662
|
+
// tagged-meta-pills) — net height reduction ~150 → ~85px.
|
|
7663
|
+
// Tone / intensity / brief-style remain editable in the room
|
|
7664
|
+
// settings overlay; only their on-header surface is collapsed.
|
|
7513
7665
|
head.innerHTML = `
|
|
7514
7666
|
<button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
|
|
7515
7667
|
<div class="room-info">
|
|
7516
|
-
<div class="room-
|
|
7517
|
-
<span class="
|
|
7518
|
-
<span class="
|
|
7668
|
+
<div class="room-kicker">
|
|
7669
|
+
<span class="kicker-num">// ROOM #${r.number}</span>
|
|
7670
|
+
<span class="kicker-sep">·</span>
|
|
7671
|
+
<span class="kicker-tone" data-tone-tip="${this.escape(TONE_TIPS[tone] || "")}">${this.escape(tone.toUpperCase())}</span>
|
|
7672
|
+
<span class="kicker-sep">·</span>
|
|
7673
|
+
<span class="kicker-intensity">${this.escape(intensity.toUpperCase())}</span>
|
|
7674
|
+
${statusWord ? `<span class="kicker-sep">·</span><span class="kicker-status status-${this.escape(r.status)}">${statusWord}</span>` : ""}
|
|
7519
7675
|
</div>
|
|
7520
7676
|
<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
7677
|
</div>
|
|
7528
7678
|
<div class="head-actions">
|
|
7529
7679
|
<div class="head-cast">${castHtml}</div>
|
|
@@ -7556,8 +7706,9 @@
|
|
|
7556
7706
|
// overflow:hidden chain (.main / .main-view both clip absolutely-
|
|
7557
7707
|
// positioned descendants and CAN'T be relaxed without breaking
|
|
7558
7708
|
// chat scroll). Body-attached fixed-position tooltip is the only
|
|
7559
|
-
// reliable approach.
|
|
7560
|
-
|
|
7709
|
+
// reliable approach. Anchor moved from the old .meta-tag pill to
|
|
7710
|
+
// .kicker-tone in the compact two-row header.
|
|
7711
|
+
const toneTag = head.querySelector(".kicker-tone[data-tone-tip]");
|
|
7561
7712
|
if (toneTag) {
|
|
7562
7713
|
toneTag.addEventListener("mouseenter", () => this.showToneTip(toneTag));
|
|
7563
7714
|
toneTag.addEventListener("mouseleave", () => this.hideToneTip());
|
|
@@ -7602,6 +7753,14 @@
|
|
|
7602
7753
|
renderChat() {
|
|
7603
7754
|
const chat = document.querySelector("[data-chat-messages]");
|
|
7604
7755
|
if (!chat) return;
|
|
7756
|
+
// Strip the composer-centring class · real chat messages take
|
|
7757
|
+
// over `[data-chat-messages]` here, and chat-message mode wants
|
|
7758
|
+
// the default top-flow scroll (messages stack from the top).
|
|
7759
|
+
const chatScroller = chat.closest(".chat");
|
|
7760
|
+
if (chatScroller) {
|
|
7761
|
+
chatScroller.classList.remove("chat--composer");
|
|
7762
|
+
chatScroller.classList.remove("chat--composer-overflow");
|
|
7763
|
+
}
|
|
7605
7764
|
const messages = this.currentMessages.slice();
|
|
7606
7765
|
const r = this.currentRoom;
|
|
7607
7766
|
const banner = r
|
|
@@ -8132,7 +8291,7 @@
|
|
|
8132
8291
|
// tight (~4 lines of body text) so even mid-length openers
|
|
8133
8292
|
// collapse — the user can always one-click to read the full
|
|
8134
8293
|
// text. Toggle handler lives at doc level (see init).
|
|
8135
|
-
const isLongOpener = (m.body || "").length >
|
|
8294
|
+
const isLongOpener = (m.body || "").length > 80;
|
|
8136
8295
|
// Follow-up rooms get an "origin" row above the question that
|
|
8137
8296
|
// names the parent room (number + subject) and links back to
|
|
8138
8297
|
// it via the hash route. Without this, a follow-up looks
|
|
@@ -9074,10 +9233,11 @@
|
|
|
9074
9233
|
? "GENERATING…"
|
|
9075
9234
|
: "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
|
|
9076
9235
|
|
|
9077
|
-
// Open Report URL · routes to /
|
|
9078
|
-
// /report.html for research-note
|
|
9079
|
-
//
|
|
9080
|
-
//
|
|
9236
|
+
// Open Report URL · routes to /magazine.html or /newspaper.html
|
|
9237
|
+
// for the structured modes and /report.html for research-note
|
|
9238
|
+
// briefs (default). The structured renderers don't need the
|
|
9239
|
+
// room id so the URL is shorter for those modes. See
|
|
9240
|
+
// briefViewerHref for the routing table.
|
|
9081
9241
|
const reportHref = this.briefViewerHref(b, this.currentRoomId)
|
|
9082
9242
|
|| (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
|
|
9083
9243
|
|
|
@@ -9221,19 +9381,6 @@
|
|
|
9221
9381
|
"Drafting the Strategic Planning Assumption",
|
|
9222
9382
|
"Polishing the final pass",
|
|
9223
9383
|
],
|
|
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
9384
|
// Magazine mode · same single-pass chair-LLM call but the
|
|
9238
9385
|
// emphasis is on editorial cover-line composition rather
|
|
9239
9386
|
// than infographic compression. Keyed under `magazine-write`
|
|
@@ -9259,6 +9406,18 @@
|
|
|
9259
9406
|
"Stacking the more-headings sidebar",
|
|
9260
9407
|
"Stamping the masthead date",
|
|
9261
9408
|
],
|
|
9409
|
+
// PPT mode · same single-pass chair-LLM call · biases toward
|
|
9410
|
+
// slide-friendly short claims and one-idea-per-slide content.
|
|
9411
|
+
// Keyed under `ppt-write` and selected at render time when
|
|
9412
|
+
// the brief's mode is ppt.
|
|
9413
|
+
"ppt-write": [
|
|
9414
|
+
"Drafting the cover slide",
|
|
9415
|
+
"Sketching the agenda",
|
|
9416
|
+
"Writing milestone slides",
|
|
9417
|
+
"Compressing recommendations to bullets",
|
|
9418
|
+
"Sizing the data callouts",
|
|
9419
|
+
"Stamping the closing takeaway",
|
|
9420
|
+
],
|
|
9262
9421
|
},
|
|
9263
9422
|
// System UI · always English. The `zh` key is kept as an alias
|
|
9264
9423
|
// of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
|
|
@@ -9430,9 +9589,9 @@
|
|
|
9430
9589
|
// pipeline labels are the app's voice (chrome around the
|
|
9431
9590
|
// generation), not the report content itself.
|
|
9432
9591
|
const STRUCTURED_WRITE_LABELS = {
|
|
9433
|
-
bento: "Composing the bento",
|
|
9434
9592
|
magazine: "Composing the magazine",
|
|
9435
9593
|
newspaper: "Composing the newspaper",
|
|
9594
|
+
ppt: "Composing the deck",
|
|
9436
9595
|
};
|
|
9437
9596
|
const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
|
|
9438
9597
|
? [
|
|
@@ -9499,15 +9658,12 @@
|
|
|
9499
9658
|
}
|
|
9500
9659
|
|
|
9501
9660
|
// 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
9661
|
// Structured-mode write stages get their own rotator copy
|
|
9508
|
-
// (
|
|
9509
|
-
//
|
|
9510
|
-
//
|
|
9662
|
+
// (`magazine-write` / `newspaper-write`) since the work is
|
|
9663
|
+
// different from research-note's chapter-by-chapter write.
|
|
9664
|
+
// Falls through to the regular stage key for every other
|
|
9665
|
+
// stage / mode.
|
|
9666
|
+
let substageText = "";
|
|
9511
9667
|
let subKey = activeDef.key;
|
|
9512
9668
|
if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
|
|
9513
9669
|
subKey = `${b.mode}-write`;
|