fraim-hub 2.0.280 → 2.0.281
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/hosts.js +14 -2
- package/dist/src/core/resolve-phase-edge.js +33 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +9 -2
- package/public/ai-hub/script.js +316 -109
- package/public/ai-hub/styles.css +48 -18
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -719,7 +719,13 @@ function extractReviewHandoffFromArgs(args) {
|
|
|
719
719
|
return direct;
|
|
720
720
|
const evidence = args.evidence;
|
|
721
721
|
if (evidence && typeof evidence === 'object') {
|
|
722
|
-
|
|
722
|
+
const fromEvidence = readReviewHandoffCandidate(evidence.reviewHandoff);
|
|
723
|
+
if (fromEvidence)
|
|
724
|
+
return fromEvidence;
|
|
725
|
+
}
|
|
726
|
+
const findings = args.findings;
|
|
727
|
+
if (findings && typeof findings === 'object') {
|
|
728
|
+
return readReviewHandoffCandidate(findings.reviewHandoff);
|
|
723
729
|
}
|
|
724
730
|
return null;
|
|
725
731
|
}
|
|
@@ -764,7 +770,13 @@ function extractNextJobRecommendationsFromArgs(args) {
|
|
|
764
770
|
return direct;
|
|
765
771
|
const evidence = args.evidence;
|
|
766
772
|
if (evidence && typeof evidence === 'object' && !Array.isArray(evidence)) {
|
|
767
|
-
|
|
773
|
+
const fromEvidence = readNextJobRecommendationsCandidate(evidence.nextJobRecommendations);
|
|
774
|
+
if (fromEvidence)
|
|
775
|
+
return fromEvidence;
|
|
776
|
+
}
|
|
777
|
+
const findings = args.findings;
|
|
778
|
+
if (findings && typeof findings === 'object' && !Array.isArray(findings)) {
|
|
779
|
+
return readNextJobRecommendationsCandidate(findings.nextJobRecommendations);
|
|
768
780
|
}
|
|
769
781
|
return null;
|
|
770
782
|
}
|
|
@@ -15,9 +15,11 @@
|
|
|
15
15
|
* change inert for every job that authors no map.
|
|
16
16
|
*/
|
|
17
17
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.FEEDBACK_PHASE_ID = void 0;
|
|
18
19
|
exports.resolvePhaseEdge = resolvePhaseEdge;
|
|
19
20
|
exports.resolveDiscriminant = resolveDiscriminant;
|
|
20
21
|
exports.discriminantKeys = discriminantKeys;
|
|
22
|
+
exports.derivePredecessorPhase = derivePredecessorPhase;
|
|
21
23
|
/** The default discriminant, and the mandatory key on every authored map. */
|
|
22
24
|
const DEFAULT_DISCRIMINANT = 'default';
|
|
23
25
|
/**
|
|
@@ -73,3 +75,34 @@ function discriminantKeys(edge) {
|
|
|
73
75
|
return [];
|
|
74
76
|
return Object.keys(edge).filter((key) => key !== DEFAULT_DISCRIMINANT);
|
|
75
77
|
}
|
|
78
|
+
/** The framework's one review/decision phase; every reviewable job routes into it. */
|
|
79
|
+
exports.FEEDBACK_PHASE_ID = 'address-feedback';
|
|
80
|
+
/**
|
|
81
|
+
* Finds the unique phase in a job's phase map whose `onSuccess` edge can
|
|
82
|
+
* resolve to `targetPhaseId` for some discriminant (including `default`).
|
|
83
|
+
*
|
|
84
|
+
* Gating-only rebuild of #1157 Change 1 ("graph-derived phase identity"),
|
|
85
|
+
* scoped per #1276: that change also added routing behavior and was reverted
|
|
86
|
+
* before merge because the two were bundled. Only the lookup survives here —
|
|
87
|
+
* it answers "which phase is this job's submission phase" without changing
|
|
88
|
+
* how any phase transition resolves. No hardcoded phase-name list: the
|
|
89
|
+
* derivation reads whatever the job's own graph declares, so it is correct
|
|
90
|
+
* for every current and future job without per-job edits.
|
|
91
|
+
*
|
|
92
|
+
* Returns `null` when zero or multiple phases match, so a caller can fail
|
|
93
|
+
* safe (treat as "unknown") rather than guess at an ambiguous graph.
|
|
94
|
+
*/
|
|
95
|
+
function derivePredecessorPhase(phases, targetPhaseId) {
|
|
96
|
+
const predecessors = [];
|
|
97
|
+
for (const [phaseId, edges] of Object.entries(phases ?? {})) {
|
|
98
|
+
if (phaseId === targetPhaseId || !edges)
|
|
99
|
+
continue;
|
|
100
|
+
const edge = edges.onSuccess;
|
|
101
|
+
const targets = typeof edge === 'string'
|
|
102
|
+
? [edge]
|
|
103
|
+
: (edge && typeof edge === 'object' ? Object.values(edge) : []);
|
|
104
|
+
if (targets.includes(targetPhaseId))
|
|
105
|
+
predecessors.push(phaseId);
|
|
106
|
+
}
|
|
107
|
+
return predecessors.length === 1 ? predecessors[0] : null;
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.281",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -179,7 +179,7 @@
|
|
|
179
179
|
"electron-updater": "^6.8.9",
|
|
180
180
|
"express": "^5.2.1",
|
|
181
181
|
"extract-zip": "^2.0.1",
|
|
182
|
-
"fraim": "2.0.
|
|
182
|
+
"fraim": "2.0.281",
|
|
183
183
|
"mongodb": "^7.0.0",
|
|
184
184
|
"node-cron": "4.2.1",
|
|
185
185
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<meta name="color-scheme" content="light dark">
|
|
7
7
|
<title>FRAIM AI Hub</title>
|
|
8
|
-
|
|
8
|
+
<!-- #1340: the real FRAIM mark, not a placeholder. tfApplyBrandFavicon() in
|
|
9
|
+
script.js already assumes this default is correct and leaves it alone
|
|
10
|
+
unless a customer org brand overrides it. -->
|
|
11
|
+
<link rel="icon" type="image/png" href="./fraim-icon.png">
|
|
9
12
|
<!-- Detect Electron + host platform before CSS paints so html.electron / html.mac /
|
|
10
13
|
html.win / html.linux rules apply on first render (no flash, correct native chrome). -->
|
|
11
14
|
<script>(function(){var h=document.documentElement,ua=navigator.userAgent;if(/Electron/.test(ua))h.classList.add('electron');var p=(navigator.userAgentData&&navigator.userAgentData.platform)||navigator.platform||'';if(/mac/i.test(p)||/Mac|iPhone|iPad|iPod/.test(ua))h.classList.add('mac');else if(/win/i.test(p)||/Windows/.test(ua))h.classList.add('win');else if(/linux/i.test(p)||/Linux|X11/.test(ua))h.classList.add('linux');
|
|
@@ -264,7 +267,6 @@
|
|
|
264
267
|
<!-- Onboarding state banner lives ABOVE the accordions so it shows even
|
|
265
268
|
when the Brief is collapsed (so the conversation keeps its height). -->
|
|
266
269
|
<div id="proj-onboarding-banner"></div>
|
|
267
|
-
<div id="hub-agent-setup-panel" class="hub-agent-setup-panel" data-testid="hub-cli-setup-panel" hidden></div>
|
|
268
270
|
<details class="ctx-acc" id="proj-brief-acc">
|
|
269
271
|
<summary><span class="ca-chev">▸</span> <span>📋 Brief</span>
|
|
270
272
|
<span class="ca-note">· this project's context and rules, captured by Project Onboarding</span></summary>
|
|
@@ -456,6 +458,11 @@
|
|
|
456
458
|
</summary>
|
|
457
459
|
<div class="panel-body" id="deliverables-body"></div>
|
|
458
460
|
</details>
|
|
461
|
+
<!-- Issue #1292: when this conversation's own agent stops being available,
|
|
462
|
+
say so. The selector keeps holding that agent; it is never quietly
|
|
463
|
+
swapped for another one. Lives outside #coach-panel so a collapsed
|
|
464
|
+
coaching panel cannot hide it. -->
|
|
465
|
+
<div class="agent-unavailable-note" id="active-agent-unavailable-note" data-testid="active-agent-unavailable-note" role="status" hidden></div>
|
|
459
466
|
<details class="panel-details panel-details--coach" id="coach-panel" open>
|
|
460
467
|
<summary>
|
|
461
468
|
<span class="panel-summary-copy">
|
package/public/ai-hub/script.js
CHANGED
|
@@ -14,12 +14,12 @@ const STORAGE_KEY_TREE_COLLAPSED = 'fraim.aiHub.treeSidebarCollapsed.v1';
|
|
|
14
14
|
const TREE_WIDTH_MIN = 176;
|
|
15
15
|
const TREE_WIDTH_MAX = 380;
|
|
16
16
|
const TREE_WIDTH_DEFAULT = 216;
|
|
17
|
-
const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis']);
|
|
17
|
+
const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis', 'create-hub-configured-agent']);
|
|
18
18
|
// Jobs scoped to the Company/Manager area — never shown in the Projects workspace rail.
|
|
19
19
|
// These are the truly area-only jobs. Issue #702: persona jobs (e.g. Ashley's) are NOT
|
|
20
20
|
// listed here — a persona can run a job from a project OR from the Manager tab, and a
|
|
21
21
|
// run's placement is decided per-invocation by conv.invokedArea, not by job id.
|
|
22
|
-
const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
|
|
22
|
+
const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements', 'create-hub-configured-agent']);
|
|
23
23
|
// Issue #702: personas whose home is the MANAGER tab because their work is inherently
|
|
24
24
|
// cross-project and manager-facing (not project work). Per the spec (R1b), Ashley — the
|
|
25
25
|
// AI Executive Assistant — is Manager-scoped. Every other hired persona works inside
|
|
@@ -134,7 +134,9 @@ function gatherElements() {
|
|
|
134
134
|
'hire-notice', 'hire-notice-text', 'hire-notice-link', 'hire-notice-back',
|
|
135
135
|
'job-persona-filter',
|
|
136
136
|
'picked-name', 'picked-desc', 'instructions',
|
|
137
|
-
'employee-select', 'agent-install-panel', '
|
|
137
|
+
'employee-select', 'agent-install-panel', 'cp-agent-install-panel', 'active-employee-select',
|
|
138
|
+
// Issue #1292: "this conversation's agent is unavailable" notice.
|
|
139
|
+
'active-agent-unavailable-note',
|
|
138
140
|
// Issue #347 additions: tracker, totals.
|
|
139
141
|
'tracker', 'tracker-rows', 'tracker-note',
|
|
140
142
|
'totals',
|
|
@@ -4015,21 +4017,99 @@ function clampSummaryText(text, maxChars = 260) {
|
|
|
4015
4017
|
return raw.slice(0, maxChars - 1).trimEnd() + '…';
|
|
4016
4018
|
}
|
|
4017
4019
|
|
|
4020
|
+
// Resolve any agent reference — a configured-agent id, a bare host id, or a
|
|
4021
|
+
// legacy `agentName` — to the configured-agent id that is an actual option
|
|
4022
|
+
// value in the selector. Returns '' when nothing in the roster matches.
|
|
4023
|
+
function normalizeActiveAgentId(agentId) {
|
|
4024
|
+
const raw = String(agentId || '');
|
|
4025
|
+
if (!raw) return '';
|
|
4026
|
+
const agent = configuredAgentForId(raw) || configuredAgentForId(baseHostIdForAgent(raw));
|
|
4027
|
+
return agent ? agent.id : raw;
|
|
4028
|
+
}
|
|
4029
|
+
|
|
4030
|
+
// Issue #1292: the ONLY writer of #active-employee-select.value.
|
|
4031
|
+
// A <select> whose options were just re-appended falls back to its first
|
|
4032
|
+
// option, so any code that rebuilds the list must put the selection back
|
|
4033
|
+
// through here. Returns false when `agentId` is not in the current option
|
|
4034
|
+
// list, so callers decide what to fall back to — the browser's silent
|
|
4035
|
+
// "first option wins" is never an acceptable answer for which agent a
|
|
4036
|
+
// conversation runs on.
|
|
4037
|
+
function setActiveAgentSelectValue(sel, agentId) {
|
|
4038
|
+
const desired = String(agentId || '');
|
|
4039
|
+
const present = Array.prototype.some.call(sel.options, (opt) => opt.value === desired);
|
|
4040
|
+
sel.value = present ? desired : '';
|
|
4041
|
+
return present;
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
// Issue #1292: if the conversation's own agent stops being available, tell the
|
|
4045
|
+
// user. The selector keeps holding that agent either way; this is the visible
|
|
4046
|
+
// half of "never substitute an agent silently".
|
|
4047
|
+
function renderActiveAgentUnavailableNote(conv) {
|
|
4048
|
+
const note = els['active-agent-unavailable-note'];
|
|
4049
|
+
if (!note) return;
|
|
4050
|
+
// No conversation, or one with no recorded agent: nothing to warn about.
|
|
4051
|
+
const convAgentId = normalizeActiveAgentId(conv && conversationAgentName(conv));
|
|
4052
|
+
if (!convAgentId) {
|
|
4053
|
+
note.hidden = true;
|
|
4054
|
+
note.textContent = '';
|
|
4055
|
+
return;
|
|
4056
|
+
}
|
|
4057
|
+
const agent = configuredAgentForId(convAgentId);
|
|
4058
|
+
if (!agent) {
|
|
4059
|
+
// Uninstalled outright — it is not even in the roster any more, so
|
|
4060
|
+
// configuredAgentLabel() can only echo the raw id back. The conversation
|
|
4061
|
+
// kept the label it ran under (foldRunIntoConversation), which is the name
|
|
4062
|
+
// the user actually recognises.
|
|
4063
|
+
const goneLabel = conv.configuredAgentLabel || configuredAgentLabel(convAgentId);
|
|
4064
|
+
note.hidden = false;
|
|
4065
|
+
note.textContent = `${goneLabel} is no longer configured on this machine. This conversation is still bound to it — pick another agent and send to move it.`;
|
|
4066
|
+
return;
|
|
4067
|
+
}
|
|
4068
|
+
if (configuredAgentIsAvailable(convAgentId)) {
|
|
4069
|
+
note.hidden = true;
|
|
4070
|
+
note.textContent = '';
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
4073
|
+
const reason = (Array.isArray(agent.reasons) && agent.reasons[0]) || 'It is not available on this machine.';
|
|
4074
|
+
note.hidden = false;
|
|
4075
|
+
note.textContent = `${agent.label} is unavailable — ${reason} This conversation stays on ${agent.label}; pick another agent and send to move it.`;
|
|
4076
|
+
}
|
|
4077
|
+
|
|
4018
4078
|
// Render the inline employee selector shown in the coach section of an
|
|
4019
4079
|
// active conversation. Allows switching agents without reopening the modal.
|
|
4020
4080
|
//
|
|
4021
|
-
//
|
|
4022
|
-
// 1. sel.value is only reset
|
|
4023
|
-
// every poll tick. This preserves a user's
|
|
4081
|
+
// Three invariants keep agent-switching working correctly:
|
|
4082
|
+
// 1. sel.value is only *reset to the conversation's agent* when the active
|
|
4083
|
+
// conversation changes — not on every poll tick. This preserves a user's
|
|
4084
|
+
// in-progress selection.
|
|
4024
4085
|
// 2. conv.agentName is NOT updated by the change handler, only by the send
|
|
4025
4086
|
// handler after a restart completes. This keeps the comparison in the send
|
|
4026
4087
|
// handler accurate: sel.value !== conv.agentName means "user changed agent".
|
|
4088
|
+
// 3. Issue #1292: rebuilding the option list never changes which agent is
|
|
4089
|
+
// selected. The rebuild is keyed on every agent's available/enabled flags,
|
|
4090
|
+
// so any roster change at all wipes the selection via `innerHTML = ''`.
|
|
4091
|
+
// Invariant 1 used to fence the only code that could put it back, so the
|
|
4092
|
+
// selector silently landed on agents[0] and the next Send read that as a
|
|
4093
|
+
// deliberate switch. The restore below is therefore unconditional: on a
|
|
4094
|
+
// poll tick with nothing changed it is a no-op, and it only ever puts back
|
|
4095
|
+
// what a rebuild dropped.
|
|
4027
4096
|
function renderActiveEmployeeSelect(conv) {
|
|
4028
4097
|
const sel = els['active-employee-select'];
|
|
4029
4098
|
if (!sel) return;
|
|
4030
4099
|
const agents = hubConfiguredAgents();
|
|
4031
4100
|
if (agents.length === 0) { sel.hidden = true; return; }
|
|
4032
4101
|
sel.hidden = false;
|
|
4102
|
+
const convId = (conv && conv.id) || '';
|
|
4103
|
+
const convChanged = sel.dataset.convId !== convId;
|
|
4104
|
+
// The conversation's own agent is the floor: it is what Send dispatches to
|
|
4105
|
+
// unless the user picks something else.
|
|
4106
|
+
const convAgentId = normalizeActiveAgentId(
|
|
4107
|
+
(conv && conversationAgentName(conv)) || state.selectedEmployeeId || 'claude'
|
|
4108
|
+
);
|
|
4109
|
+
// What must survive this render: the conversation's agent on a new
|
|
4110
|
+
// conversation, the current selection on an unchanged one.
|
|
4111
|
+
const desired = convChanged ? convAgentId : (sel.value || convAgentId);
|
|
4112
|
+
|
|
4033
4113
|
// Rebuild options when the configured-agent list changes.
|
|
4034
4114
|
const newKey = agents.map((e) => `${e.id}:${e.available}:${e.enabled}`).join('|');
|
|
4035
4115
|
if (sel.dataset.optionsKey !== newKey) {
|
|
@@ -4043,18 +4123,13 @@ function renderActiveEmployeeSelect(conv) {
|
|
|
4043
4123
|
}
|
|
4044
4124
|
sel.dataset.optionsKey = newKey;
|
|
4045
4125
|
}
|
|
4046
|
-
|
|
4047
|
-
//
|
|
4048
|
-
//
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
sel.dataset.convId = convId;
|
|
4052
|
-
sel.value = (conv && conversationAgentName(conv)) || state.selectedEmployeeId || 'claude';
|
|
4053
|
-
if (!configuredAgentForId(sel.value)) {
|
|
4054
|
-
const legacyDefault = configuredAgentForId(baseHostIdForAgent(sel.value));
|
|
4055
|
-
if (legacyDefault) sel.value = legacyDefault.id;
|
|
4056
|
-
}
|
|
4126
|
+
sel.dataset.convId = convId;
|
|
4127
|
+
// If the user's in-progress pick was uninstalled out from under them, fall
|
|
4128
|
+
// back to the conversation's own agent — never to whatever is first.
|
|
4129
|
+
if (!setActiveAgentSelectValue(sel, desired) && desired !== convAgentId) {
|
|
4130
|
+
setActiveAgentSelectValue(sel, convAgentId);
|
|
4057
4131
|
}
|
|
4132
|
+
renderActiveAgentUnavailableNote(conv);
|
|
4058
4133
|
}
|
|
4059
4134
|
|
|
4060
4135
|
// Issue #347 R1 — render the pizza tracker. Reads conv.run.stages and
|
|
@@ -5089,6 +5164,7 @@ function scrollThreadAfterViewportSync(conv, shouldScrollForUpdate, forceBottom)
|
|
|
5089
5164
|
const host = els['messages'];
|
|
5090
5165
|
if (!host) return;
|
|
5091
5166
|
if (latest.status === 'running') {
|
|
5167
|
+
if (!shouldScrollForUpdate && !forceBottom) return;
|
|
5092
5168
|
// #936: re-evaluate nearBottom at call time so deferred invocations respect
|
|
5093
5169
|
// any scroll the user made between the render tick and this callback.
|
|
5094
5170
|
const nearBottom = host.scrollHeight - host.scrollTop - host.clientHeight < 80;
|
|
@@ -7130,16 +7206,19 @@ function buildCpRow(row, flatIndex) {
|
|
|
7130
7206
|
body.appendChild(sub);
|
|
7131
7207
|
}
|
|
7132
7208
|
|
|
7209
|
+
el.appendChild(icon);
|
|
7210
|
+
el.appendChild(body);
|
|
7211
|
+
// #1340: this is the actual "+ Delegate Job" catalog (openModal → openPalette →
|
|
7212
|
+
// renderCpRows → buildCpRow) — the row never had a visualize affordance at all.
|
|
7213
|
+
// Teach rows carry a synthetic, non-catalog job object, so skip them.
|
|
7214
|
+
if (row.type !== 'teach') {
|
|
7215
|
+
el.appendChild(tfCreateJobVizControl(row.job));
|
|
7216
|
+
}
|
|
7133
7217
|
if (row.job.requiredPersonaKey) {
|
|
7134
7218
|
const tag = document.createElement('span');
|
|
7135
7219
|
tag.className = 'cp-row-tag';
|
|
7136
7220
|
tag.textContent = row.job.requiredPersonaKey;
|
|
7137
|
-
el.appendChild(icon);
|
|
7138
|
-
el.appendChild(body);
|
|
7139
7221
|
el.appendChild(tag);
|
|
7140
|
-
} else {
|
|
7141
|
-
el.appendChild(icon);
|
|
7142
|
-
el.appendChild(body);
|
|
7143
7222
|
}
|
|
7144
7223
|
|
|
7145
7224
|
el.addEventListener('click', () => selectCpRow(flatIndex));
|
|
@@ -7375,7 +7454,17 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
7375
7454
|
strong.textContent = job.title;
|
|
7376
7455
|
const span = document.createElement('span');
|
|
7377
7456
|
span.textContent = job.intent || '';
|
|
7378
|
-
|
|
7457
|
+
// #1340: the viz button used to sit in the row's trailing grid column,
|
|
7458
|
+
// where unwrapped description text pushed it off-screen. Anchoring it
|
|
7459
|
+
// next to the title (which never overflows) keeps it visible.
|
|
7460
|
+
const titleRow = document.createElement('span');
|
|
7461
|
+
titleRow.className = 'job-option-title-row';
|
|
7462
|
+
titleRow.appendChild(strong);
|
|
7463
|
+
// Issue #1278 R1/R2/R14: skip ad-hoc row, stop propagation so job is not selected.
|
|
7464
|
+
if (job.id !== '__freeform__') {
|
|
7465
|
+
titleRow.appendChild(tfCreateJobVizButton(job));
|
|
7466
|
+
}
|
|
7467
|
+
btn.appendChild(titleRow);
|
|
7379
7468
|
btn.appendChild(span);
|
|
7380
7469
|
if (isLocked) {
|
|
7381
7470
|
// R3.1: inline lock badge showing persona display name.
|
|
@@ -7385,10 +7474,6 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
7385
7474
|
lockBadge.textContent = `🔒 ${persona ? persona.displayName : job.requiredPersonaKey}`;
|
|
7386
7475
|
btn.appendChild(lockBadge);
|
|
7387
7476
|
}
|
|
7388
|
-
// Issue #1278 R1/R2/R14: skip ad-hoc row, stop propagation so job is not selected.
|
|
7389
|
-
if (job.id !== '__freeform__') {
|
|
7390
|
-
btn.appendChild(tfCreateJobVizButton(job));
|
|
7391
|
-
}
|
|
7392
7477
|
btn.addEventListener('click', () => {
|
|
7393
7478
|
if (isLocked) {
|
|
7394
7479
|
// Issue #540 R10: locked jobs are now non-blocking. Allow selection and proceed
|
|
@@ -7893,6 +7978,27 @@ async function restartConvWithAgent(conv, newAgentId, text) {
|
|
|
7893
7978
|
}
|
|
7894
7979
|
}
|
|
7895
7980
|
|
|
7981
|
+
// Issue #1292: which agent a continue/resume/auto-restart dispatches to.
|
|
7982
|
+
// These paths used to read `conversationAgentName(conv) || state.selectedEmployeeId
|
|
7983
|
+
// || 'claude'`, and applyBootstrap reassigns state.selectedEmployeeId to the
|
|
7984
|
+
// first available agent whenever the preferred one goes unavailable — so a Hub
|
|
7985
|
+
// restart could quietly re-bind a conversation to a different agent with no
|
|
7986
|
+
// agentSwitches entry and no message. The conversation's own recorded identity
|
|
7987
|
+
// wins; the fallback only applies when nothing was ever recorded, and it says so.
|
|
7988
|
+
function conversationDispatchAgentId(conv) {
|
|
7989
|
+
const recorded = conversationAgentName(conv) || (conv && conv.baseHostId) || '';
|
|
7990
|
+
if (recorded) return normalizeActiveAgentId(recorded);
|
|
7991
|
+
const fallback = normalizeActiveAgentId(state.selectedEmployeeId || 'claude');
|
|
7992
|
+
if (conv) {
|
|
7993
|
+
if (!Array.isArray(conv.events)) conv.events = [];
|
|
7994
|
+
conv.events.push({
|
|
7995
|
+
channel: 'system',
|
|
7996
|
+
text: `No agent was recorded for this conversation; continuing on ${configuredAgentLabel(fallback)}.`,
|
|
7997
|
+
});
|
|
7998
|
+
}
|
|
7999
|
+
return fallback;
|
|
8000
|
+
}
|
|
8001
|
+
|
|
7896
8002
|
async function continueRun(text, options) {
|
|
7897
8003
|
const conv = activeConversation();
|
|
7898
8004
|
if (!conv || !conv.runId) return;
|
|
@@ -7931,6 +8037,9 @@ async function continueRun(text, options) {
|
|
|
7931
8037
|
});
|
|
7932
8038
|
} catch (e) {
|
|
7933
8039
|
const isNotFound = /not found/i.test((e && e.message) || '');
|
|
8040
|
+
// Resolved once: both recovery paths below dispatch to the same agent,
|
|
8041
|
+
// and the helper emits a system event on the never-recorded fallback.
|
|
8042
|
+
const dispatchAgentId = conversationDispatchAgentId(conv);
|
|
7934
8043
|
// #521: the Hub run is in-memory and is lost on a server restart, but the
|
|
7935
8044
|
// agent session persists on disk. If the run is gone and we have a sessionId,
|
|
7936
8045
|
// resume the conversation rather than failing — carries it forward intact.
|
|
@@ -7943,8 +8052,8 @@ async function continueRun(text, options) {
|
|
|
7943
8052
|
// Issue #892: carry the scope so a project-independent onboarding resumes
|
|
7944
8053
|
// with no project (e.g. after a Hub restart).
|
|
7945
8054
|
scope: convScope(conv),
|
|
7946
|
-
hostId: baseHostIdForAgent(
|
|
7947
|
-
configuredAgentId:
|
|
8055
|
+
hostId: baseHostIdForAgent(dispatchAgentId),
|
|
8056
|
+
configuredAgentId: dispatchAgentId,
|
|
7948
8057
|
jobId: conv.jobId,
|
|
7949
8058
|
jobTitle: conv.jobTitle || conv.jobId,
|
|
7950
8059
|
conversationId: conv.id,
|
|
@@ -7964,8 +8073,8 @@ async function continueRun(text, options) {
|
|
|
7964
8073
|
headers: { 'Content-Type': 'application/json' },
|
|
7965
8074
|
body: JSON.stringify({
|
|
7966
8075
|
projectPath: state.projectPath,
|
|
7967
|
-
hostId: baseHostIdForAgent(
|
|
7968
|
-
configuredAgentId:
|
|
8076
|
+
hostId: baseHostIdForAgent(dispatchAgentId),
|
|
8077
|
+
configuredAgentId: dispatchAgentId,
|
|
7969
8078
|
jobId: conv.jobId,
|
|
7970
8079
|
jobTitle: conv.jobTitle || conv.jobId,
|
|
7971
8080
|
conversationId: conv.id,
|
|
@@ -8495,65 +8604,70 @@ function renderConfiguredAgentsPanel() {
|
|
|
8495
8604
|
const agents = hubConfiguredAgents();
|
|
8496
8605
|
panel.innerHTML = '';
|
|
8497
8606
|
panel.className = 'configured-agents-panel';
|
|
8498
|
-
|
|
8607
|
+
|
|
8499
8608
|
const toolbar = document.createElement('div');
|
|
8500
8609
|
toolbar.className = 'configured-agent-toolbar';
|
|
8501
8610
|
const add = document.createElement('button');
|
|
8502
8611
|
add.type = 'button';
|
|
8503
8612
|
add.className = 'configured-agent-action primary';
|
|
8504
|
-
add.textContent = 'Add agent';
|
|
8613
|
+
add.textContent = 'Add AI agent';
|
|
8614
|
+
add.dataset.testid = 'add-ai-agent-btn';
|
|
8505
8615
|
add.addEventListener('click', () => {
|
|
8506
|
-
|
|
8507
|
-
|
|
8616
|
+
tfOpenAreaOnboardModal(
|
|
8617
|
+
'create-hub-configured-agent',
|
|
8618
|
+
'Set up a new Hub AI agent: configure a CLI, cloud-credit route, or custom profile.',
|
|
8619
|
+
'manager'
|
|
8620
|
+
);
|
|
8508
8621
|
});
|
|
8509
8622
|
toolbar.appendChild(add);
|
|
8510
8623
|
panel.appendChild(toolbar);
|
|
8624
|
+
|
|
8511
8625
|
if (state.configuredAgentEditingId) {
|
|
8512
8626
|
panel.appendChild(buildConfiguredAgentForm(agents.find((agent) => agent.id === state.configuredAgentEditingId) || null));
|
|
8513
8627
|
}
|
|
8628
|
+
|
|
8514
8629
|
if (!agents.length) {
|
|
8515
8630
|
const empty = document.createElement('p');
|
|
8516
8631
|
empty.className = 'configured-agents-empty';
|
|
8517
8632
|
empty.textContent = 'No AI agents are available on this machine.';
|
|
8518
8633
|
panel.appendChild(empty);
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
8532
|
-
|
|
8533
|
-
|
|
8534
|
-
|
|
8535
|
-
|
|
8536
|
-
|
|
8537
|
-
|
|
8634
|
+
} else {
|
|
8635
|
+
for (const agent of agents) {
|
|
8636
|
+
const latestCheck = state.configuredAgentCheckResults[agent.id] || null;
|
|
8637
|
+
const renderedAvailable = latestCheck ? latestCheck.available !== false : agent.available !== false;
|
|
8638
|
+
const renderedEnabled = agent.enabled !== false;
|
|
8639
|
+
const renderedReasons = latestCheck?.reasons || agent.reasons || [];
|
|
8640
|
+
const card = document.createElement('div');
|
|
8641
|
+
card.className = 'configured-agent-card';
|
|
8642
|
+
card.dataset.testid = 'configured-agent-card';
|
|
8643
|
+
const head = document.createElement('div');
|
|
8644
|
+
head.className = 'configured-agent-head';
|
|
8645
|
+
const title = document.createElement('strong');
|
|
8646
|
+
title.textContent = agent.label;
|
|
8647
|
+
const status = document.createElement('span');
|
|
8648
|
+
status.className = 'configured-agent-status ' + (renderedAvailable && renderedEnabled ? 'ok' : 'warn');
|
|
8649
|
+
status.textContent = renderedAvailable && renderedEnabled ? 'Ready' : 'Check setup';
|
|
8650
|
+
head.appendChild(title);
|
|
8651
|
+
head.appendChild(status);
|
|
8652
|
+
|
|
8653
|
+
const actions = document.createElement('div');
|
|
8654
|
+
actions.className = 'configured-agent-card-actions';
|
|
8655
|
+
const check = document.createElement('button');
|
|
8656
|
+
check.type = 'button';
|
|
8657
|
+
check.className = 'configured-agent-icon-action';
|
|
8658
|
+
check.textContent = 'Check';
|
|
8659
|
+
check.addEventListener('click', async () => {
|
|
8660
|
+
try {
|
|
8661
|
+
const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
|
|
8662
|
+
state.configuredAgentCheckResults[agent.id] = result;
|
|
8663
|
+
showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
|
|
8664
|
+
renderConfiguredAgentsPanel();
|
|
8665
|
+
} catch (err) {
|
|
8666
|
+
showStatus(err.message || 'Agent check failed.', true);
|
|
8667
|
+
}
|
|
8668
|
+
});
|
|
8669
|
+
actions.appendChild(check);
|
|
8538
8670
|
|
|
8539
|
-
const actions = document.createElement('div');
|
|
8540
|
-
actions.className = 'configured-agent-card-actions';
|
|
8541
|
-
const check = document.createElement('button');
|
|
8542
|
-
check.type = 'button';
|
|
8543
|
-
check.className = 'configured-agent-icon-action';
|
|
8544
|
-
check.textContent = 'Check';
|
|
8545
|
-
check.addEventListener('click', async () => {
|
|
8546
|
-
try {
|
|
8547
|
-
const result = await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}/check`, { method: 'POST' });
|
|
8548
|
-
state.configuredAgentCheckResults[agent.id] = result;
|
|
8549
|
-
showStatus(result.available ? `${agent.label} is ready.` : `${agent.label}: ${(result.reasons || []).join(' ') || 'Check setup.'}`, !result.available);
|
|
8550
|
-
renderConfiguredAgentsPanel();
|
|
8551
|
-
} catch (err) {
|
|
8552
|
-
showStatus(err.message || 'Agent check failed.', true);
|
|
8553
|
-
}
|
|
8554
|
-
});
|
|
8555
|
-
actions.appendChild(check);
|
|
8556
|
-
if (!agent.id.endsWith('-default')) {
|
|
8557
8671
|
const edit = document.createElement('button');
|
|
8558
8672
|
edit.type = 'button';
|
|
8559
8673
|
edit.className = 'configured-agent-icon-action';
|
|
@@ -8562,42 +8676,83 @@ function renderConfiguredAgentsPanel() {
|
|
|
8562
8676
|
state.configuredAgentEditingId = agent.id;
|
|
8563
8677
|
renderConfiguredAgentsPanel();
|
|
8564
8678
|
});
|
|
8565
|
-
const del = document.createElement('button');
|
|
8566
|
-
del.type = 'button';
|
|
8567
|
-
del.className = 'configured-agent-icon-action danger';
|
|
8568
|
-
del.textContent = 'Delete';
|
|
8569
|
-
del.addEventListener('click', async () => {
|
|
8570
|
-
try {
|
|
8571
|
-
await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}`, { method: 'DELETE' });
|
|
8572
|
-
delete state.configuredAgentCheckResults[agent.id];
|
|
8573
|
-
if (state.configuredAgentEditingId === agent.id) state.configuredAgentEditingId = null;
|
|
8574
|
-
await refreshConfiguredAgents();
|
|
8575
|
-
} catch (err) {
|
|
8576
|
-
showStatus(err.message || 'Delete failed.', true);
|
|
8577
|
-
}
|
|
8578
|
-
});
|
|
8579
8679
|
actions.appendChild(edit);
|
|
8580
|
-
actions.appendChild(del);
|
|
8581
|
-
}
|
|
8582
|
-
head.appendChild(actions);
|
|
8583
8680
|
|
|
8584
|
-
|
|
8585
|
-
|
|
8586
|
-
|
|
8587
|
-
|
|
8588
|
-
|
|
8589
|
-
|
|
8681
|
+
if (!agent.id.endsWith('-default')) {
|
|
8682
|
+
const del = document.createElement('button');
|
|
8683
|
+
del.type = 'button';
|
|
8684
|
+
del.className = 'configured-agent-icon-action danger';
|
|
8685
|
+
del.textContent = 'Delete';
|
|
8686
|
+
del.addEventListener('click', async () => {
|
|
8687
|
+
try {
|
|
8688
|
+
await requestJson(`/api/ai-hub/configured-agents/${encodeURIComponent(agent.id)}`, { method: 'DELETE' });
|
|
8689
|
+
delete state.configuredAgentCheckResults[agent.id];
|
|
8690
|
+
if (state.configuredAgentEditingId === agent.id) state.configuredAgentEditingId = null;
|
|
8691
|
+
await refreshConfiguredAgents();
|
|
8692
|
+
} catch (err) {
|
|
8693
|
+
showStatus(err.message || 'Delete failed.', true);
|
|
8694
|
+
}
|
|
8695
|
+
});
|
|
8696
|
+
actions.appendChild(del);
|
|
8697
|
+
}
|
|
8590
8698
|
|
|
8591
|
-
|
|
8592
|
-
detail.className = 'configured-agent-detail';
|
|
8593
|
-
detail.textContent = (latestCheck && renderedReasons.length)
|
|
8594
|
-
? renderedReasons.join(' ')
|
|
8595
|
-
: (agent.description || (renderedReasons.join(' ') || 'Uses local Hub launch settings.'));
|
|
8699
|
+
head.appendChild(actions);
|
|
8596
8700
|
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8701
|
+
const meta = document.createElement('div');
|
|
8702
|
+
meta.className = 'configured-agent-meta';
|
|
8703
|
+
const parts = [`CLI: ${agent.baseHostId}`];
|
|
8704
|
+
if (agent.command?.command) parts.push(`command: ${agent.command.command}`);
|
|
8705
|
+
else if (agent.setupScript?.command) parts.push(`setup: ${agent.setupScript.command}`);
|
|
8706
|
+
meta.textContent = parts.join(' | ');
|
|
8707
|
+
|
|
8708
|
+
const detail = document.createElement('div');
|
|
8709
|
+
detail.className = 'configured-agent-detail';
|
|
8710
|
+
detail.textContent = (latestCheck && renderedReasons.length)
|
|
8711
|
+
? renderedReasons.join(' ')
|
|
8712
|
+
: (agent.description || (renderedReasons.join(' ') || 'Uses local Hub launch settings.'));
|
|
8713
|
+
|
|
8714
|
+
card.appendChild(head);
|
|
8715
|
+
card.appendChild(meta);
|
|
8716
|
+
card.appendChild(detail);
|
|
8717
|
+
panel.appendChild(card);
|
|
8718
|
+
}
|
|
8719
|
+
}
|
|
8720
|
+
|
|
8721
|
+
const uninstalled = hubEmployees().filter((e) => !e.available);
|
|
8722
|
+
if (uninstalled.length) {
|
|
8723
|
+
const disclosure = document.createElement('details');
|
|
8724
|
+
disclosure.className = 'configured-agents-setup-disclosure';
|
|
8725
|
+
disclosure.dataset.testid = 'setup-another-tool';
|
|
8726
|
+
const summary = document.createElement('summary');
|
|
8727
|
+
summary.textContent = 'Set up another tool';
|
|
8728
|
+
disclosure.appendChild(summary);
|
|
8729
|
+
for (const emp of uninstalled) {
|
|
8730
|
+
const row = document.createElement('div');
|
|
8731
|
+
row.className = 'install-row';
|
|
8732
|
+
const label = document.createElement('span');
|
|
8733
|
+
label.className = 'install-label';
|
|
8734
|
+
label.textContent = emp.label;
|
|
8735
|
+
const empDetail = document.createElement('span');
|
|
8736
|
+
empDetail.className = 'install-status';
|
|
8737
|
+
empDetail.textContent = emp.detail || 'Not installed';
|
|
8738
|
+
const btn = document.createElement('button');
|
|
8739
|
+
btn.type = 'button';
|
|
8740
|
+
btn.className = 'small';
|
|
8741
|
+
btn.textContent = 'Set up';
|
|
8742
|
+
btn.dataset.testid = `hub-agent-install-${emp.id}`;
|
|
8743
|
+
btn.addEventListener('click', () => {
|
|
8744
|
+
tfOpenAreaOnboardModal(
|
|
8745
|
+
'create-hub-configured-agent',
|
|
8746
|
+
`Set up ${emp.label}: install the CLI, sign in, and configure it as a Hub agent.`,
|
|
8747
|
+
'manager'
|
|
8748
|
+
);
|
|
8749
|
+
});
|
|
8750
|
+
row.appendChild(label);
|
|
8751
|
+
row.appendChild(empDetail);
|
|
8752
|
+
row.appendChild(btn);
|
|
8753
|
+
disclosure.appendChild(row);
|
|
8754
|
+
}
|
|
8755
|
+
panel.appendChild(disclosure);
|
|
8601
8756
|
}
|
|
8602
8757
|
}
|
|
8603
8758
|
|
|
@@ -9175,8 +9330,15 @@ function wireEvents() {
|
|
|
9175
9330
|
// If the user changed the agent via the inline selector, restart with the
|
|
9176
9331
|
// new agent (new run) instead of continuing on the old one. This lets them
|
|
9177
9332
|
// recover from a failed run by switching agents without opening the modal.
|
|
9178
|
-
|
|
9179
|
-
|
|
9333
|
+
// Issue #1292: normalize both sides before comparing. An empty selection
|
|
9334
|
+
// means the selector lost its value (its agent was uninstalled), which is
|
|
9335
|
+
// not a user action and must never be read as a switch. A legacy
|
|
9336
|
+
// conv.agentName of 'claude' against a selector value of 'claude-default'
|
|
9337
|
+
// is not a switch either.
|
|
9338
|
+
const chosenAgent = normalizeActiveAgentId(
|
|
9339
|
+
els['active-employee-select'] && els['active-employee-select'].value
|
|
9340
|
+
);
|
|
9341
|
+
if (conv && chosenAgent && chosenAgent !== normalizeActiveAgentId(conversationAgentName(conv))) {
|
|
9180
9342
|
state.selectedEmployeeId = chosenAgent;
|
|
9181
9343
|
await restartConvWithAgent(conv, chosenAgent, text);
|
|
9182
9344
|
} else {
|
|
@@ -14413,9 +14575,13 @@ function tfApplyOrgBrand(brand) {
|
|
|
14413
14575
|
var divEl = document.getElementById('hub-brand-divider');
|
|
14414
14576
|
var coEl = document.getElementById('hub-cobrand');
|
|
14415
14577
|
var show = !!(state.orgBrand && (state.orgBrand.name || state.orgBrand.logo));
|
|
14578
|
+
// #1340: with no customer org brand configured, the top-left slot shows
|
|
14579
|
+
// FRAIM's own identity by default instead of staying empty. A configured
|
|
14580
|
+
// customer brand replaces it, same as before; the "powered by FRAIM"
|
|
14581
|
+
// co-mark then appears alongside it in the top-right (below).
|
|
14416
14582
|
if (brandEl) {
|
|
14417
|
-
|
|
14418
|
-
brandEl.hidden =
|
|
14583
|
+
tfFillBrandLockup(brandEl, show ? state.orgBrand : { name: 'FRAIM Hub', logo: FRAIM_COMARK_SVG });
|
|
14584
|
+
brandEl.hidden = false;
|
|
14419
14585
|
}
|
|
14420
14586
|
if (divEl) divEl.hidden = !show;
|
|
14421
14587
|
if (coEl) {
|
|
@@ -14640,6 +14806,11 @@ const AREA_ONBOARD_CONFIG = {
|
|
|
14640
14806
|
title: 'Manager: operational reporting',
|
|
14641
14807
|
desc: 'FRAIM produces your weekly operating, portfolio impact, or stakeholder status report. Add any specific focus areas below.',
|
|
14642
14808
|
},
|
|
14809
|
+
// Issue #1341: agent setup launched from the "Add AI agent" button or "Set up" row.
|
|
14810
|
+
'create-hub-configured-agent': {
|
|
14811
|
+
title: 'Add AI agent',
|
|
14812
|
+
desc: 'FRAIM will help you configure a CLI, cloud-credit route, or custom profile as a Hub agent. Add any specific details below.',
|
|
14813
|
+
},
|
|
14643
14814
|
};
|
|
14644
14815
|
|
|
14645
14816
|
function tfOpenAreaOnboardModal(jobId, baseMessage, area) {
|
|
@@ -15203,11 +15374,23 @@ function tfCloseAssignJob() {
|
|
|
15203
15374
|
if (m) m.hidden = true;
|
|
15204
15375
|
}
|
|
15205
15376
|
|
|
15377
|
+
// #1340: a distinct flow/sitemap glyph, not "ⓘ" — that glyph is already the
|
|
15378
|
+
// app's convention for the "What is AI Manager?" concept-explainer buttons
|
|
15379
|
+
// (.welcome .info), so reusing it here read as "explain this job" rather
|
|
15380
|
+
// than "visualize this job's flow".
|
|
15381
|
+
const JOB_VIZ_ICON_SVG = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">'
|
|
15382
|
+
+ '<circle cx="4" cy="3.5" r="2" fill="currentColor"/>'
|
|
15383
|
+
+ '<circle cx="12" cy="3.5" r="2" fill="currentColor"/>'
|
|
15384
|
+
+ '<circle cx="8" cy="13" r="2" fill="currentColor"/>'
|
|
15385
|
+
+ '<path d="M4 5.5v2a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2M8 8.5v2.5"'
|
|
15386
|
+
+ ' stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>'
|
|
15387
|
+
+ '</svg>';
|
|
15388
|
+
|
|
15206
15389
|
function tfCreateJobVizButton(job) {
|
|
15207
15390
|
const vizBtn = document.createElement('button');
|
|
15208
15391
|
vizBtn.type = 'button';
|
|
15209
15392
|
vizBtn.className = 'job-viz-btn';
|
|
15210
|
-
vizBtn.
|
|
15393
|
+
vizBtn.innerHTML = JOB_VIZ_ICON_SVG;
|
|
15211
15394
|
vizBtn.setAttribute('aria-label', `Visualize ${job.title || job.id}`);
|
|
15212
15395
|
vizBtn.title = `Visualize ${job.title || job.id}`;
|
|
15213
15396
|
vizBtn.addEventListener('click', (e) => {
|
|
@@ -15218,6 +15401,30 @@ function tfCreateJobVizButton(job) {
|
|
|
15218
15401
|
return vizBtn;
|
|
15219
15402
|
}
|
|
15220
15403
|
|
|
15404
|
+
// #1340: buildCpRow's row is itself a <button> (click/keyboard job selection), so the
|
|
15405
|
+
// viz affordance there can't be a nested <button> — invalid HTML, and the browser's
|
|
15406
|
+
// own click/keydown handling for nested interactive controls is inconsistent. A span
|
|
15407
|
+
// with explicit button semantics gets the same click/keyboard behavior safely.
|
|
15408
|
+
function tfCreateJobVizControl(job) {
|
|
15409
|
+
const vizBtn = document.createElement('span');
|
|
15410
|
+
vizBtn.className = 'job-viz-btn';
|
|
15411
|
+
vizBtn.setAttribute('role', 'button');
|
|
15412
|
+
vizBtn.tabIndex = 0;
|
|
15413
|
+
vizBtn.innerHTML = JOB_VIZ_ICON_SVG;
|
|
15414
|
+
vizBtn.setAttribute('aria-label', `Visualize ${job.title || job.id}`);
|
|
15415
|
+
vizBtn.title = `Visualize ${job.title || job.id}`;
|
|
15416
|
+
const open = (e) => {
|
|
15417
|
+
e.stopPropagation();
|
|
15418
|
+
e.preventDefault();
|
|
15419
|
+
tfOpenJobViz(job, { returnToAssignJob: false });
|
|
15420
|
+
};
|
|
15421
|
+
vizBtn.addEventListener('click', open);
|
|
15422
|
+
vizBtn.addEventListener('keydown', (e) => {
|
|
15423
|
+
if (e.key === 'Enter' || e.key === ' ') open(e);
|
|
15424
|
+
});
|
|
15425
|
+
return vizBtn;
|
|
15426
|
+
}
|
|
15427
|
+
|
|
15221
15428
|
// Issue #1278 — Job Visualization Modal
|
|
15222
15429
|
function tfOpenJobViz(job, options) {
|
|
15223
15430
|
const modal = document.getElementById('job-viz-modal');
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -1946,6 +1946,23 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
1946
1946
|
font-size: 11px;
|
|
1947
1947
|
line-height: 1.4;
|
|
1948
1948
|
}
|
|
1949
|
+
/* Issue #1292: the conversation's agent is unavailable. Reads as a warning
|
|
1950
|
+
rather than a muted aside — the alternative the user must not fall back on
|
|
1951
|
+
is "notice by eye that the wrong agent is selected".
|
|
1952
|
+
The warning tone is carried by the tinted background and the --warn rule,
|
|
1953
|
+
not by the text colour: --warn on --warn-soft measures 3.1:1, under the
|
|
1954
|
+
4.5:1 WCAG AA floor for 12px body text. --text on the same background
|
|
1955
|
+
clears it comfortably in both themes. */
|
|
1956
|
+
.agent-unavailable-note {
|
|
1957
|
+
margin-bottom: 8px;
|
|
1958
|
+
padding: 8px 10px;
|
|
1959
|
+
border-radius: 8px;
|
|
1960
|
+
border-left: 3px solid var(--warn);
|
|
1961
|
+
background: var(--warn-soft);
|
|
1962
|
+
color: var(--text);
|
|
1963
|
+
font-size: 12px;
|
|
1964
|
+
line-height: 1.4;
|
|
1965
|
+
}
|
|
1949
1966
|
.coach-note.pending-job {
|
|
1950
1967
|
color: var(--text);
|
|
1951
1968
|
font-weight: 600;
|
|
@@ -2309,16 +2326,6 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
2309
2326
|
|
|
2310
2327
|
/* Agent install panel */
|
|
2311
2328
|
#agent-install-panel { margin-top: 10px; }
|
|
2312
|
-
.hub-agent-setup-panel {
|
|
2313
|
-
width: min(560px, 100%);
|
|
2314
|
-
margin: 16px 0;
|
|
2315
|
-
padding: 14px;
|
|
2316
|
-
border: 1px solid var(--line);
|
|
2317
|
-
border-radius: 8px;
|
|
2318
|
-
background: var(--surface);
|
|
2319
|
-
box-shadow: var(--shadow);
|
|
2320
|
-
text-align: left;
|
|
2321
|
-
}
|
|
2322
2329
|
.cp-agent-install-panel {
|
|
2323
2330
|
margin: 0 14px 12px;
|
|
2324
2331
|
padding: 12px;
|
|
@@ -2349,6 +2356,21 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
|
|
|
2349
2356
|
.install-label { font-weight: 500; min-width: 90px; }
|
|
2350
2357
|
.install-status { color: var(--muted); flex: 1; font-size: 12px; }
|
|
2351
2358
|
button.small { padding: 4px 10px; font-size: 12px; }
|
|
2359
|
+
.configured-agents-setup-disclosure {
|
|
2360
|
+
margin-top: 12px;
|
|
2361
|
+
border-top: 1px solid var(--line);
|
|
2362
|
+
padding-top: 8px;
|
|
2363
|
+
}
|
|
2364
|
+
.configured-agents-setup-disclosure > summary {
|
|
2365
|
+
font-size: 12px;
|
|
2366
|
+
font-weight: 600;
|
|
2367
|
+
color: var(--muted);
|
|
2368
|
+
cursor: pointer;
|
|
2369
|
+
list-style: none;
|
|
2370
|
+
user-select: none;
|
|
2371
|
+
}
|
|
2372
|
+
.configured-agents-setup-disclosure > summary::before { content: '▸ '; }
|
|
2373
|
+
.configured-agents-setup-disclosure[open] > summary::before { content: '▾ '; }
|
|
2352
2374
|
|
|
2353
2375
|
@media (max-width: 820px) {
|
|
2354
2376
|
/* Single-column reflow — the rigid 100vh layout doesn't make sense at
|
|
@@ -5536,8 +5558,13 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
5536
5558
|
.cp-row:hover, .cp-row--highlighted { background:var(--accent-soft,rgba(0,113,227,.08)); }
|
|
5537
5559
|
.cp-row-icon { font-size:15px; flex-shrink:0; }
|
|
5538
5560
|
.cp-row-body { flex:1; min-width:0; }
|
|
5539
|
-
|
|
5540
|
-
|
|
5561
|
+
/* #1340: block-level so overflow/ellipsis actually applies (a plain inline span never
|
|
5562
|
+
establishes its own overflow box, so a long title/description used to run on past the
|
|
5563
|
+
row and visually collide with whatever came after it, including the new viz control
|
|
5564
|
+
below). .tjd-mimic-row already carried this same fix locally; this closes the gap for
|
|
5565
|
+
the default row too. */
|
|
5566
|
+
.cp-row-title { display:block; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
5567
|
+
.cp-row-sub { display:block; font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
|
|
5541
5568
|
.cp-row-tag { font-size:10px; color:var(--muted); background:var(--bg); border:1px solid var(--line); border-radius:4px; padding:1px 5px; flex-shrink:0; }
|
|
5542
5569
|
.cp-instructions-row { border-top:1px solid var(--line); padding:12px 14px; display:flex; flex-direction:column; gap:8px; }
|
|
5543
5570
|
.cp-instructions-row[hidden] { display:none; }
|
|
@@ -6422,24 +6449,27 @@ img.eh-av { object-fit: cover; background: var(--surface); }
|
|
|
6422
6449
|
.jv-popover-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-bottom: 2px; }
|
|
6423
6450
|
.jv-skill-wrap { display: inline-flex; }
|
|
6424
6451
|
.jv-loading { color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center; }
|
|
6425
|
-
/*
|
|
6452
|
+
/* Job visualization button — Delegate Job catalog rows and the assign-job picker (#1340: anchored
|
|
6453
|
+
next to the title instead of the row's trailing grid column, which unwrapped description text
|
|
6454
|
+
could push off-screen). */
|
|
6426
6455
|
.job-option { position: relative; display: grid; grid-template-columns: 1fr auto; align-items: start; gap: 2px 6px; }
|
|
6427
|
-
.job-option
|
|
6456
|
+
.job-option-title-row { grid-column: 1; display: inline-flex; align-items: center; gap: 4px; min-width: 0; }
|
|
6428
6457
|
.job-option > span:not(.lock-badge) { grid-column: 1; }
|
|
6429
6458
|
.job-viz-btn {
|
|
6430
|
-
grid-column: 2; grid-row: 1;
|
|
6431
6459
|
background: transparent; border: none; padding: 2px 4px;
|
|
6432
|
-
color: var(--muted);
|
|
6460
|
+
color: var(--muted); cursor: pointer;
|
|
6433
6461
|
border-radius: 4px; line-height: 1;
|
|
6462
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
6463
|
+
flex-shrink: 0;
|
|
6434
6464
|
transition: background 120ms, color 120ms;
|
|
6435
6465
|
}
|
|
6466
|
+
.job-viz-btn svg { display: block; width: 12px; height: 12px; }
|
|
6436
6467
|
.job-viz-btn:hover { background: var(--soft); color: var(--accent); }
|
|
6437
6468
|
.job-viz-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
6438
6469
|
.job-row .job-viz-btn {
|
|
6439
|
-
grid-column: auto; grid-row: auto;
|
|
6440
6470
|
flex-shrink: 0; width: 26px; height: 26px;
|
|
6441
6471
|
display: inline-flex; align-items: center; justify-content: center;
|
|
6442
6472
|
border: 1px solid var(--line); border-radius: 50%;
|
|
6443
|
-
font-weight: 700;
|
|
6444
6473
|
}
|
|
6474
|
+
.job-row .job-viz-btn svg { width: 15px; height: 15px; }
|
|
6445
6475
|
/* ─── End Issue #1278 ────────────────────────────────────────────────────── */
|