fraim-hub 2.0.323 → 2.0.325

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.
@@ -7189,9 +7189,10 @@ class AiHubServer {
7189
7189
  // job that just received coaching has not reached retrospective
7190
7190
  // again yet. Clear it at send-time so a coached Done job stops
7191
7191
  // offering next-job recommendations immediately, not only once the
7192
- // resumed session's next seekMentoring signal arrives.
7192
+ // resumed session's next seekMentoring signal arrives. The active
7193
+ // reviewHandoff deliberately survives conversational coaching; its
7194
+ // lifecycle is owned by applySeekMentoringSignal (issue #1763).
7193
7195
  current.nextJobRecommendations = null;
7194
- current.reviewHandoff = null;
7195
7196
  });
7196
7197
  const startedFresh = this.runRegistry.get(run.id);
7197
7198
  if (startedFresh)
@@ -7260,9 +7261,10 @@ class AiHubServer {
7260
7261
  current.pendingDeliveryActionId = selectedReviewAction.deliveryActionId || null;
7261
7262
  }
7262
7263
  // Issue #1373 (Defect 2): see the no-session branch above for why
7263
- // this can only be stale at coaching-send time.
7264
+ // next-job recommendations can only be stale at coaching-send time.
7265
+ // applySeekMentoringSignal remains the sole reviewHandoff lifecycle
7266
+ // owner so a conversational coaching turn does not decide the review.
7264
7267
  current.nextJobRecommendations = null;
7265
- current.reviewHandoff = null;
7266
7268
  });
7267
7269
  const started = this.runRegistry.get(run.id);
7268
7270
  if (started)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.323",
3
+ "version": "2.0.325",
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",
@@ -213,7 +213,7 @@
213
213
  "electron-updater": "^6.8.9",
214
214
  "express": "^5.2.1",
215
215
  "extract-zip": "^2.0.1",
216
- "fraim": "2.0.323",
216
+ "fraim": "2.0.325",
217
217
  "mongodb": "^7.0.0",
218
218
  "node-cron": "4.2.1",
219
219
  "node-edge-tts": "^1.2.10",
@@ -439,9 +439,10 @@
439
439
  <details class="panel-details panel-details--thread" id="thread-panel" open>
440
440
  <summary>
441
441
  <span class="panel-summary-copy">
442
- <span class="panel-kicker" id="thread-panel-kicker">Updates</span>
442
+ <span class="panel-kicker" id="thread-panel-kicker">Manage</span>
443
443
  </span>
444
444
  <button class="conversation-focus-btn" id="conversation-focus-btn" type="button" aria-label="Expand conversation" title="Expand conversation" onclick="event.preventDefault(); event.stopPropagation();">&#8599;</button>
445
+ <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>
445
446
  </summary>
446
447
  <!-- Issue #1065: find a passage inside the open conversation (R44-R46). A result that
447
448
  promises "+6 more matches" is an unkept promise without this: the manager lands in
@@ -458,7 +459,21 @@
458
459
  <!-- Issue #512 R7: completion card + format-derived artifact strip
459
460
  render INSIDE #messages (as the employee's review turn) so the
460
461
  thread panel keeps its existing flex/scroll geometry. -->
461
- <div class="messages" id="messages"></div>
462
+ <div class="manage-body" id="manage-body">
463
+ <div class="messages" id="messages"></div>
464
+ <div class="manage-activity" id="micro-manage" hidden>
465
+ <button class="micro-return" id="micro-return" type="button" hidden>Return to conversation</button>
466
+ <div class="micro-activity-state" id="micro-activity-state" role="status" hidden></div>
467
+ <pre class="micro-log" id="micro-log" tabindex="0" aria-label="Detailed activity"></pre>
468
+ <div class="resume-command" id="resume-command" hidden>
469
+ <span class="resume-command-label">Resume in terminal</span>
470
+ <div class="resume-command-row">
471
+ <pre class="resume-command-code" id="resume-command-code"></pre>
472
+ <button class="resume-command-copy" id="resume-command-copy" type="button" title="Copy command">Copy</button>
473
+ </div>
474
+ </div>
475
+ </div>
476
+ </div>
462
477
  </details>
463
478
 
464
479
  <div class="support-stack">
@@ -480,22 +495,6 @@
480
495
  active coaching actions cannot hide it. -->
481
496
  <div class="agent-unavailable-note" id="active-agent-unavailable-note" data-testid="active-agent-unavailable-note" role="status" hidden></div>
482
497
 
483
- <details class="micro panel-details" id="micro-manage">
484
- <summary>
485
- <span class="panel-summary-copy">
486
- <span class="panel-kicker">Micro-manage</span>
487
- <span class="panel-summary-text">Raw host events and tool calls</span>
488
- </span>
489
- </summary>
490
- <pre class="micro-log" id="micro-log"></pre>
491
- <div class="resume-command" id="resume-command" hidden>
492
- <span class="resume-command-label">Resume in terminal</span>
493
- <div class="resume-command-row">
494
- <pre class="resume-command-code" id="resume-command-code"></pre>
495
- <button class="resume-command-copy" id="resume-command-copy" type="button" title="Copy command">Copy</button>
496
- </div>
497
- </div>
498
- </details>
499
498
  </div>
500
499
  </div>
501
500
 
@@ -140,6 +140,10 @@
140
140
  color: var(--muted, #888);
141
141
  filter: none;
142
142
  }
143
+ :root[data-theme="dark"] .review-actions .rb-approve:not(:disabled):not(.managed-disabled),
144
+ :root[data-theme="dark"] .review-actions .rb-secondary:not(:disabled):not(.managed-disabled) {
145
+ color: var(--bg, #1e1e1e);
146
+ }
143
147
  .review-actions .rb-handoff {
144
148
  font-size: 12px;
145
149
  color: var(--muted);
@@ -134,7 +134,7 @@ function gatherElements() {
134
134
  'status-line', 'coach-note',
135
135
  // Issue #1651: composer attachments.
136
136
  'coach', 'composer-dropzone', 'staged-attachments', 'attach-btn', 'attach-file-input',
137
- 'thread-panel', 'thread-panel-kicker',
137
+ 'thread-panel', 'thread-panel-kicker', 'micro-manage-toggle', 'micro-return', 'micro-activity-state',
138
138
  'quick-coach-btns',
139
139
  'positive-reinforcement-btn', 'positive-reinforcement-dropdown',
140
140
  'negative-reinforcement-btn', 'negative-reinforcement-dropdown',
@@ -1417,7 +1417,8 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
1417
1417
  try {
1418
1418
  const payload = await requestJson(`/api/ai-hub/conversations?${params.toString()}`);
1419
1419
  const body = payload && payload.conversation;
1420
- if (!body) return null;
1420
+ if (!body) throw new Error('Conversation activity is unavailable');
1421
+ activityLoadErrors.delete(manageKey(body));
1421
1422
  normalizeGeminiConversationMessages(body);
1422
1423
  const current = findConversation(body.id);
1423
1424
  // A body hydrate can be in flight while a live run poll or continue POST
@@ -1439,6 +1440,7 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
1439
1440
  return current;
1440
1441
  }
1441
1442
  const merged = mergeConversationBody(current, body);
1443
+ activityLoadErrors.delete(manageKey(merged));
1442
1444
  const bucket = convBucketKey(merged);
1443
1445
  const list = (state.conversations[bucket] || []).slice();
1444
1446
  const idx = list.findIndex((conv) => conv.id === merged.id);
@@ -1453,6 +1455,11 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
1453
1455
  return merged;
1454
1456
  } catch (error) {
1455
1457
  console.warn('Could not hydrate conversation body:', error);
1458
+ const failed = findConversation(id);
1459
+ if (failed) {
1460
+ activityLoadErrors.add(manageKey(failed));
1461
+ if (state.activeId === failed.id) syncManageActivityState(failed, failed.microEvents || failed.events || []);
1462
+ }
1456
1463
  return null;
1457
1464
  } finally {
1458
1465
  conversationBodyFetches.delete(key);
@@ -2069,12 +2076,11 @@ function managerLabel() {
2069
2076
  return id.name.split(' ')[0];
2070
2077
  }
2071
2078
 
2072
- // R2: update the thread panel kicker with "[Employee] updates".
2079
+ // Manage is stable across agents and both levels of detail.
2073
2080
  function syncThreadPanelKicker(conv) {
2074
2081
  const el = els['thread-panel-kicker'] || document.getElementById('thread-panel-kicker');
2075
2082
  if (!el) return;
2076
- const name = conv ? coachEmployeeLabel(conv) : null;
2077
- el.textContent = name ? `${name} updates` : 'Updates';
2083
+ el.textContent = 'Manage';
2078
2084
  }
2079
2085
 
2080
2086
  // Returns the FRAIM invocation prefix for the currently active employee:
@@ -2276,6 +2282,60 @@ function formatEmployeeText(text) {
2276
2282
  return out.join('\n');
2277
2283
  }
2278
2284
 
2285
+ // Presentation state is session-only and belongs to the record's own scope.
2286
+ const manageViews = new Map();
2287
+ const activityLoadErrors = new Set();
2288
+ let displayedManageKey = null;
2289
+ function manageKey(conv) { return conv ? `${convBucketKey(conv)}:${conv.id}` : null; }
2290
+ function manageViewFor(conv) {
2291
+ const key = manageKey(conv);
2292
+ if (!manageViews.has(key)) manageViews.set(key, { micro: false, messages: { top: 0, follow: true }, activity: { top: 0, follow: true } });
2293
+ return manageViews.get(key);
2294
+ }
2295
+ function captureManagePosition() {
2296
+ const view = manageViews.get(displayedManageKey);
2297
+ if (!view || !els['thread-panel'].open) return;
2298
+ const node = view.micro ? els['micro-log'] : els['messages'];
2299
+ if (!node || !node.clientHeight) return;
2300
+ view[view.micro ? 'activity' : 'messages'] = {
2301
+ top: node.scrollTop,
2302
+ follow: node.scrollHeight - node.scrollTop - node.clientHeight < 80,
2303
+ };
2304
+ }
2305
+ function restoreManagePosition(conv) {
2306
+ if (displayedManageKey !== manageKey(conv)) return;
2307
+ const view = manageViewFor(conv);
2308
+ const position = view[view.micro ? 'activity' : 'messages'];
2309
+ const node = view.micro ? els['micro-log'] : els['messages'];
2310
+ node.scrollTo({ top: position.follow ? node.scrollHeight : position.top, behavior: 'instant' });
2311
+ }
2312
+ function applyManageView(conv) {
2313
+ const micro = !!conv && conv.compareMode !== 'ab' && manageViewFor(conv).micro;
2314
+ els['micro-manage-toggle'].hidden = !conv || conv.compareMode === 'ab';
2315
+ els['micro-manage-toggle'].setAttribute('aria-checked', String(micro));
2316
+ els['micro-manage'].hidden = !micro;
2317
+ els['messages'].hidden = micro;
2318
+ if (els['conv-find']) els['conv-find'].hidden = micro || !convFind.open;
2319
+ }
2320
+ function setManageMicroMode(enabled) {
2321
+ const conv = activeConversation();
2322
+ if (!conv || conv.compareMode === 'ab') return;
2323
+ captureManagePosition();
2324
+ manageViewFor(conv).micro = enabled;
2325
+ els['thread-panel'].open = true;
2326
+ applyManageView(conv);
2327
+ restoreManagePosition(conv);
2328
+ }
2329
+ function syncManageActivityState(conv, events) {
2330
+ const error = activityLoadErrors.has(manageKey(conv));
2331
+ const message = error ? 'Activity is unavailable. You can return to the conversation.'
2332
+ : !conversationHasBody(conv) ? 'Loading activity…' : !events.length ? 'No activity yet' : '';
2333
+ els['micro-activity-state'].textContent = message;
2334
+ els['micro-activity-state'].hidden = !message;
2335
+ // Keep an explicit way back to prompts even when the detailed view owns the body.
2336
+ els['micro-return'].hidden = !(error || conversationUiState(conv) === 'waiting' || convAwaitingReview(conv));
2337
+ }
2338
+
2279
2339
  function syncConversationPanels(conv, switchedConv) {
2280
2340
  if (!conv) return;
2281
2341
  const panelState = panelStateFor(conv.id);
@@ -2326,6 +2386,8 @@ async function deleteConversation(id) {
2326
2386
  if (next.length !== list.length) { state.conversations[key] = next; removed = true; }
2327
2387
  }
2328
2388
  if (!removed) return false;
2389
+ manageViews.delete(manageKey(target));
2390
+ activityLoadErrors.delete(manageKey(target));
2329
2391
  if (state.activeId === id) {
2330
2392
  state.activeId = null;
2331
2393
  // Deleting the active run: stop its poll so no zombie interval keeps ticking
@@ -3768,6 +3830,7 @@ function restoreFocusedConversationNodes() {
3768
3830
 
3769
3831
  function closeConversationFocus() {
3770
3832
  if (!conversationFocusState.open) return;
3833
+ captureManagePosition();
3771
3834
  if (els['messages']) conversationFocusState.messageScrollTop = els['messages'].scrollTop;
3772
3835
  restoreFocusedConversationNodes();
3773
3836
  conversationFocusState.open = false;
@@ -3779,6 +3842,8 @@ function closeConversationFocus() {
3779
3842
  requestAnimationFrame(() => {
3780
3843
  if (els['messages']) els['messages'].scrollTop = conversationFocusState.messageScrollTop || 0;
3781
3844
  if (typeof syncThreadMessageViewport === 'function') syncThreadMessageViewport();
3845
+ const conv = activeConversation();
3846
+ if (conv) restoreManagePosition(conv);
3782
3847
  const target = conversationFocusState.previousFocus && document.contains(conversationFocusState.previousFocus)
3783
3848
  ? conversationFocusState.previousFocus
3784
3849
  : els['conversation-focus-btn'];
@@ -3788,6 +3853,7 @@ function closeConversationFocus() {
3788
3853
 
3789
3854
  function openConversationFocus() {
3790
3855
  const conv = activeConversation();
3856
+ captureManagePosition();
3791
3857
  const overlay = els['conversation-focus-overlay'];
3792
3858
  const panels = els['conversation-focus-panels'];
3793
3859
  const thread = els['thread-panel'];
@@ -3815,6 +3881,7 @@ function openConversationFocus() {
3815
3881
  requestAnimationFrame(() => {
3816
3882
  if (els['messages']) els['messages'].scrollTop = conversationFocusState.messageScrollTop || 0;
3817
3883
  if (typeof syncThreadMessageViewport === 'function') syncThreadMessageViewport();
3884
+ restoreManagePosition(conv);
3818
3885
  if (els['conversation-focus-close']) els['conversation-focus-close'].focus();
3819
3886
  });
3820
3887
  }
@@ -3881,6 +3948,11 @@ function renderActive() {
3881
3948
  // If we switched conversations (or this is the first render), wipe and
3882
3949
  // start fresh. Otherwise we're going to do an incremental update below.
3883
3950
  const switchedConv = renderedConvId !== conv.id;
3951
+ if (displayedManageKey !== manageKey(conv)) {
3952
+ captureManagePosition();
3953
+ displayedManageKey = manageKey(conv);
3954
+ }
3955
+ applyManageView(conv);
3884
3956
  if (switchedConv) {
3885
3957
  els['messages'].innerHTML = '';
3886
3958
  els['messages'].className = 'messages';
@@ -4055,8 +4127,11 @@ function renderActive() {
4055
4127
  // <pre> wipes the entire log every tick which is wasteful.
4056
4128
  const microEvents = Array.isArray(conv.microEvents) && conv.microEvents.length > 0 ? conv.microEvents : null;
4057
4129
  const events = microEvents || conv.events || [];
4130
+ if (!switchedConv && !els['micro-manage'].hidden) captureManagePosition();
4058
4131
  renderMicroLogEvents(events, { mode: microEvents ? 'microEvents' : 'events' });
4059
4132
  renderResumeCommand(conv);
4133
+ syncManageActivityState(conv, events);
4134
+ if (!els['micro-manage'].hidden || switchedConv) requestAnimationFrame(() => restoreManagePosition(conv));
4060
4135
 
4061
4136
  // Coaching state — only enable Send when there's text and the run is resumable.
4062
4137
  syncSendButton();
@@ -4075,7 +4150,7 @@ function renderActive() {
4075
4150
  // marks a manager is stepping through. Re-emit them from find state instead of injecting once.
4076
4151
  if (typeof convFindRefresh === 'function') convFindRefresh();
4077
4152
  const shouldScrollForUpdate = switchedConv || statusChanged || appendedMessages > 0 || messagesMutated;
4078
- const reviewFollow = switchedConv || statusChanged || threadWasNearBottom;
4153
+ const reviewFollow = (switchedConv || statusChanged || threadWasNearBottom) && (!switchedConv || manageViewFor(conv).messages.follow);
4079
4154
  scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, hasNewPendingDeliveryMessage, reviewFollow, clearedPendingDeliveryMessages);
4080
4155
  requestAnimationFrame(() => {
4081
4156
  syncThreadMessageViewport();
@@ -5709,6 +5784,7 @@ function ensurePendingAnchorScrollListener(host) {
5709
5784
  }
5710
5785
 
5711
5786
  function setThreadScrollTop(host, top) {
5787
+ if (host.hidden) return;
5712
5788
  pendingAnchorProgrammaticScrollUntil = Date.now() + 200;
5713
5789
  host.scrollTop = top;
5714
5790
  }
@@ -5721,7 +5797,7 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom,
5721
5797
  const latest = activeConversation();
5722
5798
  if (!latest || latest.id !== conv.id) return;
5723
5799
  const host = els['messages'];
5724
- if (!host) return;
5800
+ if (!host || host.hidden) return;
5725
5801
  if (latest.status === 'running') {
5726
5802
  ensurePendingAnchorScrollListener(host);
5727
5803
  if (!forceBottom && pendingAnchorUserScrolled(host)) {
@@ -7637,15 +7713,17 @@ async function approveReview(options) {
7637
7713
  const opts = options || {};
7638
7714
  const conv = activeConversation();
7639
7715
  if (!conv) return;
7716
+ const shouldResume = !!opts.resumeAfterApproval
7717
+ && !!conv.runId
7718
+ && convAwaitingReview(conv);
7719
+ if (opts.resumeAfterApproval && !shouldResume) {
7720
+ showStatus('Approve could not resume this run. The review gate is no longer active. Use Mark complete if you intend to close it manually.', true);
7721
+ return;
7722
+ }
7640
7723
  if (state.pollHandle) {
7641
7724
  window.clearInterval(state.pollHandle);
7642
7725
  state.pollHandle = null;
7643
7726
  }
7644
- const shouldResume = !!opts.resumeAfterApproval
7645
- && !!conv.runId
7646
- && !!conv.sessionId
7647
- && !!reviewHandoffForConversation(conv)
7648
- && convAwaitingReview(conv);
7649
7727
  if (shouldResume) {
7650
7728
  const selectedReviewAction = opts.reviewAction && typeof opts.reviewAction === 'object'
7651
7729
  ? opts.reviewAction
@@ -10234,6 +10312,16 @@ function wireEvents() {
10234
10312
  }
10235
10313
 
10236
10314
  // R6: thread panel toggle — persist open/close state per conversation.
10315
+ els['micro-manage-toggle'].addEventListener('click', (event) => {
10316
+ event.preventDefault();
10317
+ event.stopPropagation();
10318
+ setManageMicroMode(els['micro-manage-toggle'].getAttribute('aria-checked') !== 'true');
10319
+ });
10320
+ els['micro-return'].addEventListener('click', () => {
10321
+ setManageMicroMode(false);
10322
+ els['micro-manage-toggle'].focus();
10323
+ scrollThreadForReview(activeConversation());
10324
+ });
10237
10325
  if (els['thread-panel']) {
10238
10326
  els['thread-panel'].addEventListener('toggle', () => {
10239
10327
  const conv = activeConversation();
@@ -11325,6 +11413,7 @@ function convFindOpen(term) {
11325
11413
  if (!bar || !input) return;
11326
11414
  const cleaned = convFindTextFromQuery(term);
11327
11415
  if (!cleaned) return;
11416
+ setManageMicroMode(false);
11328
11417
  bar.hidden = false;
11329
11418
  convFind.open = true;
11330
11419
  input.value = cleaned;
@@ -4246,7 +4246,66 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4246
4246
  }
4247
4247
  .workspace-conv #thread-panel:not([open]) #messages { display: none; }
4248
4248
 
4249
- /* Support stack: Coach (input) + Micro-manage (log). It sizes to its content and
4249
+ /* Both Manage views use the same flex slot; switching does not resize the pane. */
4250
+ #thread-panel .manage-body {
4251
+ display: flex;
4252
+ flex-direction: column;
4253
+ flex: 1 1 0;
4254
+ min-height: 0;
4255
+ min-width: 0;
4256
+ overflow: hidden;
4257
+ }
4258
+ #thread-panel:not([open]) .manage-body { display: none; }
4259
+ #thread-panel .manage-activity {
4260
+ display: flex;
4261
+ flex-direction: column;
4262
+ flex: 1 1 0;
4263
+ min-height: 0;
4264
+ min-width: 0;
4265
+ overflow: hidden;
4266
+ }
4267
+ #thread-panel .manage-activity[hidden],
4268
+ #thread-panel #messages[hidden] { display: none; }
4269
+ #thread-panel .manage-activity .micro-log {
4270
+ flex: 1 1 0;
4271
+ min-height: 0;
4272
+ max-height: 100%;
4273
+ overflow: auto;
4274
+ white-space: pre;
4275
+ }
4276
+ #thread-panel .resume-command { flex: 0 0 auto; }
4277
+ #thread-panel > summary { gap: 10px; }
4278
+ #thread-panel > summary .panel-summary-copy { margin-right: auto; }
4279
+ .micro-manage-toggle {
4280
+ display: inline-flex;
4281
+ align-items: center;
4282
+ justify-content: center;
4283
+ gap: 8px;
4284
+ flex-shrink: 0;
4285
+ min-height: 36px;
4286
+ border: 0;
4287
+ background: transparent;
4288
+ color: var(--text);
4289
+ font: inherit;
4290
+ font-size: 12px;
4291
+ cursor: pointer;
4292
+ white-space: nowrap;
4293
+ }
4294
+ .micro-manage-toggle[hidden] { display: none; }
4295
+ .micro-switch-track { width: 30px; height: 18px; border: 1px solid var(--muted); border-radius: 12px; background: var(--line); }
4296
+ .micro-switch-track::after { content: ''; display: block; width: 12px; height: 12px; margin: 2px; border-radius: 50%; background: var(--muted); }
4297
+ .micro-manage-toggle[aria-checked="true"] .micro-switch-track { background: var(--accent); border-color: var(--accent); }
4298
+ .micro-manage-toggle[aria-checked="true"] .micro-switch-track::after { transform: translateX(12px); background: white; }
4299
+ .micro-manage-toggle:focus-visible, .micro-return:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
4300
+ .micro-activity-state, .micro-return { margin: 0 16px 12px; color: var(--text); }
4301
+ .micro-return { align-self: flex-start; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); cursor: pointer; }
4302
+ @media (max-width: 480px) {
4303
+ #thread-panel > summary { flex-wrap: wrap; }
4304
+ #thread-panel > summary .conversation-focus-btn { order: 1; margin-left: auto; }
4305
+ #thread-panel > summary .micro-manage-toggle { margin-left: auto; }
4306
+ }
4307
+
4308
+ /* Support stack: coaching controls. It sizes to its content and
4250
4309
  never grows past it, so the thread keeps the spare real estate. Each open body
4251
4310
  has a viewport-relative cap and scrolls internally, so it shows more on bigger
4252
4311
  screens (and far more than the old 1-line peek) without crowding the thread. */
@@ -4537,6 +4596,12 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
4537
4596
  }
4538
4597
  }
4539
4598
 
4599
+ @media (max-width: 820px) {
4600
+ .workspace-conv #thread-panel[open] { max-height: none; }
4601
+ .workspace-conv #thread-panel[open] .manage-body { height: clamp(180px, 36vh, 320px); flex: none; }
4602
+ .workspace-conv #thread-panel[open] .manage-body #messages { flex: 1 1 0; height: auto; max-height: none; }
4603
+ }
4604
+
4540
4605
  /* Review surfaces */
4541
4606
  .completion-card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; margin-bottom: 8px; }
4542
4607
  .completion-card .cc-title { font-weight: 700; margin-bottom: 6px; font-size: 14px; }