fraim-hub 2.0.333 → 2.0.335

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.333",
3
+ "version": "2.0.335",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -221,7 +221,7 @@
221
221
  "electron-updater": "^6.8.9",
222
222
  "express": "^5.2.1",
223
223
  "extract-zip": "^2.0.1",
224
- "fraim": "2.0.333",
224
+ "fraim": "2.0.335",
225
225
  "mongodb": "^7.0.0",
226
226
  "node-cron": "4.2.1",
227
227
  "node-edge-tts": "^1.2.10",
@@ -437,12 +437,12 @@
437
437
  </div>
438
438
 
439
439
  <div class="manage-panel-shell">
440
- <details class="panel-details panel-details--thread" id="thread-panel" open>
441
- <summary>
440
+ <section class="panel-details panel-details--thread" id="thread-panel">
441
+ <div class="thread-header">
442
442
  <span class="panel-summary-copy">
443
443
  <span class="panel-kicker" id="thread-panel-kicker">Manage</span>
444
444
  </span>
445
- </summary>
445
+ </div>
446
446
  <!-- Issue #1065: find a passage inside the open conversation (R44-R46). A result that
447
447
  promises "+6 more matches" is an unkept promise without this: the manager lands in
448
448
  a thread that can run to hundreds of messages with no way to reach the match. -->
@@ -473,26 +473,13 @@
473
473
  </div>
474
474
  </div>
475
475
  </div>
476
- </details>
476
+ </section>
477
477
  <div class="manage-header-actions">
478
- <button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Expand conversation" title="Expand conversation">&#8599;</button>
479
478
  <button class="micro-manage-toggle" id="micro-manage-toggle" type="button" role="switch" aria-checked="false" aria-controls="manage-body"><span>Micro-Manage</span><span class="micro-switch-track" aria-hidden="true"></span></button>
480
479
  </div>
481
480
  </div>
482
481
 
483
482
  <div class="support-stack">
484
- <!-- Issue #770: single-run deliverables live here (a stable collapsible
485
- panel), not pinned to the bottom of the thread. Static-and-hidden,
486
- populated + toggled by renderDeliverablesPanel. -->
487
- <details class="panel-details panel-details--deliverables" id="deliverables-panel" hidden>
488
- <summary>
489
- <span class="panel-summary-copy">
490
- <span class="panel-kicker">Deliverables</span>
491
- <span class="panel-summary-text" id="deliverables-summary"></span>
492
- </span>
493
- </summary>
494
- <div class="panel-body" id="deliverables-body"></div>
495
- </details>
496
483
  <!-- Issue #1292: when this conversation's own agent stops being available,
497
484
  say so. The selector keeps holding that agent; it is never quietly
498
485
  swapped for another one. Lives outside the composer controls so
@@ -519,23 +506,23 @@
519
506
  </div>
520
507
  </div>
521
508
 
522
- <details class="panel-details panel-details--thread" id="ab-direct-thread-panel" open>
523
- <summary>
509
+ <section class="panel-details panel-details--thread" id="ab-direct-thread-panel">
510
+ <div class="thread-header">
524
511
  <span class="panel-summary-copy">
525
512
  <span class="panel-kicker">Direct output (raw)</span>
526
513
  </span>
527
- </summary>
514
+ </div>
528
515
  <pre class="ab-direct-log" id="ab-direct-log"></pre>
529
- </details>
516
+ </section>
530
517
 
531
518
  <div class="support-stack">
532
- <details class="panel-details panel-details--coach" id="ab-direct-reply-panel" open>
533
- <summary>
519
+ <section class="panel-details panel-details--coach" id="ab-direct-reply-panel">
520
+ <div class="thread-header">
534
521
  <span class="panel-summary-copy">
535
522
  <span class="panel-kicker">Reply to Direct agent</span>
536
- <span class="panel-summary-text" id="ab-direct-reply-summary">Open to send a follow-up</span>
523
+ <span class="panel-summary-text" id="ab-direct-reply-summary">Send a follow-up</span>
537
524
  </span>
538
- </summary>
525
+ </div>
539
526
  <div class="panel-body">
540
527
  <div class="coach">
541
528
  <div class="coach-input">
@@ -544,7 +531,7 @@
544
531
  </div>
545
532
  </div>
546
533
  </div>
547
- </details>
534
+ </section>
548
535
  </div>
549
536
  </div>
550
537
 
@@ -555,9 +542,7 @@
555
542
  visible even when no conversation is active"). Only the content
556
543
  ABOVE this div changes (idle placeholder, hire strip, thread, or
557
544
  the A/B compare panel); the composer itself never opens, closes,
558
- or relocates - except into #conversation-focus-panels during
559
- "expand conversation", handled by openConversationFocus/
560
- closeConversationFocus alongside #thread-panel. -->
545
+ or relocates. -->
561
546
  <div id="persistent-composer">
562
547
  <div class="composer-suggestions" id="composer-suggestions" hidden>
563
548
  <div id="cp-results" class="cp-results-scroll">
@@ -589,14 +574,10 @@
589
574
  <button type="button" id="cp-armed-job-clear" aria-label="Change job">&times;</button>
590
575
  </div>
591
576
  <div class="staged-attachments" id="staged-attachments"></div>
592
- <div class="review-actions" id="review-actions" hidden>
593
- <button class="rb-approve" type="button" id="review-approve">&#10003; Approve</button>
594
- <button class="rb-secondary" type="button" id="review-request-changes">&#8617; Request changes</button>
595
- <span class="rb-handoff" id="review-handoff">Comment on the doc to leave inline notes, or type below to coach.</span>
596
- </div>
577
+
597
578
  <div class="coach-input">
598
579
  <input type="file" id="attach-file-input" multiple accept="image/png,image/jpeg,image/gif,.pdf,.txt,.log" hidden>
599
- <textarea id="coach-text" aria-label="Type an instruction" placeholder="Type a job name, /handle, or describe what you need..." data-placeholder-idle="Type a job name, /handle, or describe what you need..." data-placeholder-active="Coach this run, or type new instructions..." data-placeholder-armed="Now type what you want done..."></textarea>
580
+ <textarea id="coach-text" aria-label="Type an instruction" placeholder="Type a job name, /handle, or describe what you need..." data-placeholder-idle="Type a job name, /handle, or describe what you need..." data-placeholder-review="Reply to continue the run, or use Approve / Request changes above..." data-placeholder-active="Coach this run, or type new instructions..." data-placeholder-armed="Now type what you want done..."></textarea>
600
581
  <div class="composer-footer">
601
582
  <button class="attach-btn" type="button" id="attach-btn" aria-label="Attach a file or screenshot" title="Attach a file or screenshot">&#128206;</button>
602
583
  <div class="quick-coach-row composer-coach-actions" id="quick-coach-btns" hidden>
@@ -683,7 +664,7 @@
683
664
  </main>
684
665
  </div>
685
666
 
686
- <p class="status-line" id="status-line" role="status" aria-live="polite"></p>
667
+
687
668
  </div>
688
669
 
689
670
  <!-- Issue #1065: search results replace the conversation panel while a query is active,
@@ -1130,19 +1111,6 @@
1130
1111
  </div>
1131
1112
  </div>
1132
1113
 
1133
- <!-- Issue #539: Command palette overlay — replaces 2-step modal as job-launch surface -->
1134
- <section class="conversation-focus-overlay" id="conversation-focus-overlay" role="dialog" aria-modal="true" aria-labelledby="conversation-focus-title" hidden>
1135
- <div class="conversation-focus-dialog">
1136
- <div class="conversation-focus-header">
1137
- <div class="conversation-focus-copy">
1138
- <strong id="conversation-focus-title">Focused conversation</strong>
1139
- <span id="conversation-focus-subtitle">Conversation thread and coaching</span>
1140
- </div>
1141
- <button class="conversation-focus-close" id="conversation-focus-close" type="button" aria-label="Close focused conversation">Close</button>
1142
- </div>
1143
- <div class="conversation-focus-panels" id="conversation-focus-panels"></div>
1144
- </div>
1145
- </section>
1146
1114
 
1147
1115
  <!-- Issue #1657 Treatment 3: #cp-modal is retired as a separate dialog.
1148
1116
  The ids below (cp-recent-list, cp-catalog-list, cp-agent-picker, etc.)
@@ -1265,7 +1233,15 @@
1265
1233
  </div>
1266
1234
  </div>
1267
1235
 
1236
+ <div id="review-action-storage" hidden>
1237
+ <div class="review-actions" id="review-actions" hidden>
1238
+ <button class="rb-approve" type="button" id="review-approve">&#10003; Approve</button>
1239
+ <button class="rb-secondary" type="button" id="review-request-changes">&#8617; Request changes</button>
1240
+ <span class="rb-handoff" id="review-handoff">Comment on the doc to leave inline notes, or type below to coach.</span>
1241
+ </div>
1242
+ </div>
1268
1243
  <script src="./script.js?v=dark-status-20260721"></script>
1269
1244
  <script src="./remote-ui-loader.js?v=server-driven-ui-20260724"></script>
1245
+
1270
1246
  </body>
1271
1247
  </html>
@@ -422,8 +422,6 @@
422
422
  .rev-item .rev-dismiss:hover { background: var(--soft); }
423
423
 
424
424
  /* ── #770: Deliverables panel body spacing for the relocated artifact strip. ── */
425
- #deliverables-panel .panel-body { padding-top: 8px; }
426
- #deliverables-panel .rc-artifact-strip { margin: 0; }
427
425
 
428
426
  /* ── R9: project-scoped learnings panel (over the workspace conversation) ── */
429
427
  .proj-learnings-panel {
@@ -633,3 +631,6 @@
633
631
  flex-direction: column;
634
632
  gap: 6px;
635
633
  }
634
+
635
+ .review-turn .review-actions { border: 0; margin: 0; padding: 0; }
636
+ .review-turn .rb-handoff { flex-basis: 100%; }
@@ -140,10 +140,9 @@ function gatherElements() {
140
140
  // Issue #385: team roster
141
141
  'team-roster',
142
142
  'empty', 'active-conv', 'active-title', 'active-identity', 'run-state-pill', 'run-stop-btn',
143
- 'conversation-focus-btn', 'conversation-focus-overlay', 'conversation-focus-close', 'conversation-focus-title', 'conversation-focus-subtitle', 'conversation-focus-panels',
144
143
  'messages',
145
144
  'coach-text', 'send', 'micro-manage', 'micro-log', 'resume-command', 'resume-command-code', 'resume-command-copy',
146
- 'status-line', 'coach-note',
145
+ 'coach-note',
147
146
  // Issue #1651: composer attachments.
148
147
  'coach', 'composer-dropzone', 'staged-attachments', 'attach-btn', 'attach-file-input',
149
148
  'thread-panel', 'thread-panel-kicker', 'micro-manage-toggle', 'micro-return', 'micro-activity-state',
@@ -171,8 +170,6 @@ function gatherElements() {
171
170
  'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
172
171
  // Issue #512 S6: review experience (R7) + learnings (R9) + reverse mentoring (R10).
173
172
  'review-actions', 'review-approve', 'review-request-changes', 'review-handoff',
174
- // Issue #770: Deliverables panel (single-run artifact home).
175
- 'deliverables-panel', 'deliverables-summary', 'deliverables-body',
176
173
  // Issue #540: persona grid, manager team pool, hire strip, hire checkout modal.
177
174
  'persona-grid', 'manager-team-pool',
178
175
  'hire-strip', 'hs-avatar', 'hs-title', 'hs-sub', 'hs-add-btn', 'hs-get-btn', 'hs-skip-btn',
@@ -366,8 +363,7 @@ function applyBootstrap(bootstrap, docUrl) {
366
363
  }
367
364
  // Render header project name + status line.
368
365
  els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
369
- els['status-line'].textContent = bootstrap.project.message || '';
370
- els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
366
+ if (!bootstrap.project.exists || !bootstrap.project.hasFraim) showStatus(bootstrap.project.message, true);
371
367
  // Update the compact project label shown in task-pane surface rail.
372
368
  const tpLabel = document.getElementById('tp-project-label');
373
369
  if (tpLabel) {
@@ -2181,6 +2177,8 @@ function syncComposerPlaceholder(conv = activeConversation()) {
2181
2177
  if (!textarea || textarea.disabled) return;
2182
2178
  if (state.cpSelectedJob) {
2183
2179
  textarea.placeholder = textarea.dataset.placeholderArmed || 'Now type what you want done...';
2180
+ } else if (convAwaitingReview(conv)) {
2181
+ textarea.placeholder = textarea.dataset.placeholderReview;
2184
2182
  } else if (conv) {
2185
2183
  textarea.placeholder = textarea.dataset.placeholderActive || 'Coach this run, or type new instructions...';
2186
2184
  } else {
@@ -2323,7 +2321,7 @@ function manageViewFor(conv) {
2323
2321
  }
2324
2322
  function captureManagePosition() {
2325
2323
  const view = manageViews.get(displayedManageKey);
2326
- if (!view || !els['thread-panel'].open) return;
2324
+ if (!view) return;
2327
2325
  const node = view.micro ? els['micro-log'] : els['messages'];
2328
2326
  if (!node || !node.clientHeight) return;
2329
2327
  view[view.micro ? 'activity' : 'messages'] = {
@@ -2351,7 +2349,6 @@ function setManageMicroMode(enabled) {
2351
2349
  if (!conv || conv.compareMode === 'ab') return;
2352
2350
  captureManagePosition();
2353
2351
  manageViewFor(conv).micro = enabled;
2354
- els['thread-panel'].open = true;
2355
2352
  applyManageView(conv);
2356
2353
  syncMicroArchive(conv);
2357
2354
  restoreManagePosition(conv);
@@ -2372,15 +2369,6 @@ function syncManageActivityState(conv, events) {
2372
2369
  els['micro-return'].hidden = !(error || conversationUiState(conv) === 'waiting' || convAwaitingReview(conv));
2373
2370
  }
2374
2371
 
2375
- function syncConversationPanels(conv, switchedConv) {
2376
- if (!conv) return;
2377
- const panelState = panelStateFor(conv.id);
2378
- if (switchedConv) {
2379
- if (els['thread-panel']) {
2380
- els['thread-panel'].open = panelState.thread ?? true;
2381
- }
2382
- }
2383
- }
2384
2372
 
2385
2373
  function upsertConversation(conv, options) {
2386
2374
  // Issue #708: route to the conversation's scope bucket (manager/company → sentinel
@@ -2402,6 +2390,24 @@ function newConversationId() {
2402
2390
  // #533 R6: remove a run from the Hub. This is a Hub-local projection delete — it
2403
2391
  // drops the conversation from state + localStorage and clears any pointer to it.
2404
2392
  // It never touches artifacts the employee wrote to disk (the confirm copy says so).
2393
+ function clearDeletedConversationReferences(id) {
2394
+ if (!id) return;
2395
+ if (activeConversationId === id || state.activeId === id || deletedConversationIds.has(activeConversationId)) {
2396
+ activeConversationId = null;
2397
+ state.activeId = null;
2398
+ }
2399
+ if (state.areaActiveId) {
2400
+ for (const area of Object.keys(state.areaActiveId)) {
2401
+ if (state.areaActiveId[area] === id) state.areaActiveId[area] = null;
2402
+ }
2403
+ }
2404
+ if (typeof tf === 'object' && tf && tf.areaSelection) {
2405
+ for (const area of Object.keys(tf.areaSelection)) {
2406
+ if (tf.areaSelection[area] === id) tf.areaSelection[area] = null;
2407
+ }
2408
+ }
2409
+ }
2410
+
2405
2411
  async function deleteConversation(id) {
2406
2412
  if (!id) return false;
2407
2413
  const target = findConversation(id);
@@ -2410,9 +2416,8 @@ async function deleteConversation(id) {
2410
2416
  // Tombstone before the async delete so any in-flight header/body hydrate that
2411
2417
  // resolves during the server round trip cannot reselect or reinsert this run.
2412
2418
  deletedConversationIds.add(id);
2413
- if (activeConversationId === id || state.activeId === id) {
2414
- activeConversationId = null;
2415
- state.activeId = null;
2419
+ clearDeletedConversationReferences(id);
2420
+ if (!state.activeId) {
2416
2421
  if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
2417
2422
  }
2418
2423
  try {
@@ -2431,12 +2436,10 @@ async function deleteConversation(id) {
2431
2436
  if (next.length !== list.length) { state.conversations[key] = next; removed = true; }
2432
2437
  }
2433
2438
  const clearDeletedActive = () => {
2434
- if (!(activeConversationId === id || state.activeId === id || deletedConversationIds.has(activeConversationId) || deletedConversationIds.has(state.activeId))) return;
2435
- activeConversationId = null;
2436
- state.activeId = null;
2439
+ clearDeletedConversationReferences(id);
2437
2440
  // Deleting the active run: stop its poll so no zombie interval keeps ticking
2438
2441
  // against a conversation that no longer exists.
2439
- if (state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
2442
+ if (!state.activeId && state.pollHandle) { window.clearInterval(state.pollHandle); state.pollHandle = null; }
2440
2443
  };
2441
2444
  clearDeletedActive();
2442
2445
  if (!removed) return false;
@@ -3856,18 +3859,6 @@ function syncMicroArchive(conv) {
3856
3859
  els['micro-log'].onscroll = captureManagePosition;
3857
3860
  microArchiveTimer = setInterval(() => { if (microArchiveIsVisible(conv)) void loadMicroArchive(conv); }, 2000);
3858
3861
  }
3859
- const conversationFocusState = {
3860
- open: false,
3861
- previousFocus: null,
3862
- threadParent: null,
3863
- threadNext: null,
3864
- // Issue #1657 Treatment 3: the composer is permanently docked; "expand
3865
- // conversation" moves it into the overlay alongside the thread so the
3866
- // manager can still type while expanded.
3867
- composerParent: null,
3868
- composerNext: null,
3869
- messageScrollTop: 0,
3870
- };
3871
3862
  // #521 UI rework: the project workspace shows EITHER a job conversation (full
3872
3863
  // height) OR the Brief & Learnings page — never both stacked, so the thread,
3873
3864
  // coach, and micro-manage all have room. Toggle the mode class the CSS keys off,
@@ -3908,110 +3899,12 @@ function formatMicroLogLine(event, mode = 'events') {
3908
3899
  return `[${channel}] ${event && event.text != null ? String(event.text) : ''}\n`;
3909
3900
  }
3910
3901
 
3911
- function syncConversationFocusTitle(conv) {
3912
- if (!els['conversation-focus-title']) return;
3913
- els['conversation-focus-title'].textContent = conv ? conversationTitle(conv) : 'Focused conversation';
3914
- if (els['conversation-focus-subtitle']) {
3915
- els['conversation-focus-subtitle'].textContent = 'Conversation thread and coaching';
3916
- }
3917
- }
3918
-
3919
- function restoreFocusedConversationNodes() {
3920
- const thread = els['thread-panel'].closest('.manage-panel-shell') || els['thread-panel'];
3921
- const composer = document.getElementById('persistent-composer');
3922
- if (thread && conversationFocusState.threadParent) {
3923
- const next = conversationFocusState.threadNext;
3924
- if (next && next.parentNode === conversationFocusState.threadParent) {
3925
- conversationFocusState.threadParent.insertBefore(thread, next);
3926
- } else {
3927
- conversationFocusState.threadParent.appendChild(thread);
3928
- }
3929
- }
3930
- if (composer && conversationFocusState.composerParent) {
3931
- const next = conversationFocusState.composerNext;
3932
- if (next && next.parentNode === conversationFocusState.composerParent) {
3933
- conversationFocusState.composerParent.insertBefore(composer, next);
3934
- } else {
3935
- conversationFocusState.composerParent.appendChild(composer);
3936
- }
3937
- }
3938
- }
3939
-
3940
- function closeConversationFocus() {
3941
- if (!conversationFocusState.open) return;
3942
- captureManagePosition();
3943
- if (els['messages']) conversationFocusState.messageScrollTop = els['messages'].scrollTop;
3944
- restoreFocusedConversationNodes();
3945
- conversationFocusState.open = false;
3946
- document.body.classList.remove('conversation-focus-open');
3947
- if (els['conversation-focus-overlay']) {
3948
- els['conversation-focus-overlay'].hidden = true;
3949
- els['conversation-focus-overlay'].classList.remove('open');
3950
- }
3951
- requestAnimationFrame(() => {
3952
- if (els['messages']) els['messages'].scrollTop = conversationFocusState.messageScrollTop || 0;
3953
- if (typeof syncThreadMessageViewport === 'function') syncThreadMessageViewport();
3954
- const conv = activeConversation();
3955
- if (conv) restoreManagePosition(conv);
3956
- const target = conversationFocusState.previousFocus && document.contains(conversationFocusState.previousFocus)
3957
- ? conversationFocusState.previousFocus
3958
- : els['conversation-focus-btn'];
3959
- if (target && typeof target.focus === 'function') target.focus();
3960
- });
3961
- }
3962
-
3963
- function openConversationFocus() {
3964
- const conv = activeConversation();
3965
- captureManagePosition();
3966
- const overlay = els['conversation-focus-overlay'];
3967
- const panels = els['conversation-focus-panels'];
3968
- const thread = els['thread-panel'].closest('.manage-panel-shell') || els['thread-panel'];
3969
- const composer = document.getElementById('persistent-composer');
3970
- if (!conv || !overlay || !panels || !thread) return;
3971
- if (conversationFocusState.open) return;
3972
-
3973
- conversationFocusState.open = true;
3974
- conversationFocusState.previousFocus = document.activeElement;
3975
- conversationFocusState.threadParent = thread.parentNode;
3976
- conversationFocusState.threadNext = thread.nextSibling;
3977
- if (composer) {
3978
- conversationFocusState.composerParent = composer.parentNode;
3979
- conversationFocusState.composerNext = composer.nextSibling;
3980
- }
3981
- conversationFocusState.messageScrollTop = els['messages'] ? els['messages'].scrollTop : 0;
3982
-
3983
- syncConversationFocusTitle(conv);
3984
- panels.appendChild(thread);
3985
- if (composer) panels.appendChild(composer);
3986
- overlay.hidden = false;
3987
- overlay.classList.add('open');
3988
- document.body.classList.add('conversation-focus-open');
3989
-
3990
- requestAnimationFrame(() => {
3991
- if (els['messages']) els['messages'].scrollTop = conversationFocusState.messageScrollTop || 0;
3992
- if (typeof syncThreadMessageViewport === 'function') syncThreadMessageViewport();
3993
- restoreManagePosition(conv);
3994
- if (els['conversation-focus-close']) els['conversation-focus-close'].focus();
3995
- });
3996
- }
3997
-
3998
- function handleConversationFocusEscape(e) {
3999
- if (e.key !== 'Escape' || !conversationFocusState.open || e.defaultPrevented) return;
4000
- const openCoachMenu = COACH_DROPDOWN_PAIRS
4001
- .some(([id]) => { const menu = els[id] || document.getElementById(id); return menu && !menu.hidden; });
4002
- const reviewMenu = document.getElementById('review-approve-menu');
4003
- if (openCoachMenu || (reviewMenu && !reviewMenu.hidden)) return;
4004
- e.preventDefault();
4005
- closeConversationFocus();
4006
- }
4007
-
4008
3902
  function renderActive() {
4009
3903
  const conv = activeConversation();
4010
3904
  if (typeof syncTaskPaneSurfaceMode === 'function') syncTaskPaneSurfaceMode();
4011
3905
  if (typeof tfApplyWorkspaceMode === 'function') tfApplyWorkspaceMode();
4012
3906
  if (!conv) {
4013
3907
  syncMicroArchive(null);
4014
- closeConversationFocus();
4015
3908
  els['empty'].hidden = false;
4016
3909
  els['active-conv'].hidden = true;
4017
3910
  renderHubAgentSetupPanel();
@@ -4024,7 +3917,6 @@ function renderActive() {
4024
3917
  syncComposerPlaceholder(null);
4025
3918
  syncComposerStopButton(null);
4026
3919
  // #770: hide the Deliverables panel when there is no active conversation.
4027
- if (els['deliverables-panel']) { els['deliverables-panel'].hidden = true; els['deliverables-body'].innerHTML = ''; }
4028
3920
  syncThreadUiState(null);
4029
3921
  renderedConvId = null;
4030
3922
  renderedMessageCount = 0;
@@ -4050,7 +3942,6 @@ function renderActive() {
4050
3942
  syncConvictionDropdownLabel();
4051
3943
  syncCoachNote(conv);
4052
3944
  syncComposerPlaceholder(conv);
4053
- if (conversationFocusState.open) syncConversationFocusTitle(conv);
4054
3945
 
4055
3946
  // If we switched conversations (or this is the first render), wipe and
4056
3947
  // start fresh. Otherwise we're going to do an incremental update below.
@@ -4094,7 +3985,6 @@ function renderActive() {
4094
3985
  }
4095
3986
  const statusChanged = renderedStatus !== conv.status;
4096
3987
  syncThreadUiState(conv);
4097
- syncConversationPanels(conv, switchedConv);
4098
3988
  syncQuickCoachButtons(conv);
4099
3989
  // Issue #1723: renderActive() runs on every poll cycle for the active
4100
3990
  // conversation but previously never re-synced the Stop button here, so a
@@ -4245,13 +4135,14 @@ function renderActive() {
4245
4135
  renderReviewExperience(conv);
4246
4136
  // Issue #848 — "What's next?" chips below a completed conversation (post-retro).
4247
4137
  renderNextJobRecommendations(conv);
4138
+ renderDeliverablesBar(conv);
4248
4139
  syncManagedDelegationAccess(conv);
4249
4140
  syncThreadMessageViewport();
4250
4141
  // Issue #1065: the thread is rebuilt from state on a poll cadence, which would wipe the find
4251
4142
  // marks a manager is stepping through. Re-emit them from find state instead of injecting once.
4252
4143
  if (typeof convFindRefresh === 'function') convFindRefresh();
4253
4144
  const shouldScrollForUpdate = switchedConv || statusChanged || appendedMessages > 0 || messagesMutated;
4254
- const reviewFollow = (switchedConv || statusChanged || threadWasNearBottom) && (!switchedConv || manageViewFor(conv).messages.follow);
4145
+ const reviewFollow = (switchedConv || threadWasNearBottom) && (!switchedConv || manageViewFor(conv).messages.follow);
4255
4146
  scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage, reviewFollow, clearedPendingDeliveryMessages);
4256
4147
  requestAnimationFrame(() => {
4257
4148
  syncThreadMessageViewport();
@@ -4273,7 +4164,7 @@ function renderActive() {
4273
4164
  renderTracker(conv);
4274
4165
  renderTotals(conv);
4275
4166
  renderManagerOversightBoard(conv);
4276
- renderDeliverablesPanel(conv); // #770
4167
+
4277
4168
  renderDelegationLedger(conv);
4278
4169
  syncConversationRefreshPolling();
4279
4170
  syncTemplatePickerVisibility();
@@ -4422,6 +4313,25 @@ function renderRunStatePill(conv) {
4422
4313
  // conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
4423
4314
  pill.textContent = conversationStateLabel(conv).toUpperCase();
4424
4315
  pill.className = `run-state-pill ${conversationUiState(conv)}`;
4316
+ const pending = convAwaitingReview(conv);
4317
+ pill.tabIndex = pending ? 0 : -1;
4318
+ if (pending) {
4319
+ pill.setAttribute('role', 'button');
4320
+ pill.setAttribute('aria-label', 'Waiting on you. Go to the pending decision.');
4321
+ } else {
4322
+ pill.removeAttribute('role');
4323
+ pill.removeAttribute('aria-label');
4324
+ }
4325
+ const jump = () => {
4326
+ const current = activeConversation();
4327
+ if (!convAwaitingReview(current)) return;
4328
+ setManageMicroMode(false);
4329
+ els['thread-panel'].scrollIntoView({ block: 'nearest' });
4330
+ scrollThreadForReview(current);
4331
+ els['review-approve'].focus({ preventScroll: true });
4332
+ };
4333
+ pill.onclick = pending ? jump : null;
4334
+ pill.onkeydown = pending ? (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); jump(); } } : null;
4425
4335
  syncComposerStopButton(conv);
4426
4336
  }
4427
4337
 
@@ -5811,7 +5721,7 @@ function ensureThreadMessageViewportObserver() {
5811
5721
  function syncThreadMessageViewport() {
5812
5722
  const messages = els['messages'];
5813
5723
  if (!messages) return;
5814
- // Flex layout now owns the thread height: #thread-panel[open] grows to fill the
5724
+ // Flex layout now owns the thread height: #thread-panel grows to fill the
5815
5725
  // available real estate and #messages (flex child, overflow-y:auto) scrolls
5816
5726
  // internally. Clear any legacy inline sizing so CSS stays authoritative.
5817
5727
  messages.style.height = '';
@@ -5899,8 +5809,8 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom,
5899
5809
  if (!latest || latest.id !== conv.id) return;
5900
5810
  const host = els['messages'];
5901
5811
  if (!host || host.hidden) return;
5812
+ ensurePendingAnchorScrollListener(host);
5902
5813
  if (latest.status === 'running') {
5903
- ensurePendingAnchorScrollListener(host);
5904
5814
  if (!forceBottom && pendingAnchorUserScrolled(host)) {
5905
5815
  return;
5906
5816
  }
@@ -5970,7 +5880,7 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom,
5970
5880
  }
5971
5881
  }
5972
5882
  return;
5973
- } else if (shouldScrollForUpdate && reviewFollow) {
5883
+ } else if (shouldScrollForUpdate && reviewFollow && !pendingAnchorUserScrolled(host)) {
5974
5884
  // Issue #1665: jump to the review target only on a real transition (switch or
5975
5885
  // status change) or when the manager was already following the latest message.
5976
5886
  // A live update landing while they read history must not yank the view.
@@ -5997,13 +5907,16 @@ function scrollThreadForReview(conv) {
5997
5907
  const targetRect = target.getBoundingClientRect();
5998
5908
  const currentTop = host.scrollTop;
5999
5909
  const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
6000
- const reviewOffset = Math.max(24, host.clientHeight * 0.16);
5910
+ const bar = host.querySelector('#deliverables-bar:not([hidden])');
5911
+ const reservedHeight = bar ? bar.getBoundingClientRect().height : 0;
5912
+ const availableLead = Math.max(0, host.clientHeight - reservedHeight - targetRect.height - 24);
5913
+ const reviewOffset = Math.min(Math.max(24, host.clientHeight * 0.16), availableLead);
6001
5914
  const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
6002
5915
  // #936: use instant scroll so the animation completes synchronously. A smooth
6003
5916
  // scroll takes ~300-500ms; if the user sets scrollTop before the animation
6004
5917
  // finishes the CSS scroll-behavior causes the browser to re-animate, making
6005
5918
  // the user's intent hard to honor. Instant + one-time gate = clean hand-off.
6006
- host.scrollTop = desiredTop;
5919
+ host.scrollTo({ top: desiredTop, behavior: 'instant' });
6007
5920
  }
6008
5921
 
6009
5922
  // ---------------------------------------------------------------------------
@@ -6268,6 +6181,7 @@ function resizeComposerTextarea() {
6268
6181
  }
6269
6182
 
6270
6183
  function syncSendButton() {
6184
+ syncReviewHint();
6271
6185
  resizeComposerTextarea();
6272
6186
  const conv = activeConversation();
6273
6187
  const hasText = els['coach-text'].value.trim().length > 0;
@@ -6981,37 +6895,95 @@ function deriveDeliverableFormat(conv) {
6981
6895
  };
6982
6896
  }
6983
6897
 
6898
+ // Review history belongs to the conversation record. It never grants approval:
6899
+ // the live run state remains the only source of action eligibility.
6900
+ function syncReviewTurns(conv) {
6901
+ if (!conv) return [];
6902
+ const turns = Array.isArray(conv.reviewTurns) ? conv.reviewTurns : [];
6903
+ if (!conversationHasBody(conv)) return turns;
6904
+ const awaiting = convAwaitingReview(conv);
6905
+ const signature = JSON.stringify(reviewHandoffForConversation(conv));
6906
+ let pending = turns.find((turn) => !turn.resolution);
6907
+ let changed = false;
6908
+ if (pending && (!awaiting || pending.signature !== signature)) {
6909
+ pending.resolution = conv.pauseReason === 'done' ? 'You marked this complete.'
6910
+ : conv.reviewApproved ? 'You approved this.'
6911
+ : awaiting ? 'A newer review replaced this decision.' : 'The run continued without a decision.';
6912
+ pending.resolvedAt = Date.now();
6913
+ pending = null;
6914
+ changed = true;
6915
+ }
6916
+ if (awaiting && !pending) {
6917
+ const last = (conv.messages || []).at(-1);
6918
+ turns.push({ id: String(Date.now()) + '-' + turns.length, signature,
6919
+ messageCount: (conv.messages || []).length, at: last ? messageTimestamp(last) : Date.now() });
6920
+ changed = true;
6921
+ }
6922
+ if (changed) {
6923
+ conv.reviewTurns = turns;
6924
+ persistConversation(conv);
6925
+ }
6926
+ return turns;
6927
+ }
6928
+
6929
+ function resolveReviewTurn(conv, resolution) {
6930
+ const turn = (conv.reviewTurns || syncReviewTurns(conv)).find((entry) => !entry.resolution);
6931
+ if (!turn) return;
6932
+ turn.resolution = resolution;
6933
+ turn.resolvedAt = Date.now();
6934
+ }
6935
+
6936
+ function syncReviewHint(conv = activeConversation()) {
6937
+ const hint = els['review-handoff'];
6938
+ if (!hint || !convAwaitingReview(conv)) return;
6939
+ hint.textContent = els['coach-text'].value.trim()
6940
+ ? 'Request changes will send what you typed. Send replies without deciding.'
6941
+ : 'Review the update and deliverables, then approve or type changes below. Send replies without deciding.';
6942
+ }
6943
+
6984
6944
  function renderReviewExperience(conv) {
6985
6945
  const actions = els['review-actions'];
6986
- if (!actions) return;
6987
-
6946
+ const host = els['messages'];
6947
+ if (!actions || !host) return;
6948
+ const turns = syncReviewTurns(conv);
6988
6949
  const awaiting = convAwaitingReview(conv);
6950
+ const rows = Array.from(host.children).filter((node) => node.classList.contains('message') && !node.classList.contains('review-turn'));
6951
+ for (const turn of turns) {
6952
+ let row = Array.from(host.querySelectorAll('.review-turn')).find((node) => node.dataset.reviewTurn === turn.id);
6953
+ if (!row) {
6954
+ row = document.createElement('div');
6955
+ row.className = 'message employee review-turn';
6956
+ row.dataset.reviewTurn = turn.id;
6957
+ const bubble = document.createElement('div');
6958
+ bubble.className = 'bubble';
6959
+ const label = document.createElement('div');
6960
+ label.className = 'review-turn-label';
6961
+ label.textContent = 'Ready for your review';
6962
+ bubble.appendChild(label);
6963
+ row.appendChild(bubble);
6964
+ host.insertBefore(row, rows[turn.messageCount] || host.querySelector('#deliverables-bar, #next-job-section'));
6965
+ }
6966
+ const bubble = row.querySelector('.bubble');
6967
+ if (!turn.resolution && awaiting) {
6968
+ if (actions.parentNode !== bubble) bubble.appendChild(actions);
6969
+ actions.hidden = false;
6970
+ renderDynamicReviewActions(conv, reviewHandoffForConversation(conv));
6971
+ syncReviewHint(conv);
6972
+ } else {
6973
+ let settled = bubble.querySelector('.review-resolution');
6974
+ if (!settled) {
6975
+ settled = document.createElement('div');
6976
+ settled.className = 'review-resolution';
6977
+ bubble.appendChild(settled);
6978
+ }
6979
+ const at = new Date(turn.resolvedAt);
6980
+ settled.textContent = turn.resolution + (Number.isFinite(at.getTime()) ? ' ' + at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '');
6981
+ }
6982
+ }
6989
6983
  if (!awaiting) {
6990
6984
  clearDynamicReviewActionButtons(actions);
6991
6985
  actions.hidden = true;
6992
- return;
6993
- }
6994
-
6995
- const fmt = deriveDeliverableFormat(conv);
6996
-
6997
- // --- Format-derived artifact strip (R7.2) ---
6998
- // #770 A1: real deliverables (artifact_set / pull_request / deck / report /
6999
- // markdown) render in the Deliverables panel (renderDeliverablesPanel), not
7000
- // pinned in the thread.
7001
-
7002
- // --- Unified action bar: review actions appear beside the always-on chips ---
7003
- actions.hidden = false;
7004
- renderDynamicReviewActions(conv, reviewHandoffForConversation(conv));
7005
- const handoff = els['review-handoff'];
7006
- if (handoff) {
7007
- const hasArtifact = !!(fmt.actions && fmt.actions.length);
7008
- handoff.textContent = fmt.key === 'pull_request'
7009
- ? 'Comment on the PR to leave inline notes, or approve / type changes below.'
7010
- : isManagerTemplateJob(conv && conv.jobId)
7011
- ? 'Open the updated files, then approve or type changes below.'
7012
- : hasArtifact
7013
- ? 'Open the deliverable above, then approve or type changes below.'
7014
- : 'Review the update above, then approve or type changes below.';
6986
+ document.getElementById('review-action-storage').appendChild(actions);
7015
6987
  }
7016
6988
  }
7017
6989
 
@@ -7232,34 +7204,46 @@ function approvalCommandFromLabel(action) {
7232
7204
  return label ? `Approved and ${label}.` : APPROVAL_MESSAGE;
7233
7205
  }
7234
7206
 
7235
- // #770 A1: single-run deliverables live in a stable collapsible panel in the
7236
- // support stack, decoupled from the transient approve/reject decision UI. Fed by
7237
- // the same format derivation as the old in-thread strip, so a genuine structured
7238
- // handoff / recorded run artifact yields open/view chips; prose-only replies
7239
- // yield nothing (panel hidden). For manager-oversight runs the format includes
7240
- // a union of Mandy's own artifacts and every reviewed child's artifacts (#1566).
7241
- function renderDeliverablesPanel(conv) {
7242
- const panel = els['deliverables-panel'];
7243
- const body = els['deliverables-body'];
7244
- if (!panel || !body) return;
7245
- const fmt = conv ? deriveDeliverableFormat(conv) : null;
7207
+ // Run outputs stay reachable independently of the current review decision.
7208
+ function renderDeliverablesBar(conv) {
7209
+ const messages = els['messages'];
7210
+ if (!messages) return;
7211
+ let panel = messages.querySelector('#deliverables-bar');
7212
+ if (!panel) {
7213
+ panel = document.createElement('div');
7214
+ panel.id = 'deliverables-bar';
7215
+ panel.setAttribute('aria-label', 'Deliverables');
7216
+ }
7217
+ const body = panel;
7218
+ let fmt = conv ? deriveDeliverableFormat(conv) : null;
7219
+ // A later run snapshot may clear its handoff; produced outputs remain available.
7220
+ // Historical data supplies artifact links only, never decision eligibility.
7221
+ if (conv && (!reviewHandoffForConversation(conv) || reviewHandoffForConversation(conv).reviewRequired === false) && (!fmt || !fmt.actions.length)) {
7222
+ const previous = (conv.reviewTurns || []).at(-1);
7223
+ if (previous) {
7224
+ try { fmt = deriveFormatFromReviewHandoff(normalizeReviewHandoff(JSON.parse(previous.signature))); }
7225
+ catch { /* Ignore malformed history from older/imported records. */ }
7226
+ }
7227
+ }
7246
7228
  const isDeliverableFmt = !!fmt && (fmt.key === 'artifact_set' || fmt.key === 'pull_request'
7247
7229
  || fmt.key === 'deck' || fmt.key === 'report'
7248
7230
  || (fmt.key === 'markdown' && fmt.actions && fmt.actions.length));
7249
7231
  const actions = isDeliverableFmt ? fmt.actions.filter((a) => a.kind !== 'feedback') : [];
7250
- // #770 / #512 R7.2: a handoff that EXPLICITLY declines review (reviewRequired:false,
7251
- // e.g. org-onboarding wrote in-place context files reviewed via the Company/Manager
7252
- // Brief tab) is not a deliverable to surface here. This preserves the prior "no
7253
- // artifact strip for a no-review handoff" behavior now that the strip is decoupled
7254
- // from the (hidden) review card.
7255
7232
  const currentHandoff = conv ? reviewHandoffForConversation(conv) : null;
7256
- const declinedReview = !!currentHandoff && currentHandoff.reviewRequired === false;
7233
+ const declinedReview = !!currentHandoff && currentHandoff.reviewRequired === false && !(conv.reviewTurns || []).length;
7257
7234
  if (!conv || declinedReview || actions.length === 0) {
7258
7235
  panel.hidden = true;
7236
+ delete panel.dataset.signature;
7259
7237
  body.innerHTML = '';
7260
7238
  return;
7261
7239
  }
7262
7240
  panel.hidden = false;
7241
+ const signature = JSON.stringify(actions);
7242
+ if (panel.dataset.signature === signature) {
7243
+ if (messages.lastElementChild !== panel) messages.appendChild(panel);
7244
+ return;
7245
+ }
7246
+ panel.dataset.signature = signature;
7263
7247
  body.innerHTML = '';
7264
7248
  const strip = document.createElement('div');
7265
7249
  strip.className = 'rc-artifact-strip';
@@ -7292,13 +7276,7 @@ function renderDeliverablesPanel(conv) {
7292
7276
  strip.appendChild(btn);
7293
7277
  }
7294
7278
  body.appendChild(strip);
7295
- const summary = els['deliverables-summary'];
7296
- if (summary) {
7297
- summary.textContent = actions.length === 1 ? '1 file from this run' : `${actions.length} files from this run`;
7298
- }
7299
- const panelState = panelStateFor(conv.id);
7300
- const shouldOpen = panelState.deliverables !== undefined ? panelState.deliverables : convAwaitingReview(conv);
7301
- if (panel.open !== shouldOpen) panel.open = shouldOpen;
7279
+ messages.appendChild(panel);
7302
7280
  }
7303
7281
 
7304
7282
  function jobCatalogEntry(jobId) {
@@ -7829,6 +7807,7 @@ async function approveReview(options) {
7829
7807
  const selectedReviewAction = opts.reviewAction && typeof opts.reviewAction === 'object'
7830
7808
  ? opts.reviewAction
7831
7809
  : { id: 'approve', label: 'Approve', kind: 'approve', style: 'primary' };
7810
+ resolveReviewTurn(conv, 'You approved this.');
7832
7811
  conv.reviewApproved = true;
7833
7812
  conv.selectedReviewAction = selectedReviewAction;
7834
7813
  conv.pendingDeliveryActionId = selectedReviewAction.deliveryActionId || null;
@@ -7845,6 +7824,7 @@ async function approveReview(options) {
7845
7824
  await continueRun(approvalMessage, { preserveReviewApproved: true, selectedReviewAction });
7846
7825
  return;
7847
7826
  }
7827
+ resolveReviewTurn(conv, 'You marked this complete.');
7848
7828
  conv.status = 'completed';
7849
7829
  conv.reviewApproved = false;
7850
7830
  conv.pendingDeliveryActionId = null;
@@ -7864,6 +7844,8 @@ async function approveReview(options) {
7864
7844
  async function requestChangesReview() {
7865
7845
  const conv = activeConversation();
7866
7846
  if (!conv) return;
7847
+ if (!convAwaitingReview(conv)) return;
7848
+ resolveReviewTurn(conv, 'You asked for changes.');
7867
7849
  const typedText = (els['coach-text'] && els['coach-text'].value.trim()) || '';
7868
7850
  const text = typedText || DEFAULT_REQUEST_CHANGES_MESSAGE;
7869
7851
  // Re-open the review gate: a change request means the run is no longer "done".
@@ -8553,7 +8535,7 @@ function showRerunToast(msg) {
8553
8535
  const t = document.createElement('div');
8554
8536
  t.className = 'cp-rerun-toast';
8555
8537
  // Issue #1627: matches this app's own convention for transient status text (#be-status,
8556
- // #status-line, etc.) so a screen reader announces the message instead of it being silently
8538
+ // status notifications, etc.) so a screen reader announces the message instead of it being silently
8557
8539
  // visual-only - relevant beyond the original re-run case now that this toast also carries the
8558
8540
  // real reason a restart-to-latest attempt failed.
8559
8541
  t.setAttribute('role', 'status');
@@ -9087,6 +9069,7 @@ function conversationDispatchAgentId(conv) {
9087
9069
  async function continueRun(text, options) {
9088
9070
  const conv = activeConversation();
9089
9071
  if (!conv || !conv.runId) return;
9072
+ if (convAwaitingReview(conv)) resolveReviewTurn(conv, 'You replied instead. The run continued without a decision.');
9090
9073
  const opts = options || {};
9091
9074
  // Consume any pending coaching job selected via quick-coach or template picker.
9092
9075
  // The server adds the correct invocation prefix ($fraim / /fraim) based on
@@ -9205,6 +9188,7 @@ async function continueRun(text, options) {
9205
9188
  }
9206
9189
 
9207
9190
  function foldRunIntoConversation(conv, run) {
9191
+ syncReviewTurns(conv);
9208
9192
  const createdAt = stableConversationCreatedAt(conv, run);
9209
9193
  if (createdAt !== undefined) conv.createdAt = createdAt;
9210
9194
  // Issue #710: server run identity is canonical. The browser may have
@@ -9505,9 +9489,47 @@ function switchToConversation(id) {
9505
9489
  // Status line + project picker
9506
9490
  // ---------------------------------------------------------------------------
9507
9491
 
9492
+ let statusNotificationTimer;
9493
+ function positionStatusNotification() {
9494
+ const notice = document.getElementById('hub-status-notification');
9495
+ const composer = document.getElementById('persistent-composer');
9496
+ if (!notice || notice.hidden) return;
9497
+ const box = composer && composer.getBoundingClientRect();
9498
+ const visible = box && box.width > 0 && box.top > 0 && box.top < innerHeight;
9499
+ notice.style.bottom = (visible ? innerHeight - box.top + 8 : 16) + 'px';
9500
+ notice.style.left = (visible ? box.left + box.width / 2 : innerWidth / 2) + 'px';
9501
+ }
9508
9502
  function showStatus(text, isError) {
9509
- els['status-line'].textContent = text || '';
9510
- els['status-line'].classList.toggle('error', !!isError);
9503
+ clearTimeout(statusNotificationTimer);
9504
+ let notice = document.getElementById('hub-status-notification');
9505
+ if (!notice) {
9506
+ notice = document.createElement('div');
9507
+ notice.id = 'hub-status-notification';
9508
+ document.body.appendChild(notice);
9509
+ window.addEventListener('resize', positionStatusNotification);
9510
+ document.addEventListener('scroll', positionStatusNotification, true);
9511
+ const composer = document.getElementById('persistent-composer');
9512
+ if (composer && typeof ResizeObserver !== 'undefined') new ResizeObserver(positionStatusNotification).observe(composer);
9513
+ }
9514
+ notice.className = 'cp-rerun-toast hub-status-notification' + (isError ? ' error' : '');
9515
+ notice.replaceChildren();
9516
+ notice.hidden = !text;
9517
+ notice.setAttribute('role', isError ? 'alert' : 'status');
9518
+ notice.setAttribute('aria-live', isError ? 'assertive' : 'polite');
9519
+ const message = document.createElement('span');
9520
+ message.textContent = text || '';
9521
+ notice.appendChild(message);
9522
+ if (isError) {
9523
+ const dismiss = document.createElement('button');
9524
+ dismiss.type = 'button';
9525
+ dismiss.setAttribute('aria-label', 'Dismiss notification');
9526
+ dismiss.textContent = '\u00d7';
9527
+ dismiss.onclick = () => { notice.hidden = true; };
9528
+ notice.appendChild(dismiss);
9529
+ } else {
9530
+ statusNotificationTimer = setTimeout(() => { notice.hidden = true; }, 5000);
9531
+ }
9532
+ positionStatusNotification();
9511
9533
  }
9512
9534
 
9513
9535
  async function pickProject() {
@@ -10418,25 +10440,7 @@ function wireEvents() {
10418
10440
  els['micro-manage-toggle'].focus();
10419
10441
  scrollThreadForReview(activeConversation());
10420
10442
  });
10421
- if (els['thread-panel']) {
10422
- els['thread-panel'].addEventListener('toggle', () => {
10423
- const conv = activeConversation();
10424
- if (!conv) return;
10425
- panelStateFor(conv.id).thread = els['thread-panel'].open;
10426
- if (els['thread-panel'].open) requestAnimationFrame(() => restoreManagePosition(conv));
10427
- });
10428
- }
10429
-
10430
- // #770: Deliverables panel toggle: persist open/close state per conversation.
10431
- if (els['deliverables-panel']) {
10432
- els['deliverables-panel'].addEventListener('toggle', () => {
10433
- const conv = activeConversation();
10434
- if (conv) panelStateFor(conv.id).deliverables = els['deliverables-panel'].open;
10435
- });
10436
- }
10437
-
10438
- // Issue #512 R7.7: unified action-bar review buttons. They live in the same
10439
- // bar as the coaching chips; the chips/input never disappear.
10443
+ // The live review controls retain their handlers when moved into a review turn.
10440
10444
  if (els['review-approve']) {
10441
10445
  els['review-approve'].addEventListener('click', () => approveReview({ resumeAfterApproval: true }));
10442
10446
  }
@@ -10551,18 +10555,6 @@ function wireEvents() {
10551
10555
  return;
10552
10556
  }
10553
10557
  });
10554
- if (els['conversation-focus-btn']) {
10555
- els['conversation-focus-btn'].addEventListener('click', openConversationFocus);
10556
- }
10557
- if (els['conversation-focus-close']) {
10558
- els['conversation-focus-close'].addEventListener('click', closeConversationFocus);
10559
- }
10560
- if (els['conversation-focus-overlay']) {
10561
- els['conversation-focus-overlay'].addEventListener('click', (e) => {
10562
- if (e.target === els['conversation-focus-overlay']) closeConversationFocus();
10563
- });
10564
- }
10565
- document.addEventListener('keydown', handleConversationFocusEscape);
10566
10558
 
10567
10559
  // Issue #1657: #coach-text's own keydown handler (wired above, alongside
10568
10560
  // arrow-key suggestion navigation) now owns Enter-to-send directly via
@@ -47,7 +47,7 @@
47
47
  /* z-index layers (ascending, avoid magic numbers in property declarations). */
48
48
  --z-cmd-palette: 9000;
49
49
  --z-dropdown-menu: 9100;
50
- /* Job-viz modal must clear #composer-suggestions/.conversation-focus-overlay
50
+ /* Job-viz modal must clear #composer-suggestions
51
51
  (--z-cmd-palette). The #jv-tt tooltip portal (--z-jv-tt) must clear the
52
52
  modal so phase/skill popovers float above it when the modal is open. */
53
53
  --z-job-viz: 9200;
@@ -910,35 +910,6 @@ img.conv-employee-avatar {
910
910
  }
911
911
  .run-stop-btn:hover { background: #fee4e2; }
912
912
  .run-stop-btn:disabled { opacity: .55; cursor: default; }
913
- .conversation-focus-btn {
914
- flex-shrink: 0;
915
- display: inline-flex;
916
- align-items: center;
917
- justify-content: center;
918
- width: 32px;
919
- height: 32px;
920
- font-size: 17px;
921
- font-weight: 700;
922
- line-height: 1;
923
- color: var(--accent-strong);
924
- background: var(--accent-soft);
925
- border: 1px solid color-mix(in srgb, var(--accent) 28%, var(--line));
926
- border-radius: 8px;
927
- padding: 0;
928
- cursor: pointer;
929
- }
930
- .panel-details--thread .conversation-focus-btn {
931
- margin-left: auto;
932
- }
933
- .conversation-focus-btn:hover {
934
- border-color: var(--accent);
935
- color: var(--accent);
936
- }
937
- .conversation-focus-btn:focus-visible,
938
- .conversation-focus-close:focus-visible {
939
- outline: 2px solid var(--accent);
940
- outline-offset: 2px;
941
- }
942
913
  /* Job title inline with employee avatar — replaces the old separate h2 row */
943
914
  .conv-job-title {
944
915
  flex: 1;
@@ -2401,14 +2372,7 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
2401
2372
  }
2402
2373
  .resume-command-copy:hover { background: color-mix(in srgb, var(--accent) 10%, var(--surface)); }
2403
2374
 
2404
- .status-line {
2405
- margin: 0;
2406
- color: var(--muted);
2407
- font-size: 13px;
2408
- text-align: center;
2409
- min-height: 1.2em;
2410
- }
2411
- .status-line.error { color: var(--danger); }
2375
+
2412
2376
 
2413
2377
  /* Modal */
2414
2378
  .modal-backdrop {
@@ -4216,37 +4180,22 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4216
4180
  .workspace-conv #active-conv > .conv-topline,
4217
4181
  .workspace-conv #active-conv > .conversation-status { flex: 0 0 auto; }
4218
4182
 
4219
- /* Manager/employee thread — the primary conversation. The "Ready for review"
4220
- card lives inside #messages, so it scrolls with the thread. Open thread grows
4221
- to fill spare height; collapsed it is just its summary bar. */
4222
- .workspace-conv #thread-panel { flex: 0 0 auto; min-height: 0; }
4223
- /* Open thread is the sole space-filler: it grows to take all height the support
4224
- stack does not need, and scrolls #messages internally. Collapsed it is just
4225
- its summary bar. */
4226
- .workspace-conv #thread-panel[open] {
4183
+ /* The static Manage section fills spare height and scrolls messages internally. */
4184
+ .workspace-conv #thread-panel {
4227
4185
  flex: 1 1 0;
4228
4186
  display: flex;
4229
4187
  flex-direction: column;
4230
4188
  min-height: 120px;
4231
4189
  }
4232
- .workspace-conv #thread-panel > summary { flex: 0 0 auto; }
4233
- /* Chromium wraps <details> content in a ::details-content slot, so #messages is
4234
- not a direct flex child of the panel. Make the slot a column flex container
4235
- that fills the panel; then #messages can flex-grow and scroll inside it. */
4236
- .workspace-conv #thread-panel[open]::details-content {
4237
- flex: 1 1 0;
4238
- min-height: 0;
4239
- display: flex;
4240
- flex-direction: column;
4241
- overflow: hidden;
4242
- }
4243
- .workspace-conv #thread-panel[open] #messages {
4190
+ .workspace-conv #thread-panel > .thread-header { flex: 0 0 auto; }
4191
+ /* The conversation body shrinks inside the static Manage section. */
4192
+
4193
+ .workspace-conv #thread-panel #messages {
4244
4194
  flex: 1 1 0;
4245
4195
  min-height: 0;
4246
4196
  max-height: none;
4247
4197
  overflow-y: auto;
4248
4198
  }
4249
- .workspace-conv #thread-panel:not([open]) #messages { display: none; }
4250
4199
 
4251
4200
  /* Both Manage views use the same flex slot; switching does not resize the pane. */
4252
4201
  #thread-panel .manage-body {
@@ -4257,7 +4206,6 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4257
4206
  min-width: 0;
4258
4207
  overflow: hidden;
4259
4208
  }
4260
- #thread-panel:not([open]) .manage-body { display: none; }
4261
4209
  #thread-panel .manage-activity {
4262
4210
  display: flex;
4263
4211
  flex-direction: column;
@@ -4280,10 +4228,9 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4280
4228
 
4281
4229
  /* Keep independent header controls outside the native disclosure button. */
4282
4230
  .manage-panel-shell { position: relative; display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; min-width: 0; }
4283
- .manage-panel-shell:has(#thread-panel:not([open])) { flex: 0 0 auto; }
4284
4231
  .manage-header-actions { position: absolute; top: 12px; right: 24px; display: flex; align-items: center; gap: 10px; }
4285
- #thread-panel > summary { gap: 10px; min-height: 60px; padding-right: 190px; box-sizing: border-box; }
4286
- #thread-panel > summary .panel-summary-copy { margin-right: auto; }
4232
+ #thread-panel > .thread-header { gap: 10px; min-height: 60px; padding-right: 190px; box-sizing: border-box; }
4233
+ #thread-panel > .thread-header .panel-summary-copy { margin-right: auto; }
4287
4234
  .micro-manage-toggle {
4288
4235
  display: inline-flex;
4289
4236
  align-items: center;
@@ -4308,8 +4255,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4308
4255
  .micro-activity-state, .micro-return { margin: 0 16px 12px; color: var(--text); }
4309
4256
  .micro-return { align-self: flex-start; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); cursor: pointer; }
4310
4257
  @media (max-width: 480px) {
4311
- #thread-panel > summary { flex-wrap: wrap; }
4312
- .manage-header-actions .conversation-focus-btn { order: 1; }
4258
+ #thread-panel > .thread-header { flex-wrap: wrap; }
4313
4259
  .manage-header-actions { right: 24px; }
4314
4260
  }
4315
4261
 
@@ -4359,14 +4305,6 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4359
4305
  overflow-y: auto;
4360
4306
  }
4361
4307
 
4362
- /* #770: Deliverables panel sizes to its content and scrolls internally when open,
4363
- mirroring the coach/micro support-stack panels. */
4364
- .workspace-conv .support-stack > .panel-details--deliverables { flex: 0 0 auto; }
4365
- .workspace-conv .support-stack > .panel-details--deliverables[open] > .panel-body {
4366
- max-height: clamp(120px, 22vh, 240px);
4367
- overflow-y: auto;
4368
- }
4369
-
4370
4308
  .workspace-conv #active-conv.has-delegation-board .delegation-ledger[open] .delegation-ledger-body {
4371
4309
  max-height: clamp(140px, 24vh, 260px);
4372
4310
  }
@@ -4391,125 +4329,6 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4391
4329
  .workspace-conv .support-stack > .panel-details,
4392
4330
  .workspace-conv .support-stack > .micro { margin-bottom: 0; }
4393
4331
 
4394
- .conversation-focus-overlay {
4395
- position: fixed;
4396
- inset: 0;
4397
- z-index: var(--z-cmd-palette);
4398
- display: flex;
4399
- align-items: stretch;
4400
- justify-content: center;
4401
- padding: 18px;
4402
- background: rgba(14, 21, 32, 0.42);
4403
- overflow: hidden;
4404
- }
4405
- .conversation-focus-overlay[hidden] {
4406
- display: none;
4407
- }
4408
- .conversation-focus-dialog {
4409
- width: min(1180px, 100%);
4410
- height: 100%;
4411
- min-width: 0;
4412
- min-height: 0;
4413
- display: flex;
4414
- flex-direction: column;
4415
- gap: 10px;
4416
- border: 1px solid var(--line);
4417
- border-radius: 10px;
4418
- background: var(--surface);
4419
- box-shadow: var(--shadow-lg);
4420
- padding: 12px;
4421
- overflow: hidden;
4422
- }
4423
- .conversation-focus-header {
4424
- display: flex;
4425
- align-items: center;
4426
- justify-content: space-between;
4427
- gap: 12px;
4428
- flex: 0 0 auto;
4429
- min-width: 0;
4430
- }
4431
- .conversation-focus-copy {
4432
- display: grid;
4433
- gap: 2px;
4434
- min-width: 0;
4435
- }
4436
- .conversation-focus-copy strong {
4437
- color: var(--text);
4438
- font-size: 15px;
4439
- }
4440
- .conversation-focus-copy span {
4441
- color: var(--muted);
4442
- font-size: 12px;
4443
- overflow: hidden;
4444
- text-overflow: ellipsis;
4445
- white-space: nowrap;
4446
- }
4447
- .conversation-focus-close {
4448
- flex-shrink: 0;
4449
- border: 1px solid var(--line);
4450
- border-radius: 8px;
4451
- background: var(--surface);
4452
- color: var(--text);
4453
- font-size: 12px;
4454
- font-weight: 700;
4455
- padding: 7px 11px;
4456
- cursor: pointer;
4457
- }
4458
- .conversation-focus-close:hover {
4459
- border-color: var(--accent);
4460
- color: var(--accent-strong);
4461
- }
4462
- .conversation-focus-panels {
4463
- flex: 1 1 0;
4464
- min-height: 0;
4465
- min-width: 0;
4466
- display: flex;
4467
- flex-direction: column;
4468
- gap: 8px;
4469
- overflow: hidden;
4470
- }
4471
- .conversation-focus-panels #thread-panel {
4472
- margin: 0;
4473
- min-height: 0;
4474
- }
4475
- .conversation-focus-panels #thread-panel[open] {
4476
- flex: 1 1 0;
4477
- display: flex;
4478
- flex-direction: column;
4479
- }
4480
- .conversation-focus-panels #thread-panel[open]::details-content {
4481
- flex: 1 1 0;
4482
- min-height: 0;
4483
- display: flex;
4484
- flex-direction: column;
4485
- overflow: hidden;
4486
- }
4487
- .conversation-focus-panels #thread-panel[open] #messages {
4488
- flex: 1 1 0;
4489
- min-height: 0;
4490
- max-height: none;
4491
- overflow-y: auto;
4492
- }
4493
- .conversation-focus-panels #persistent-composer .coach {
4494
- min-height: 0;
4495
- }
4496
- .conversation-focus-panels #persistent-composer .coach-input textarea {
4497
- min-height: 68px;
4498
- }
4499
- .conversation-focus-panels:has(#thread-panel:not([open])) #persistent-composer,
4500
- .conversation-focus-panels:has(#thread-panel:not([open])) #persistent-composer .coach,
4501
- .conversation-focus-panels:has(#thread-panel:not([open])) #persistent-composer .coach-input {
4502
- flex: 1 1 0;
4503
- }
4504
- .conversation-focus-panels:has(#thread-panel:not([open])) #persistent-composer .coach {
4505
- display: flex;
4506
- flex-direction: column;
4507
- }
4508
- .conversation-focus-panels:has(#thread-panel:not([open])) #persistent-composer .coach-input textarea {
4509
- flex: 1 1 0;
4510
- min-height: 160px;
4511
- }
4512
-
4513
4332
  @media (max-width: 820px) {
4514
4333
  /* On narrow screens drop the fill model: panels flow naturally and the page
4515
4334
  scrolls, with each open body keeping a viewport-relative scroll cap. */
@@ -4518,7 +4337,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4518
4337
  flex-direction: column;
4519
4338
  overflow: visible;
4520
4339
  }
4521
- .workspace-conv #thread-panel[open],
4340
+ .workspace-conv #thread-panel,
4522
4341
  .workspace-conv .support-stack,
4523
4342
  .workspace-conv .support-stack:has(.micro[open]),
4524
4343
  .workspace-conv .support-stack > .micro[open] {
@@ -4526,7 +4345,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4526
4345
  display: block;
4527
4346
  min-height: 0;
4528
4347
  }
4529
- .workspace-conv #thread-panel[open] #messages,
4348
+ .workspace-conv #thread-panel #messages,
4530
4349
  .workspace-conv .support-stack > .micro[open] > .micro-log {
4531
4350
  flex: initial;
4532
4351
  height: auto;
@@ -4541,22 +4360,6 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4541
4360
 
4542
4361
  @media (max-width: 640px) {
4543
4362
  body.hub-shell { overflow-x: hidden; }
4544
- .conversation-focus-overlay {
4545
- padding: 8px;
4546
- }
4547
- .conversation-focus-dialog {
4548
- border-radius: 8px;
4549
- padding: 8px;
4550
- }
4551
- .conversation-focus-header {
4552
- align-items: flex-start;
4553
- }
4554
- .conversation-focus-copy span {
4555
- white-space: normal;
4556
- }
4557
- .conversation-focus-close {
4558
- padding: 7px 9px;
4559
- }
4560
4363
  .gs-rail { overflow-x: auto; padding-inline: 10px; }
4561
4364
  .hub-tabs { min-width: 0; }
4562
4365
  .htab { padding-inline: 14px; }
@@ -4605,9 +4408,9 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4605
4408
  }
4606
4409
 
4607
4410
  @media (max-width: 820px) {
4608
- .workspace-conv #thread-panel[open] { max-height: none; }
4609
- .workspace-conv #thread-panel[open] .manage-body { height: clamp(180px, 36vh, 320px); flex: none; }
4610
- .workspace-conv #thread-panel[open] .manage-body #messages { flex: 1 1 0; height: auto; max-height: none; }
4411
+ .workspace-conv #thread-panel { max-height: none; }
4412
+ .workspace-conv #thread-panel .manage-body { height: clamp(320px, 44vh, 420px); flex: none; }
4413
+ .workspace-conv #thread-panel .manage-body #messages { flex: 1 1 0; height: auto; max-height: none; }
4611
4414
  }
4612
4415
 
4613
4416
  /* Review surfaces */
@@ -6855,7 +6658,7 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6855
6658
  /* ─── End Issue #1178 ────────────────────────────────────────────────────── */
6856
6659
 
6857
6660
  /* ─── Issue #1278: Job Visualization Modal ──────────────────────────────── */
6858
- /* Must sit above #composer-suggestions/.conversation-focus-overlay
6661
+ /* Must sit above #composer-suggestions
6859
6662
  (var(--z-cmd-palette) = 9000) so the modal is reachable when opened from
6860
6663
  the persistent composer's catalog suggestions. Token: --z-job-viz. */
6861
6664
  #job-viz-modal { z-index: var(--z-job-viz); }
@@ -6950,7 +6753,7 @@ img.eh-av { object-fit: cover; background: var(--surface); }
6950
6753
  siblings get flex:1 so whichever is visible fills the remaining height and
6951
6754
  pushes the composer to the true bottom edge. */
6952
6755
  #empty { flex: 1; min-height: 0; overflow-y: auto; }
6953
- #conversation:has(> #persistent-composer) { padding-bottom: 8px; }
6756
+ #conversation:has(> #persistent-composer) { padding-bottom: 0; }
6954
6757
  #persistent-composer {
6955
6758
  flex-shrink: 0;
6956
6759
  display: flex;
@@ -7096,3 +6899,31 @@ img.eh-av { object-fit: cover; background: var(--surface); }
7096
6899
  }
7097
6900
  #cp-armed-job-clear:hover { color: var(--text); }
7098
6901
  /* ─── End Issue #1657 Treatment 3 ────────────────────────────────────────── */
6902
+
6903
+ /* Conversation panes remain open; only the transcript owns the scrolling area. */
6904
+ .thread-header { display: flex; align-items: center; padding: 12px 16px; flex: 0 0 auto; }
6905
+ #thread-panel > .thread-header { min-height: 48px; padding-right: 176px; }
6906
+ .manage-header-actions { top: 6px; right: 16px; }
6907
+ #conversation:has(> #persistent-composer) { padding-bottom: 0; }
6908
+ #run-state-pill[role="button"] { cursor: pointer; }
6909
+ #run-state-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
6910
+ #deliverables-bar { position: sticky; bottom: 0; z-index: 2; flex: 0 0 auto; margin-top: auto; padding: 10px; background: var(--surface); border-top: 1px solid var(--line); }
6911
+ #deliverables-bar[hidden], .hub-status-notification[hidden] { display: none; }
6912
+ #deliverables-bar .rc-art-btn:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
6913
+ #deliverables-bar .rc-art-btn { overflow-wrap: anywhere; max-width: 100%; box-sizing: border-box; }
6914
+ .review-turn { flex: 0 0 auto; }
6915
+ .review-turn .bubble { min-width: 0; max-width: 100%; }
6916
+ .review-turn-label { font-size: 11px; font-weight: 700; color: var(--muted); margin-bottom: 8px; }
6917
+ .review-resolution { font-size: 13px; color: var(--muted); }
6918
+ .hub-status-notification { pointer-events: auto; display: flex; align-items: center; gap: 12px; max-width: min(480px, calc(100vw - 32px)); box-sizing: border-box; white-space: normal; overflow-wrap: anywhere; }
6919
+ .hub-status-notification.error { border: 1px solid var(--danger); }
6920
+ .hub-status-notification button { flex: 0 0 auto; background: transparent; color: inherit; border: 0; cursor: pointer; font-size: 20px; }
6921
+ .hub-status-notification button:focus-visible { outline: 2px solid currentColor; }
6922
+ #ab-direct-thread-panel { display: flex; flex-direction: column; min-height: 0; }
6923
+ @media (max-width: 480px) {
6924
+ #deliverables-bar .rc-artifact-strip { flex-direction: column; }
6925
+ #deliverables-bar { max-height: 160px; overflow-y: auto; }
6926
+ .review-turn .review-actions { flex-direction: column; align-items: stretch; }
6927
+ }
6928
+
6929
+ :root[data-theme="dark"] #deliverables-bar .rc-art-btn:not(.ghost) { color: var(--bg); }