privateboard 0.1.7 → 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 +1423 -46
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
- package/public/adjourn-overlay.css +195 -0
- package/public/agent-profile.css +525 -0
- package/public/agent-profile.js +278 -1
- package/public/app.js +634 -118
- package/public/home.html +389 -17
- package/public/index.html +325 -130
- package/public/magazine.html +1685 -0
- package/public/newspaper.html +1892 -0
- package/public/ppt.html +2623 -0
- package/public/report.html +366 -52
- package/public/room-settings.css +40 -4
- package/public/room-settings.js +44 -2
- package/public/user-settings.css +117 -68
- package/public/user-settings.js +77 -46
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 = [];
|
|
@@ -912,11 +910,37 @@
|
|
|
912
910
|
}
|
|
913
911
|
} else if (kind === "brief-started") {
|
|
914
912
|
this.markBriefEvent();
|
|
913
|
+
// Mode determines BOTH the seeded stage map and which pip
|
|
914
|
+
// rail renderBriefStages picks. Bento, magazine, and
|
|
915
|
+
// newspaper modes each emit only 2 stage events (extract
|
|
916
|
+
// + write), so seeding the 7-stage layout for a
|
|
917
|
+
// structured-mode brief leaves the 5 middle pips forever
|
|
918
|
+
// pending — the rail then renders with a row of dead
|
|
919
|
+
// black pips between extract and write. Read mode from
|
|
920
|
+
// the payload (server includes it on brief-started for
|
|
921
|
+
// exactly this reason) and seed accordingly.
|
|
922
|
+
const isStructured = this.isStructuredBriefMode(payload.mode);
|
|
923
|
+
const briefMode = isStructured ? payload.mode : "research-note";
|
|
924
|
+
const seededStages = isStructured
|
|
925
|
+
? {
|
|
926
|
+
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
927
|
+
write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
928
|
+
}
|
|
929
|
+
: {
|
|
930
|
+
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
931
|
+
compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
932
|
+
"scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
933
|
+
"scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
934
|
+
"scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
935
|
+
"scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
936
|
+
write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
937
|
+
};
|
|
915
938
|
const newBrief = {
|
|
916
939
|
id: payload.briefId,
|
|
917
940
|
title: "Generating…",
|
|
918
941
|
bodyMd: "",
|
|
919
942
|
style: payload.style || "mckinsey",
|
|
943
|
+
mode: briefMode,
|
|
920
944
|
// Carry the supplement so the tab strip can render the
|
|
921
945
|
// in-progress brief with its supplement label ("xxx 视角")
|
|
922
946
|
// immediately, instead of waiting for brief-final to
|
|
@@ -931,21 +955,13 @@
|
|
|
931
955
|
language: payload.language === "zh" ? "zh" : "en",
|
|
932
956
|
pipelineStartedAt: Date.now(),
|
|
933
957
|
createdAt: Date.now(),
|
|
934
|
-
// Stage checklist · seeded
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
stages:
|
|
941
|
-
extract: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
942
|
-
compose: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
943
|
-
"scaffold-anchor": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
944
|
-
"scaffold-findings": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
945
|
-
"scaffold-cluster": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
946
|
-
"scaffold-actions": { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
947
|
-
write: { status: "pending", detail: "", progress: null, startedAt: null, etaSec: null },
|
|
948
|
-
},
|
|
958
|
+
// Stage checklist · seeded according to the brief mode (see
|
|
959
|
+
// briefMode resolution above). brief-stage events flip
|
|
960
|
+
// them active → done as the pipeline progresses.
|
|
961
|
+
// startedAt is captured when each stage first becomes
|
|
962
|
+
// active so the UI can display elapsed time alongside the
|
|
963
|
+
// ETA range.
|
|
964
|
+
stages: seededStages,
|
|
949
965
|
};
|
|
950
966
|
this.currentBrief = newBrief;
|
|
951
967
|
// Insert the in-progress brief into currentBriefs so the tab
|
|
@@ -1076,10 +1092,14 @@
|
|
|
1076
1092
|
}
|
|
1077
1093
|
}
|
|
1078
1094
|
}
|
|
1079
|
-
//
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
|
|
1095
|
+
// No typing-SFX during the brief writer's Stage 3 stream.
|
|
1096
|
+
// The write phase is a long, off-screen streaming pass (the
|
|
1097
|
+
// user is usually reading prior content or doing something
|
|
1098
|
+
// else while the report builds) — a continuous click track
|
|
1099
|
+
// for tens of seconds reads as background noise, not a
|
|
1100
|
+
// presence cue. Director / chair turns still tick because
|
|
1101
|
+
// those land in the active conversation the user is
|
|
1102
|
+
// following.
|
|
1083
1103
|
} else if (kind === "brief-final") {
|
|
1084
1104
|
this.markBriefEvent();
|
|
1085
1105
|
const target = this._briefById(payload.briefId);
|
|
@@ -1484,6 +1504,196 @@
|
|
|
1484
1504
|
return false;
|
|
1485
1505
|
},
|
|
1486
1506
|
|
|
1507
|
+
/** Has the brief got its body content yet? Mode-aware check ·
|
|
1508
|
+
* research-note briefs land their content in `bodyMd`; bento
|
|
1509
|
+
* briefs land theirs in `bodyJson` and leave bodyMd empty. The
|
|
1510
|
+
* card-rendering / placeholder-detection / generating-check
|
|
1511
|
+
* logic uses this everywhere so a bento brief doesn't stay
|
|
1512
|
+
* stuck in "generating…" state after the BentoScaffold has
|
|
1513
|
+
* actually been persisted.
|
|
1514
|
+
*
|
|
1515
|
+
* Without this helper, a successful bento generation followed
|
|
1516
|
+
* by a page refresh hid the View Report button forever (the
|
|
1517
|
+
* card thought it was still generating because bodyMd was empty
|
|
1518
|
+
* — which is the steady-state shape for bento, not an
|
|
1519
|
+
* in-flight signal). */
|
|
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. */
|
|
1525
|
+
isStructuredBriefMode(mode) {
|
|
1526
|
+
return mode === "magazine" || mode === "newspaper" || mode === "ppt";
|
|
1527
|
+
},
|
|
1528
|
+
|
|
1529
|
+
briefHasBody(b) {
|
|
1530
|
+
if (!b) return false;
|
|
1531
|
+
if (this.isStructuredBriefMode(b.mode)) {
|
|
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.
|
|
1535
|
+
const j = b.bodyJson;
|
|
1536
|
+
return !!(j && typeof j === "object" && (j.title || j.milestones));
|
|
1537
|
+
}
|
|
1538
|
+
// Default research-note · body lives in markdown.
|
|
1539
|
+
return !!(b.bodyMd && b.bodyMd.trim());
|
|
1540
|
+
},
|
|
1541
|
+
|
|
1542
|
+
/** Build the right viewer URL for a brief based on its mode.
|
|
1543
|
+
* · 'research-note' (default · or unknown) → `/report.html?r=R&b=B`
|
|
1544
|
+
* · 'magazine' → `/magazine.html?b=B`
|
|
1545
|
+
* · 'newspaper' → `/newspaper.html?b=B`
|
|
1546
|
+
* · 'ppt' → `/ppt.html?b=B`
|
|
1547
|
+
*
|
|
1548
|
+
* Structured renderers don't need the room context · the brief
|
|
1549
|
+
* is self-contained. Used by the View Report button, the
|
|
1550
|
+
* brief-picker popover, the open-report link in the brief card,
|
|
1551
|
+
* and the All Reports page — one helper so a future renderer
|
|
1552
|
+
* route change touches one place. */
|
|
1553
|
+
briefViewerHref(b, roomId) {
|
|
1554
|
+
if (!b || !b.id) return null;
|
|
1555
|
+
const id = encodeURIComponent(b.id);
|
|
1556
|
+
if (b.mode === "magazine") return `/magazine.html?b=${id}`;
|
|
1557
|
+
if (b.mode === "newspaper") return `/newspaper.html?b=${id}`;
|
|
1558
|
+
if (b.mode === "ppt") return `/ppt.html?b=${id}`;
|
|
1559
|
+
const r = roomId ? encodeURIComponent(roomId) : "";
|
|
1560
|
+
return r ? `/report.html?r=${r}&b=${id}` : `/report.html?b=${id}`;
|
|
1561
|
+
},
|
|
1562
|
+
|
|
1563
|
+
/** Human-readable label for a brief's mode · used in the brief-card
|
|
1564
|
+
* banner so the user sees which renderer the report will use
|
|
1565
|
+
* before they open it.
|
|
1566
|
+
*
|
|
1567
|
+
* IMPORTANT · the user-facing label for `research-note` is
|
|
1568
|
+
* "Report", NOT "Research Note". The internal mode key stays as
|
|
1569
|
+
* `research-note` (storage column, API value, picker option id),
|
|
1570
|
+
* but every user-facing surface — the mode picker (`renderBrief-
|
|
1571
|
+
* ModePicker` line ~1662 in this file), the report viewer's
|
|
1572
|
+
* header, etc. — calls it "Report". Keep this map in sync with
|
|
1573
|
+
* the picker labels rather than inventing a fresh user-facing
|
|
1574
|
+
* vocabulary.
|
|
1575
|
+
*
|
|
1576
|
+
* Legacy rows without an explicit `mode` default to "Report" by
|
|
1577
|
+
* the same backfill the storage layer applies. */
|
|
1578
|
+
briefModeLabel(b) {
|
|
1579
|
+
const mode = (b && b.mode) || "research-note";
|
|
1580
|
+
switch (mode) {
|
|
1581
|
+
case "magazine": return "Magazine";
|
|
1582
|
+
case "newspaper": return "Newspaper";
|
|
1583
|
+
case "ppt": return "Slides";
|
|
1584
|
+
default: return "Report";
|
|
1585
|
+
}
|
|
1586
|
+
},
|
|
1587
|
+
|
|
1588
|
+
/** Render the report-mode picker · four icon-tile cards that let
|
|
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).
|
|
1596
|
+
*
|
|
1597
|
+
* Used by both the adjourn overlay (filing the report at
|
|
1598
|
+
* adjourn-time / generate-brief flow) AND the supplement
|
|
1599
|
+
* overlay (regenerating with an extra perspective). The picker
|
|
1600
|
+
* reads its selected option via `input[name="brief-mode"]:checked`
|
|
1601
|
+
* from inside whatever overlay it lives in · the click-to-toggle
|
|
1602
|
+
* handler scopes to the `.adjourn-mode-options` container so it
|
|
1603
|
+
* works for any overlay embedding the picker. */
|
|
1604
|
+
renderBriefModePicker(defaultMode) {
|
|
1605
|
+
const safe = this.isStructuredBriefMode(defaultMode) ? defaultMode : "research-note";
|
|
1606
|
+
// Icon SVGs · `currentColor` lets each tile pick up its hover /
|
|
1607
|
+
// selected accent from the parent `.adjourn-mode-icon { color }`
|
|
1608
|
+
// rule. Stroke hierarchy is deliberate · main outlines at 1.0,
|
|
1609
|
+
// a single emphasis rule at 1.2 (newspaper headline + magazine
|
|
1610
|
+
// masthead bar), body details at 0.8. Filled blocks soften to
|
|
1611
|
+
// currentColor at 0.5 opacity so the lime selected-state reads
|
|
1612
|
+
// as a tint rather than a slab. The 24-unit viewBox renders at
|
|
1613
|
+
// 36px on screen → 1.5× scale, so stroke-1.0 is a clean 1.5px.
|
|
1614
|
+
const ICONS = {
|
|
1615
|
+
"research-note": `
|
|
1616
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1617
|
+
<path d="M6 3h9l4 4v14H6z"/>
|
|
1618
|
+
<path d="M15 3v4h4"/>
|
|
1619
|
+
<line x1="9" y1="12" x2="16" y2="12"/>
|
|
1620
|
+
<line x1="9" y1="15" x2="16" y2="15"/>
|
|
1621
|
+
<line x1="9" y1="18" x2="14" y2="18"/>
|
|
1622
|
+
</svg>`,
|
|
1623
|
+
"magazine": `
|
|
1624
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1625
|
+
<line x1="3" y1="4" x2="21" y2="4" stroke-width="1.2"/>
|
|
1626
|
+
<rect x="3" y="7" width="6" height="9" rx="1.2"/>
|
|
1627
|
+
<rect x="11" y="7" width="4.5" height="4" rx="1.2"/>
|
|
1628
|
+
<rect x="16.5" y="7" width="4.5" height="4" rx="1.2"/>
|
|
1629
|
+
<rect x="11" y="12" width="4.5" height="4" rx="1.2"/>
|
|
1630
|
+
<rect x="16.5" y="12" width="4.5" height="4" rx="1.2"/>
|
|
1631
|
+
<rect x="3" y="18" width="18" height="3" rx="1" fill="currentColor" fill-opacity="0.45" stroke="none"/>
|
|
1632
|
+
</svg>`,
|
|
1633
|
+
"newspaper": `
|
|
1634
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
1635
|
+
<rect x="3" y="3" width="18" height="2.5" fill="currentColor" fill-opacity="0.85" stroke="none"/>
|
|
1636
|
+
<line x1="7" y1="8" x2="17" y2="8" stroke-width="1.2"/>
|
|
1637
|
+
<line x1="4" y1="11.5" x2="8" y2="11.5" stroke-width="0.8"/>
|
|
1638
|
+
<line x1="4" y1="13.5" x2="8" y2="13.5" stroke-width="0.8"/>
|
|
1639
|
+
<line x1="4" y1="15.5" x2="6.5" y2="15.5" stroke-width="0.8"/>
|
|
1640
|
+
<line x1="10" y1="11.5" x2="14" y2="11.5" stroke-width="0.8"/>
|
|
1641
|
+
<line x1="10" y1="13.5" x2="14" y2="13.5" stroke-width="0.8"/>
|
|
1642
|
+
<line x1="10" y1="15.5" x2="12.5" y2="15.5" stroke-width="0.8"/>
|
|
1643
|
+
<line x1="16" y1="11.5" x2="20" y2="11.5" stroke-width="0.8"/>
|
|
1644
|
+
<line x1="16" y1="13.5" x2="20" y2="13.5" stroke-width="0.8"/>
|
|
1645
|
+
<line x1="16" y1="15.5" x2="18.5" y2="15.5" stroke-width="0.8"/>
|
|
1646
|
+
<line x1="3" y1="18.5" x2="21" y2="18.5" stroke-width="0.9"/>
|
|
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>`,
|
|
1659
|
+
};
|
|
1660
|
+
const opt = (value, title, deck, primary) => `
|
|
1661
|
+
<label class="adjourn-mode-option${primary ? " adjourn-mode-option-primary" : ""}${safe === value ? " on" : ""}">
|
|
1662
|
+
<input type="radio" name="brief-mode" value="${value}"${safe === value ? " checked" : ""}>
|
|
1663
|
+
<div class="adjourn-mode-icon">${ICONS[value] || ""}</div>
|
|
1664
|
+
<div class="adjourn-mode-body">
|
|
1665
|
+
<div class="adjourn-mode-title">${title}</div>
|
|
1666
|
+
<div class="adjourn-mode-deck">${deck}</div>
|
|
1667
|
+
</div>
|
|
1668
|
+
</label>`;
|
|
1669
|
+
return `
|
|
1670
|
+
<div class="adjourn-mode-picker" data-mode-picker>
|
|
1671
|
+
<div class="adjourn-mode-label">// report format</div>
|
|
1672
|
+
<div class="adjourn-mode-options adjourn-mode-options-4">
|
|
1673
|
+
${opt("research-note", "Report", "Long-form markdown · bottom line, findings, recommendations.", true)}
|
|
1674
|
+
${opt("magazine", "Magazine", "Editorial spread · cover line, 5 cards, dark closer.")}
|
|
1675
|
+
${opt("newspaper", "Newspaper", "Broadsheet · banner masthead, 3-column editorial.")}
|
|
1676
|
+
${opt("ppt", "Slides", "Slide deck · 7-9 slides, arrow-key navigation, present mode.")}
|
|
1677
|
+
</div>
|
|
1678
|
+
</div>`;
|
|
1679
|
+
},
|
|
1680
|
+
|
|
1681
|
+
/** Read the user's last-picked report mode from localStorage so the
|
|
1682
|
+
* picker defaults to their previous choice. Falls back to
|
|
1683
|
+
* 'research-note' on first run / private mode. */
|
|
1684
|
+
lastBriefMode() {
|
|
1685
|
+
try {
|
|
1686
|
+
const v = localStorage.getItem("pb.briefMode");
|
|
1687
|
+
return this.isStructuredBriefMode(v) ? v : "research-note";
|
|
1688
|
+
} catch { return "research-note"; }
|
|
1689
|
+
},
|
|
1690
|
+
saveLastBriefMode(mode) {
|
|
1691
|
+
try {
|
|
1692
|
+
const safe = this.isStructuredBriefMode(mode) ? mode : "research-note";
|
|
1693
|
+
localStorage.setItem("pb.briefMode", safe);
|
|
1694
|
+
} catch { /* private-mode etc. — silently ignore */ }
|
|
1695
|
+
},
|
|
1696
|
+
|
|
1487
1697
|
/** Pure cache read · returns true when the local app.keys map
|
|
1488
1698
|
* reports any model provider as configured. Used by the gate
|
|
1489
1699
|
* and (via wrappers) anywhere downstream UI needs the answer
|
|
@@ -1647,6 +1857,9 @@
|
|
|
1647
1857
|
async adjournRoom(opts) {
|
|
1648
1858
|
if (!this.currentRoomId) return;
|
|
1649
1859
|
const skipBrief = !!(opts && opts.skipBrief);
|
|
1860
|
+
const mode = opts && this.isStructuredBriefMode(opts.mode)
|
|
1861
|
+
? opts.mode
|
|
1862
|
+
: "research-note";
|
|
1650
1863
|
// Pre-flight · adjourn-with-brief triggers the brief writer
|
|
1651
1864
|
// pipeline (3 LLM stages). When skipBrief=true the chair just
|
|
1652
1865
|
// posts the no-brief marker without LLM calls, so we let it
|
|
@@ -1657,7 +1870,7 @@
|
|
|
1657
1870
|
{
|
|
1658
1871
|
method: "POST",
|
|
1659
1872
|
headers: { "content-type": "application/json" },
|
|
1660
|
-
body: JSON.stringify(skipBrief ? { skipBrief: true } : {}),
|
|
1873
|
+
body: JSON.stringify(skipBrief ? { skipBrief: true } : { mode }),
|
|
1661
1874
|
},
|
|
1662
1875
|
);
|
|
1663
1876
|
if (!r.ok) {
|
|
@@ -1691,6 +1904,10 @@
|
|
|
1691
1904
|
const confirmTxt = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
|
|
1692
1905
|
const subjectTxt = room.subject || room.name || "—";
|
|
1693
1906
|
const memberCount = (this.currentMembers || []).length;
|
|
1907
|
+
// Default the mode picker to whatever the user picked last. Bento
|
|
1908
|
+
// is opt-in · 'research-note' is the safe initial default for new
|
|
1909
|
+
// users / private-mode browsers (where localStorage is empty).
|
|
1910
|
+
const defaultMode = this.lastBriefMode();
|
|
1694
1911
|
const html = `
|
|
1695
1912
|
<div class="adjourn-overlay" id="adjourn-overlay" role="dialog" aria-modal="true" data-adjourn-mode="${this.escape(mode)}">
|
|
1696
1913
|
<div class="adjourn-backdrop" data-adjourn-close></div>
|
|
@@ -1711,9 +1928,12 @@
|
|
|
1711
1928
|
|
|
1712
1929
|
<div class="adjourn-body">
|
|
1713
1930
|
<div class="adjourn-summary">
|
|
1714
|
-
<div class="adjourn-summary-row">
|
|
1931
|
+
<div class="adjourn-summary-row adjourn-summary-row-subject">
|
|
1715
1932
|
<span class="adjourn-summary-key">// subject</span>
|
|
1716
|
-
<
|
|
1933
|
+
<div class="adjourn-summary-val adjourn-subject-wrap">
|
|
1934
|
+
<span class="adjourn-subject-text is-clamped" data-adjourn-subject>${this.escape(subjectTxt)}</span>
|
|
1935
|
+
<button type="button" class="adjourn-subject-toggle" data-adjourn-subject-toggle hidden>Show more</button>
|
|
1936
|
+
</div>
|
|
1717
1937
|
</div>
|
|
1718
1938
|
<div class="adjourn-summary-row">
|
|
1719
1939
|
<span class="adjourn-summary-key">// authors</span>
|
|
@@ -1725,10 +1945,12 @@
|
|
|
1725
1945
|
</div>
|
|
1726
1946
|
</div>
|
|
1727
1947
|
<p class="adjourn-summary-note">
|
|
1728
|
-
The chair compiles a
|
|
1729
|
-
|
|
1730
|
-
|
|
1948
|
+
The chair compiles a report from the room's transcript. Pick the
|
|
1949
|
+
format below — the room is marked adjourned and the report is
|
|
1950
|
+
filed in the chat once it's ready.
|
|
1731
1951
|
</p>
|
|
1952
|
+
|
|
1953
|
+
${this.renderBriefModePicker(defaultMode)}
|
|
1732
1954
|
</div>
|
|
1733
1955
|
|
|
1734
1956
|
<footer class="adjourn-foot">
|
|
@@ -1749,6 +1971,19 @@
|
|
|
1749
1971
|
wrap.innerHTML = html.trim();
|
|
1750
1972
|
document.body.appendChild(wrap.firstChild);
|
|
1751
1973
|
document.body.style.overflow = "hidden";
|
|
1974
|
+
// Subject clamp · the subject value is clamped to 3 lines by
|
|
1975
|
+
// default (a long room title shouldn't crowd the rest of the
|
|
1976
|
+
// overlay). After mount, measure whether the text actually
|
|
1977
|
+
// overflows the clamp — only then reveal the Show more / less
|
|
1978
|
+
// toggle. rAF lets the browser settle the initial layout so
|
|
1979
|
+
// scrollHeight is meaningful.
|
|
1980
|
+
requestAnimationFrame(() => {
|
|
1981
|
+
const subjEl = document.querySelector("[data-adjourn-subject]");
|
|
1982
|
+
const subjBtn = document.querySelector("[data-adjourn-subject-toggle]");
|
|
1983
|
+
if (subjEl && subjBtn && subjEl.scrollHeight > subjEl.clientHeight + 1) {
|
|
1984
|
+
subjBtn.hidden = false;
|
|
1985
|
+
}
|
|
1986
|
+
});
|
|
1752
1987
|
// Esc closes the overlay. Listener auto-detaches on close so
|
|
1753
1988
|
// we don't accumulate handlers across opens.
|
|
1754
1989
|
this._adjournEsc = (ev) => {
|
|
@@ -1788,6 +2023,12 @@
|
|
|
1788
2023
|
confirm: "[ Regenerate ]",
|
|
1789
2024
|
confirmBusy: "[ Regenerating… ]",
|
|
1790
2025
|
};
|
|
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();
|
|
1791
2032
|
const html = `
|
|
1792
2033
|
<div class="supplement-overlay" id="supplement-overlay" role="dialog" aria-modal="true">
|
|
1793
2034
|
<div class="supplement-backdrop" data-supplement-close></div>
|
|
@@ -1806,6 +2047,7 @@
|
|
|
1806
2047
|
<div class="supplement-body">
|
|
1807
2048
|
<textarea class="supplement-input" data-supplement-input rows="6" placeholder="${this.escape(t.placeholder)}"></textarea>
|
|
1808
2049
|
<p class="supplement-hint">${this.escape(t.hint)}</p>
|
|
2050
|
+
${this.renderBriefModePicker(defaultMode)}
|
|
1809
2051
|
</div>
|
|
1810
2052
|
<footer class="supplement-foot">
|
|
1811
2053
|
<button type="button" class="supplement-cancel" data-supplement-close>${this.escape(t.cancel)}</button>
|
|
@@ -2019,7 +2261,7 @@
|
|
|
2019
2261
|
const adjournedLine = room.adjournedAt
|
|
2020
2262
|
? `${t.adjournedAtPrefix} ${this.timeFmt(room.adjournedAt)}`
|
|
2021
2263
|
: "";
|
|
2022
|
-
const
|
|
2264
|
+
const subjectFull = room.subject || "(no subject)";
|
|
2023
2265
|
|
|
2024
2266
|
// Tone + intensity inherit from parent. Trigger uses the same
|
|
2025
2267
|
// `.cmp-dd` markup as the new-room composer's toolbar buttons —
|
|
@@ -2069,8 +2311,11 @@
|
|
|
2069
2311
|
</header>
|
|
2070
2312
|
<div class="supplement-body">
|
|
2071
2313
|
<div class="followup-parent-card">
|
|
2072
|
-
<div class="followup-parent-subject">${this.escape(
|
|
2073
|
-
<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>
|
|
2074
2319
|
<div class="followup-parent-note">${this.escape(t.contextNote)}</div>
|
|
2075
2320
|
</div>
|
|
2076
2321
|
|
|
@@ -2144,6 +2389,24 @@
|
|
|
2144
2389
|
// so listeners are GC'd when the modal is removed.
|
|
2145
2390
|
const overlayEl = document.getElementById("followup-overlay");
|
|
2146
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
|
+
}
|
|
2147
2410
|
const sameCheckbox = overlayEl.querySelector("[data-followup-same-cast]");
|
|
2148
2411
|
const castBtn = overlayEl.querySelector("[data-followup-cast-btn]");
|
|
2149
2412
|
if (sameCheckbox && castBtn) {
|
|
@@ -2455,6 +2718,17 @@
|
|
|
2455
2718
|
if (input) input.focus();
|
|
2456
2719
|
return;
|
|
2457
2720
|
}
|
|
2721
|
+
// Read the report-mode picker (research-note / bento). The
|
|
2722
|
+
// overlay defaults the picker to the existing brief's mode, so a
|
|
2723
|
+
// plain "regenerate with this perspective" keeps the same
|
|
2724
|
+
// format. Persisting the choice keeps the picker stable next
|
|
2725
|
+
// time. Server-side the same `/api/rooms/:id/brief` route reads
|
|
2726
|
+
// `mode` regardless of whether the call came from supplement or
|
|
2727
|
+
// generate-brief, so no backend change is needed.
|
|
2728
|
+
const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
|
|
2729
|
+
const v = briefModeInput && briefModeInput.value;
|
|
2730
|
+
const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
|
|
2731
|
+
this.saveLastBriefMode(briefMode);
|
|
2458
2732
|
// Frontend in-flight guard · without this, a slow server roundtrip
|
|
2459
2733
|
// gives the user time to click confirm twice (or to close+reopen
|
|
2460
2734
|
// the overlay and click again). Each click was firing its own POST,
|
|
@@ -2473,7 +2747,7 @@
|
|
|
2473
2747
|
{
|
|
2474
2748
|
method: "POST",
|
|
2475
2749
|
headers: { "content-type": "application/json" },
|
|
2476
|
-
body: JSON.stringify({ supplement: text }),
|
|
2750
|
+
body: JSON.stringify({ supplement: text, mode: briefMode }),
|
|
2477
2751
|
},
|
|
2478
2752
|
);
|
|
2479
2753
|
if (!r.ok) {
|
|
@@ -2504,7 +2778,7 @@
|
|
|
2504
2778
|
* whether streaming is actually in flight. */
|
|
2505
2779
|
isBriefPlaceholder(brief, room) {
|
|
2506
2780
|
if (!brief) return false;
|
|
2507
|
-
if (
|
|
2781
|
+
if (this.briefHasBody(brief)) return false;
|
|
2508
2782
|
if (!room) return true;
|
|
2509
2783
|
// Title matches the seed (room subject) → never got an updated title.
|
|
2510
2784
|
// Or title is the literal "Generating…" placeholder.
|
|
@@ -2621,7 +2895,7 @@
|
|
|
2621
2895
|
const target = (this.currentBriefs || []).find((b) => b.id === briefId);
|
|
2622
2896
|
// System UI · always English. Confirm + alert dialogs are app
|
|
2623
2897
|
// chrome and stay fixed-string regardless of brief language.
|
|
2624
|
-
const isStillGenerating = !!(target &&
|
|
2898
|
+
const isStillGenerating = !!(target && !this.briefHasBody(target));
|
|
2625
2899
|
const confirmText = isStillGenerating
|
|
2626
2900
|
? "This report is still generating. Deleting will stop the generation and remove the report. This can't be undone."
|
|
2627
2901
|
: (target?.supplement
|
|
@@ -2723,17 +2997,23 @@
|
|
|
2723
2997
|
const mode = overlay.getAttribute("data-adjourn-mode") || "adjourn";
|
|
2724
2998
|
const isGen = mode === "generate-brief";
|
|
2725
2999
|
const skipPicked = overlay.querySelector(".adjourn-skip-btn.picked") !== null;
|
|
3000
|
+
// Read the report-mode picker (research-note / bento). Persist the
|
|
3001
|
+
// choice so the picker defaults to the same option next time.
|
|
3002
|
+
const briefModeInput = overlay.querySelector('input[name="brief-mode"]:checked');
|
|
3003
|
+
const v = briefModeInput && briefModeInput.value;
|
|
3004
|
+
const briefMode = this.isStructuredBriefMode(v) ? v : "research-note";
|
|
3005
|
+
this.saveLastBriefMode(briefMode);
|
|
2726
3006
|
const btn = overlay.querySelector("[data-adjourn-confirm]");
|
|
2727
3007
|
const origLabel = isGen ? "[ Generate ]" : "[ Adjourn & file ]";
|
|
2728
3008
|
const busyLabel = isGen ? "[ Generating… ]" : "[ Adjourning… ]";
|
|
2729
3009
|
if (btn) { btn.disabled = true; btn.textContent = busyLabel; }
|
|
2730
3010
|
try {
|
|
2731
3011
|
if (isGen) {
|
|
2732
|
-
await this.generateBriefForAdjournedRoom();
|
|
3012
|
+
await this.generateBriefForAdjournedRoom(briefMode);
|
|
2733
3013
|
} else if (skipPicked) {
|
|
2734
3014
|
await this.adjournRoom({ skipBrief: true });
|
|
2735
3015
|
} else {
|
|
2736
|
-
await this.adjournRoom({});
|
|
3016
|
+
await this.adjournRoom({ mode: briefMode });
|
|
2737
3017
|
}
|
|
2738
3018
|
this.closeAdjournOverlay();
|
|
2739
3019
|
} catch (e) {
|
|
@@ -2746,8 +3026,9 @@
|
|
|
2746
3026
|
* whose user originally skipped the brief. Server emits the same
|
|
2747
3027
|
* brief-started / brief-token / brief-final SSE events as a normal
|
|
2748
3028
|
* adjourn, so the existing handlers in connectSSE handle the rest. */
|
|
2749
|
-
async generateBriefForAdjournedRoom() {
|
|
3029
|
+
async generateBriefForAdjournedRoom(mode) {
|
|
2750
3030
|
if (!this.currentRoomId) return;
|
|
3031
|
+
const briefMode = this.isStructuredBriefMode(mode) ? mode : "research-note";
|
|
2751
3032
|
// Pre-flight · the brief writer is a 3-stage LLM pipeline (per-
|
|
2752
3033
|
// director extract → composer → final write). All require a key.
|
|
2753
3034
|
if (!(await this.requireModelKey())) return;
|
|
@@ -2756,7 +3037,7 @@
|
|
|
2756
3037
|
{
|
|
2757
3038
|
method: "POST",
|
|
2758
3039
|
headers: { "content-type": "application/json" },
|
|
2759
|
-
body:
|
|
3040
|
+
body: JSON.stringify({ mode: briefMode }),
|
|
2760
3041
|
},
|
|
2761
3042
|
);
|
|
2762
3043
|
if (!r.ok) {
|
|
@@ -3627,6 +3908,47 @@
|
|
|
3627
3908
|
this.composerMode = "room";
|
|
3628
3909
|
},
|
|
3629
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
|
+
|
|
3630
3952
|
/** Render the sidebar's Agents panel from the live agent catalog.
|
|
3631
3953
|
* Three buckets so the seeded directors keep their familiar
|
|
3632
3954
|
* groupings while user-created ones stack into a Custom section
|
|
@@ -4346,7 +4668,44 @@
|
|
|
4346
4668
|
if (brief) brief.innerHTML = "";
|
|
4347
4669
|
|
|
4348
4670
|
const chatScroller = document.querySelector(".chat");
|
|
4349
|
-
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
|
+
}
|
|
4350
4709
|
},
|
|
4351
4710
|
|
|
4352
4711
|
/** Open the All Reports page · cross-room brief index in a card
|
|
@@ -4723,16 +5082,34 @@
|
|
|
4723
5082
|
}
|
|
4724
5083
|
},
|
|
4725
5084
|
|
|
4726
|
-
/** Single reading-list row · room kicker, title,
|
|
5085
|
+
/** Single reading-list row · room kicker, title, subtitle excerpt,
|
|
4727
5086
|
* meta tail. No card chrome — entries are separated by a hairline
|
|
4728
|
-
* 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. */
|
|
4729
5095
|
renderReportItemHtml(b) {
|
|
4730
5096
|
const json = b.bodyJson || {};
|
|
4731
5097
|
const bottomLine = json.bottomLine || {};
|
|
4732
|
-
|
|
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
|
+
);
|
|
4733
5109
|
const time = this.relTime(b.createdAt) || "";
|
|
4734
5110
|
const roomLabel = b.roomName || b.roomSubject || "—";
|
|
4735
5111
|
const roomNumLabel = b.roomNumber != null ? `#${String(b.roomNumber).padStart(3, "0")}` : "";
|
|
5112
|
+
const typeLabel = this.briefModeLabel(b);
|
|
4736
5113
|
const findingsCount = Array.isArray(json.headlineFindings) ? json.headlineFindings.length : 0;
|
|
4737
5114
|
const positionsCount = Array.isArray(json.positions) ? json.positions.length : 0;
|
|
4738
5115
|
const metaParts = [];
|
|
@@ -4749,16 +5126,45 @@
|
|
|
4749
5126
|
<span class="reports-item-num">${this.escape(roomNumLabel)}</span>
|
|
4750
5127
|
<span class="reports-item-sep">·</span>
|
|
4751
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>
|
|
4752
5131
|
<span class="reports-item-time">${this.escape(time)}</span>
|
|
4753
5132
|
</div>
|
|
4754
5133
|
<h3 class="reports-item-title">${this.escape(b.title || "Untitled brief")}</h3>
|
|
4755
|
-
${
|
|
5134
|
+
${subtitle ? `<p class="reports-item-judgement">${this.escape(subtitle)}</p>` : ""}
|
|
4756
5135
|
${metaHtml}
|
|
4757
5136
|
</a>
|
|
4758
5137
|
</li>
|
|
4759
5138
|
`;
|
|
4760
5139
|
},
|
|
4761
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
|
+
|
|
4762
5168
|
// ── All Notes view · chairman's notes index ───────────────
|
|
4763
5169
|
/** Open the All Notes page · cross-room saved-excerpt index in
|
|
4764
5170
|
* the same main-view-replacement pattern as openAllReports.
|
|
@@ -5437,10 +5843,12 @@
|
|
|
5437
5843
|
// of which composer we're switching to.
|
|
5438
5844
|
document.querySelectorAll("[data-reports-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
5439
5845
|
document.querySelectorAll("[data-notes-trigger].active").forEach((el) => el.classList.remove("active"));
|
|
5440
|
-
// If the URL still carries
|
|
5441
|
-
// navigation, drop it — otherwise refresh would bounce
|
|
5442
|
-
// back to the
|
|
5443
|
-
|
|
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 || "")) {
|
|
5444
5852
|
try { history.replaceState(null, "", location.pathname + location.search); } catch { /* ignore */ }
|
|
5445
5853
|
}
|
|
5446
5854
|
|
|
@@ -5481,7 +5889,7 @@
|
|
|
5481
5889
|
const t = {
|
|
5482
5890
|
greet: greeting,
|
|
5483
5891
|
prompt: "What's on your mind today?",
|
|
5484
|
-
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?",
|
|
5485
5893
|
convene: "Convene",
|
|
5486
5894
|
tuneLabel: "tune",
|
|
5487
5895
|
starterLabel: "starter",
|
|
@@ -6610,7 +7018,7 @@
|
|
|
6610
7018
|
year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
|
|
6611
7019
|
})
|
|
6612
7020
|
: "";
|
|
6613
|
-
const href =
|
|
7021
|
+
const href = this.briefViewerHref(b, roomId);
|
|
6614
7022
|
return `
|
|
6615
7023
|
<a class="brief-picker-row" href="${this.escape(href)}" target="_blank" rel="noopener" data-brief-picker-row data-brief-id="${this.escape(b.id)}">
|
|
6616
7024
|
<span class="brief-picker-num">${this.escape(num)}</span>
|
|
@@ -7226,18 +7634,16 @@
|
|
|
7226
7634
|
|
|
7227
7635
|
const tone = r.mode || "constructive";
|
|
7228
7636
|
const intensity = r.intensity || "sharp";
|
|
7229
|
-
const style = r.briefStyle || "auto";
|
|
7230
7637
|
|
|
7231
|
-
// Status
|
|
7232
|
-
//
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
}
|
|
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
|
+
);
|
|
7241
7647
|
|
|
7242
7648
|
// Three primary actions, one per state — CSS hides the wrong ones based
|
|
7243
7649
|
// on html[data-status]. Per PRD §5.2.3:
|
|
@@ -7250,20 +7656,24 @@
|
|
|
7250
7656
|
// the DOM stays stable; the collapsed state flips room-head's
|
|
7251
7657
|
// grid template to a 3-track layout so this button takes the
|
|
7252
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.
|
|
7253
7665
|
head.innerHTML = `
|
|
7254
7666
|
<button type="button" class="room-head-expand" data-sidebar-expand title="Expand sidebar" aria-label="Expand sidebar"></button>
|
|
7255
7667
|
<div class="room-info">
|
|
7256
|
-
<div class="room-
|
|
7257
|
-
<span class="
|
|
7258
|
-
<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>` : ""}
|
|
7259
7675
|
</div>
|
|
7260
7676
|
<h1 class="room-subject" title="${this.escape(r.subject)}">${this.escape(r.subject)}</h1>
|
|
7261
|
-
<div class="room-meta" data-room-meta>
|
|
7262
|
-
<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>
|
|
7263
|
-
<span class="meta-tag tag-intensity"><span class="k">intensity</span><span class="v">${this.escape(intensity)}</span></span>
|
|
7264
|
-
<span class="meta-tag tag-report"><span class="k">report</span><span class="v">${this.escape(style)}</span></span>
|
|
7265
|
-
${stamp ? `<span class="meta-stamp">${this.escape(stamp)}</span>` : ""}
|
|
7266
|
-
</div>
|
|
7267
7677
|
</div>
|
|
7268
7678
|
<div class="head-actions">
|
|
7269
7679
|
<div class="head-cast">${castHtml}</div>
|
|
@@ -7280,7 +7690,7 @@
|
|
|
7280
7690
|
// opens the picker.
|
|
7281
7691
|
const briefs = Array.isArray(this.currentBriefs) ? this.currentBriefs : [];
|
|
7282
7692
|
const multi = briefs.length > 1;
|
|
7283
|
-
const directHref =
|
|
7693
|
+
const directHref = this.briefViewerHref(this.currentBrief, r.id) || `/report.html?r=${this.escape(r.id)}`;
|
|
7284
7694
|
if (!multi) {
|
|
7285
7695
|
return `<a href="${directHref}" target="_blank" rel="noopener" class="view-report-btn" data-view-report>[ View Report ]</a>`;
|
|
7286
7696
|
}
|
|
@@ -7296,8 +7706,9 @@
|
|
|
7296
7706
|
// overflow:hidden chain (.main / .main-view both clip absolutely-
|
|
7297
7707
|
// positioned descendants and CAN'T be relaxed without breaking
|
|
7298
7708
|
// chat scroll). Body-attached fixed-position tooltip is the only
|
|
7299
|
-
// reliable approach.
|
|
7300
|
-
|
|
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]");
|
|
7301
7712
|
if (toneTag) {
|
|
7302
7713
|
toneTag.addEventListener("mouseenter", () => this.showToneTip(toneTag));
|
|
7303
7714
|
toneTag.addEventListener("mouseleave", () => this.hideToneTip());
|
|
@@ -7342,6 +7753,14 @@
|
|
|
7342
7753
|
renderChat() {
|
|
7343
7754
|
const chat = document.querySelector("[data-chat-messages]");
|
|
7344
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
|
+
}
|
|
7345
7764
|
const messages = this.currentMessages.slice();
|
|
7346
7765
|
const r = this.currentRoom;
|
|
7347
7766
|
const banner = r
|
|
@@ -7872,7 +8291,7 @@
|
|
|
7872
8291
|
// tight (~4 lines of body text) so even mid-length openers
|
|
7873
8292
|
// collapse — the user can always one-click to read the full
|
|
7874
8293
|
// text. Toggle handler lives at doc level (see init).
|
|
7875
|
-
const isLongOpener = (m.body || "").length >
|
|
8294
|
+
const isLongOpener = (m.body || "").length > 80;
|
|
7876
8295
|
// Follow-up rooms get an "origin" row above the question that
|
|
7877
8296
|
// names the parent room (number + subject) and links back to
|
|
7878
8297
|
// it via the hash route. Without this, a follow-up looks
|
|
@@ -8722,7 +9141,7 @@
|
|
|
8722
9141
|
// a failed-brief tab becomes unreachable — clicking it just
|
|
8723
9142
|
// re-renders whichever good brief the salvage path picks.
|
|
8724
9143
|
const goodBrief = bypassSalvage ? null : (this.currentBriefs || []).find(
|
|
8725
|
-
(x) => x && x.id !== b.id && !x.error &&
|
|
9144
|
+
(x) => x && x.id !== b.id && !x.error && this.briefHasBody(x),
|
|
8726
9145
|
);
|
|
8727
9146
|
if (goodBrief) {
|
|
8728
9147
|
const failed = b;
|
|
@@ -8782,6 +9201,7 @@
|
|
|
8782
9201
|
${tabsStripHtml}
|
|
8783
9202
|
<div class="brief-banner">
|
|
8784
9203
|
<span class="brief-banner-tag" style="color: var(--red);">// report</span>
|
|
9204
|
+
<span class="brief-banner-type" style="color: var(--red); border-color: var(--red);">${this.escape(this.briefModeLabel(b))}</span>
|
|
8785
9205
|
<span class="brief-banner-stamp" style="color: var(--red);">${this.escape(copy.stamp)}</span>
|
|
8786
9206
|
</div>
|
|
8787
9207
|
<div class="brief-body brief-body-error">
|
|
@@ -8804,7 +9224,7 @@
|
|
|
8804
9224
|
return;
|
|
8805
9225
|
}
|
|
8806
9226
|
|
|
8807
|
-
const generating = b.isGenerating === true || !b
|
|
9227
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
8808
9228
|
const signed = this.currentMembers
|
|
8809
9229
|
.map((a) => `<img src="${this.escape(a.avatarPath)}" alt="${this.escape(a.name)}" title="${this.escape(a.name)}">`)
|
|
8810
9230
|
.join("");
|
|
@@ -8813,12 +9233,13 @@
|
|
|
8813
9233
|
? "GENERATING…"
|
|
8814
9234
|
: "FILED · " + (b.createdAt ? this.timeFmt(b.createdAt) : this.timeFmt(Date.now()));
|
|
8815
9235
|
|
|
8816
|
-
// Open Report
|
|
8817
|
-
//
|
|
8818
|
-
//
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
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.
|
|
9241
|
+
const reportHref = this.briefViewerHref(b, this.currentRoomId)
|
|
9242
|
+
|| (this.currentRoomId ? `/report.html?r=${encodeURIComponent(this.currentRoomId)}` : null);
|
|
8822
9243
|
|
|
8823
9244
|
// Tab strip — already computed at the top of renderBrief as
|
|
8824
9245
|
// `tabsStripHtml` so both error and success paths can mount it.
|
|
@@ -8837,6 +9258,7 @@
|
|
|
8837
9258
|
${tabsHtml}
|
|
8838
9259
|
<div class="brief-banner">
|
|
8839
9260
|
<span class="brief-banner-tag">// report</span>
|
|
9261
|
+
<span class="brief-banner-type">${this.escape(this.briefModeLabel(b))}</span>
|
|
8840
9262
|
<span class="brief-banner-stamp">${filedLabel}</span>
|
|
8841
9263
|
</div>
|
|
8842
9264
|
|
|
@@ -8955,11 +9377,47 @@
|
|
|
8955
9377
|
"Writing the 3 Headline Findings",
|
|
8956
9378
|
"Drafting Convergence + Divergence sections",
|
|
8957
9379
|
"Composing Recommendations",
|
|
8958
|
-
"Writing the Pre-mortem",
|
|
8959
9380
|
"Surfacing New Questions",
|
|
8960
9381
|
"Drafting the Strategic Planning Assumption",
|
|
8961
9382
|
"Polishing the final pass",
|
|
8962
9383
|
],
|
|
9384
|
+
// Magazine mode · same single-pass chair-LLM call but the
|
|
9385
|
+
// emphasis is on editorial cover-line composition rather
|
|
9386
|
+
// than infographic compression. Keyed under `magazine-write`
|
|
9387
|
+
// and selected at render time when the brief's mode is
|
|
9388
|
+
// magazine.
|
|
9389
|
+
"magazine-write": [
|
|
9390
|
+
"Drafting the cover headline",
|
|
9391
|
+
"Writing the subdeck",
|
|
9392
|
+
"Picking the 5 numbered cards",
|
|
9393
|
+
"Sequencing the 3 setup steps",
|
|
9394
|
+
"Selecting why-this-matters reasons",
|
|
9395
|
+
"Stamping the masthead byline",
|
|
9396
|
+
],
|
|
9397
|
+
// Newspaper mode · same single-pass chair-LLM call but voice
|
|
9398
|
+
// shifts to broadsheet front-page journalism. Keyed under
|
|
9399
|
+
// `newspaper-write` and selected at render time when the
|
|
9400
|
+
// brief's mode is newspaper.
|
|
9401
|
+
"newspaper-write": [
|
|
9402
|
+
"Setting the banner headline",
|
|
9403
|
+
"Writing the subdeck",
|
|
9404
|
+
"Filing the three column stories",
|
|
9405
|
+
"Drafting the bottom-line callout",
|
|
9406
|
+
"Stacking the more-headings sidebar",
|
|
9407
|
+
"Stamping the masthead date",
|
|
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
|
+
],
|
|
8963
9421
|
},
|
|
8964
9422
|
// System UI · always English. The `zh` key is kept as an alias
|
|
8965
9423
|
// of `en` so any caller indexing BRIEF_SUBSTAGES["zh"] still
|
|
@@ -8980,7 +9438,7 @@
|
|
|
8980
9438
|
}
|
|
8981
9439
|
const stages = b.stages || {};
|
|
8982
9440
|
const anyActive = Object.values(stages).some((s) => s && s.status === "active");
|
|
8983
|
-
const generating = b.isGenerating === true || !b
|
|
9441
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
8984
9442
|
if (!anyActive && !generating) {
|
|
8985
9443
|
this.stopBriefStageTick();
|
|
8986
9444
|
return;
|
|
@@ -9031,7 +9489,7 @@
|
|
|
9031
9489
|
if (this._briefStallWatchTimer) return;
|
|
9032
9490
|
const b = this.currentBrief;
|
|
9033
9491
|
if (!b || !b.id || b.error) return;
|
|
9034
|
-
const generating = b.isGenerating === true || !b
|
|
9492
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
9035
9493
|
if (!generating) return;
|
|
9036
9494
|
if (!this._lastBriefEventAt) this._lastBriefEventAt = Date.now();
|
|
9037
9495
|
this._lastBriefHealthPollAt = 0;
|
|
@@ -9051,7 +9509,7 @@
|
|
|
9051
9509
|
async tickBriefStallWatch() {
|
|
9052
9510
|
const b = this.currentBrief;
|
|
9053
9511
|
if (!b || b.error) { this.stopBriefStallWatch(); return; }
|
|
9054
|
-
const generating = b.isGenerating === true || !b
|
|
9512
|
+
const generating = b.isGenerating === true || !this.briefHasBody(b) || b.title === "Generating…";
|
|
9055
9513
|
if (!generating) { this.stopBriefStallWatch(); return; }
|
|
9056
9514
|
|
|
9057
9515
|
const now = Date.now();
|
|
@@ -9112,27 +9570,43 @@
|
|
|
9112
9570
|
? (b.bodyMd.trim().match(/\S+/g) || []).length
|
|
9113
9571
|
: 0;
|
|
9114
9572
|
|
|
9115
|
-
// Stage definitions ·
|
|
9116
|
-
//
|
|
9573
|
+
// Stage definitions · mode-aware. The wire format emitted by
|
|
9574
|
+
// emitStage() in src/orchestrator/brief.ts depends on which
|
|
9575
|
+
// pipeline ran:
|
|
9576
|
+
//
|
|
9577
|
+
// research-note (default) · extract → compose → scaffold-
|
|
9578
|
+
// {anchor,findings,cluster,actions} → write (7 stages · the
|
|
9579
|
+
// 4 scaffold sub-stages are driven by JSON-key arrival in
|
|
9580
|
+
// the streaming buffer, see SCAFFOLD_TRIGGERS in brief.ts)
|
|
9117
9581
|
//
|
|
9118
|
-
// extract →
|
|
9582
|
+
// bento + magazine · extract → write (2 stages · runBentoStage
|
|
9583
|
+
// runs ONE chair-LLM call that produces the BentoScaffold;
|
|
9584
|
+
// composer and the scaffold sub-stages are skipped entirely.
|
|
9585
|
+
// Both modes share the same 2-stage rail · only the write
|
|
9586
|
+
// label and renderer differ)
|
|
9119
9587
|
//
|
|
9120
|
-
//
|
|
9121
|
-
//
|
|
9122
|
-
// brief.ts), so each pip transition reflects a real moment in the
|
|
9123
|
-
// model's output — not a synthetic timer.
|
|
9124
|
-
// System UI · always English regardless of brief language.
|
|
9125
|
-
// The pipeline labels are the app's voice (chrome around the
|
|
9588
|
+
// System UI · always English regardless of brief language. The
|
|
9589
|
+
// pipeline labels are the app's voice (chrome around the
|
|
9126
9590
|
// generation), not the report content itself.
|
|
9127
|
-
const
|
|
9128
|
-
|
|
9129
|
-
|
|
9130
|
-
|
|
9131
|
-
|
|
9132
|
-
|
|
9133
|
-
|
|
9134
|
-
|
|
9135
|
-
|
|
9591
|
+
const STRUCTURED_WRITE_LABELS = {
|
|
9592
|
+
magazine: "Composing the magazine",
|
|
9593
|
+
newspaper: "Composing the newspaper",
|
|
9594
|
+
ppt: "Composing the deck",
|
|
9595
|
+
};
|
|
9596
|
+
const STAGE_DEFS = this.isStructuredBriefMode(b.mode)
|
|
9597
|
+
? [
|
|
9598
|
+
{ key: "extract", label: "Reading what each director said", pipShort: "read" },
|
|
9599
|
+
{ key: "write", label: STRUCTURED_WRITE_LABELS[b.mode] || "Composing the report", pipShort: "compose" },
|
|
9600
|
+
]
|
|
9601
|
+
: [
|
|
9602
|
+
{ key: "extract", label: "Reading what each director said", pipShort: "read" },
|
|
9603
|
+
{ key: "compose", label: "Picking the report shape", pipShort: "pick" },
|
|
9604
|
+
{ key: "scaffold-anchor", label: "Setting the anchor", pipShort: "anchor" },
|
|
9605
|
+
{ key: "scaffold-findings", label: "Sketching findings", pipShort: "find" },
|
|
9606
|
+
{ key: "scaffold-cluster", label: "Mapping consensus + dissent", pipShort: "split" },
|
|
9607
|
+
{ key: "scaffold-actions", label: "Drafting actions + risks", pipShort: "act" },
|
|
9608
|
+
{ key: "write", label: "Writing the report", pipShort: "write" },
|
|
9609
|
+
];
|
|
9136
9610
|
|
|
9137
9611
|
const meta = this.BRIEF_STAGE_META;
|
|
9138
9612
|
const substages = (this.BRIEF_SUBSTAGES[lang] || this.BRIEF_SUBSTAGES.en);
|
|
@@ -9184,9 +9658,18 @@
|
|
|
9184
9658
|
}
|
|
9185
9659
|
|
|
9186
9660
|
// Rotating sub-line · advances every 3s within the active stage.
|
|
9661
|
+
// Structured-mode write stages get their own rotator copy
|
|
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.
|
|
9187
9666
|
let substageText = "";
|
|
9188
|
-
|
|
9189
|
-
|
|
9667
|
+
let subKey = activeDef.key;
|
|
9668
|
+
if (activeDef.key === "write" && this.isStructuredBriefMode(b.mode)) {
|
|
9669
|
+
subKey = `${b.mode}-write`;
|
|
9670
|
+
}
|
|
9671
|
+
if (activeStatus === "active" && substages[subKey]?.length) {
|
|
9672
|
+
const list = substages[subKey];
|
|
9190
9673
|
substageText = list[Math.floor(activeElapsed / 3) % list.length];
|
|
9191
9674
|
}
|
|
9192
9675
|
|
|
@@ -9684,6 +10167,27 @@
|
|
|
9684
10167
|
});
|
|
9685
10168
|
return;
|
|
9686
10169
|
}
|
|
10170
|
+
// Brief-mode picker · clicking a label (or its radio) toggles the
|
|
10171
|
+
// .on class on that option AND off on the siblings. Used by both
|
|
10172
|
+
// the adjourn overlay and the supplement overlay. We scope to the
|
|
10173
|
+
// picker's `.adjourn-mode-options` container (set in
|
|
10174
|
+
// renderBriefModePicker) instead of a specific overlay id, so the
|
|
10175
|
+
// same handler works wherever the picker is embedded. The native
|
|
10176
|
+
// radio behaviour handles `:checked` state but we use a manual
|
|
10177
|
+
// class for visual styling (left accent stripe + tint) since we
|
|
10178
|
+
// can't `:has()` reliably on older browsers.
|
|
10179
|
+
const modeOpt = e.target.closest(".adjourn-mode-option");
|
|
10180
|
+
if (modeOpt) {
|
|
10181
|
+
const group = modeOpt.closest(".adjourn-mode-options");
|
|
10182
|
+
if (group) {
|
|
10183
|
+
group.querySelectorAll(".adjourn-mode-option").forEach((el) => el.classList.remove("on"));
|
|
10184
|
+
modeOpt.classList.add("on");
|
|
10185
|
+
const radio = modeOpt.querySelector('input[type="radio"]');
|
|
10186
|
+
if (radio) radio.checked = true;
|
|
10187
|
+
}
|
|
10188
|
+
// Don't preventDefault · let the label's native click behaviour
|
|
10189
|
+
// also tick the radio for keyboard / a11y users.
|
|
10190
|
+
}
|
|
9687
10191
|
// Adjourn overlay · "skip report" footer button — clicking commits
|
|
9688
10192
|
// immediately (mark picked + dispatch + close).
|
|
9689
10193
|
if (e.target.closest("[data-adjourn-skip]")) {
|
|
@@ -9707,6 +10211,18 @@
|
|
|
9707
10211
|
app.closeAdjournOverlay();
|
|
9708
10212
|
return;
|
|
9709
10213
|
}
|
|
10214
|
+
// Adjourn overlay · subject Show more / less toggle. Flips the
|
|
10215
|
+
// `is-clamped` class on the value span and swaps the button label.
|
|
10216
|
+
const subjToggle = e.target.closest("[data-adjourn-subject-toggle]");
|
|
10217
|
+
if (subjToggle) {
|
|
10218
|
+
e.preventDefault();
|
|
10219
|
+
const subjEl = document.querySelector("[data-adjourn-subject]");
|
|
10220
|
+
if (subjEl) {
|
|
10221
|
+
const expanded = subjEl.classList.toggle("is-clamped") === false;
|
|
10222
|
+
subjToggle.textContent = expanded ? "Show less" : "Show more";
|
|
10223
|
+
}
|
|
10224
|
+
return;
|
|
10225
|
+
}
|
|
9710
10226
|
// Brief card · "Add a perspective" → opens the supplement overlay.
|
|
9711
10227
|
if (e.target.closest("[data-brief-supplement]")) {
|
|
9712
10228
|
e.preventDefault();
|