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.
- package/dist/src/ai-hub/desktop-main.js +101 -5
- package/dist/src/ai-hub/devtools-window.js +17 -0
- package/dist/src/ai-hub/hosts.js +71 -12
- package/dist/src/ai-hub/hub-app-materializer.js +33 -3
- package/dist/src/ai-hub/hub-main-diagnostics.js +278 -0
- package/dist/src/ai-hub/restart-recovery-policy.js +11 -3
- package/dist/src/ai-hub/server.js +68 -16
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/cli/setup/user-level-sync.js +4 -0
- package/dist/src/config/persona-capability-bundles.js +12 -1
- package/package.json +2 -2
- package/public/ai-hub/index.html +1 -1
- package/public/ai-hub/script.js +482 -120
- package/public/ai-hub/styles.css +29 -0
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 = () => {
|
|
5560
|
-
|
|
5561
|
-
|
|
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
|
-
|
|
5579
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
//
|
|
7597
|
-
//
|
|
7598
|
-
//
|
|
7599
|
-
|
|
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:
|
|
7620
|
-
// flatIndex continues from recentRows so it
|
|
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
|
-
|
|
8083
|
-
|
|
8084
|
-
|
|
8085
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9185
|
-
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
9195
|
-
|
|
9196
|
-
|
|
9197
|
-
|
|
9198
|
-
|
|
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
|
-
|
|
12929
|
-
|
|
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
|
-
|
|
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) =>
|
|
16998
|
-
.then((
|
|
16999
|
-
if (
|
|
17000
|
-
|
|
17001
|
-
|
|
17002
|
-
|
|
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
|
-
|
|
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
|
|