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.
@@ -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
- return readReviewHandoffCandidate(evidence.reviewHandoff);
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
- return readNextJobRecommendationsCandidate(evidence.nextJobRecommendations);
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.280",
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.280",
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",
@@ -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
- <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Crect width='48' height='48' rx='10' fill='%233d8a6e'/%3E%3Ctext x='24' y='32' text-anchor='middle' font-family='Helvetica,Arial,sans-serif' font-size='22' font-weight='700' fill='white'%3EH%3C/text%3E%3C/svg%3E">
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">
@@ -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', 'hub-agent-setup-panel', 'cp-agent-install-panel', 'active-employee-select',
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
- // Two invariants keep agent-switching working correctly:
4022
- // 1. sel.value is only reset when the active conversation changes not on
4023
- // every poll tick. This preserves a user's in-progress selection.
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
- // Only reset the selected value when the conversation itself changes.
4047
- // Never reset it during normal poll ticks that would clobber the user's
4048
- // in-progress agent selection before they click Send.
4049
- const convId = (conv && conv.id) || '';
4050
- if (sel.dataset.convId !== convId) {
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
- btn.appendChild(strong);
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(conversationAgentName(conv) || state.selectedEmployeeId || 'claude'),
7947
- configuredAgentId: conversationAgentName(conv) || state.selectedEmployeeId || 'claude',
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(conversationAgentName(conv) || state.selectedEmployeeId || 'claude'),
7968
- configuredAgentId: conversationAgentName(conv) || state.selectedEmployeeId || 'claude',
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
- buildCliAgentCards(hubEmployees()).forEach((card) => panel.appendChild(card));
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
- state.configuredAgentEditingId = '__new__';
8507
- renderConfiguredAgentsPanel();
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
- return;
8520
- }
8521
- for (const agent of agents) {
8522
- const latestCheck = state.configuredAgentCheckResults[agent.id] || null;
8523
- const renderedAvailable = latestCheck ? latestCheck.available !== false : agent.available !== false;
8524
- const renderedEnabled = agent.enabled !== false;
8525
- const renderedReasons = latestCheck?.reasons || agent.reasons || [];
8526
- const card = document.createElement('div');
8527
- card.className = 'configured-agent-card';
8528
- card.dataset.testid = 'configured-agent-card';
8529
- const head = document.createElement('div');
8530
- head.className = 'configured-agent-head';
8531
- const title = document.createElement('strong');
8532
- title.textContent = agent.label;
8533
- const status = document.createElement('span');
8534
- status.className = 'configured-agent-status ' + (renderedAvailable && renderedEnabled ? 'ok' : 'warn');
8535
- status.textContent = renderedAvailable && renderedEnabled ? 'Ready' : 'Check setup';
8536
- head.appendChild(title);
8537
- head.appendChild(status);
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
- const meta = document.createElement('div');
8585
- meta.className = 'configured-agent-meta';
8586
- const parts = [`CLI: ${agent.baseHostId}`];
8587
- if (agent.command?.command) parts.push(`command: ${agent.command.command}`);
8588
- else if (agent.setupScript?.command) parts.push(`setup: ${agent.setupScript.command}`);
8589
- meta.textContent = parts.join(' | ');
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
- const detail = document.createElement('div');
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
- card.appendChild(head);
8598
- card.appendChild(meta);
8599
- card.appendChild(detail);
8600
- panel.appendChild(card);
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
- const chosenAgent = els['active-employee-select'] && els['active-employee-select'].value;
9179
- if (conv && chosenAgent && chosenAgent !== conversationAgentName(conv)) {
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
- if (show) tfFillBrandLockup(brandEl, state.orgBrand);
14418
- brandEl.hidden = !show;
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.textContent = 'ⓘ';
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');
@@ -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
- .cp-row-title { font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
5540
- .cp-row-sub { font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; margin-top:1px; }
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
- /* button on job cards in the assign-job picker */
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 > strong { grid-column: 1; }
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); font-size: 13px; cursor: pointer;
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 ────────────────────────────────────────────────────── */