fraim-hub 2.0.310 → 2.0.312

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.
@@ -1234,7 +1234,17 @@ const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'del
1234
1234
  // It is listed in CLIENT_ONLY_CONV_FIELDS so `cleanConversationHeader()` strips
1235
1235
  // any inbound value. That is what preserves issue #913: a marker arriving over
1236
1236
  // the wire is never trusted, only one this client set after a real fetch.
1237
- const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_stopping'];
1237
+ //
1238
+ // Issue #1634: `_bodyFetchedRunId`/`_bodyFetchedLastUpdatedAt` record the
1239
+ // runId/lastUpdatedAt that were true at the moment THIS CLIENT's currently-held
1240
+ // message/event content was last made current (a full body fetch, or a live-run
1241
+ // poll fold — see mergeConversationBody/foldRunIntoConversation). The 30s header
1242
+ // poll (bgRefreshConversations/hydrateProjectConversationHeaders) merges fresh
1243
+ // runId/lastUpdatedAt directly into the SAME conversation object findConversation()
1244
+ // returns — there is no separate header copy — so comparing those fields against
1245
+ // this fetch-time snapshot is what lets conversationBodyIsStale() detect that a
1246
+ // scheduled/webhook fire replaced the run out from under an already-open tab.
1247
+ const CLIENT_ONLY_CONV_FIELDS = ['_bodyLoaded', '_bodyFetched', '_bodyFetchedRunId', '_bodyFetchedLastUpdatedAt', '_stopping'];
1238
1248
  function slimConversationForPersist(conv) {
1239
1249
  if (!conv || typeof conv !== 'object') return conv;
1240
1250
  const slim = { ...conv };
@@ -1310,9 +1320,30 @@ function mergeConversationBody(existing, body) {
1310
1320
  // Issue #1090: the body request came back, so this conversation is hydrated
1311
1321
  // regardless of which fields the response happened to contain.
1312
1322
  merged._bodyFetched = true;
1323
+ // Issue #1634: stamp the runId/lastUpdatedAt this fetch reflects, so a later
1324
+ // header-poll-observed change can be detected as staleness (see
1325
+ // conversationBodyIsStale / CLIENT_ONLY_CONV_FIELDS comment above).
1326
+ merged._bodyFetchedRunId = body.runId;
1327
+ merged._bodyFetchedLastUpdatedAt = body.lastUpdatedAt;
1313
1328
  return merged;
1314
1329
  }
1315
1330
 
1331
+ // Issue #1634 (Defect C): `conversationHasBody()` only answers "was a body ever
1332
+ // fetched" — true forever after the first fetch (issue #1090), even once a
1333
+ // scheduled/webhook fire replaces the run underneath an already-open tab. This
1334
+ // answers the separate question "is the body we're holding still current",
1335
+ // by comparing the runId/lastUpdatedAt in effect when we last made the body
1336
+ // current (mergeConversationBody / foldRunIntoConversation) against the same
1337
+ // object's own runId/lastUpdatedAt fields, which the 30s header poll keeps
1338
+ // fresh in place (mergeConversationHeader merges into the SAME object
1339
+ // findConversation() returns — there is no separate header copy to compare
1340
+ // against).
1341
+ function conversationBodyIsStale(existing) {
1342
+ if (!existing || existing._bodyFetchedRunId === undefined) return false;
1343
+ if (existing.runId !== existing._bodyFetchedRunId) return true;
1344
+ return timestampMillis(existing.lastUpdatedAt) > timestampMillis(existing._bodyFetchedLastUpdatedAt);
1345
+ }
1346
+
1316
1347
  function mergeConversationListHeaders(existingList, headers) {
1317
1348
  const existingById = new Map((existingList || []).map((conv) => [conv.id, conv]));
1318
1349
  return (headers || []).map((header) => mergeConversationHeader(existingById.get(header.id), header));
@@ -1346,7 +1377,7 @@ function clearConversationBodyPendingFor(conv, fallbackProjectPath, id) {
1346
1377
  async function hydrateConversationBody(projectPath, id, options = {}) {
1347
1378
  if (!id) return null;
1348
1379
  const existing = findConversation(id);
1349
- if (!options.force && conversationHasBody(existing)) {
1380
+ if (!options.force && conversationHasBody(existing) && !conversationBodyIsStale(existing)) {
1350
1381
  clearConversationBodyPendingFor(existing, projectPath, id);
1351
1382
  return existing;
1352
1383
  }
@@ -1361,6 +1392,24 @@ async function hydrateConversationBody(projectPath, id, options = {}) {
1361
1392
  if (!body) return null;
1362
1393
  normalizeGeminiConversationMessages(body);
1363
1394
  const current = findConversation(body.id);
1395
+ // A body hydrate can be in flight while a live run poll or continue POST
1396
+ // folds a newer run snapshot into the same conversation. Do not let the
1397
+ // slower, older body response replace fresh messages/events with the
1398
+ // persisted copy it started from.
1399
+ const currentUpdatedAt = timestampMillis(current && current.lastUpdatedAt);
1400
+ const bodyUpdatedAt = timestampMillis(body.lastUpdatedAt);
1401
+ if (
1402
+ !options.force
1403
+ && current
1404
+ && current.runId === body.runId
1405
+ && conversationHasBody(current)
1406
+ && currentUpdatedAt
1407
+ && bodyUpdatedAt
1408
+ && currentUpdatedAt > bodyUpdatedAt
1409
+ ) {
1410
+ clearConversationBodyPendingFor(current, projectPath, current.id);
1411
+ return current;
1412
+ }
1364
1413
  const merged = mergeConversationBody(current, body);
1365
1414
  const bucket = convBucketKey(merged);
1366
1415
  const list = (state.conversations[bucket] || []).slice();
@@ -1398,7 +1447,15 @@ async function rehydrateConversationAfterRunNotFound(conv) {
1398
1447
 
1399
1448
  function ensureActiveConversationBody() {
1400
1449
  const conv = activeConversation();
1401
- if (!conv || conversationHasBody(conv)) return;
1450
+ if (!conv) return;
1451
+ // Issue #1634: this own early-return used to check only conversationHasBody(),
1452
+ // which short-circuits BEFORE hydrateConversationBody()'s own staleness check
1453
+ // ever runs — the exact call path the 30s background poll uses for an
1454
+ // already-open, never-switched-away-from conversation (bgRefreshConversations
1455
+ // -> ensureActiveConversationBody). Without this, a scheduled/webhook fire
1456
+ // against the tab a manager is actively looking at would never be picked up
1457
+ // by that poll at all, regardless of the hydrateConversationBody fix below.
1458
+ if (conversationHasBody(conv) && !conversationBodyIsStale(conv)) return;
1402
1459
  const bucket = convBucketKey(conv);
1403
1460
  state.conversationBodiesPending = bucket;
1404
1461
  hydrateConversationBody(bucket, conv.id).catch((error) =>
@@ -3833,6 +3890,9 @@ function renderActive() {
3833
3890
  pendingAnchorShownAt = null;
3834
3891
  pendingAnchorGraceActiveLastTick = false;
3835
3892
  userScrolledAwayDuringPendingAnchor = false;
3893
+ pendingAnchorForceStartedAt = 0;
3894
+ window.__fraimPendingAnchorUserScroll = false;
3895
+ delete els['messages'].dataset.pendingAnchorUserScroll;
3836
3896
  // A pending coaching job is scoped to the active conversation — discard it
3837
3897
  // whenever the user switches to a different conversation.
3838
3898
  clearPendingCoachingJob();
@@ -3853,7 +3913,14 @@ function renderActive() {
3853
3913
  // existing rows don't re-animate. If for some reason the data shrunk
3854
3914
  // (server revoked a message), fall back to a full re-render.
3855
3915
  const bodyReady = conversationHasBody(conv);
3856
- if (!bodyReady) ensureActiveConversationBody();
3916
+ // Issue #1634: call unconditionally, not just when the body was never
3917
+ // fetched. ensureActiveConversationBody() now does its own staleness check
3918
+ // internally (conversationBodyIsStale) and is a cheap no-op when the body is
3919
+ // both present and current; this is the render-path trigger that actually
3920
+ // fires on every poll-driven re-render for the active conversation, so the
3921
+ // fix only reaches this call site if it isn't gated behind the old
3922
+ // conversationHasBody()-only condition.
3923
+ ensureActiveConversationBody();
3857
3924
  const messages = bodyReady ? (conv.messages || []) : [];
3858
3925
  // Issue #820: while the two-phase hydrate is still fetching bodies, show a loading indicator
3859
3926
  // for the active conversation instead of an empty transcript. A dedicated node (kept separate
@@ -3913,6 +3980,14 @@ function renderActive() {
3913
3980
  // genuinely new messages, not every row a messagesMutated full rebuild just
3914
3981
  // re-appended (e.g. an existing message's badge disappearing).
3915
3982
  const priorRenderedMessageCount = renderedMessageCount;
3983
+ const hadPendingRowsBeforeRebuild = !!els['messages'].querySelector('.message[data-pending="true"]');
3984
+ if (
3985
+ hadPendingRowsBeforeRebuild
3986
+ && els['messages'].scrollTop <= 20
3987
+ && els['messages'].scrollHeight - els['messages'].clientHeight > 80
3988
+ ) {
3989
+ userScrolledAwayDuringPendingAnchor = true;
3990
+ }
3916
3991
  if (messages.length < renderedMessageCount || messagesMutated) {
3917
3992
  els['messages'].innerHTML = '';
3918
3993
  renderedMessageCount = 0;
@@ -5520,7 +5595,7 @@ function pendingBacklogScrollTop(host) {
5520
5595
  if (!firstPending) return host.scrollHeight;
5521
5596
  const backlogTop = firstPending.getBoundingClientRect().top - host.getBoundingClientRect().top + host.scrollTop;
5522
5597
  const maxScrollTop = host.scrollHeight - host.clientHeight;
5523
- return Math.max(0, Math.min(backlogTop, maxScrollTop));
5598
+ return Math.max(0, Math.min(backlogTop - 16, maxScrollTop));
5524
5599
  }
5525
5600
 
5526
5601
  // Issue #1570: the #1249 anchor above never released. Once nearBottom went
@@ -5539,6 +5614,8 @@ let pendingAnchorShownAt = null;
5539
5614
  let pendingAnchorGraceActiveLastTick = false;
5540
5615
  let pendingAnchorScrollListenerHost = null;
5541
5616
  let userScrolledAwayDuringPendingAnchor = false;
5617
+ let pendingAnchorProgrammaticScrollUntil = 0;
5618
+ let pendingAnchorForceStartedAt = 0;
5542
5619
 
5543
5620
  // A manual scroll away from the anchor must be detected from a real user
5544
5621
  // gesture, not from the 'scroll' event a programmatic assignment ALSO fires —
@@ -5556,10 +5633,32 @@ let userScrolledAwayDuringPendingAnchor = false;
5556
5633
  function ensurePendingAnchorScrollListener(host) {
5557
5634
  if (pendingAnchorScrollListenerHost === host) return;
5558
5635
  pendingAnchorScrollListenerHost = host;
5559
- const markUserScroll = () => { userScrolledAwayDuringPendingAnchor = true; };
5560
- host.addEventListener('wheel', markUserScroll, { passive: true });
5561
- host.addEventListener('touchmove', markUserScroll, { passive: true });
5636
+ const markUserScroll = () => {
5637
+ userScrolledAwayDuringPendingAnchor = true;
5638
+ host.dataset.pendingAnchorUserScroll = 'true';
5639
+ window.__fraimPendingAnchorUserScroll = true;
5640
+ };
5641
+ host.addEventListener('wheel', markUserScroll, { passive: true, capture: true });
5642
+ host.addEventListener('touchmove', markUserScroll, { passive: true, capture: true });
5643
+ window.addEventListener('wheel', markUserScroll, { passive: true, capture: true });
5644
+ window.addEventListener('touchmove', markUserScroll, { passive: true, capture: true });
5562
5645
  host.addEventListener('mousedown', (event) => { if (event.target === host) markUserScroll(); });
5646
+ host.addEventListener('scroll', () => {
5647
+ if (pendingAnchorShownAt === null) return;
5648
+ if (Date.now() <= pendingAnchorProgrammaticScrollUntil) return;
5649
+ const pendingRow = host.querySelector('.message[data-pending="true"]');
5650
+ if (!pendingRow) return;
5651
+ if (host.scrollTop <= 20 && host.scrollHeight - host.clientHeight > 80) markUserScroll();
5652
+ }, { passive: true });
5653
+ }
5654
+
5655
+ function setThreadScrollTop(host, top) {
5656
+ pendingAnchorProgrammaticScrollUntil = Date.now() + 200;
5657
+ host.scrollTop = top;
5658
+ }
5659
+
5660
+ function pendingAnchorUserScrolled(host) {
5661
+ return userScrolledAwayDuringPendingAnchor || host.dataset.pendingAnchorUserScroll === 'true' || window.__fraimPendingAnchorUserScroll === true;
5563
5662
  }
5564
5663
 
5565
5664
  function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom) {
@@ -5569,19 +5668,56 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
5569
5668
  if (!host) return;
5570
5669
  if (latest.status === 'running') {
5571
5670
  ensurePendingAnchorScrollListener(host);
5671
+ if (!forceBottom && pendingAnchorUserScrolled(host)) {
5672
+ host.scrollTop = 0;
5673
+ return;
5674
+ }
5572
5675
  const pendingRow = host.querySelector('.message[data-pending="true"]');
5573
5676
  // forceBottom is true exactly when a NEW pending (queued/redirecting)
5574
5677
  // message just arrived (see the caller's hasNewPendingDeliveryMessage) —
5575
5678
  // that's the moment to (re)start the grace window and trust the manager's
5576
5679
  // scroll position again, not every tick a pending row happens to exist.
5577
5680
  if (forceBottom) {
5578
- pendingAnchorShownAt = Date.now();
5579
- userScrolledAwayDuringPendingAnchor = false;
5681
+ const now = Date.now();
5682
+ const deferredSameForce = pendingAnchorForceStartedAt > 0 && (now - pendingAnchorForceStartedAt) < 250;
5683
+ if (
5684
+ deferredSameForce
5685
+ && (
5686
+ pendingAnchorUserScrolled(host)
5687
+ || (
5688
+ now > pendingAnchorProgrammaticScrollUntil
5689
+ && host.scrollTop <= 20
5690
+ && host.scrollHeight - host.clientHeight > 80
5691
+ )
5692
+ )
5693
+ ) {
5694
+ userScrolledAwayDuringPendingAnchor = true;
5695
+ host.dataset.pendingAnchorUserScroll = 'true';
5696
+ window.__fraimPendingAnchorUserScroll = true;
5697
+ } else if (!deferredSameForce) {
5698
+ userScrolledAwayDuringPendingAnchor = false;
5699
+ delete host.dataset.pendingAnchorUserScroll;
5700
+ window.__fraimPendingAnchorUserScroll = false;
5701
+ pendingAnchorForceStartedAt = now;
5702
+ }
5703
+ pendingAnchorShownAt = now;
5580
5704
  } else if (!pendingRow) {
5581
5705
  pendingAnchorShownAt = null;
5582
5706
  }
5583
5707
  const anchorGraceActive = !!pendingRow && pendingAnchorShownAt !== null &&
5584
5708
  (Date.now() - pendingAnchorShownAt) < PENDING_ANCHOR_GRACE_MS;
5709
+ if (
5710
+ pendingRow
5711
+ && pendingAnchorShownAt !== null
5712
+ && !forceBottom
5713
+ && Date.now() > pendingAnchorProgrammaticScrollUntil
5714
+ && host.scrollTop <= 20
5715
+ && host.scrollHeight - host.clientHeight > 80
5716
+ ) {
5717
+ userScrolledAwayDuringPendingAnchor = true;
5718
+ host.dataset.pendingAnchorUserScroll = 'true';
5719
+ window.__fraimPendingAnchorUserScroll = true;
5720
+ }
5585
5721
  // Fires exactly once, on the tick where the anchor stops being active —
5586
5722
  // either the pending badge just cleared, or the grace window for a
5587
5723
  // still-unresolved pending row (e.g. Defect 1's kill still in flight)
@@ -5595,12 +5731,22 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
5595
5731
  // #936: re-evaluate nearBottom at call time so deferred invocations respect
5596
5732
  // any scroll the user made between the render tick and this callback.
5597
5733
  const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
5598
- if (catchUpToBottom && userScrolledAwayDuringPendingAnchor) return;
5734
+ if (catchUpToBottom && host.scrollTop <= 20 && host.scrollHeight - host.clientHeight > 80) {
5735
+ userScrolledAwayDuringPendingAnchor = true;
5736
+ host.dataset.pendingAnchorUserScroll = 'true';
5737
+ window.__fraimPendingAnchorUserScroll = true;
5738
+ }
5739
+ if (catchUpToBottom && pendingAnchorUserScrolled(host)) return;
5740
+ if (forceBottom && pendingAnchorUserScrolled(host)) return;
5741
+ if (anchorGraceActive && !pendingAnchorUserScrolled(host)) {
5742
+ setThreadScrollTop(host, pendingBacklogScrollTop(host));
5743
+ return;
5744
+ }
5599
5745
  // Issue #1249: a message the manager just sent while the agent was
5600
5746
  // mid-turn (queued or redirecting) must be visible without the manager
5601
5747
  // having to scroll, even if they had scrolled up to review history.
5602
5748
  if (nearBottom || forceBottom || catchUpToBottom) {
5603
- host.scrollTop = anchorGraceActive ? pendingBacklogScrollTop(host) : host.scrollHeight;
5749
+ setThreadScrollTop(host, anchorGraceActive ? pendingBacklogScrollTop(host) : host.scrollHeight);
5604
5750
  }
5605
5751
  return;
5606
5752
  } else if (shouldScrollForUpdate) {
@@ -7593,13 +7739,21 @@ function renderCpRows(searchText) {
7593
7739
  }
7594
7740
  }
7595
7741
 
7596
- // Build flat row list: recent first, then catalog jobs, then teach entries
7597
- // last. Teach entries go after the catalog jobs so the first runnable job
7598
- // stays the default keyboard selection (ArrowDown+Enter runs a job, not a
7599
- // teach flow). The flat order must match the render order below so
7742
+ // Issue #1610 R13: pinned "Ad-hoc" row in the catalog list when a non-empty
7743
+ // search yields zero catalog matches. Clicking calls startAdhoc with the
7744
+ // current search text so the user's query seeds the freeform instructions.
7745
+ const adhocRows = q && catalogJobs.length === 0
7746
+ ? [{ type: 'adhoc', job: { id: 'adhoc-prompt', title: 'Ad-hoc', intent: 'run as custom instructions' }, instructions: '' }]
7747
+ : [];
7748
+
7749
+ // Build flat row list: recent first, then adhoc (if shown), then catalog jobs,
7750
+ // then teach entries last. Teach entries go after the catalog jobs so the first
7751
+ // runnable job stays the default keyboard selection (ArrowDown+Enter runs a job,
7752
+ // not a teach flow). The flat order must match the render order below so
7600
7753
  // click/keyboard indices line up.
7601
7754
  state.cpRows = [
7602
7755
  ...recentRows,
7756
+ ...adhocRows,
7603
7757
  ...catalogJobs.map((j) => ({ type: 'job', job: j, instructions: '' })),
7604
7758
  ...teachRows,
7605
7759
  ];
@@ -7616,16 +7770,20 @@ function renderCpRows(searchText) {
7616
7770
  });
7617
7771
  }
7618
7772
 
7619
- // Render catalog section: catalog jobs first, then teach entries last.
7620
- // flatIndex continues from recentRows so it matches state.cpRows order.
7773
+ // Render catalog section: adhoc pinned row first (when shown), then catalog
7774
+ // jobs, then teach entries last. flatIndex continues from recentRows so it
7775
+ // matches state.cpRows order.
7621
7776
  const catalogList = document.getElementById('cp-catalog-list');
7622
7777
  if (catalogList) {
7623
7778
  catalogList.innerHTML = '';
7779
+ adhocRows.forEach((row, i) => {
7780
+ catalogList.appendChild(buildCpRow(row, recentRows.length + i));
7781
+ });
7624
7782
  catalogJobs.forEach((job, i) => {
7625
- catalogList.appendChild(buildCpRow({ type: 'job', job, instructions: '' }, recentRows.length + i));
7783
+ catalogList.appendChild(buildCpRow({ type: 'job', job, instructions: '' }, recentRows.length + adhocRows.length + i));
7626
7784
  });
7627
7785
  teachRows.forEach((row, i) => {
7628
- catalogList.appendChild(buildCpRow(row, recentRows.length + catalogJobs.length + i));
7786
+ catalogList.appendChild(buildCpRow(row, recentRows.length + adhocRows.length + catalogJobs.length + i));
7629
7787
  });
7630
7788
  }
7631
7789
 
@@ -7642,11 +7800,20 @@ function renderCpAgentPicker() {
7642
7800
  if (!picker) return;
7643
7801
  picker.innerHTML = '';
7644
7802
  const list = hubConfiguredAgents();
7803
+ const hasAvailable = list.some((e) => e.available !== false && e.enabled !== false);
7804
+ const footer = picker.closest('.cp-employee-footer');
7805
+ if (footer) footer.hidden = !hasAvailable;
7645
7806
  const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false && e.enabled !== false);
7646
7807
  if (!curOk) {
7647
7808
  const firstAvail = list.find((e) => e.available !== false && e.enabled !== false);
7648
7809
  state.cpEmployee = firstAvail ? firstAvail.id : null;
7649
7810
  }
7811
+ if (!hasAvailable) {
7812
+ renderCpAgentInstallPanel();
7813
+ const startBtn = document.getElementById('cp-start-btn');
7814
+ if (startBtn) startBtn.disabled = true;
7815
+ return;
7816
+ }
7650
7817
  for (const e of list) {
7651
7818
  const pill = document.createElement('button');
7652
7819
  pill.type = 'button';
@@ -7675,7 +7842,7 @@ function buildCpRow(row, flatIndex) {
7675
7842
 
7676
7843
  const icon = document.createElement('span');
7677
7844
  icon.className = 'cp-row-icon';
7678
- icon.textContent = row.type === 'recent' ? '🕐' : row.type === 'teach' ? '🎓' : '📋';
7845
+ icon.textContent = row.type === 'recent' ? '🕐' : row.type === 'teach' ? '🎓' : row.type === 'adhoc' ? '✨' : '📋';
7679
7846
 
7680
7847
  const body = document.createElement('span');
7681
7848
  body.className = 'cp-row-body';
@@ -7702,7 +7869,7 @@ function buildCpRow(row, flatIndex) {
7702
7869
  // #1340: this is the actual "+ Delegate Job" catalog (openModal → openPalette →
7703
7870
  // renderCpRows → buildCpRow) — the row never had a visualize affordance at all.
7704
7871
  // Teach rows carry a synthetic, non-catalog job object, so skip them.
7705
- if (row.type !== 'teach') {
7872
+ if (row.type !== 'teach' && row.type !== 'adhoc') {
7706
7873
  el.appendChild(tfCreateJobVizControl(row.job));
7707
7874
  }
7708
7875
  if (row.job.requiredPersonaKey) {
@@ -7736,6 +7903,15 @@ function renderCpHighlight() {
7736
7903
  function selectCpRow(index) {
7737
7904
  const row = state.cpRows[index];
7738
7905
  if (!row) return;
7906
+ // Issue #1610 R13: adhoc row launches freeform directly with the current search text.
7907
+ // Close the palette first so the step-2 modal opens on a clean slate.
7908
+ if (row.type === 'adhoc') {
7909
+ const search = document.getElementById('cp-search');
7910
+ const searchText = search ? search.value.trim() : '';
7911
+ closePalette();
7912
+ startAdhoc(searchText);
7913
+ return;
7914
+ }
7739
7915
  state.cpSelectedJob = row.job;
7740
7916
  state.cpHighlightIndex = index;
7741
7917
  renderCpHighlight();
@@ -7808,6 +7984,12 @@ function rerunLastJob() {
7808
7984
  function showRerunToast(msg) {
7809
7985
  const t = document.createElement('div');
7810
7986
  t.className = 'cp-rerun-toast';
7987
+ // Issue #1627: matches this app's own convention for transient status text (#be-status,
7988
+ // #status-line, etc.) so a screen reader announces the message instead of it being silently
7989
+ // visual-only - relevant beyond the original re-run case now that this toast also carries the
7990
+ // real reason a restart-to-latest attempt failed.
7991
+ t.setAttribute('role', 'status');
7992
+ t.setAttribute('aria-live', 'polite');
7811
7993
  t.textContent = msg;
7812
7994
  document.body.appendChild(t);
7813
7995
  setTimeout(() => { if (t.parentNode) t.parentNode.removeChild(t); }, 5000);
@@ -8079,11 +8261,122 @@ function renderCpAgentInstallPanel() {
8079
8261
  panel.innerHTML = '';
8080
8262
  return;
8081
8263
  }
8082
- renderAgentInstallPanelInto(panel, {
8083
- heading: 'Set up a CLI agent before starting',
8084
- intro: 'Install, sign in, and verify one Hub agent to enable Start.',
8085
- testPrefix: 'cp-agent',
8264
+ // Keep setup guidance centralized in Manager -> AI Agents.
8265
+ panel.hidden = false;
8266
+ panel.innerHTML = '';
8267
+
8268
+ const heading = document.createElement('div');
8269
+ heading.className = 'install-panel-heading';
8270
+ heading.textContent = 'No AI agent is set up yet';
8271
+ panel.appendChild(heading);
8272
+
8273
+ const intro = document.createElement('p');
8274
+ intro.className = 'install-panel-copy';
8275
+ intro.textContent = 'Install, sign in, and verify a CLI agent in Manager -> AI Agents, then come back here to start.';
8276
+ panel.appendChild(intro);
8277
+
8278
+ const link = document.createElement('button');
8279
+ link.type = 'button';
8280
+ link.className = 'secondary small';
8281
+ link.textContent = 'Go to Manager -> AI Agents';
8282
+ link.setAttribute('data-testid', 'cp-cli-setup-goto-manager');
8283
+ link.addEventListener('click', () => {
8284
+ closePalette();
8285
+ if (typeof tfShowArea === 'function') tfShowArea('manager');
8286
+ const acc = document.getElementById('manager-agents-acc');
8287
+ if (acc) {
8288
+ acc.open = true;
8289
+ acc.scrollIntoView({ behavior: 'smooth', block: 'start' });
8290
+ }
8086
8291
  });
8292
+ panel.appendChild(link);
8293
+ }
8294
+
8295
+ // Issue #1618 (R3): the single shared install/sign-in row builder. Used both by the job
8296
+ // composer's standalone panels (renderAgentInstallPanelInto's loop below) and by the
8297
+ // Manager AI Agents panel's per-card mount (renderConfiguredAgentsPanel), so the two
8298
+ // surfaces share one state machine and cannot drift out of sync. `options.testPrefix`
8299
+ // namespaces testids per mount point (e.g. 'hub-agent' vs 'manager-agent').
8300
+ // PR feedback (#1620): `options.showLabel` (default true) and `options.resetLabel`
8301
+ // (default 'Choose another agent') let a mount point adapt wording/labeling without a
8302
+ // second implementation. `options.buttonClass`/`options.secondaryButtonClass` (default
8303
+ // 'secondary small'/'ghost small', the job composer's own standalone-panel button style)
8304
+ // let a mount point match its own surrounding card/button language instead. The job
8305
+ // composer's 3 mounts pass none of these and are unchanged.
8306
+ function buildAgentInstallRow(emp, options) {
8307
+ const buttonClass = options.buttonClass || 'secondary small';
8308
+ const secondaryButtonClass = options.secondaryButtonClass || 'ghost small';
8309
+
8310
+ const row = document.createElement('div');
8311
+ row.className = 'install-row';
8312
+ row.id = `install-row-${emp.id}`;
8313
+
8314
+ if (options.showLabel !== false) {
8315
+ const label = document.createElement('span');
8316
+ label.className = 'install-label';
8317
+ label.textContent = emp.label;
8318
+ row.appendChild(label);
8319
+ }
8320
+
8321
+ const status = document.createElement('span');
8322
+ status.className = 'install-status';
8323
+ status.id = `install-status-${emp.id}`;
8324
+ status.textContent = agentInstallState[emp.id]?.statusText || '';
8325
+ row.appendChild(status);
8326
+
8327
+ const btn = document.createElement('button');
8328
+ btn.className = buttonClass;
8329
+ btn.id = `install-btn-${emp.id}`;
8330
+ btn.dataset.hubId = emp.id;
8331
+
8332
+ const st = agentInstallState[emp.id] || {};
8333
+ if (!st.phase) {
8334
+ btn.textContent = `Download / Install ${emp.label}`;
8335
+ btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
8336
+ btn.addEventListener('click', () => startAgentInstall(emp.id));
8337
+ } else if (st.phase === 'installing') {
8338
+ btn.textContent = 'Installing...';
8339
+ btn.disabled = true;
8340
+ } else if (st.phase === 'needs-login') {
8341
+ btn.textContent = 'Sign In';
8342
+ btn.addEventListener('click', () => triggerAgentLogin(emp.id));
8343
+ } else if (st.phase === 'login-triggered') {
8344
+ const checkBtn = document.createElement('button');
8345
+ checkBtn.className = buttonClass;
8346
+ checkBtn.textContent = 'Check if Ready';
8347
+ checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
8348
+ checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
8349
+ row.appendChild(checkBtn);
8350
+
8351
+ const skipBtn = document.createElement('button');
8352
+ skipBtn.className = secondaryButtonClass;
8353
+ // PR feedback (#1620): "Choose another agent" describes the job composer's picker
8354
+ // context (abandon this one, pick a different employee/tool). The Manager panel has
8355
+ // no picker to return to — clicking this just resets THIS card's install phase — so
8356
+ // that mount passes a clearer label via options.resetLabel.
8357
+ skipBtn.textContent = options.resetLabel || 'Choose another agent';
8358
+ skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
8359
+ skipBtn.style.marginLeft = '6px';
8360
+ skipBtn.addEventListener('click', () => {
8361
+ delete agentInstallState[emp.id];
8362
+ renderAgentInstallPanel();
8363
+ renderHubAgentSetupPanel();
8364
+ renderCpAgentInstallPanel();
8365
+ renderConfiguredAgentsPanel();
8366
+ });
8367
+ row.appendChild(skipBtn);
8368
+ return row;
8369
+ } else if (st.phase === 'ready') {
8370
+ btn.textContent = 'Ready';
8371
+ btn.disabled = true;
8372
+ btn.style.color = 'var(--accent)';
8373
+ } else if (st.phase === 'error') {
8374
+ btn.textContent = 'Retry';
8375
+ btn.addEventListener('click', () => startAgentInstall(emp.id));
8376
+ }
8377
+
8378
+ row.appendChild(btn);
8379
+ return row;
8087
8380
  }
8088
8381
 
8089
8382
  function renderAgentInstallPanelInto(panel, options) {
@@ -8113,70 +8406,7 @@ function renderAgentInstallPanelInto(panel, options) {
8113
8406
  }
8114
8407
 
8115
8408
  for (const emp of unavailable) {
8116
- const row = document.createElement('div');
8117
- row.className = 'install-row';
8118
- row.id = `install-row-${emp.id}`;
8119
-
8120
- const label = document.createElement('span');
8121
- label.className = 'install-label';
8122
- label.textContent = emp.label;
8123
- row.appendChild(label);
8124
-
8125
- const status = document.createElement('span');
8126
- status.className = 'install-status';
8127
- status.id = `install-status-${emp.id}`;
8128
- status.textContent = agentInstallState[emp.id]?.statusText || '';
8129
- row.appendChild(status);
8130
-
8131
- const btn = document.createElement('button');
8132
- btn.className = 'secondary small';
8133
- btn.id = `install-btn-${emp.id}`;
8134
- btn.dataset.hubId = emp.id;
8135
-
8136
- const st = agentInstallState[emp.id] || {};
8137
- if (!st.phase) {
8138
- btn.textContent = `Download / Install ${emp.label}`;
8139
- btn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-install-${emp.id}`);
8140
- btn.addEventListener('click', () => startAgentInstall(emp.id));
8141
- } else if (st.phase === 'installing') {
8142
- btn.textContent = 'Installing...';
8143
- btn.disabled = true;
8144
- } else if (st.phase === 'needs-login') {
8145
- btn.textContent = 'Sign In';
8146
- btn.addEventListener('click', () => triggerAgentLogin(emp.id));
8147
- } else if (st.phase === 'login-triggered') {
8148
- const checkBtn = document.createElement('button');
8149
- checkBtn.className = 'secondary small';
8150
- checkBtn.textContent = 'Check if Ready';
8151
- checkBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-check-${emp.id}`);
8152
- checkBtn.addEventListener('click', () => checkAgentReady(emp.id));
8153
- row.appendChild(checkBtn);
8154
-
8155
- const skipBtn = document.createElement('button');
8156
- skipBtn.className = 'ghost small';
8157
- skipBtn.textContent = 'Choose another agent';
8158
- skipBtn.setAttribute('data-testid', `${options.testPrefix || 'hub-agent'}-reset-${emp.id}`);
8159
- skipBtn.style.marginLeft = '6px';
8160
- skipBtn.addEventListener('click', () => {
8161
- delete agentInstallState[emp.id];
8162
- renderAgentInstallPanel();
8163
- renderHubAgentSetupPanel();
8164
- renderCpAgentInstallPanel();
8165
- });
8166
- row.appendChild(skipBtn);
8167
- panel.appendChild(row);
8168
- continue;
8169
- } else if (st.phase === 'ready') {
8170
- btn.textContent = 'Ready';
8171
- btn.disabled = true;
8172
- btn.style.color = 'var(--accent)';
8173
- } else if (st.phase === 'error') {
8174
- btn.textContent = 'Retry';
8175
- btn.addEventListener('click', () => startAgentInstall(emp.id));
8176
- }
8177
-
8178
- row.appendChild(btn);
8179
- panel.appendChild(row);
8409
+ panel.appendChild(buildAgentInstallRow(emp, options));
8180
8410
  }
8181
8411
  }
8182
8412
 
@@ -8186,6 +8416,10 @@ function setInstallState(hubId, phase, statusText) {
8186
8416
  renderHubAgentSetupPanel();
8187
8417
  renderCpAgentInstallPanel();
8188
8418
  renderCpAgentPicker();
8419
+ // Issue #1618: the Manager AI Agents panel's per-card install row shares this same
8420
+ // agentInstallState, so it must refresh at the same choke point as the job composer's
8421
+ // three panels or it drifts out of sync with them.
8422
+ renderConfiguredAgentsPanel();
8189
8423
  }
8190
8424
 
8191
8425
  // Issue #1256 (slice c, AC-C4): backstop above the server's own AGENT_INSTALL_TIMEOUT_MS
@@ -8257,6 +8491,9 @@ async function refreshEmployees() {
8257
8491
  renderAgentInstallPanel();
8258
8492
  renderHubAgentSetupPanel();
8259
8493
  renderCpAgentInstallPanel();
8494
+ // Issue #1618: the Manager AI Agents panel's per-card install row depends on the same
8495
+ // refreshed employee roster (e.g. after "Check if Ready" confirms a host is now available).
8496
+ renderConfiguredAgentsPanel();
8260
8497
  } catch { /* best-effort */ }
8261
8498
  }
8262
8499
 
@@ -8738,6 +8975,12 @@ function foldRunIntoConversation(conv, run) {
8738
8975
  // can derive the correct pill without heuristics.
8739
8976
  if (run.pauseReason !== undefined) conv.pauseReason = run.pauseReason;
8740
8977
  conv.lastUpdatedAt = Date.now();
8978
+ // Issue #1634: this fold just made conv's messages/events current for run.id,
8979
+ // as of the lastUpdatedAt stamped above — record that so a subsequent
8980
+ // hydrateConversationBody() call (e.g. on switch-away/switch-back while this
8981
+ // same run is still live) sees the content as fresh and does not re-fetch.
8982
+ conv._bodyFetchedRunId = run.id;
8983
+ conv._bodyFetchedLastUpdatedAt = conv.lastUpdatedAt;
8741
8984
  }
8742
8985
 
8743
8986
  // Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
@@ -9166,6 +9409,26 @@ function renderConfiguredAgentsPanel() {
9166
9409
  const renderedAvailable = latestCheck ? latestCheck.available !== false : agent.available !== false;
9167
9410
  const renderedEnabled = agent.enabled !== false;
9168
9411
  const renderedReasons = latestCheck?.reasons || agent.reasons || [];
9412
+
9413
+ // Issue #1618 (R2/R3), moved up (PR feedback #1620): computed before the actions
9414
+ // row so the redundant top-of-card "Check" button can be omitted whenever the
9415
+ // install row itself is about to render — the install row's own state machine
9416
+ // (Download/Install -> Sign In -> Check if Ready) already provides a live-check
9417
+ // path once installed, and "Check" against a not-yet-installed host communicates
9418
+ // nothing the status pill/status pill text does not already say.
9419
+ //
9420
+ // Gated strictly on the underlying host CLI's own readiness (`emp.available ===
9421
+ // false`), never on the configured-agent's `enabled` flag or any other not-ready
9422
+ // reason (PR feedback #1620: an earlier, broader formula also showed the row for a
9423
+ // profile that was merely disabled, or one with a good host but a broken/missing
9424
+ // setup script -- neither of which installing/signing-in the CLI would fix; Edit
9425
+ // is the correct path for both). `renderedAvailable` already prefers a fresh
9426
+ // `latestCheck` result over the possibly-stale roster snapshot, so clicking "Check
9427
+ // if Ready" inside the row hides the row (and restores the top Check button)
9428
+ // immediately once ready.
9429
+ const emp = hubEmployees().find((e) => e.id === agent.baseHostId);
9430
+ const showInstallRow = emp ? emp.available === false : false;
9431
+
9169
9432
  const card = document.createElement('div');
9170
9433
  card.className = 'configured-agent-card';
9171
9434
  card.dataset.testid = 'configured-agent-card';
@@ -9181,21 +9444,23 @@ function renderConfiguredAgentsPanel() {
9181
9444
 
9182
9445
  const actions = document.createElement('div');
9183
9446
  actions.className = 'configured-agent-card-actions';
9184
- const check = document.createElement('button');
9185
- check.type = 'button';
9186
- check.className = 'configured-agent-icon-action';
9187
- check.textContent = 'Check';
9188
- check.addEventListener('click', async () => {
9189
- try {
9190
- const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
9191
- state.configuredAgentCheckResults[agent.id] = result;
9192
- showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
9193
- renderConfiguredAgentsPanel();
9194
- } catch (err) {
9195
- showStatus(err.message || 'Agent check failed.', true);
9196
- }
9197
- });
9198
- actions.appendChild(check);
9447
+ if (!showInstallRow) {
9448
+ const check = document.createElement('button');
9449
+ check.type = 'button';
9450
+ check.className = 'configured-agent-icon-action';
9451
+ check.textContent = 'Check';
9452
+ check.addEventListener('click', async () => {
9453
+ try {
9454
+ const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
9455
+ state.configuredAgentCheckResults[agent.id] = result;
9456
+ showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
9457
+ renderConfiguredAgentsPanel();
9458
+ } catch (err) {
9459
+ showStatus(err.message || 'Agent check failed.', true);
9460
+ }
9461
+ });
9462
+ actions.appendChild(check);
9463
+ }
9199
9464
 
9200
9465
  const edit = document.createElement('button');
9201
9466
  edit.type = 'button';
@@ -9243,6 +9508,38 @@ function renderConfiguredAgentsPanel() {
9243
9508
  card.appendChild(head);
9244
9509
  card.appendChild(meta);
9245
9510
  card.appendChild(detail);
9511
+
9512
+ // Issue #1618 (R2/R3/R4): a per-card install/sign-in affordance for an agent
9513
+ // that isn't ready, reusing the job composer's shared row builder rather than a
9514
+ // second parallel implementation. `showInstallRow` is computed above (it also
9515
+ // gates the top "Check" button). The buttons are styled with the card's own
9516
+ // `.configured-agent-icon-action` class (PR feedback #1620: the job composer's
9517
+ // `.secondary small`/`.ghost small` classes read as a visually different, duller
9518
+ // button style next to Check/Edit/Delete) instead of the job composer's own
9519
+ // standalone-panel button classes.
9520
+ if (showInstallRow) {
9521
+ const installWrap = document.createElement('div');
9522
+ installWrap.className = 'configured-agent-install-row';
9523
+ installWrap.appendChild(buildAgentInstallRow(
9524
+ emp || { id: agent.baseHostId, label: agent.label, available: false },
9525
+ // PR feedback (#1620): the card's own title already names this configured
9526
+ // agent, so the row's own `emp.label` line (the underlying host's label,
9527
+ // which can legitimately differ from a custom configured-agent label) is
9528
+ // suppressed here to avoid reading as a second, unrelated agent name.
9529
+ // "Choose another agent" is replaced with "Start over" since there is no
9530
+ // agent picker on this panel to return to -- the button only resets this
9531
+ // card's own install phase.
9532
+ {
9533
+ testPrefix: 'manager-agent',
9534
+ showLabel: false,
9535
+ resetLabel: 'Start over',
9536
+ buttonClass: 'configured-agent-icon-action',
9537
+ secondaryButtonClass: 'configured-agent-icon-action',
9538
+ },
9539
+ ));
9540
+ card.appendChild(installWrap);
9541
+ }
9542
+
9246
9543
  panel.appendChild(card);
9247
9544
  }
9248
9545
  }
@@ -12746,6 +13043,21 @@ function buildDeploymentRow(dep) {
12746
13043
  title.className = 'dep-row-title';
12747
13044
  title.textContent = dep.label;
12748
13045
  body.appendChild(title);
13046
+ // Issue #1610 R10: for adhoc-prompt deployments show the job name (resolved from
13047
+ // catalog metadata) and the first 50 chars of instructions as a subtitle.
13048
+ if (dep.jobId === 'adhoc-prompt') {
13049
+ const depJobInfo = (state.bootstrap?.jobs ?? []).find((j) => j.id === dep.jobId);
13050
+ const jobLabel = document.createElement('span');
13051
+ jobLabel.className = 'dep-row-job-label';
13052
+ jobLabel.textContent = (depJobInfo && depJobInfo.title) || dep.jobId;
13053
+ body.appendChild(jobLabel);
13054
+ if (dep.instructions) {
13055
+ const sub = document.createElement('span');
13056
+ sub.className = 'dep-row-sub';
13057
+ sub.textContent = dep.instructions.slice(0, 50);
13058
+ body.appendChild(sub);
13059
+ }
13060
+ }
12749
13061
  const meta = document.createElement('span');
12750
13062
  meta.className = 'assign-row-meta';
12751
13063
  const badge = document.createElement('span');
@@ -12916,6 +13228,23 @@ function populateAgentSelect(sel, currentHostId) {
12916
13228
  if (pick) sel.value = pick;
12917
13229
  }
12918
13230
 
13231
+ // Issue #1610 R8: sync the instructions label text and placeholder when the job
13232
+ // selection changes. adhoc-prompt requires instructions; all other jobs treat
13233
+ // them as optional context.
13234
+ function updateDepInstructionsLabel() {
13235
+ const jobSel = document.getElementById('dep-job');
13236
+ const labelEl = document.getElementById('dep-inst-optional-label');
13237
+ const textarea = document.getElementById('dep-instructions');
13238
+ const isAdhoc = jobSel && jobSel.value === 'adhoc-prompt';
13239
+ if (labelEl) labelEl.textContent = isAdhoc ? '(required)' : '(optional)';
13240
+ if (textarea) {
13241
+ textarea.placeholder = isAdhoc
13242
+ ? 'Describe what to do each run…'
13243
+ : 'Optional message sent to the agent at the start of each run';
13244
+ textarea.setAttribute('aria-required', isAdhoc ? 'true' : 'false');
13245
+ }
13246
+ }
13247
+
12919
13248
  // #693 R2: open the single consolidated assignment modal. `dep` present = edit;
12920
13249
  // its .type locks the segmented control. New assignments default to Scheduled.
12921
13250
  function openDeploymentModal(dep) {
@@ -12925,13 +13254,24 @@ function openDeploymentModal(dep) {
12925
13254
 
12926
13255
  const jobSel = document.getElementById('dep-job');
12927
13256
  jobSel.innerHTML = '';
12928
- // Issue #1451: sort alphabetically by title so the New Assignment job list is scannable.
12929
- for (const j of sortJobsByTitle(jobs)) {
13257
+ const catalogJobs = jobs.filter((j) => j && j.id !== 'adhoc-prompt');
13258
+ const deploymentJobs = sortJobsByTitle([
13259
+ { id: 'adhoc-prompt', title: 'Ad-hoc (custom instructions)' },
13260
+ ...catalogJobs,
13261
+ ]);
13262
+ // Issue #1451: sort alphabetically by the displayed title so the New
13263
+ // Assignment job list is scannable. Ad-hoc remains available, but it is not
13264
+ // pinned above catalog jobs because it requires instructions before saving.
13265
+ for (const j of deploymentJobs) {
12930
13266
  const opt = document.createElement('option');
12931
13267
  opt.value = j.id; opt.textContent = j.title;
12932
13268
  jobSel.appendChild(opt);
12933
13269
  }
12934
- if (dep?.jobId) jobSel.value = dep.jobId;
13270
+ const defaultCatalogJob = sortJobsByTitle(catalogJobs)[0];
13271
+ const selectedJobId = dep?.jobId || defaultCatalogJob?.id || 'adhoc-prompt';
13272
+ if (selectedJobId) jobSel.value = selectedJobId;
13273
+ updateDepInstructionsLabel();
13274
+ jobSel.onchange = updateDepInstructionsLabel;
12935
13275
 
12936
13276
  populateAgentSelect(document.getElementById('dep-agent'), dep?.configuredAgentId || dep?.hostId || 'claude');
12937
13277
  document.getElementById('dep-label').value = dep?.label ?? '';
@@ -12979,6 +13319,9 @@ async function saveDeployment() {
12979
13319
  const instructions = document.getElementById('dep-instructions').value.trim();
12980
13320
  const isEdit = _editingDepId !== null;
12981
13321
  if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
13322
+ // Issue #1610 R9: adhoc-prompt has no fixed task — block save before any network
13323
+ // request so the user gets immediate feedback rather than a server 400.
13324
+ if (jobId === 'adhoc-prompt' && !instructions) { errEl.textContent = 'Instructions are required for ad-hoc assignments.'; errEl.hidden = false; return; }
12982
13325
 
12983
13326
  if (_depType === 'scheduled') {
12984
13327
  const isCustom = _activeSchPreset === 'custom';
@@ -16994,23 +17337,42 @@ function tfRestartHubToLatest(event) {
16994
17337
  badgeEl.textContent = '⬆️ Restarting…';
16995
17338
  }
16996
17339
  fetch('/api/ai-hub/restart-to-latest', { method: 'POST' })
16997
- .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`status ${r.status}`))))
16998
- .then((info) => {
16999
- if (info && info.restarting) {
17000
- tfPollForHubRestart(info.latest || null, 0);
17001
- } else if (badgeEl) {
17002
- badgeEl.disabled = false;
17003
- badgeEl.textContent = '⬆️ Updates available';
17340
+ .then((r) => r.json().catch(() => ({})).then((body) => ({ ok: r.ok, body })))
17341
+ .then(({ ok, body }) => {
17342
+ if (!ok) throw new Error((body && body.error) || 'Could not update FRAIM Hub.');
17343
+ if (body && body.restarting) {
17344
+ tfPollForHubRestart(body.latest || null, 0);
17345
+ return;
17004
17346
  }
17347
+ // Issue #1627: restarting:false used to be treated as "nothing to do" unconditionally, so a
17348
+ // genuine materialization failure looked identical to "already current" and the badge just
17349
+ // silently reverted with no explanation. An `error` here means the restart was attempted and
17350
+ // failed - show the real reason instead of pretending the click did nothing.
17351
+ tfShowHubRestartFailure(badgeEl, body && body.error);
17005
17352
  })
17006
- .catch(() => {
17007
- if (badgeEl) {
17008
- badgeEl.disabled = false;
17009
- badgeEl.textContent = '⬆️ Updates available';
17010
- }
17353
+ .catch((err) => {
17354
+ tfShowHubRestartFailure(badgeEl, err && err.message);
17011
17355
  });
17012
17356
  }
17013
17357
 
17358
+ // Issue #1627: how long the badge shows "Restart failed" before settling back to its steady
17359
+ // "an update is still available, click to try again" state.
17360
+ const HUB_RESTART_FAILURE_DISPLAY_MS = 8000;
17361
+
17362
+ function tfShowHubRestartFailure(badgeEl, reason) {
17363
+ const message = reason
17364
+ ? `Could not update FRAIM Hub: ${reason}. Check your network or npm access, then click to try again.`
17365
+ : 'Could not update FRAIM Hub. Check your network or npm access, then click to try again.';
17366
+ showRerunToast(message);
17367
+ if (!badgeEl) return;
17368
+ badgeEl.disabled = false;
17369
+ badgeEl.textContent = '⬆️ Restart failed';
17370
+ badgeEl.title = message;
17371
+ setTimeout(() => {
17372
+ if (badgeEl.textContent === '⬆️ Restart failed') badgeEl.textContent = '⬆️ Updates available';
17373
+ }, HUB_RESTART_FAILURE_DISPLAY_MS);
17374
+ }
17375
+
17014
17376
  const HUB_RESTART_POLL_MS = 1000;
17015
17377
  const HUB_RESTART_POLL_MAX_ATTEMPTS = 45; // ~45s: generous for a fresh materialize-from-npm relaunch
17016
17378