fraim-hub 2.0.285 → 2.0.287

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.
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.runMandatoryDesktopUpdateCheck = runMandatoryDesktopUpdateCheck;
4
+ let updateCheckInFlight = null;
5
+ function runMandatoryDesktopUpdateCheck(options) {
6
+ if (!options.isPackaged)
7
+ return Promise.resolve({ action: 'skipped-unpackaged' });
8
+ if (updateCheckInFlight)
9
+ return updateCheckInFlight;
10
+ updateCheckInFlight = runMandatoryDesktopUpdateCheckOnce(options)
11
+ .finally(() => {
12
+ updateCheckInFlight = null;
13
+ });
14
+ return updateCheckInFlight;
15
+ }
16
+ async function runMandatoryDesktopUpdateCheckOnce(options) {
17
+ const { updater, prompt, currentVersion, logger = console } = options;
18
+ updater.autoDownload = false;
19
+ updater.autoInstallOnAppQuit = false;
20
+ const checkResult = await checkForDesktopUpdate(updater, prompt, currentVersion, logger);
21
+ if (checkResult.action === 'check-failed')
22
+ return checkResult;
23
+ if (!checkResult.update?.isUpdateAvailable) {
24
+ logger.info('[fraim] desktop update check found no newer version');
25
+ return { action: 'current', availableVersion: checkResult.update?.updateInfo?.version };
26
+ }
27
+ const availableVersion = checkResult.update.updateInfo?.version;
28
+ try {
29
+ logger.info(`[fraim] desktop update ${availableVersion ?? 'unknown'} available; downloading`);
30
+ if (checkResult.update.downloadPromise) {
31
+ await checkResult.update.downloadPromise;
32
+ }
33
+ else {
34
+ await updater.downloadUpdate();
35
+ }
36
+ }
37
+ catch (error) {
38
+ const message = errorMessage(error);
39
+ logger.error(`[fraim] desktop update download failed: ${message}`);
40
+ await promptUpdateFailure(prompt, 'FRAIM Hub update download failed', 'FRAIM Hub found an update but could not download it. Please restart FRAIM Hub or reinstall from the latest installer.', message);
41
+ return { action: 'download-failed', availableVersion, error: message };
42
+ }
43
+ try {
44
+ logger.info(`[fraim] desktop update ${availableVersion ?? 'unknown'} downloaded; installing`);
45
+ updater.quitAndInstall(false, true);
46
+ return { action: 'installing', availableVersion };
47
+ }
48
+ catch (error) {
49
+ const message = errorMessage(error);
50
+ logger.error(`[fraim] desktop update install failed: ${message}`);
51
+ await promptUpdateFailure(prompt, 'FRAIM Hub update install failed', 'FRAIM Hub downloaded an update but could not start the installer. Please restart FRAIM Hub or reinstall from the latest installer.', message);
52
+ return { action: 'install-failed', availableVersion, error: message };
53
+ }
54
+ }
55
+ async function checkForDesktopUpdate(updater, prompt, currentVersion, logger) {
56
+ try {
57
+ logger.info(`[fraim] checking for desktop update from ${currentVersion}`);
58
+ return { action: 'checked', update: await updater.checkForUpdates() };
59
+ }
60
+ catch (error) {
61
+ const message = errorMessage(error);
62
+ logger.warn(`[fraim] desktop update check failed: ${message}`);
63
+ await promptUpdateFailure(prompt, 'FRAIM Hub update check failed', 'FRAIM Hub could not check for updates. It will continue starting, but this installed app may be stale.', message);
64
+ return { action: 'check-failed', error: message };
65
+ }
66
+ }
67
+ function promptUpdateFailure(prompt, title, message, detail) {
68
+ return prompt.showErrorBox(title, message, detail);
69
+ }
70
+ function errorMessage(error) {
71
+ if (error instanceof Error)
72
+ return error.message;
73
+ return String(error);
74
+ }
@@ -19,6 +19,7 @@ const bundled_asset_resolver_1 = require("./bundled-asset-resolver");
19
19
  const server_2 = require("../first-run/server");
20
20
  const session_service_1 = require("../first-run/session-service");
21
21
  const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
22
+ const desktop_auto_updater_1 = require("./desktop-auto-updater");
22
23
  // Keep installed, running, and user-pinned Windows shortcuts grouped under the
23
24
  // stable identity declared in packages/fraim-hub/package.json.
24
25
  electron_1.app.setAppUserModelId('ai.fraim.hub');
@@ -110,18 +111,33 @@ function ensureLoginItem() {
110
111
  fs_1.default.mkdirSync(path_1.default.dirname(flagPath), { recursive: true });
111
112
  fs_1.default.writeFileSync(flagPath, '1');
112
113
  }
113
- function configureAutoUpdater() {
114
+ async function configureAutoUpdater() {
114
115
  if (!electron_1.app.isPackaged)
115
- return;
116
+ return false;
116
117
  // #1110: electron-updater compiles ~114 files (js-yaml, builder-util-runtime, ...) that a
117
118
  // non-packaged `npx fraim-hub` launch never uses, and this whole function returns early
118
119
  // there. Requiring it lazily keeps those file reads off the cold-start path, which is what
119
120
  // dominates time-to-ready on a freshly unpacked install.
120
121
  // eslint-disable-next-line @typescript-eslint/no-require-imports
121
122
  const { autoUpdater } = require('electron-updater');
122
- autoUpdater.autoDownload = true;
123
- autoUpdater.checkForUpdatesAndNotify().catch((err) => {
124
- console.warn('[fraim] auto-update check failed:', err);
123
+ const result = await (0, desktop_auto_updater_1.runMandatoryDesktopUpdateCheck)({
124
+ isPackaged: electron_1.app.isPackaged,
125
+ updater: autoUpdater,
126
+ currentVersion: electron_1.app.getVersion(),
127
+ logger: console,
128
+ prompt: {
129
+ showErrorBox: (title, message, detail) => {
130
+ electron_1.dialog.showErrorBox(title, detail ? `${message}\n\n${detail}` : message);
131
+ },
132
+ },
133
+ });
134
+ return result.action === 'installing';
135
+ }
136
+ function checkForUpdateAfterSecondInstance() {
137
+ if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
138
+ return;
139
+ void configureAutoUpdater().catch((err) => {
140
+ console.warn('[fraim] second-instance update check failed:', err);
125
141
  });
126
142
  }
127
143
  // ---------------------------------------------------------------------------
@@ -467,6 +483,7 @@ async function bootstrap() {
467
483
  return;
468
484
  }
469
485
  electron_1.app.on('second-instance', () => {
486
+ void electron_1.app.whenReady().then(checkForUpdateAfterSecondInstance);
470
487
  if (mainWindow) {
471
488
  mainWindow.show();
472
489
  mainWindow.focus();
@@ -486,7 +503,9 @@ async function bootstrap() {
486
503
  // First-launch housekeeping (idempotent, fast on subsequent runs)
487
504
  if (process.env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1') {
488
505
  ensureLoginItem();
489
- configureAutoUpdater();
506
+ const installingUpdate = await configureAutoUpdater();
507
+ if (installingUpdate)
508
+ return;
490
509
  }
491
510
  electron_1.app.on('activate', () => {
492
511
  // macOS: clicking dock icon re-shows the window
@@ -2071,6 +2071,29 @@ class CliHostRuntime {
2071
2071
  return null;
2072
2072
  return active.pending.length + 1;
2073
2073
  }
2074
+ stopActiveSession(hostId, sessionId) {
2075
+ const key = `${hostId}::${sessionId}`;
2076
+ const active = this.activeContinueRuns.get(key);
2077
+ if (!active)
2078
+ return false;
2079
+ active.pending.splice(0);
2080
+ this.activeContinueRuns.delete(key);
2081
+ if (active.child.pid == null)
2082
+ return false;
2083
+ try {
2084
+ this.killTree(active.child.pid, 'SIGTERM');
2085
+ return true;
2086
+ }
2087
+ catch (error) {
2088
+ console.warn('[ai-hub] failed to stop active host session process tree:', {
2089
+ hostId,
2090
+ sessionId,
2091
+ pid: active.child.pid,
2092
+ error: error instanceof Error ? error.message : String(error),
2093
+ });
2094
+ return false;
2095
+ }
2096
+ }
2074
2097
  guardedContinue(hostId, sessionId, entry) {
2075
2098
  const key = `${hostId}::${sessionId}`;
2076
2099
  const active = this.activeContinueRuns.get(key);
@@ -417,7 +417,12 @@ class AiHubRunRegistry {
417
417
  (0, tree_kill_1.default)(child.pid, 'SIGTERM');
418
418
  return true;
419
419
  }
420
- catch {
420
+ catch (error) {
421
+ console.warn('[ai-hub] failed to stop run process tree:', {
422
+ runId,
423
+ pid: child.pid,
424
+ error: error instanceof Error ? error.message : String(error),
425
+ });
421
426
  return false;
422
427
  }
423
428
  }
@@ -1911,6 +1916,24 @@ function buildManagedLoginCommand(command) {
1911
1916
  function getUserHubDir() {
1912
1917
  return path_1.default.join(os_1.default.homedir(), '.fraim');
1913
1918
  }
1919
+ // Issue #1345: recursively find first file matching name under dir.
1920
+ function findFileRecursive(dir, filename) {
1921
+ try {
1922
+ for (const entry of fs_1.default.readdirSync(dir, { withFileTypes: true })) {
1923
+ const full = path_1.default.join(dir, entry.name);
1924
+ if (entry.isDirectory()) {
1925
+ const hit = findFileRecursive(full, filename);
1926
+ if (hit)
1927
+ return hit;
1928
+ }
1929
+ else if (entry.isFile() && entry.name === filename) {
1930
+ return full;
1931
+ }
1932
+ }
1933
+ }
1934
+ catch { /* ignore unreadable dirs */ }
1935
+ return null;
1936
+ }
1914
1937
  function ensureDirectoryPath(projectPath) {
1915
1938
  const trimmed = (projectPath || '').trim();
1916
1939
  if (!trimmed) {
@@ -3237,6 +3260,7 @@ class AiHubServer {
3237
3260
  // so completed conversations render "What's next?" chips after reload.
3238
3261
  nextJobRecommendations: run.nextJobRecommendations || null,
3239
3262
  issueNumber: run.issueNumber ?? null,
3263
+ executionMode: run.executionMode || null,
3240
3264
  managedByRunId: run.managedByRunId || null,
3241
3265
  managedByPersonaKey: run.managedByPersonaKey || null,
3242
3266
  humanCoachingDisabled: run.humanCoachingDisabled || false,
@@ -5681,6 +5705,25 @@ class AiHubServer {
5681
5705
  return res.status(404).json({ error: 'Configured agent not found.' });
5682
5706
  return res.json((0, configured_agents_1.checkConfiguredAgentReadiness)(agent, employees));
5683
5707
  });
5708
+ // Issue #1345: accept executionMode forwarded from get_fraim_job response by the local proxy.
5709
+ // The proxy parses the Execution Mode Context block and POSTs here; Hub script reads from run poll.
5710
+ this.app.post('/api/ai-hub/runs/by-session/:sessionId/execution-mode', (req, res) => {
5711
+ if (!this.requireTrustedHubOrigin(req, res))
5712
+ return;
5713
+ const { sessionId } = req.params;
5714
+ const { mode, completedRuns } = req.body;
5715
+ if (!sessionId)
5716
+ return res.status(400).json({ error: 'sessionId required' });
5717
+ const normalizedMode = mode === 'trusted' ? 'trusted' : 'coached';
5718
+ const normalizedRuns = typeof completedRuns === 'number' ? completedRuns : 0;
5719
+ const run = this.runRegistry.all().find((r) => r.sessionId === sessionId);
5720
+ if (!run)
5721
+ return res.status(404).json({ error: 'Run not found for session.' });
5722
+ this.runRegistry.update(run.id, (current) => {
5723
+ current.executionMode = { mode: normalizedMode, completedRuns: normalizedRuns };
5724
+ });
5725
+ return res.json({ ok: true });
5726
+ });
5684
5727
  this.app.post('/api/ai-hub/runs', (req, res) => {
5685
5728
  try {
5686
5729
  // Issue #892: project-independent (manager/company) runs resolve a working dir
@@ -5721,6 +5764,26 @@ class AiHubServer {
5721
5764
  if (!jobId) {
5722
5765
  throw new Error('Choose a FRAIM job before starting a run.');
5723
5766
  }
5767
+ // #1394: Dedup guard — return an already-running identical run rather than spawning a second
5768
+ // process. Key includes managerDisplay (messages[0].text) so runs for different issues are
5769
+ // never blocked even when they share the same jobId + hostId + projectPath.
5770
+ // Window of 300 ms: UI race duplicates arrive within ~100 ms (React render cycle);
5771
+ // sequential test runs with the same params are separated by 600 ms+ (test body +
5772
+ // beforeEach page.goto), so the window never fires across test boundaries.
5773
+ const MANAGER_DEDUP_WINDOW_MS = 300;
5774
+ if ((req.body.sourceTrigger ?? 'manager') === 'manager') {
5775
+ const now = Date.now();
5776
+ const activeRun = this.runRegistry.all().find((r) => r.status === 'running'
5777
+ && r.projectPath === projectPath
5778
+ && r.jobId === jobId
5779
+ && r.hostId === hostId
5780
+ && r.messages[0]?.text === managerDisplay
5781
+ && now - Date.parse(r.createdAt) < MANAGER_DEDUP_WINDOW_MS);
5782
+ if (activeRun) {
5783
+ console.warn('[ai-hub] hub.duplicate_run_blocked', { projectPath, jobId, hostId, existingRunId: activeRun.id });
5784
+ return res.json(this.enrichRunForResponse(activeRun));
5785
+ }
5786
+ }
5724
5787
  const startTimestamp = new Date().toISOString();
5725
5788
  const jobMetadata = this.resolveHubJob(projectPath, jobId);
5726
5789
  const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
@@ -5760,7 +5823,9 @@ class AiHubServer {
5760
5823
  phaseVisits: [],
5761
5824
  totals: emptyTotals(),
5762
5825
  lastStatusChangeAt: startTimestamp,
5763
- personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
5826
+ // Issue #1357: fall back to custom persona resolution before the catalog lookup
5827
+ // so custom employee owners (e.g. SidCoder for feature-implementation) are used.
5828
+ personaKey: jobMetadata?.personaKey ?? getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
5764
5829
  // Issue #892: persist the invocation scope so the run is routed to the right
5765
5830
  // conversation bucket (manager/company get a project-independent home) and so
5766
5831
  // the resolved fallback working dir is never mistaken for the active project.
@@ -5945,7 +6010,11 @@ class AiHubServer {
5945
6010
  return res.json(this.enrichRunForResponse(run));
5946
6011
  }
5947
6012
  this.runRegistry.update(run.id, (current) => { current.stoppedByUser = true; });
5948
- const killed = this.runRegistry.stop(run.id);
6013
+ const killedRunChild = this.runRegistry.stop(run.id);
6014
+ const killedHostSession = run.sessionId
6015
+ ? this.hostRuntime.stopActiveSession?.(run.hostId, run.sessionId) === true
6016
+ : false;
6017
+ const killed = killedRunChild || killedHostSession;
5949
6018
  // Park it immediately (don't wait for onExit, which may lag or not fire on a
5950
6019
  // host that already detached). onExit, if it fires, keeps this same state.
5951
6020
  this.runRegistry.update(run.id, (current) => {
@@ -6221,7 +6290,8 @@ class AiHubServer {
6221
6290
  totals: persistedRun?.totals || emptyTotals(),
6222
6291
  lastStatusChangeAt: now,
6223
6292
  runDiscriminant: persistedConversation?.status !== 'completed' ? (persistedRun?.runDiscriminant || undefined) : undefined,
6224
- personaKey: getHubPersonaForJob(jobId),
6293
+ // Issue #1357: consult custom persona owner before falling back to catalog.
6294
+ personaKey: getCustomPersonaForJob(projectPath, jobId) ?? getHubPersonaForJob(jobId),
6225
6295
  continuityDecision: conversationId ? 'same_continuity' : 'new_conversation',
6226
6296
  };
6227
6297
  host_session_state_1.hostSessionState.applySession(run, { configuredAgentId: configuredAgent.id, baseHostId: hostId }, sessionId, { sourceRunId: run.id, status: resolvedHostSession?.status || 'suspect' });
@@ -7016,7 +7086,8 @@ class AiHubServer {
7016
7086
  phaseVisits: [],
7017
7087
  totals: emptyTotals(),
7018
7088
  lastStatusChangeAt: startTimestamp,
7019
- personaKey: getHubPersonaForJob(jobName),
7089
+ // Issue #1357: consult custom persona owner before falling back to catalog.
7090
+ personaKey: getCustomPersonaForJob(projectPath, jobName) ?? getHubPersonaForJob(jobName),
7020
7091
  };
7021
7092
  // Register the run before spawning so onEvent/onExit callbacks can
7022
7093
  // safely call update() even if they fire synchronously (FakeHostRuntime).
@@ -7169,7 +7240,8 @@ class AiHubServer {
7169
7240
  phaseVisits: [],
7170
7241
  totals: emptyTotals(),
7171
7242
  lastStatusChangeAt: startTimestamp,
7172
- personaKey: jobMetadata?.personaKey ?? getHubPersonaForJob(jobId),
7243
+ // Issue #1357: fall back to custom persona resolution before the catalog lookup.
7244
+ personaKey: jobMetadata?.personaKey ?? getCustomPersonaForJob(deployment.projectPath, jobId) ?? getHubPersonaForJob(jobId),
7173
7245
  };
7174
7246
  // Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
7175
7247
  // can call runRegistry.update without "Run not found" throws.
@@ -7309,7 +7381,7 @@ class AiHubServer {
7309
7381
  const delay = recoveryBackoffMs(attempt);
7310
7382
  const tid = setTimeout(() => {
7311
7383
  const current = this.runRegistry.get(runId);
7312
- if (!current || current.status !== 'running')
7384
+ if (!current || current.status !== 'running' || current.stoppedByUser)
7313
7385
  return;
7314
7386
  const message = classification.recoveryKind === 'compaction'
7315
7387
  ? buildHubCompactionRecoveryContinueMessage(current, exitCode, attempt)
@@ -29,7 +29,10 @@ exports.FRAIM_MCP_UNAVAILABLE_MANAGER_GUIDANCE = [
29
29
  'If required FRAIM MCP tools remain unavailable after the deferred-tool preload/retry step, stop the FRAIM job.',
30
30
  'Do not continue from memory, local stubs, cached instructions, or prior context.',
31
31
  'In the blocker sentence, state only the work-focused blocker and what outcome is blocked; do not narrate ToolSearch/tool_search, individual tool names, server internals, or retry mechanics.',
32
- 'Always help troubleshoot the unavailable FRAIM MCP connection: tell the user to reconnect or enable the FRAIM MCP server in their IDE/agent, restart or refresh the agent session, retry the FRAIM job, and if it still fails run `fraim doctor --test-mcp` and share the failing check output.'
32
+ 'Always help troubleshoot the unavailable FRAIM MCP connection: tell the user to reconnect or enable the FRAIM MCP server in their IDE/agent, restart or refresh the agent session, retry the FRAIM job, and if it still fails run `fraim doctor --test-mcp` and share the failing check output.',
33
+ 'Exception — troubleshooting FRAIM itself: if the user\'s request is to diagnose or fix a FRAIM issue and FRAIM MCP is unavailable, do NOT stop.',
34
+ 'Instead, read `~/.fraim/docs/TROUBLESHOOTING.md` directly from disk (it is always synced in full by `fraim sync` and is available without MCP) and follow its guidance to diagnose the issue.',
35
+ 'After completing the local diagnosis, prompt the user to re-run `fraim sync` and restart their agent session, then offer to run the `troubleshoot-fraim` FRAIM job for a structured investigation once MCP is restored.'
33
36
  ].join(' ');
34
37
  function buildDeferredToolBootstrapSection(profile) {
35
38
  if (profile === 'none') {
@@ -31,6 +31,7 @@ const GENERALIST_PROFILE = {
31
31
  };
32
32
  /** Role key -> the human manager best suited to manage that AI employee. Mirror of registry/scripts/ai-manager-hiring.ts. */
33
33
  exports.HUMAN_MANAGER_PROFILES = {
34
+ aida: { humanTitle: 'Head of AI Engineering', keywords: ['"Head of AI Engineering"', '"Director of AI"', '"AI Engineering Manager"', '"VP Engineering"'] },
34
35
  maestro: { humanTitle: 'Co-Founder / General Manager', keywords: ['"Co-Founder"', '"General Manager"', '"Chief of Staff"', '"Founder"'] },
35
36
  beza: { humanTitle: 'Head of Strategy', keywords: ['"Head of Strategy"', '"Strategy Director"', '"Chief of Staff"'] },
36
37
  pam: { humanTitle: 'Head of Product', keywords: ['"Head of Product"', '"Group Product Manager"', '"Director of Product"'] },
@@ -61,6 +61,7 @@ exports.JOB_DOMAIN_MAP = {
61
61
  'security': 'engineering',
62
62
  'delivery-ops': 'engineering',
63
63
  'salesforce': 'engineering',
64
+ 'ai-engineering': 'engineering',
64
65
  // product
65
66
  'product-management': 'product',
66
67
  'customer-development': 'product',
@@ -291,10 +291,20 @@ for (const bundle of Object.values(exports.PERSONA_CAPABILITY_BUNDLES)) {
291
291
  // named specialist persona. They resolve through ownership (never short-circuited
292
292
  // as "free") so the Hub attributes them to FRAIMworker, but they are never
293
293
  // hire-gated because FRAIMworker is not a purchasable persona.
294
+ //
295
+ // Issue #1398: manager-agreements / organization-onboarding / organizational-learning-
296
+ // synthesis are Manager/Company area-level jobs, not tied to a named specialist — they
297
+ // were falling through to DEFAULT_UNASSIGNED_PERSONA_KEY ('mandy'), which misattributed
298
+ // them to an employee who never runs them and left them outside the Manager/Company
299
+ // employee rail's FRAIMworker group.
294
300
  const GENERIC_WORKER_OWNED_JOBS = new Set([
295
301
  'contribute-to-fraim',
296
302
  'file-fraim-issue',
297
303
  'praise-fraim',
304
+ 'troubleshoot-fraim',
305
+ 'manager-agreements',
306
+ 'organization-onboarding',
307
+ 'organizational-learning-synthesis',
298
308
  ]);
299
309
  function getPersonaCapabilityBundle(personaKey) {
300
310
  return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
@@ -170,6 +170,8 @@ class FraimDbService {
170
170
  // Issue #563 — shared organization context (FRAIM-cloud backend).
171
171
  this.orgArtifactsCollection = this.db.collection('fraim_org_artifacts');
172
172
  this.orgAuditCollection = this.db.collection('fraim_org_audit');
173
+ // Issue #1345 — job execution modes (Coached/Trusted).
174
+ this.jobExecutionModesCollection = this.db.collection('fraim_job_execution_modes');
173
175
  }
174
176
  async initializeIndexes() {
175
177
  if (!this.db)
@@ -199,6 +201,7 @@ class FraimDbService {
199
201
  // requirement — swallow failures like the other Cosmos-sensitive indexes above.
200
202
  await this.orgArtifactsCollection.createIndex({ orgId: 1, relativePath: 1 }, { unique: true }).catch(() => { });
201
203
  await this.orgAuditCollection.createIndex({ orgId: 1, at: -1 }).catch(() => { });
204
+ await this.jobExecutionModesCollection.createIndex({ userId: 1, jobName: 1 }, { unique: true }).catch(() => { });
202
205
  await this.pendingVerificationsCollection.createIndex({ email: 1 });
203
206
  // Compound index covers `findOne({email}, { sort: { createdAt: -1 } })` —
204
207
  // the request-access flow's lookup of the most-recent pending row per
@@ -286,6 +289,36 @@ class FraimDbService {
286
289
  throw new Error('DB not connected');
287
290
  return await this.orgAuditCollection.find({ orgId }).sort({ at: -1 }).toArray();
288
291
  }
292
+ async getJobExecutionMode(userId, jobName) {
293
+ if (!this.jobExecutionModesCollection)
294
+ return null;
295
+ return await this.jobExecutionModesCollection.findOne({ userId, jobName }) ?? null;
296
+ }
297
+ async upsertJobExecutionMode(userId, update) {
298
+ if (!this.jobExecutionModesCollection)
299
+ return;
300
+ const jobName = String(update['jobName'] ?? '');
301
+ if (!jobName)
302
+ return;
303
+ const existing = await this.jobExecutionModesCollection.findOne({ userId, jobName });
304
+ let completedRuns = typeof existing?.completedRuns === 'number' ? existing.completedRuns : 0;
305
+ if (update['incrementRun'] === true)
306
+ completedRuns += 1;
307
+ const record = {
308
+ userId,
309
+ jobName,
310
+ mode: (update['mode'] === 'trusted' || update['mode'] === 'coached')
311
+ ? update['mode']
312
+ : (existing?.mode ?? 'coached'),
313
+ completedRuns,
314
+ trustedSince: typeof update['trustedSince'] === 'string' ? update['trustedSince'] : existing?.trustedSince,
315
+ trustedAtRun: typeof update['trustedAtRun'] === 'number' ? update['trustedAtRun'] : existing?.trustedAtRun,
316
+ lastGraduationOfferRun: typeof update['lastGraduationOfferRun'] === 'number' ? update['lastGraduationOfferRun'] : existing?.lastGraduationOfferRun,
317
+ graduationSuppressedUntilRun: typeof update['graduationSuppressedUntilRun'] === 'number' ? update['graduationSuppressedUntilRun'] : existing?.graduationSuppressedUntilRun,
318
+ updatedAt: new Date(),
319
+ };
320
+ await this.jobExecutionModesCollection.replaceOne({ userId, jobName }, record, { upsert: true });
321
+ }
289
322
  async verifyApiKey(key) {
290
323
  if (!this.keysCollection)
291
324
  throw new Error('DB not connected');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.285",
3
+ "version": "2.0.287",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "author": "Sid Mathur <sid.mathur@gmail.com>",
6
6
  "homepage": "https://github.com/mathursrus/FRAIM#readme",
@@ -210,7 +210,7 @@
210
210
  "electron-updater": "^6.8.9",
211
211
  "express": "^5.2.1",
212
212
  "extract-zip": "^2.0.1",
213
- "fraim": "2.0.285",
213
+ "fraim": "2.0.287",
214
214
  "mongodb": "^7.0.0",
215
215
  "node-cron": "4.2.1",
216
216
  "node-edge-tts": "^1.2.10",
@@ -827,8 +827,10 @@
827
827
  </div>
828
828
 
829
829
  <!-- #1351: unified Add Agent dialog — add a configured profile directly, or delegate
830
- setup to FRAIM. Replaces the separate "Add AI agent" button + "setup another tool"
831
- disclosure both opening the same generic onboarding modal with no manual option. -->
830
+ setup to FRAIM. This is the single entry point for agent setup: the toolbar's
831
+ "Add AI agent" button and, on #1390, the per-tool quick-picks inside the Delegate
832
+ tab (which replaced the old standalone "setup another tool" disclosure) both open
833
+ this same dialog. -->
832
834
  <div id="add-agent-modal" class="modal-overlay" role="dialog" aria-modal="true" aria-labelledby="aam-title" hidden>
833
835
  <div class="modal-card">
834
836
  <div class="modal-hdr">
@@ -845,6 +847,11 @@
845
847
  <div class="modal-body">
846
848
  <div id="aam-manual-pane"></div>
847
849
  <div id="aam-delegate-pane" hidden>
850
+ <div id="aam-delegate-quickpicks" class="aam-delegate-quickpicks" data-testid="aam-delegate-quickpicks" hidden>
851
+ <p class="aam-delegate-quickpicks-label">Not set up yet on this machine:</p>
852
+ <div id="aam-delegate-quickpicks-list" class="aam-delegate-quickpicks-list"></div>
853
+ <p id="aam-delegate-quickpick-caption" class="aam-delegate-quickpick-caption" data-testid="aam-delegate-quickpick-caption" hidden></p>
854
+ </div>
848
855
  <div class="np-field">
849
856
  <label for="aam-delegate-context">Any specific direction for this run? <span class="np-optional">(optional, leave blank to start with defaults)</span></label>
850
857
  <textarea id="aam-delegate-context" rows="4" data-testid="add-agent-delegate-context" placeholder="e.g. Configure a CLI, a cloud-credit route, or a custom profile."></textarea>
@@ -91,6 +91,11 @@ const state = {
91
91
  cpPersonaOverride: null, // issue #945: custom:* key to stamp on conv.personaKey
92
92
  lastRun: null, // {job, instructions, employeeId} for Cmd+Shift+R
93
93
  configuredAgentCheckResults: {},
94
+ // #1389: an active Company Brand draft must survive poll-driven tfRenderCompany()
95
+ // calls, just as #1351 preserves an open Manager-tab configured-agent form.
96
+ brandEditorDraftActive: false,
97
+ brandEditorStatus: '',
98
+ brandEditorStatusWarn: false,
94
99
  // Issue #540 R10: pending run params captured when hire strip is shown.
95
100
  _hireStripPending: null,
96
101
  };
@@ -3600,6 +3605,7 @@ function renderActive() {
3600
3605
  els['active-conv'].classList.toggle('has-manager-oversight', isManagerOversightConversation(conv));
3601
3606
  renderConversationIdentity(conv);
3602
3607
  renderRunStatePill(conv);
3608
+ renderJobModeChip(conv);
3603
3609
  syncCoachEmployeeLabel(conv);
3604
3610
  syncThreadPanelKicker(conv);
3605
3611
  syncCoachPanelKicker(conv);
@@ -3616,6 +3622,7 @@ function renderActive() {
3616
3622
  els['micro-log'].textContent = '';
3617
3623
  if (els['resume-command']) els['resume-command'].hidden = true;
3618
3624
  renderedConvId = conv.id;
3625
+ _modeChipLastKey = null;
3619
3626
  renderedMessageCount = 0;
3620
3627
  renderedMessageFingerprints = [];
3621
3628
  renderedEventCount = 0;
@@ -3861,6 +3868,65 @@ function renderConversationIdentity(conv) {
3861
3868
  host.appendChild(text);
3862
3869
  }
3863
3870
 
3871
+ // Issue #1345: "In Training" chip in .conv-topline for Coached-mode jobs (R10).
3872
+ // Shown when mode is coached (not trusted). Training instructions are now injected by the MCP.
3873
+ let _modeChipLastKey = null;
3874
+ function renderJobModeChip(conv) {
3875
+ const topline = document.querySelector('#active-conv > .conv-topline');
3876
+ const titleEl = els['active-title'];
3877
+ if (!topline || !titleEl) return;
3878
+
3879
+ const removeChip = () => {
3880
+ const old = topline.querySelector('.job-mode-chip');
3881
+ if (old) old.remove();
3882
+ _modeChipLastKey = null;
3883
+ };
3884
+
3885
+ const jobName = conv && conv.jobId && conv.jobId !== '__freeform__' ? conv.jobId : null;
3886
+ const personaKey = conv && conv.personaKey ? conv.personaKey : null;
3887
+ if (!jobName || !personaKey) { removeChip(); return; }
3888
+
3889
+ // Issue #1345: read executionMode from run data folded in by foldRunIntoConversation.
3890
+ // No fetch needed — the local proxy forwards it from the get_fraim_job response.
3891
+ const data = conv.executionMode;
3892
+ if (!data || data.mode === 'trusted') { removeChip(); return; }
3893
+
3894
+ const key = `${personaKey}:${jobName}`;
3895
+ if (_modeChipLastKey === key && topline.querySelector('.job-mode-chip')) return;
3896
+
3897
+ _modeChipLastKey = key;
3898
+ let chip = topline.querySelector('.job-mode-chip');
3899
+ if (!chip) {
3900
+ chip = document.createElement('span');
3901
+ chip.className = 'job-mode-chip job-mode-chip--in-training';
3902
+ chip.setAttribute('aria-label', 'In Training: training pauses active on this job');
3903
+
3904
+ const label = document.createElement('span');
3905
+ label.textContent = 'In Training';
3906
+ chip.appendChild(label);
3907
+
3908
+ const tooltip = document.createElement('div');
3909
+ tooltip.className = 'chip-tooltip';
3910
+
3911
+ const runCount = typeof data.completedRuns === 'number' ? data.completedRuns : 0;
3912
+ const personaDisplay = (() => {
3913
+ const p = (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === personaKey);
3914
+ return p ? p.displayName : personaKey;
3915
+ })();
3916
+ const personaDisplayEsc = tfEscape(personaDisplay);
3917
+ const threshold = 3;
3918
+
3919
+ tooltip.innerHTML = [
3920
+ `<div class="chip-tooltip-section"><div class="chip-tooltip-label">Why</div>${personaDisplayEsc} is still learning how you like this job to be structured. ${runCount === 0 ? "You haven't run it together yet." : `${runCount === 1 ? "You've done it once together" : `You've done it ${runCount} times together`} so far.`}</div>`,
3921
+ `<div class="chip-tooltip-section"><div class="chip-tooltip-label">What this means</div>${personaDisplayEsc} will show you an outline of what they plan to build before drafting, so you can redirect the shape before any prose is written.</div>`,
3922
+ `<div class="chip-tooltip-section"><div class="chip-tooltip-label">How to graduate</div>After ${threshold} completed runs, the retrospective recommends the <strong>graduate-job</strong>. Run it to promote this job to Trusted mode and skip the training pauses on future runs.</div>`,
3923
+ ].join('');
3924
+ chip.appendChild(tooltip);
3925
+
3926
+ titleEl.insertAdjacentElement('afterend', chip);
3927
+ }
3928
+ }
3929
+
3864
3930
  function renderRunStatePill(conv) {
3865
3931
  const pill = els['run-state-pill'];
3866
3932
  if (!pill) return;
@@ -8277,6 +8343,8 @@ function foldRunIntoConversation(conv, run) {
8277
8343
  }
8278
8344
  // Issue #848: fold next-job recommendations + the issue pointer from the run.
8279
8345
  conv.nextJobRecommendations = normalizeNextJobRecommendations(run.nextJobRecommendations);
8346
+ // Issue #1345: execution mode forwarded from get_fraim_job response via local proxy.
8347
+ conv.executionMode = run.executionMode || null;
8280
8348
  if (run.issueNumber !== undefined && run.issueNumber !== null) conv.issueNumber = run.issueNumber;
8281
8349
  if (Array.isArray(run.agentSwitches)) conv.agentSwitches = run.agentSwitches;
8282
8350
  if (run.handoffSummary !== undefined) conv.handoffSummary = run.handoffSummary;
@@ -8443,8 +8511,10 @@ function switchToConversation(id) {
8443
8511
  const info = document.getElementById(tf.area + '-info-view');
8444
8512
  if (host) host.hidden = false;
8445
8513
  if (info) info.hidden = true;
8446
- // #1351: deliberately opening a run overrides any earlier pin to the info view.
8447
- if (tf.viewPinned) tf.viewPinned[tf.area] = false;
8514
+ // #1397: an area selection represents every explicit destination, not only
8515
+ // the Info view. Poll-driven renders must preserve this conversation until
8516
+ // the user chooses another destination or the conversation disappears.
8517
+ if (tf.areaSelection) tf.areaSelection[tf.area] = id;
8448
8518
  }
8449
8519
  renderRail();
8450
8520
  renderActive();
@@ -8802,39 +8872,6 @@ function renderConfiguredAgentsPanel() {
8802
8872
  }
8803
8873
  }
8804
8874
 
8805
- const uninstalled = hubEmployees().filter((e) => !e.available);
8806
- if (uninstalled.length) {
8807
- const disclosure = document.createElement('details');
8808
- disclosure.className = 'configured-agents-setup-disclosure';
8809
- disclosure.dataset.testid = 'setup-another-tool';
8810
- const summary = document.createElement('summary');
8811
- summary.textContent = 'Set up another tool';
8812
- disclosure.appendChild(summary);
8813
- for (const emp of uninstalled) {
8814
- const row = document.createElement('div');
8815
- row.className = 'install-row';
8816
- const label = document.createElement('span');
8817
- label.className = 'install-label';
8818
- label.textContent = emp.label;
8819
- const empDetail = document.createElement('span');
8820
- empDetail.className = 'install-status';
8821
- empDetail.textContent = emp.detail || 'Not installed';
8822
- const btn = document.createElement('button');
8823
- btn.type = 'button';
8824
- btn.className = 'small';
8825
- btn.textContent = 'Set up';
8826
- btn.dataset.testid = `hub-agent-install-${emp.id}`;
8827
- btn.addEventListener('click', () => openAddAgentModal(
8828
- 'delegate',
8829
- `Set up ${emp.label}: install the CLI, sign in, and configure it as a Hub agent.`
8830
- ));
8831
- row.appendChild(label);
8832
- row.appendChild(empDetail);
8833
- row.appendChild(btn);
8834
- disclosure.appendChild(row);
8835
- }
8836
- panel.appendChild(disclosure);
8837
- }
8838
8875
  }
8839
8876
 
8840
8877
  // #1351: unified Add Agent dialog. "Add Manually" hosts the existing structured
@@ -8846,16 +8883,59 @@ function renderConfiguredAgentsPanel() {
8846
8883
  // dialog's tab chrome would show an unrelated "Add Manually" tab next to those jobs.
8847
8884
  // Reusing tfStartOnboardingJob (the actual capability) rather than the modal markup
8848
8885
  // (incidental UI chrome) is the correct level of reuse here.
8886
+ //
8887
+ // #1390: this is the single setup entry point for AI agents. The panel's toolbar
8888
+ // "Add AI agent" button is the only affordance rendered inline on the AI Agents tab;
8889
+ // the per-tool shortcuts that used to live in a standalone "Set up another tool"
8890
+ // disclosure on that panel now live as quick-pick chips inside this dialog's Delegate
8891
+ // tab (renderAddAgentDelegateQuickpicks), so there is exactly one place to start setup.
8849
8892
  function openAddAgentModal(tab, delegateMessage) {
8850
8893
  state._addAgentDelegateMessage = delegateMessage
8851
8894
  || 'Set up a new Hub AI agent: configure a CLI, cloud-credit route, or custom profile.';
8895
+ state._addAgentDelegateEmployeeId = null;
8852
8896
  const ctx = document.getElementById('aam-delegate-context');
8853
8897
  if (ctx) ctx.value = '';
8898
+ renderAddAgentDelegateQuickpicks();
8854
8899
  const modal = document.getElementById('add-agent-modal');
8855
8900
  if (modal) modal.hidden = false;
8856
8901
  setAddAgentTab(tab === 'delegate' ? 'delegate' : 'manual');
8857
8902
  }
8858
8903
 
8904
+ // #1390: quick-pick chips for tools not yet installed on this machine, shown inside
8905
+ // the Delegate tab. Clicking one targets the delegate job at that specific tool —
8906
+ // the same outcome the old standalone "Set up another tool" disclosure gave, without
8907
+ // a second setup entry point on the AI Agents panel itself.
8908
+ function renderAddAgentDelegateQuickpicks() {
8909
+ const wrap = document.getElementById('aam-delegate-quickpicks');
8910
+ const list = document.getElementById('aam-delegate-quickpicks-list');
8911
+ const caption = document.getElementById('aam-delegate-quickpick-caption');
8912
+ if (!wrap || !list) return;
8913
+ list.innerHTML = '';
8914
+ const uninstalled = hubEmployees().filter((e) => !e.available);
8915
+ wrap.hidden = !uninstalled.length;
8916
+ for (const emp of uninstalled) {
8917
+ const chip = document.createElement('button');
8918
+ chip.type = 'button';
8919
+ chip.className = 'jf-chip aam-delegate-quickpick';
8920
+ chip.textContent = emp.label;
8921
+ chip.dataset.testid = `hub-agent-install-${emp.id}`;
8922
+ chip.classList.toggle('active', state._addAgentDelegateEmployeeId === emp.id);
8923
+ chip.addEventListener('click', () => selectAddAgentDelegateQuickpick(emp));
8924
+ list.appendChild(chip);
8925
+ }
8926
+ if (caption) {
8927
+ const selected = uninstalled.find((e) => e.id === state._addAgentDelegateEmployeeId);
8928
+ caption.textContent = selected ? state._addAgentDelegateMessage : '';
8929
+ caption.hidden = !selected;
8930
+ }
8931
+ }
8932
+
8933
+ function selectAddAgentDelegateQuickpick(emp) {
8934
+ state._addAgentDelegateEmployeeId = emp.id;
8935
+ state._addAgentDelegateMessage = `Set up ${emp.label}: install the CLI, sign in, and configure it as a Hub agent.`;
8936
+ renderAddAgentDelegateQuickpicks();
8937
+ }
8938
+
8859
8939
  function closeAddAgentModal() {
8860
8940
  const modal = document.getElementById('add-agent-modal');
8861
8941
  if (modal) modal.hidden = true;
@@ -9993,12 +10073,11 @@ const tf = {
9993
10073
  // #769: hire-pending state for post-Stripe redirect polling
9994
10074
  hirePendingState: null, // null | 'polling' | 'timeout'
9995
10075
  hirePendingBaseCount: 0, // hired persona count at polling start
9996
- // #1351: a manual tfToggleAreaView(area, 'info') pins that area to the info view
9997
- // until the user (or a genuinely new run in that area) changes it. Without this,
9998
- // tfRenderManager()/tfRenderCompany() re-derive the visible sub-view from
9999
- // tfActiveMgrConv()/tfActiveOrgConv() on every ~1s poll tick, silently reverting a
10000
- // deliberate manual navigation choice while a running conversation still exists.
10001
- viewPinned: { manager: false, company: false },
10076
+ // #1397: the explicit destination selected in each area. `null` means the area
10077
+ // may auto-home to its most relevant conversation; `info` and conversation IDs
10078
+ // are user choices that survive every poll-driven render. This replaces the
10079
+ // former info-only viewPinned special case with one navigation-state contract.
10080
+ areaSelection: { manager: null, company: null },
10002
10081
  };
10003
10082
 
10004
10083
  // ---------------------------------------------------------------------------
@@ -12945,6 +13024,52 @@ function tfActiveMgrConv() {
12945
13024
  );
12946
13025
  }
12947
13026
 
13027
+ // #1397: resolve one stable destination for an area. Polling may refresh the
13028
+ // conversations behind that destination, but it may not choose a different one.
13029
+ // A deleted or re-scoped conversation invalidates the selection and restores the
13030
+ // area's normal auto-home behavior.
13031
+ function tfResolveAreaView(area, fallbackConv) {
13032
+ const selected = tf.areaSelection && tf.areaSelection[area];
13033
+ if (selected === 'info') return { info: true, conversation: null };
13034
+ if (selected) {
13035
+ const conv = findConversation(selected);
13036
+ if (conv && convScope(conv) === area) return { info: false, conversation: conv };
13037
+ tf.areaSelection[area] = null;
13038
+ }
13039
+ return { info: !fallbackConv, conversation: fallbackConv || null };
13040
+ }
13041
+
13042
+ function tfAreaScopedConversations(area) {
13043
+ return Object.values(state.conversations || {}).flat()
13044
+ .filter((conv) => conv && convScope(conv) === area)
13045
+ .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
13046
+ }
13047
+
13048
+ // Every area has FRAIMworker. Named employees join an area when that area is
13049
+ // their home (Ashley on Manager) or when they actually have a run there. This
13050
+ // keeps other hired project employees out of Manager/Company until relevant,
13051
+ // while ensuring every scoped run remains reachable under its owner.
13052
+ function tfAreaRailPersonas(area) {
13053
+ const personas = new Map([[GENERIC_WORKER_PERSONA.key, GENERIC_WORKER_PERSONA]]);
13054
+ const bootstrapPersonas = (state.bootstrap && state.bootstrap.personas) || [];
13055
+ if (area === 'manager') {
13056
+ const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
13057
+ for (const persona of bootstrapPersonas) {
13058
+ if (!MANAGER_PERSONA_KEYS.has(persona.key) || !tfIsPersonaHired(persona.key)) continue;
13059
+ // Defensive: only give a home persona an employee group if the catalog actually has
13060
+ // a job that requires them, mirroring the original Manager-tab-only guard.
13061
+ if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
13062
+ personas.set(persona.key, persona);
13063
+ }
13064
+ }
13065
+ for (const conv of tfAreaScopedConversations(area)) {
13066
+ if (!conv.personaKey || personas.has(conv.personaKey)) continue;
13067
+ const persona = getConversationPersona(conv);
13068
+ if (persona) personas.set(persona.key, persona);
13069
+ }
13070
+ return [...personas.values()];
13071
+ }
13072
+
12948
13073
  // #702: is a persona hired (has a company seat) so its manager-tab jobs should show?
12949
13074
  // (MANAGER_ASHLEY_JOBS is defined once at the top of this file as the single source of truth.)
12950
13075
  function tfIsPersonaHired(personaKey) {
@@ -12965,7 +13090,7 @@ function tfStartManagerPersonaJob(jobId) {
12965
13090
  // identical on both tabs.
12966
13091
  function tfBuildManagerRunItem(conv) {
12967
13092
  const btn = document.createElement('button');
12968
- btn.className = 'conv-item';
13093
+ btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
12969
13094
  btn.type = 'button';
12970
13095
  btn.dataset.conv = conv.id;
12971
13096
  const body = document.createElement('span'); body.className = 'conv-body';
@@ -12990,11 +13115,9 @@ function tfBuildManagerRunItem(conv) {
12990
13115
  // visual as the Projects rail employee groups (avatar + name + role + count + "+" that
12991
13116
  // opens the job palette), with the persona's MANAGER-invoked runs listed under it.
12992
13117
  // Keeps a persona looking like an employee, consistent across tabs.
12993
- function tfRenderManagerPersonaGroup(rail, persona) {
13118
+ function tfRenderAreaPersonaGroup(rail, persona, area) {
12994
13119
  const sample = { personaKey: persona.key };
12995
- const runs = Object.values(state.conversations || {}).flat()
12996
- .filter((c) => c && c.personaKey === persona.key && convScope(c) === 'manager')
12997
- .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
13120
+ const runs = tfAreaScopedConversations(area).filter((c) => c.personaKey === persona.key);
12998
13121
  const details = document.createElement('details');
12999
13122
  details.className = 'conv-employee-group';
13000
13123
  details.open = true;
@@ -13013,8 +13136,8 @@ function tfRenderManagerPersonaGroup(rail, persona) {
13013
13136
  addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
13014
13137
  addBtn.addEventListener('click', (e) => {
13015
13138
  e.preventDefault(); e.stopPropagation();
13016
- // Launch from the Manager tab: palette prefiltered to this persona. Runs started
13017
- // while the manager tab is active default to invokedArea='manager'.
13139
+ // Launch from this area: palette prefiltered to the employee. startRun stamps
13140
+ // the active area on the conversation so it returns to the same rail.
13018
13141
  openPalette({ employeeId: (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude', prefixSearch: '/' + persona.key });
13019
13142
  });
13020
13143
  const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
@@ -13033,6 +13156,39 @@ function tfRenderManagerPersonaGroup(rail, persona) {
13033
13156
  rail.appendChild(details);
13034
13157
  }
13035
13158
 
13159
+ // #1397/#1398: shared employee rail for the Manager and Company tabs. FRAIMworker is
13160
+ // always present (issue #1398 — every area has a generic owner to fall back to); named
13161
+ // employees join when the area is their home (Ashley on Manager) or they actually have
13162
+ // a run there (e.g. Aida's manager-scoped runs), via tfAreaRailPersonas. `ownerlessConv`
13163
+ // is the area's own top-level job run (manager-agreements / org onboarding) or any other
13164
+ // run with no persona group to live under; it gets its own rail entry unless a group
13165
+ // below will already list it.
13166
+ function tfRenderAreaEmployeeRail(rail, area, ownerlessConv) {
13167
+ const areaPersonas = tfAreaRailPersonas(area);
13168
+ // A group only actually lists conversations tfAreaScopedConversations(area) returns
13169
+ // (i.e. correctly scope/invokedArea-tagged). A legacy conv that predates that tagging
13170
+ // (see #1351) can share a persona key with a rail group yet not appear inside it — it
13171
+ // still needs its own top-level entry, or it becomes unreachable.
13172
+ const scopedIds = new Set(tfAreaScopedConversations(area).map((c) => c.id));
13173
+ const ownedByGroup = !!(ownerlessConv && ownerlessConv.personaKey && scopedIds.has(ownerlessConv.id)
13174
+ && areaPersonas.some((p) => p.key === ownerlessConv.personaKey));
13175
+ if (ownerlessConv && !ownedByGroup) rail.appendChild(tfBuildManagerRunItem(ownerlessConv));
13176
+ let any = false;
13177
+ for (const persona of areaPersonas) {
13178
+ if (!any) {
13179
+ const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
13180
+ rail.appendChild(head);
13181
+ any = true;
13182
+ }
13183
+ tfRenderAreaPersonaGroup(rail, persona, area);
13184
+ }
13185
+ if (any) {
13186
+ const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
13187
+ pNote.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work. Its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
13188
+ rail.appendChild(pNote);
13189
+ }
13190
+ }
13191
+
13036
13192
  // Move the shared .page conversation panel into the specified area's workspace-conv host.
13037
13193
  // All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
13038
13194
  // because the area-conv-host elements carry the workspace-conv class.
@@ -13057,12 +13213,13 @@ function tfToggleAreaView(area, view) {
13057
13213
  tfEnsurePageInArea('projects');
13058
13214
  state.activeId = null;
13059
13215
  renderActive();
13060
- // #1351: a manual switch to the info view is a deliberate navigation choice
13061
- // pin it so the next poll-driven tfRenderManager()/tfRenderCompany() doesn't
13062
- // silently flip back to the run view just because a conversation is still
13063
- // running. Cleared by tfStartOnboardingJob() when a genuinely new run starts
13064
- // in this area, or by the user opening a run directly (switchToConversation).
13065
- if (tf.viewPinned && (area === 'manager' || area === 'company')) tf.viewPinned[area] = true;
13216
+ // #1397: a manual switch to the info view is a deliberate navigation choice, exactly
13217
+ // like picking a specific conversation — record it as this area's explicit selection so
13218
+ // the next poll-driven tfRenderManager()/tfRenderCompany() doesn't silently flip back to
13219
+ // the run view just because a conversation is still running. Overwritten by
13220
+ // tfStartOnboardingJob() when a genuinely new run starts in this area, or by the user
13221
+ // opening a run directly (switchToConversation).
13222
+ if (tf.areaSelection && (area === 'manager' || area === 'company')) tf.areaSelection[area] = 'info';
13066
13223
  }
13067
13224
  }
13068
13225
 
@@ -13071,7 +13228,11 @@ function tfRenderCompany() {
13071
13228
  // + organizational-learning-synthesis) — not in any project's job list, not as
13072
13229
  // chips elsewhere.
13073
13230
  // #744: render the Brand editor (its host #company-brand-editor is static in the info view).
13074
- if (typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
13231
+ // #1389: background conversation/status polls call tfRenderCompany(). Rebuilding
13232
+ // the editor while its draft is active replaces the focused node and resets its
13233
+ // closure-owned unsaved values. Save/Reset clears the guard below; the status
13234
+ // state keeps their feedback stable if the next poll then performs a render.
13235
+ if (!state.brandEditorDraftActive && typeof tfRenderBrandEditor === 'function') tfRenderBrandEditor();
13075
13236
  const rail = document.getElementById('company-rail');
13076
13237
  if (rail) {
13077
13238
  rail.innerHTML = '';
@@ -13086,10 +13247,14 @@ function tfRenderCompany() {
13086
13247
  // Issue #1124: when a company-scoped org conv exists, add a visible rail entry so
13087
13248
  // the user can see and navigate to it. Previously the rail had only the static info
13088
13249
  // button and the conversation was unreachable from the left nav.
13089
- if (orgConv) rail.appendChild(tfBuildManagerRunItem(orgConv));
13090
13250
  // #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
13091
13251
  // now run from the section they populate — Run Organization Onboarding in
13092
13252
  // "Context & rules", Synthesize company learnings in "Company learnings".
13253
+ // #1398: FRAIMworker (and any hired employee with a company-scoped run, e.g. Aida)
13254
+ // get the same employee-group presentation as the Manager rail — not just an orphan
13255
+ // entry for the single "most active" org conv.
13256
+ tfRenderAreaEmployeeRail(rail, 'company', orgConv);
13257
+ tfReopenPendingRunDelete(rail);
13093
13258
  }
13094
13259
  const profile = document.getElementById('company-profile');
13095
13260
  const learn = document.getElementById('company-learnings');
@@ -13157,10 +13322,12 @@ function tfRenderCompany() {
13157
13322
  const orgConv = tfActiveOrgConv();
13158
13323
  const host = document.getElementById('company-conv-host');
13159
13324
  const info = document.getElementById('company-info-view');
13160
- // #1351: a manual "Company Info" pin overrides an otherwise-active org conv until
13161
- // the user (or a genuinely new run) changes it see tf.viewPinned.
13162
- if (orgConv && tf.area === 'company' && !(tf.viewPinned && tf.viewPinned.company)) {
13163
- state.activeId = orgConv.id;
13325
+ // #1397: resolve the area's one stable destination an explicit conversation choice,
13326
+ // an explicit "Company Info" choice, or (absent either) the org conv auto-home. Replaces
13327
+ // the former Info-only tf.viewPinned special case with one navigation-state contract.
13328
+ const companyView = tfResolveAreaView('company', orgConv);
13329
+ if (tf.area === 'company' && !companyView.info && companyView.conversation) {
13330
+ state.activeId = companyView.conversation.id;
13164
13331
  tfEnsurePageInArea('company');
13165
13332
  if (host) host.hidden = false;
13166
13333
  if (info) info.hidden = true;
@@ -13242,46 +13409,14 @@ function tfRenderManager() {
13242
13409
  infoBtn.textContent = '📋 Manager Info';
13243
13410
  infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
13244
13411
  rail.appendChild(infoBtn);
13245
- // #1351: mirror tfRenderCompany's "if (orgConv) rail.appendChild(tfBuildManagerRunItem(orgConv))"
13246
- // pattern. Without this, a manager-scope run with no persona owner (create-hub-configured-agent,
13247
- // manager-agreements) has NO rail entry at all — the persona-group loop below only lists runs
13248
- // tagged with a hired persona's own key. That leaves the run reachable only while its poll tick
13249
- // forces the run view open; the moment the user pins to "Manager Info" (or the pin survives a
13250
- // tab switch), there is no way back to it. Guarded so a persona-owned active conv is not listed
13251
- // twice (once here, once in its own employee group below).
13252
- const mgrConvOwnedByGroup = !!(mgrConv && mgrConv.personaKey
13253
- && MANAGER_PERSONA_KEYS.has(mgrConv.personaKey) && tfIsPersonaHired(mgrConv.personaKey));
13254
- if (mgrConv && !mgrConvOwnedByGroup) rail.appendChild(tfBuildManagerRunItem(mgrConv));
13255
13412
  // #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
13256
13413
  // Manager agreements now runs from the "Context & rules" section it populates.
13257
- // Issue #702: for each hired persona with catalog jobs, render an EMPLOYEE GROUP on
13258
- // the Manager rail the same avatar + name + runs + "+" presentation as the Projects
13259
- // rail (tfRenderManagerPersonaGroup reuses conv-employee-group), so a persona looks
13260
- // identical on both tabs. Data-driven from bootstrap job metadata (job.requiredPersonaKey),
13261
- // not a hardcoded list. Runs launched here are tagged invokedArea='manager'; the same
13262
- // employee's job launched inside a project stays in that project.
13263
- const mgrPersonas = (state.bootstrap && state.bootstrap.personas) || [];
13264
- const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
13265
- let anyMgrEmployee = false;
13266
- for (const persona of mgrPersonas) {
13267
- // Only manager-scoped personas (Ashley) live on the Manager tab; project employees
13268
- // are shown on Projects, not here (#702 R1b). Without this, an all-hired legacy
13269
- // workspace surfaced every employee on the Manager rail.
13270
- if (!MANAGER_PERSONA_KEYS.has(persona.key)) continue;
13271
- if (!tfIsPersonaHired(persona.key)) continue;
13272
- if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
13273
- if (!anyMgrEmployee) {
13274
- const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
13275
- rail.appendChild(head);
13276
- anyMgrEmployee = true;
13277
- }
13278
- tfRenderManagerPersonaGroup(rail, persona);
13279
- }
13280
- if (anyMgrEmployee) {
13281
- const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
13282
- pNote.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work. Its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
13283
- rail.appendChild(pNote);
13284
- }
13414
+ // Issue #702/#1398: render an EMPLOYEE GROUP for FRAIMworker plus every hired persona
13415
+ // that is either home to this area (Ashley on Manager) or has an actual run here (e.g.
13416
+ // Aida's manager-scoped runs) the same avatar + name + runs + "+" presentation as the
13417
+ // Projects rail. `mgrConv` (the area's own top-level run, e.g. manager-agreements) gets
13418
+ // its own rail entry unless a group above already lists it.
13419
+ tfRenderAreaEmployeeRail(rail, 'manager', mgrConv);
13285
13420
  // #824: the manager rail also attaches run-delete controls; re-attach an open
13286
13421
  // confirm this innerHTML='' rebuild would otherwise drop.
13287
13422
  tfReopenPendingRunDelete(rail);
@@ -13382,10 +13517,11 @@ function tfRenderManager() {
13382
13517
  const mgrConv = tfActiveMgrConv();
13383
13518
  const mgrHost = document.getElementById('manager-conv-host');
13384
13519
  const mgrInfo = document.getElementById('manager-info-view');
13385
- // #1351: a manual "Manager Info" pin overrides an otherwise-active manager conv
13386
- // until the user (or a genuinely new run) changes it — see tf.viewPinned.
13387
- if (mgrConv && tf.area === 'manager' && !(tf.viewPinned && tf.viewPinned.manager)) {
13388
- state.activeId = mgrConv.id;
13520
+ // #1397: resolve the area's one stable destination see tfRenderCompany's companyView
13521
+ // for the full rationale. Replaces the former Info-only tf.viewPinned special case.
13522
+ const managerView = tfResolveAreaView('manager', mgrConv);
13523
+ if (tf.area === 'manager' && !managerView.info && managerView.conversation) {
13524
+ state.activeId = managerView.conversation.id;
13389
13525
  tfEnsurePageInArea('manager');
13390
13526
  if (mgrHost) mgrHost.hidden = false;
13391
13527
  if (mgrInfo) mgrInfo.hidden = true;
@@ -14667,6 +14803,7 @@ function tfApplyOrgBrand(brand) {
14667
14803
  function tfRenderBrandEditor() {
14668
14804
  var host = document.getElementById('company-brand-editor');
14669
14805
  if (!host) return;
14806
+ state.brandEditorDraftActive = false;
14670
14807
  var draft = {
14671
14808
  name: (state.orgBrand && state.orgBrand.name) || '',
14672
14809
  color: (state.orgBrand && state.orgBrand.color) || '',
@@ -14689,13 +14826,13 @@ function tfRenderBrandEditor() {
14689
14826
  + ' <span class="brand-hint" id="be-contrast-hint" style="margin:0"></span></div></div>'
14690
14827
  + ' <div class="brand-editor-actions"><button class="send-button" type="button" id="be-save">Save brand</button>'
14691
14828
  + ' <button class="ghost" type="button" id="be-clear">Reset to FRAIM</button>'
14692
- + ' <span class="brand-hint" id="be-status" style="margin:0"></span></div>'
14829
+ + ' <span class="brand-hint" id="be-status" role="status" aria-live="polite" style="margin:0"></span></div>'
14693
14830
  + ' <div class="brand-preview"><div class="brand-preview-cap">Top nav preview</div>'
14694
- + ' <nav class="hub-tabs" style="display:flex"><span class="hub-brand" id="be-prev-brand"></span>'
14831
+ + ' <nav class="hub-tabs" aria-hidden="true" style="display:flex"><span class="hub-brand" id="be-prev-brand"></span>'
14695
14832
  + ' <span class="hub-brand-divider" id="be-prev-div"></span>'
14696
- + ' <button class="hub-tab on" type="button">Projects</button><button class="hub-tab" type="button">Company</button>'
14833
+ + ' <button class="hub-tab on" type="button" tabindex="-1">Projects</button><button class="hub-tab" type="button" tabindex="-1">Company</button>'
14697
14834
  + ' <div class="nav-right"><span class="hub-cobrand">powered by <span class="hub-cobrand-mark"><img src="' + FRAIM_MARK_SRC + '" alt="FRAIM"></span> FRAIM</span>'
14698
- + ' <button class="avatar-btn" type="button">SM</button></div></nav></div>'
14835
+ + ' <button class="avatar-btn" type="button" tabindex="-1">SM</button></div></nav></div>'
14699
14836
  + '</div>';
14700
14837
 
14701
14838
  var nameEl = host.querySelector('#be-name');
@@ -14704,13 +14841,21 @@ function tfRenderBrandEditor() {
14704
14841
  var dropEl = host.querySelector('#be-logo-drop');
14705
14842
  var swatches = host.querySelector('#be-swatches');
14706
14843
  var statusEl = host.querySelector('#be-status');
14844
+ host.addEventListener('focusin', function () { state.brandEditorDraftActive = true; });
14845
+ function setStatus(message, warn) {
14846
+ state.brandEditorStatus = message;
14847
+ state.brandEditorStatusWarn = !!warn;
14848
+ statusEl.textContent = message;
14849
+ statusEl.className = warn ? 'brand-hint warn' : 'brand-hint';
14850
+ }
14851
+ setStatus(state.brandEditorStatus || '', state.brandEditorStatusWarn);
14707
14852
  nameEl.value = draft.name;
14708
14853
  hexEl.value = draft.color;
14709
14854
 
14710
14855
  BRAND_COLOR_PRESETS.forEach(function (c) {
14711
14856
  var b = document.createElement('button');
14712
14857
  b.type = 'button'; b.className = 'brand-swatch'; b.style.background = c; b.title = c;
14713
- b.addEventListener('click', function () { draft.color = c; hexEl.value = c; refresh(); });
14858
+ b.addEventListener('click', function () { setStatus('', false); draft.color = c; hexEl.value = c; refresh(); });
14714
14859
  swatches.appendChild(b);
14715
14860
  });
14716
14861
 
@@ -14744,14 +14889,15 @@ function tfRenderBrandEditor() {
14744
14889
  }
14745
14890
  }
14746
14891
 
14747
- nameEl.addEventListener('input', function () { draft.name = nameEl.value; refresh(); });
14748
- hexEl.addEventListener('input', function () { draft.color = hexEl.value; refresh(); });
14892
+ nameEl.addEventListener('input', function () { setStatus('', false); draft.name = nameEl.value; refresh(); });
14893
+ hexEl.addEventListener('input', function () { setStatus('', false); draft.color = hexEl.value; refresh(); });
14749
14894
  dropEl.addEventListener('click', function () { fileEl.click(); });
14750
14895
  // Keyboard access (a11y): Enter/Space on the focusable drop zone opens the picker.
14751
14896
  dropEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileEl.click(); } });
14752
14897
  fileEl.addEventListener('change', function () {
14753
14898
  var f = fileEl.files && fileEl.files[0];
14754
14899
  if (!f) return;
14900
+ setStatus('', false);
14755
14901
  if (f.size > 512 * 1024) { host.querySelector('#be-logo-hint').textContent = 'That file is over 512 KB. Pick a smaller logo.'; host.querySelector('#be-logo-hint').className = 'brand-hint warn'; return; }
14756
14902
  var reader = new FileReader();
14757
14903
  reader.onload = function () {
@@ -14763,23 +14909,24 @@ function tfRenderBrandEditor() {
14763
14909
  });
14764
14910
 
14765
14911
  host.querySelector('#be-save').addEventListener('click', function () {
14766
- statusEl.textContent = 'Saving…'; statusEl.className = 'brand-hint';
14912
+ setStatus('Saving…', false);
14767
14913
  var body = { name: draft.name, color: draft.color, logo: draft.logo };
14768
14914
  if (state.projectPath) body.projectPath = state.projectPath;
14769
14915
  requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
14770
14916
  .then(function (resp) {
14771
14917
  tfApplyOrgBrand(resp && resp.brand ? resp.brand : null);
14772
- statusEl.textContent = 'Saved. Applied to your Hub.'; statusEl.className = 'brand-hint';
14918
+ state.brandEditorDraftActive = false;
14919
+ setStatus('Saved. Applied to your Hub.', false);
14773
14920
  tfShowBrandTeamNote(host);
14774
14921
  })
14775
- .catch(function (err) { statusEl.textContent = 'Save failed: ' + (err && err.message ? err.message : 'error'); statusEl.className = 'brand-hint warn'; });
14922
+ .catch(function (err) { setStatus('Save failed: ' + (err && err.message ? err.message : 'error'), true); });
14776
14923
  });
14777
14924
  host.querySelector('#be-clear').addEventListener('click', function () {
14778
14925
  var body = { name: '', color: '', logo: '' };
14779
14926
  if (state.projectPath) body.projectPath = state.projectPath;
14780
14927
  requestJson('/api/ai-hub/brand', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
14781
- .then(function () { tfApplyOrgBrand(null); tfRenderBrandEditor(); })
14782
- .catch(function () {});
14928
+ .then(function () { state.brandEditorDraftActive = false; setStatus('', false); tfApplyOrgBrand(null); tfRenderBrandEditor(); })
14929
+ .catch(function (err) { setStatus('Reset failed: ' + (err && err.message ? err.message : 'error'), true); });
14783
14930
  });
14784
14931
 
14785
14932
  refresh();
@@ -14918,11 +15065,14 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
14918
15065
  const finalMessage = (userContext && userContext.trim()) ? userContext.trim() : jobMessage;
14919
15066
  if (job && typeof startRun === 'function') {
14920
15067
  if (targetArea) tfShowArea(targetArea);
14921
- // #1351: a genuinely new run in this area always wins over an earlier pin to
14922
- // the info view — otherwise the just-started run (e.g. create-hub-configured-agent)
14923
- // would stay invisible behind a stale manual "Manager Info" choice.
14924
- if (tf.viewPinned && (targetArea === 'manager' || targetArea === 'company')) tf.viewPinned[targetArea] = false;
14925
15068
  await startRun(job, finalMessage, employeeId, undefined, targetArea);
15069
+ // #1397: a genuinely new run in this area is itself an explicit destination — it must
15070
+ // win over an earlier selection (Info view or a different conversation), otherwise the
15071
+ // just-started run (e.g. create-hub-configured-agent) would stay invisible behind a
15072
+ // stale manual choice. startRun() already set state.activeId to the new conversation.
15073
+ if (tf.areaSelection && (targetArea === 'manager' || targetArea === 'company') && state.activeId) {
15074
+ tf.areaSelection[targetArea] = state.activeId;
15075
+ }
14926
15076
  // After run creation refresh the area panel so the conversation becomes visible.
14927
15077
  if (targetArea === 'company') tfRenderCompany();
14928
15078
  else if (targetArea === 'manager') tfRenderManager();
@@ -1033,6 +1033,66 @@ img.conv-employee-avatar {
1033
1033
  color: #2e7d32;
1034
1034
  font-weight: 800;
1035
1035
  }
1036
+ /* Issue #1345: job execution mode chip — shown in .conv-topline for Coached-mode jobs. */
1037
+ .job-mode-chip {
1038
+ font-size: 10px;
1039
+ font-weight: 600;
1040
+ letter-spacing: .03em;
1041
+ padding: 2px 8px;
1042
+ border-radius: 999px;
1043
+ flex-shrink: 0;
1044
+ cursor: default;
1045
+ position: relative;
1046
+ }
1047
+ .job-mode-chip--in-training {
1048
+ background: rgba(0, 88, 176, 0.10);
1049
+ color: #0058b0;
1050
+ border: 1px solid rgba(0, 88, 176, 0.18);
1051
+ }
1052
+ [data-theme="dark"] .job-mode-chip--in-training {
1053
+ background: rgba(96, 160, 224, 0.14);
1054
+ color: #60a0e0;
1055
+ border-color: rgba(96, 160, 224, 0.22);
1056
+ }
1057
+ .job-mode-chip--in-training .chip-tooltip {
1058
+ display: none;
1059
+ position: absolute;
1060
+ top: calc(100% + 8px);
1061
+ left: 0;
1062
+ width: 260px;
1063
+ background: #fff;
1064
+ border: 1px solid #d0d0d5;
1065
+ border-radius: 10px;
1066
+ box-shadow: 0 4px 16px rgba(0,0,0,.12);
1067
+ padding: 12px 14px;
1068
+ font-size: 12px;
1069
+ color: #1d1d1f;
1070
+ line-height: 1.6;
1071
+ z-index: var(--z-dropdown-menu);
1072
+ pointer-events: none;
1073
+ white-space: normal;
1074
+ text-transform: none;
1075
+ letter-spacing: normal;
1076
+ font-weight: 400;
1077
+ }
1078
+ [data-theme="dark"] .job-mode-chip--in-training .chip-tooltip {
1079
+ background: #2a2a2e;
1080
+ border-color: #3a3a3f;
1081
+ color: #e8e8ea;
1082
+ }
1083
+ .job-mode-chip--in-training:hover .chip-tooltip { display: block; }
1084
+ .chip-tooltip-section { margin-bottom: 8px; }
1085
+ .chip-tooltip-section:last-child { margin-bottom: 0; }
1086
+ .chip-tooltip-label {
1087
+ font-size: 10px;
1088
+ font-weight: 700;
1089
+ letter-spacing: .04em;
1090
+ text-transform: uppercase;
1091
+ color: #0058b0;
1092
+ margin-bottom: 2px;
1093
+ }
1094
+ [data-theme="dark"] .chip-tooltip-label { color: #60a0e0; }
1095
+
1036
1096
  .conv-header {
1037
1097
  display: flex;
1038
1098
  flex-wrap: wrap;
@@ -2361,21 +2421,21 @@ img.coach-employee-avatar { object-fit: cover; border-radius: 4px; }
2361
2421
  .install-label { font-weight: 500; min-width: 90px; }
2362
2422
  .install-status { color: var(--muted); flex: 1; font-size: 12px; }
2363
2423
  button.small { padding: 4px 10px; font-size: 12px; }
2364
- .configured-agents-setup-disclosure {
2365
- margin-top: 12px;
2366
- border-top: 1px solid var(--line);
2367
- padding-top: 8px;
2368
- }
2369
- .configured-agents-setup-disclosure > summary {
2424
+ /* #1390: quick-pick chips for uninstalled tools inside the Add Agent dialog's
2425
+ Delegate tab — the single setup entry point (see aam-* rules above). */
2426
+ .aam-delegate-quickpicks { margin-bottom: 14px; }
2427
+ .aam-delegate-quickpicks-label {
2370
2428
  font-size: 12px;
2371
2429
  font-weight: 600;
2372
2430
  color: var(--muted);
2373
- cursor: pointer;
2374
- list-style: none;
2375
- user-select: none;
2431
+ margin: 0 0 6px;
2432
+ }
2433
+ .aam-delegate-quickpicks-list { display: flex; flex-wrap: wrap; gap: 6px; }
2434
+ .aam-delegate-quickpick-caption {
2435
+ font-size: 12px;
2436
+ color: var(--muted);
2437
+ margin: 8px 0 0;
2376
2438
  }
2377
- .configured-agents-setup-disclosure > summary::before { content: '▸ '; }
2378
- .configured-agents-setup-disclosure[open] > summary::before { content: '▾ '; }
2379
2439
 
2380
2440
  @media (max-width: 820px) {
2381
2441
  /* Single-column reflow — the rigid 100vh layout doesn't make sense at
@@ -5407,11 +5467,15 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5407
5467
  .hub-cobrand[hidden] { display: none; }
5408
5468
  .hub-cobrand-mark { width: 17px; height: 17px; border-radius: 5px; display: block; }
5409
5469
  .hub-cobrand-mark img { width: 100%; height: 100%; object-fit: contain; display: block; border-radius: 5px; }
5470
+ /* Tablet-width navs cannot hold the company lockup, tabs, search, co-mark, and
5471
+ account control together. Keep the functional controls and shed the co-mark. */
5472
+ @media (max-width: 820px) {
5473
+ .hub-cobrand { display: none; }
5474
+ }
5410
5475
  /* Very narrow windows (the Hub is desktop/Electron-first, but keep the dense nav
5411
5476
  from forcing horizontal page scroll): drop the co-mark and the company name,
5412
5477
  keeping the company logo mark + tabs. */
5413
5478
  @media (max-width: 560px) {
5414
- .hub-cobrand { display: none; }
5415
5479
  .hub-brand-name { display: none; }
5416
5480
  .hub-brand { padding: 0 2px 0 10px; }
5417
5481
  .hub-brand-divider { margin: 0 4px 0 8px; }
@@ -5431,6 +5495,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5431
5495
  .brand-field .brand-hint { font-size: 12px; color: var(--muted); margin-top: 5px; }
5432
5496
  .brand-field .brand-hint.warn { color: var(--warn); }
5433
5497
  .brand-logo-drop { display: flex; align-items: center; gap: 14px; border: 1px dashed color-mix(in srgb, var(--accent) 40%, var(--line)); border-radius: 12px; padding: 14px; background: var(--accent-soft); cursor: pointer; }
5498
+ .brand-logo-drop:focus-visible, .brand-swatch:focus-visible { outline: 2px solid var(--accent-strong); outline-offset: 2px; }
5434
5499
  .brand-logo-drop .blp { width: 44px; height: 44px; border-radius: 10px; box-shadow: 0 0 0 1px var(--line); flex-shrink: 0; overflow: hidden; background: var(--surface); display: flex; align-items: center; justify-content: center; }
5435
5500
  .brand-logo-drop .blp svg, .brand-logo-drop .blp img { width: 100%; height: 100%; object-fit: contain; }
5436
5501
  .brand-logo-drop .blp-txt { font-size: 12.5px; color: var(--text); }
@@ -5439,6 +5504,7 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
5439
5504
  .brand-swatch.sel { box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent); }
5440
5505
  .brand-color-hex { width: 96px; }
5441
5506
  .brand-editor-actions { display: flex; gap: 10px; margin-top: 6px; align-items: center; }
5507
+ [data-theme="dark"] .brand-editor-actions .send-button { color: var(--bg); }
5442
5508
  .brand-preview { border: 1px solid var(--line); border-radius: 12px; overflow: hidden; margin-top: 14px; }
5443
5509
  .brand-preview-cap { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 10px 14px; background: var(--bg); border-bottom: 1px solid var(--line); }
5444
5510