privateboard 0.1.9 → 0.1.11

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/public/index.html CHANGED
@@ -158,6 +158,7 @@
158
158
  }
159
159
  .brand-mark .bg { fill: var(--lime); }
160
160
  .brand-mark .fg { fill: var(--bg); }
161
+
161
162
  @keyframes pulse { 0%, 60% { opacity: 1; } 80% { opacity: 0.4; } }
162
163
 
163
164
  /* ═══════════════════════════════════════════
@@ -568,19 +569,9 @@
568
569
  overflow: hidden;
569
570
  text-overflow: ellipsis;
570
571
  }
571
- /* Time tag · same register as `.row-time` in the rooms list
572
- (10.5px mono, text-dim) so both lists read as one consistent
573
- "title · time" rhythm. Earlier this was 9px uppercase faint —
574
- visually a different "kind of metadata" from the rooms time. */
575
- .agent-row .agent-row-time {
576
- color: var(--text-dim);
577
- font-size: 10.5px;
578
- font-family: var(--mono);
579
- letter-spacing: 0;
580
- text-transform: none;
581
- white-space: nowrap;
582
- flex-shrink: 0;
583
- }
572
+ /* Time tag removed · the agent-row no longer surfaces creation
573
+ time. The space is given back to the title (which can now use
574
+ the full row width minus the hover-revealed pin button). */
584
575
  .agent-row .agent-row-subtitle {
585
576
  font-family: var(--mono);
586
577
  font-size: 9.5px;
@@ -934,13 +925,10 @@
934
925
  .agents-section-header.pinned { color: var(--text-soft); }
935
926
 
936
927
  /* ─── Pin toggle (per-row · hover-revealed for non-pinned) ───
937
- Positioned absolutely so the in-flow time tag (.agent-row-time /
938
- .row-time) sits flush against the right edge — same as the rooms
939
- list. Earlier the pin took 22px (18 + 4 margin) of in-flow width
940
- even when invisible (opacity:0 still occupies space), which shoved
941
- the agent's "7h" tag inward compared to the rooms list and made
942
- the two columns of timestamps look mis-aligned. The row-shell
943
- ancestor needs `position: relative` for this to anchor correctly. */
928
+ Positioned absolutely so it overlays the right edge of the row
929
+ without taking in-flow width (opacity:0 alone would still
930
+ reserve 22px and shrink the title). The row-shell ancestor
931
+ needs `position: relative` for this to anchor correctly. */
944
932
  .agent-row-shell, .session-row-shell { position: relative; }
945
933
  .row-top-line .pin-toggle,
946
934
  .agent-row-top-line .pin-toggle {
@@ -968,23 +956,12 @@
968
956
  }
969
957
  .session-row:hover .pin-toggle,
970
958
  .agent-row:hover .pin-toggle { opacity: 0.55; }
971
- /* Hover · hide the in-flow time so the absolutely-positioned pin
972
- button has the right edge to itself. Same pattern as the rooms
973
- list (`.session-row-shell:hover .row-time { visibility: hidden }`). */
974
- .agent-row-shell:hover .agent-row-time { visibility: hidden; }
975
959
  .pin-toggle:hover { opacity: 1 !important; color: var(--lime); }
976
960
  .session-row.pinned .pin-toggle,
977
961
  .agent-row.pinned .pin-toggle {
978
962
  opacity: 1;
979
963
  color: var(--lime);
980
964
  }
981
- /* Pinned · the lime pin glyph stays visible permanently, so the
982
- in-flow time tag has to hide too — otherwise the two stack on
983
- the right edge (pin + "7h" overlapping in the same 18px slot).
984
- The hover rule above only handles hover; pinned rows live in
985
- "always visible" state and need their own hide. */
986
- .agent-row.pinned .agent-row-time,
987
- .session-row.pinned .row-time { visibility: hidden; }
988
965
 
989
966
  /* The shell wraps the link + delete button as siblings so the click on
990
967
  the X button doesn't get absorbed by the anchor's navigation.
@@ -1038,10 +1015,8 @@
1038
1015
  z-index: 2;
1039
1016
  }
1040
1017
  .session-row-shell:hover .row-delete { display: flex; }
1041
- /* On hover the timestamp hides so the delete button doesn't double
1042
- up. The subtitle reserves right padding so its ellipsis stops
1043
- before the X. */
1044
- .session-row-shell:hover .row-time { visibility: hidden; }
1018
+ /* Subtitle reserves right padding so its ellipsis stops before
1019
+ the absolutely-positioned X delete button on hover. */
1045
1020
  .session-row-shell .row-subtitle { padding-right: 26px; }
1046
1021
  .row-delete:hover {
1047
1022
  border-color: var(--red);
@@ -1130,17 +1105,14 @@
1130
1105
  font-family: var(--sans);
1131
1106
  letter-spacing: -0.005em;
1132
1107
  }
1133
- .row-time {
1134
- font-size: 10.5px;
1135
- color: var(--text-dim);
1136
- flex-shrink: 0;
1137
- font-family: var(--mono);
1138
- }
1139
- .session-row-shell[data-status="live"] .row-time {
1108
+ /* Live indicator · was a `● ` prefix on the (now-removed) .row-time
1109
+ tag. Migrated to a title prefix so live rooms still read at a
1110
+ glance. The rest of the title stays its usual colour. */
1111
+ .session-row-shell[data-status="live"] .row-title::before {
1112
+ content: "● ";
1140
1113
  color: var(--lime);
1141
1114
  font-weight: 700;
1142
1115
  }
1143
- .session-row-shell[data-status="live"] .row-time::before { content: "● "; }
1144
1116
 
1145
1117
  .row-subtitle {
1146
1118
  font-size: 11.5px;
@@ -1845,8 +1817,7 @@
1845
1817
  .notes-item-passage { padding-right: 30px; }
1846
1818
  /* On hover · hide the trailing time tag so the delete button
1847
1819
  occupies the right-edge slot cleanly. visibility:hidden keeps
1848
- the layout box (the meta row doesn't reflow either). Same
1849
- pattern as `.session-row-shell:hover .row-time` in rooms. */
1820
+ the layout box (the meta row doesn't reflow either). */
1850
1821
  .notes-item:hover .notes-item-time { visibility: hidden; }
1851
1822
  .notes-item-link {
1852
1823
  display: block;
@@ -2841,6 +2812,11 @@
2841
2812
  text-transform: uppercase;
2842
2813
  margin-bottom: 4px;
2843
2814
  }
2815
+ .brief-kicker-sep {
2816
+ color: var(--text-faint);
2817
+ opacity: 0.5;
2818
+ margin: 0 2px;
2819
+ }
2844
2820
  .brief-title {
2845
2821
  font-family: var(--font-human);
2846
2822
  font-size: 15px;
@@ -2896,6 +2872,25 @@
2896
2872
  .brief-meta-words.is-long { color: var(--red); opacity: 0.85; }
2897
2873
  .brief-meta-words.is-too-long { color: var(--red); font-weight: 700; }
2898
2874
 
2875
+ /* Mode chip · folded into the brief-meta-row alongside the
2876
+ authors / word-count pieces. The standalone .brief-banner
2877
+ strip that used to host this was retired (too tall for the
2878
+ info it carried). Renders as a tiny mono pill so the report
2879
+ type ("REPORT" / "MAGAZINE" / "NEWSPAPER" / "SLIDES") is
2880
+ visible at a glance without competing for emphasis with the
2881
+ title or kicker. */
2882
+ .brief-meta-type {
2883
+ font-family: var(--mono);
2884
+ font-size: 8.5px;
2885
+ font-weight: 700;
2886
+ letter-spacing: 0.14em;
2887
+ text-transform: uppercase;
2888
+ color: var(--text-soft);
2889
+ border: 0.5px solid var(--line-bright, var(--line));
2890
+ padding: 1px 6px;
2891
+ white-space: nowrap;
2892
+ }
2893
+
2899
2894
  /* Signatures · folded into the meta line, no top border */
2900
2895
  .brief-signed {
2901
2896
  display: inline-flex;
@@ -3206,6 +3201,98 @@
3206
3201
  }
3207
3202
  .brief-stat-fact.brief-stat-live { color: var(--lime); }
3208
3203
 
3204
+ .brief-llm-trace {
3205
+ border-top: 1px solid var(--line);
3206
+ padding-top: 10px;
3207
+ }
3208
+ .brief-llm-toggle {
3209
+ width: 100%;
3210
+ display: flex;
3211
+ align-items: center;
3212
+ gap: 8px;
3213
+ padding: 7px 0;
3214
+ border: 0;
3215
+ background: transparent;
3216
+ color: var(--text-soft);
3217
+ font-family: var(--mono);
3218
+ font-size: 10px;
3219
+ letter-spacing: 0.08em;
3220
+ text-transform: uppercase;
3221
+ text-align: left;
3222
+ cursor: pointer;
3223
+ }
3224
+ .brief-llm-toggle:hover { color: var(--lime); }
3225
+ .brief-llm-toggle-mark {
3226
+ color: var(--lime);
3227
+ font-weight: 700;
3228
+ }
3229
+ .brief-llm-toggle-meta {
3230
+ margin-left: auto;
3231
+ color: var(--text-faint);
3232
+ font-size: 10px;
3233
+ letter-spacing: 0.04em;
3234
+ text-transform: none;
3235
+ }
3236
+ .brief-llm-panel {
3237
+ display: flex;
3238
+ flex-direction: column;
3239
+ gap: 10px;
3240
+ padding-top: 8px;
3241
+ }
3242
+ .brief-llm-row {
3243
+ padding: 10px 0;
3244
+ border-top: 1px solid var(--line-bright);
3245
+ }
3246
+ .brief-llm-row:first-child { border-top: 0; }
3247
+ .brief-llm-row-head {
3248
+ display: flex;
3249
+ align-items: baseline;
3250
+ justify-content: space-between;
3251
+ gap: 12px;
3252
+ margin-bottom: 7px;
3253
+ }
3254
+ .brief-llm-row-title {
3255
+ font-family: var(--mono);
3256
+ font-size: 10px;
3257
+ letter-spacing: 0.08em;
3258
+ text-transform: uppercase;
3259
+ color: var(--text);
3260
+ }
3261
+ .brief-llm-row.is-running .brief-llm-row-title { color: var(--lime); }
3262
+ .brief-llm-row.is-failed .brief-llm-row-title { color: var(--red); }
3263
+ .brief-llm-row-meta {
3264
+ font-family: var(--mono);
3265
+ font-size: 10px;
3266
+ letter-spacing: 0.04em;
3267
+ color: var(--text-faint);
3268
+ white-space: nowrap;
3269
+ }
3270
+ .brief-llm-error {
3271
+ margin-bottom: 8px;
3272
+ color: var(--red);
3273
+ font-family: var(--mono);
3274
+ font-size: 10px;
3275
+ line-height: 1.5;
3276
+ }
3277
+ .brief-llm-output {
3278
+ max-height: 220px;
3279
+ overflow: auto;
3280
+ margin: 0;
3281
+ padding: 10px;
3282
+ background: var(--panel);
3283
+ border: 1px solid var(--line);
3284
+ color: var(--text-soft);
3285
+ font-family: var(--mono);
3286
+ font-size: 11px;
3287
+ line-height: 1.55;
3288
+ white-space: pre-wrap;
3289
+ word-break: break-word;
3290
+ }
3291
+ .brief-llm-empty {
3292
+ color: var(--text-faint);
3293
+ font-style: italic;
3294
+ }
3295
+
3209
3296
  /* Brief error / interrupted state · retry button matches the
3210
3297
  supplement-row treatment but uses red accent for "something went
3211
3298
  wrong, click to retry" affordance. */
@@ -7558,6 +7645,12 @@
7558
7645
  border-color: var(--lime-dim);
7559
7646
  }
7560
7647
 
7648
+ /* Voice mode toggle now reuses `.ap-skill-row-toggle .cmp-ws-toggle`
7649
+ vocabulary (see agent-profile.css). The previous `.cmp-voice-
7650
+ switch` rules are gone — the toolbar fitting in `.cmp-toolbar
7651
+ .cmp-ws-toggle` above already covers the voice toggle since
7652
+ they share the same class set. */
7653
+
7561
7654
  .cmp-go {
7562
7655
  flex-shrink: 0;
7563
7656
  width: 36px; height: 36px;
@@ -7575,6 +7668,17 @@
7575
7668
  transform: translateX(1px);
7576
7669
  }
7577
7670
  .cmp-go.busy { opacity: 0.55; pointer-events: none; }
7671
+ /* SVG icon · both the room composer's "Convene" button and the
7672
+ agent composer's "Generate director" button use the same
7673
+ Lucide `arrow-right` (clean stroke, consistent across fonts).
7674
+ Stroke colour inherits from the button's `color` (var(--bg)),
7675
+ so the icon reads as ink on the lime fill. The legacy
7676
+ `.cmp-go-arrow` text-glyph rule below stays for the `…` busy
7677
+ state and any other text fallbacks. */
7678
+ .cmp-go-icon {
7679
+ display: block;
7680
+ flex-shrink: 0;
7681
+ }
7578
7682
  .cmp-go-arrow {
7579
7683
  font-size: 16px;
7580
7684
  line-height: 1;
@@ -8119,6 +8223,25 @@
8119
8223
  text-transform: uppercase;
8120
8224
  color: var(--text-faint);
8121
8225
  }
8226
+ /* Stop button · sits at the right edge of the generating header.
8227
+ Shares the bracketed mono register the rest of the composer
8228
+ uses ([ Generate ] / [ Cancel ]) so it reads as a peer action.
8229
+ Clicking aborts the in-flight controller via discardAgentSpec. */
8230
+ .ag-gen-stop {
8231
+ font-family: var(--mono);
8232
+ font-size: 10px;
8233
+ font-weight: 700;
8234
+ letter-spacing: 0.04em;
8235
+ color: var(--text-soft);
8236
+ background: transparent;
8237
+ border: 0;
8238
+ padding: 4px 8px;
8239
+ cursor: pointer;
8240
+ align-self: center;
8241
+ transition: color 0.12s;
8242
+ }
8243
+ .ag-gen-stop:hover { color: var(--red, #C85A5A); }
8244
+ .ag-gen-stop:active { color: var(--red, #C85A5A); }
8122
8245
  .ag-gen-stages {
8123
8246
  list-style: none;
8124
8247
  margin: 0;
@@ -9291,6 +9414,7 @@
9291
9414
  <link rel="stylesheet" href="agent-overlay.css">
9292
9415
  <!-- Shared /api/models cache · loads first so every picker reads the
9293
9416
  same source. See models-cache.js for the API surface. -->
9417
+ <script src="i18n.js" defer></script>
9294
9418
  <script src="models-cache.js" defer></script>
9295
9419
  <script src="agent-overlay.js" defer></script>
9296
9420
  <link rel="stylesheet" href="room-settings.css">
@@ -9303,7 +9427,10 @@
9303
9427
  <link rel="stylesheet" href="new-agent.css">
9304
9428
  <script src="new-agent.js" defer></script>
9305
9429
  <link rel="stylesheet" href="user-settings.css">
9430
+ <script type="module" src="keys-store.js"></script>
9306
9431
  <script src="user-settings.js" defer></script>
9432
+ <link rel="stylesheet" href="voice-replay.css">
9433
+ <script src="voice-replay.js" defer></script>
9307
9434
  <script src="auto-hide-scroll.js" defer></script>
9308
9435
  <link rel="stylesheet" href="onboarding.css">
9309
9436
  <script src="onboarding.js" defer></script>
@@ -9336,7 +9463,7 @@
9336
9463
  <div class="sys-notice" data-sys-notice hidden>
9337
9464
  <span class="sys-notice-mark">▸</span>
9338
9465
  <span class="sys-notice-text" data-sys-notice-text></span>
9339
- <button type="button" class="sys-notice-close" data-sys-notice-close aria-label="Dismiss">✕</button>
9466
+ <button type="button" class="sys-notice-close" data-sys-notice-close data-i18n-aria="sys_notice_dismiss" aria-label="Dismiss">✕</button>
9340
9467
  </div>
9341
9468
 
9342
9469
  <div class="control">
@@ -9364,20 +9491,20 @@
9364
9491
  <rect class="fg" x="2" y="10" width="1" height="4"/>
9365
9492
  <rect class="fg" x="13" y="10" width="1" height="4"/>
9366
9493
  </svg>
9367
- <span class="sidebar-brand-name">PRIVATEBOARD</span>
9494
+ <span class="sidebar-brand-name" data-i18n="brand_privateboard">PRIVATEBOARD</span>
9368
9495
  </a>
9369
- <button type="button" class="sidebar-collapse-btn" data-sidebar-collapse aria-label="Collapse sidebar" title="Collapse sidebar"></button>
9496
+ <button type="button" class="sidebar-collapse-btn" data-sidebar-collapse data-i18n-title="sidebar_collapse" aria-label="Collapse sidebar" title="Collapse sidebar"></button>
9370
9497
  </div>
9371
9498
 
9372
9499
  <div class="sidebar-tabs" role="tablist">
9373
9500
  <a href="#" class="sidebar-tab active" data-sidebar-tab="rooms" role="tab" aria-selected="true">
9374
9501
  <i class="ic ic-rooms ico"></i>
9375
- <span>Rooms</span>
9502
+ <span data-i18n="sidebar_tab_rooms">Rooms</span>
9376
9503
  <span class="count" data-sidebar-tab-count="rooms">0</span>
9377
9504
  </a>
9378
9505
  <a href="#" class="sidebar-tab" data-sidebar-tab="agents" role="tab" aria-selected="false">
9379
9506
  <i class="ic ic-agents ico"></i>
9380
- <span>Agents</span>
9507
+ <span data-i18n="sidebar_tab_agents">Agents</span>
9381
9508
  <span class="count" data-sidebar-tab-count="agents">0</span>
9382
9509
  </a>
9383
9510
  </div>
@@ -9385,14 +9512,14 @@
9385
9512
  <!-- ─── ROOMS PANEL ─── -->
9386
9513
  <div class="sidebar-panel" data-sidebar-panel="rooms">
9387
9514
 
9388
- <a href="#convene" class="new-btn" data-convene-trigger>New room</a>
9515
+ <a href="#convene" class="new-btn" data-convene-trigger data-i18n="sidebar_new_room">New room</a>
9389
9516
  <!-- "All Reports" cross-room index · opens a Perplexity-Discover-
9390
9517
  style grid of every brief any room has produced. Sits below
9391
9518
  "New room" so it reads as a peer destination, not a list
9392
9519
  item, with a square glyph echoing the dashboard glyphs
9393
9520
  elsewhere in the sidebar. -->
9394
9521
  <a href="#" class="new-btn nav-reports" data-reports-trigger>
9395
- <span class="nav-label">All Reports</span>
9522
+ <span class="nav-label" data-i18n="sidebar_all_reports">All Reports</span>
9396
9523
  <span class="nav-count" data-reports-count hidden></span>
9397
9524
  </a>
9398
9525
  <!-- "All Notes" · chairman's-notes index. Sits directly below
@@ -9402,7 +9529,7 @@
9402
9529
  Reports' FileText glyph. The data-notes-count badge updates
9403
9530
  live as new notes are saved or deleted. -->
9404
9531
  <a href="#" class="new-btn nav-notes" data-notes-trigger>
9405
- <span class="nav-label">All Notes</span>
9532
+ <span class="nav-label" data-i18n="sidebar_all_notes">All Notes</span>
9406
9533
  <span class="nav-count" data-notes-count hidden></span>
9407
9534
  </a>
9408
9535
 
@@ -9415,7 +9542,7 @@
9415
9542
  <!-- ─── AGENTS PANEL ─── -->
9416
9543
  <div class="sidebar-panel" data-sidebar-panel="agents" hidden>
9417
9544
 
9418
- <a href="#" class="new-btn" data-agent-composer-trigger>New agent</a>
9545
+ <a href="#" class="new-btn" data-agent-composer-trigger data-i18n="sidebar_new_agent">New agent</a>
9419
9546
 
9420
9547
  <div class="agents-scroll">
9421
9548
  <!-- Rendered dynamically by app.renderSidebarAgents() from
@@ -9429,16 +9556,16 @@
9429
9556
  <div class="user-av" data-user-avatar>K</div>
9430
9557
  <div class="user-info">
9431
9558
  <div class="user-name" data-user-name>—</div>
9432
- <div class="user-meta" data-user-meta>// host</div>
9559
+ <div class="user-meta" data-user-meta data-i18n="sidebar_host">// host</div>
9433
9560
  </div>
9434
9561
  </a>
9435
- <a href="#" class="icon-btn" title="Settings" data-user-settings-trigger>⚙</a>
9562
+ <a href="#" class="icon-btn" data-i18n-title="settings_title" data-user-settings-trigger>⚙</a>
9436
9563
  </div>
9437
9564
 
9438
9565
  </aside>
9439
9566
 
9440
9567
  <!-- Resizable handle: sidebar | main -->
9441
- <div class="col-resizer" data-resize data-var="--sidebar-w" data-side="left" data-min="220" data-max="480" title="drag to resize"></div>
9568
+ <div class="col-resizer" data-resize data-var="--sidebar-w" data-side="left" data-min="220" data-max="480" data-i18n-title="col_resize"></div>
9442
9569
 
9443
9570
  <!-- ═══════════════ MAIN: room view + agent profile view ═══════════════ -->
9444
9571
  <main class="main">
@@ -9461,20 +9588,20 @@
9461
9588
  <div class="speaking-queue" id="speaking-queue">
9462
9589
  <div class="queue-head">
9463
9590
  <div class="queue-head-expanded">
9464
- <span class="label">speaking queue</span>
9465
- <span class="mode-tag">mode: <span class="v">constructive</span></span>
9591
+ <span class="label" data-i18n="queue_speaking_queue">speaking queue</span>
9592
+ <span class="mode-tag"><span data-i18n="queue_mode">mode:</span> <span class="v">constructive</span></span>
9466
9593
  </div>
9467
9594
  <div class="queue-head-collapsed" data-queue-collapsed>
9468
9595
  <!-- populated by app.renderQueue() · summary follows
9469
9596
  the real queue: speaking, pending, or idle. -->
9470
9597
  </div>
9471
- <button type="button" class="queue-toggle" title="collapse / expand" aria-expanded="true">
9598
+ <button type="button" class="queue-toggle" data-i18n-title="queue_toggle" aria-expanded="true">
9472
9599
  <span class="chev">▼</span>
9473
9600
  </button>
9474
9601
  </div>
9475
9602
  <ul class="queue-list" data-queue-list></ul>
9476
9603
  <div class="queue-foot" data-queue-foot>
9477
- <span class="queue-foot-hint" data-queue-foot-hint>type to interject · <kbd>@</kbd> to direct · the chair files the round-end vote in chat</span>
9604
+ <span class="queue-foot-hint" data-queue-foot-hint data-i18n-html="queue_foot_hint">type to interject · <kbd>@</kbd> to direct · the chair files the round-end vote in chat</span>
9478
9605
  </div>
9479
9606
  </div>
9480
9607
 
@@ -9487,12 +9614,12 @@
9487
9614
  brackets) but at a tighter scale. Adjourn turns warn
9488
9615
  on hover to flag its destructiveness. -->
9489
9616
  <div class="input-bar-actions">
9490
- <button type="button" class="ib-action ib-pause" data-pause aria-label="Pause discussion" data-tip="Pause · you can resume later"></button>
9491
- <button type="button" class="ib-action ib-adjourn" data-adjourn aria-label="Adjourn the room" data-tip="Adjourn · file the report and end"></button>
9617
+ <button type="button" class="ib-action ib-pause" data-pause data-i18n-title="ib_pause_tip" data-i18n-aria="ib_pause_label" data-i18n-tip="ib_pause_tip"></button>
9618
+ <button type="button" class="ib-action ib-adjourn" data-adjourn data-i18n-title="ib_adjourn_tip" data-i18n-aria="ib_adjourn_label" data-i18n-tip="ib_adjourn_tip"></button>
9492
9619
  </div>
9493
9620
  <div class="input-wrap">
9494
- <input type="text" placeholder="interject anytime · @first_p to direct a director..." data-send-input>
9495
- <button class="send-btn" data-send-button>[ Send ]</button>
9621
+ <input type="text" data-i18n-placeholder="send_placeholder" placeholder="interject anytime · @first_p to direct a director..." data-send-input>
9622
+ <button class="send-btn" data-send-button data-i18n="send_button">[ Send ]</button>
9496
9623
  </div>
9497
9624
  </footer>
9498
9625
 
@@ -9512,6 +9639,7 @@
9512
9639
  </div>
9513
9640
  <div class="adjourned-bar-actions">
9514
9641
  <a href="#" class="ghost-btn" data-room-export>[↓] Export</a>
9642
+ <a href="#" class="ghost-btn" data-room-replay>[♪] Voice Replay</a>
9515
9643
  <a href="#" class="ghost-btn" data-room-followup>[→] Convene Follow-up</a>
9516
9644
  </div>
9517
9645
  </footer>
@@ -9546,7 +9674,7 @@
9546
9674
  below the topbar / brand logo, where the sidebar's own header
9547
9675
  sat before collapse. Frosted black glass with a 3-line
9548
9676
  hamburger glyph. -->
9549
- <button type="button" class="sidebar-expand-btn" data-sidebar-expand aria-label="Expand sidebar" title="Expand sidebar">
9677
+ <button type="button" class="sidebar-expand-btn" data-sidebar-expand data-i18n-title="sidebar_expand">
9550
9678
  <svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" aria-hidden="true">
9551
9679
  <line x1="2.5" y1="4.25" x2="13.5" y2="4.25"/>
9552
9680
  <line x1="2.5" y1="8" x2="13.5" y2="8"/>
@@ -9795,17 +9923,37 @@
9795
9923
  const lsGet = (k) => { try { return localStorage.getItem(k); } catch (_) { return null; } };
9796
9924
  const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch (_) {} };
9797
9925
 
9798
- /* Fall back to the first session row in the sidebar when we have
9799
- no explicit user choice. Ordering matches DOM order (live first,
9800
- then paused, then adjourned), which is the same ordering the user
9801
- sees picking [0] always means "the one at the top." Returns null
9802
- when the sidebar has no rooms at all. */
9803
- function firstRoomId() {
9804
- const row = document.querySelector(".session-row-shell[data-room-id]");
9805
- return row ? row.dataset.roomId : null;
9926
+ /* Truth source for "does this room exist?" is `window.app.rooms`,
9927
+ not the DOM. The sidebar may not have rendered yet on first
9928
+ restore (app.init's loadInitial is async and runs concurrently
9929
+ with our DOMContentLoaded restore), and a DOM-empty sidebar
9930
+ does NOT mean "user has no rooms." Querying the in-memory
9931
+ array avoids the race entirely. Returns null until app data is
9932
+ loaded, in which case the caller defers. */
9933
+ function appRoomsLoaded() {
9934
+ return !!(window.app && Array.isArray(window.app.rooms));
9935
+ }
9936
+ function appRoomExists(id) {
9937
+ return appRoomsLoaded() && window.app.rooms.some((r) => r && r.id === id);
9938
+ }
9939
+ function appFirstRoomId() {
9940
+ if (!appRoomsLoaded()) return null;
9941
+ const live = window.app.rooms.filter((r) => r.status === "live");
9942
+ const paused = window.app.rooms.filter((r) => r.status === "paused");
9943
+ const adj = window.app.rooms.filter((r) => r.status === "adjourned");
9944
+ const ordered = [...live, ...paused, ...adj];
9945
+ return ordered.length > 0 ? ordered[0].id : null;
9806
9946
  }
9807
9947
 
9948
+ /* applyRoomsSubState may run BEFORE app.init's loadInitial fetch
9949
+ completes — in that case window.app.rooms is undefined and any
9950
+ existence check is a lie. Defer with a short retry until data
9951
+ lands; only then apply the sub-state. The retry is gated by a
9952
+ generation token so a later applyRoomsSubState call cancels
9953
+ any in-flight retry from an earlier one. */
9954
+ let _applyGen = 0;
9808
9955
  function applyRoomsSubState(sub) {
9956
+ const gen = ++_applyGen;
9809
9957
  // Make sure the room main view is visible (in case the agent
9810
9958
  // profile main view was up). setComposerMode also does this when
9811
9959
  // sub === "new"; doing it here covers the roomId branch too.
@@ -9833,22 +9981,30 @@
9833
9981
  }
9834
9982
  return;
9835
9983
  }
9836
- // Navigate to the saved room but only if it still exists in
9837
- // the sidebar list. A stale id (deleted room, never-existed)
9838
- // falls back to the most-recently-viewed room, then the first
9839
- // row, before resorting to the composer. Without the fallback,
9840
- // a deleted-room id would land the user on a blank composer
9841
- // every tab-switch even when other rooms are available.
9842
- let exists = !!document.querySelector(`.session-row-shell[data-room-id="${sub}"]`);
9843
- if (!exists) {
9984
+ // Saved sub is a roomId. We must consult the in-memory data
9985
+ // (not DOM); if it isn't loaded yet, defer rather than blow
9986
+ // away ROOMS_KEY with "new" — that's the bug that was eating
9987
+ // the user's last selection on every refresh.
9988
+ if (!appRoomsLoaded()) {
9989
+ let attempts = 0;
9990
+ const tick = () => {
9991
+ if (gen !== _applyGen) return; // a newer call superseded us
9992
+ if (appRoomsLoaded()) { applyRoomsSubState(sub); return; }
9993
+ if (++attempts < 40) setTimeout(tick, 100); // up to ~4s
9994
+ };
9995
+ setTimeout(tick, 100);
9996
+ return;
9997
+ }
9998
+ // Data loaded · resolve existence against window.app.rooms.
9999
+ // Stale id (deleted, never-existed) falls back to last-opened,
10000
+ // then first row, before resorting to the composer.
10001
+ let resolved = sub;
10002
+ if (!appRoomExists(resolved)) {
9844
10003
  const last = lsGet(ROOMS_LAST_KEY);
9845
- const candidate = (last && document.querySelector(`.session-row-shell[data-room-id="${last}"]`))
9846
- ? last
9847
- : firstRoomId();
10004
+ const candidate = (last && appRoomExists(last)) ? last : appFirstRoomId();
9848
10005
  if (candidate) {
9849
- sub = candidate;
10006
+ resolved = candidate;
9850
10007
  lsSet(ROOMS_KEY, candidate);
9851
- exists = true;
9852
10008
  } else {
9853
10009
  lsSet(ROOMS_KEY, "new");
9854
10010
  if (window.app && typeof window.app.setComposerMode === "function") {
@@ -9857,17 +10013,17 @@
9857
10013
  return;
9858
10014
  }
9859
10015
  }
9860
- const want = "#/r/" + sub;
10016
+ const want = "#/r/" + resolved;
9861
10017
  if (location.hash !== want) {
9862
10018
  location.hash = want;
9863
- } else if (window.app && window.app.currentRoomId !== sub && typeof window.app.openRoom === "function") {
9864
- window.app.openRoom(sub);
10019
+ } else if (window.app && window.app.currentRoomId !== resolved && typeof window.app.openRoom === "function") {
10020
+ window.app.openRoom(resolved);
9865
10021
  } else if (window.app && typeof window.app.markActiveRoom === "function") {
9866
10022
  // Both hash and currentRoomId already match — but the sidebar
9867
10023
  // session-row .active highlight may have been cleared by an
9868
10024
  // intervening agent-profile open (markActiveAgent wipes it).
9869
10025
  // Re-apply so the row reads as selected on tab return.
9870
- window.app.markActiveRoom(sub);
10026
+ window.app.markActiveRoom(resolved);
9871
10027
  }
9872
10028
  }
9873
10029
 
@@ -9928,12 +10084,13 @@
9928
10084
  } else if (fromTabClick) {
9929
10085
  // No saved sub-state · fresh user clicking the tab. Prefer
9930
10086
  // the last actually-opened room so the tab feels populated
9931
- // instead of bouncing them to a blank composer.
10087
+ // instead of bouncing them to a blank composer. Truth source
10088
+ // is window.app.rooms (DOM may not have rendered yet).
9932
10089
  const last = lsGet(ROOMS_LAST_KEY);
9933
- if (last && document.querySelector(`.session-row-shell[data-room-id="${last}"]`)) {
10090
+ if (last && appRoomExists(last)) {
9934
10091
  target = last;
9935
10092
  } else {
9936
- target = firstRoomId() || "new";
10093
+ target = appFirstRoomId() || "new";
9937
10094
  }
9938
10095
  } else {
9939
10096
  target = "new"; // initial load with no history → composer
@@ -0,0 +1,63 @@
1
+ /**
2
+ * keys-store.js · canonical key-state module
3
+ *
4
+ * Loaded as <script type="module"> so it can be imported by vitest
5
+ * directly for unit-testing without a browser. The module also writes
6
+ * itself onto window.keysStore so the non-module user-settings.js IIFE
7
+ * can delegate to it at runtime.
8
+ *
9
+ * State lives HERE — user-settings.js must not maintain a parallel copy.
10
+ */
11
+
12
+ export let keysMeta = {};
13
+
14
+ /** Rebuild keysMeta from the server. Replaces the whole object so callers
15
+ * that hold a reference to the old snapshot see stale data — always read
16
+ * via the exported binding, not a captured variable. */
17
+ export async function fetchKeyMeta() {
18
+ try {
19
+ const r = await fetch("/api/keys");
20
+ if (!r.ok) return;
21
+ const data = await r.json();
22
+ const next = {};
23
+ for (const row of (data.keys || [])) next[row.provider] = row;
24
+ keysMeta = next;
25
+ } catch { /* keep last snapshot on network failure */ }
26
+ }
27
+
28
+ /** PUT (non-empty value) or DELETE (empty/whitespace) a single key.
29
+ * Mirrors the resulting meta into keysMeta and returns it, or null on
30
+ * error. */
31
+ export async function setProviderKey(provider, value) {
32
+ try {
33
+ const trimmed = (value || "").trim();
34
+ const r = await fetch("/api/keys/" + encodeURIComponent(provider), {
35
+ method: trimmed ? "PUT" : "DELETE",
36
+ headers: trimmed ? { "content-type": "application/json" } : undefined,
37
+ body: trimmed ? JSON.stringify({ key: trimmed }) : undefined,
38
+ });
39
+ if (!r.ok) return null;
40
+ const meta = await r.json();
41
+ keysMeta[provider] = meta;
42
+ return meta;
43
+ } catch { return null; }
44
+ }
45
+
46
+ /** Returns only the configured providers (existence-check safe). */
47
+ export function getConfiguredKeys() {
48
+ const out = {};
49
+ for (const [p, m] of Object.entries(keysMeta)) {
50
+ if (m && m.configured) out[p] = m;
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // Expose globally for non-module scripts (user-settings.js IIFE).
56
+ if (typeof window !== "undefined") {
57
+ window.keysStore = {
58
+ get keysMeta() { return keysMeta; },
59
+ fetchKeyMeta,
60
+ setProviderKey,
61
+ getConfiguredKeys,
62
+ };
63
+ }