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.
- package/dist/src/ai-hub/desktop-auto-updater.js +74 -0
- package/dist/src/ai-hub/desktop-main.js +25 -6
- package/dist/src/ai-hub/hosts.js +23 -0
- package/dist/src/ai-hub/server.js +79 -7
- package/dist/src/cli/setup/ide-invocation-surfaces.js +4 -1
- package/dist/src/config/ai-manager-hiring.js +1 -0
- package/dist/src/config/learning-domains.js +1 -0
- package/dist/src/config/persona-capability-bundles.js +10 -0
- package/dist/src/fraim/db-service.js +33 -0
- package/package.json +2 -2
- package/public/ai-hub/index.html +9 -2
- package/public/ai-hub/script.js +268 -118
- package/public/ai-hub/styles.css +78 -12
|
@@ -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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"'] },
|
|
@@ -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.
|
|
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.
|
|
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",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -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.
|
|
831
|
-
|
|
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>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
// #
|
|
8447
|
-
|
|
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
|
-
// #
|
|
9997
|
-
//
|
|
9998
|
-
//
|
|
9999
|
-
//
|
|
10000
|
-
|
|
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
|
|
13118
|
+
function tfRenderAreaPersonaGroup(rail, persona, area) {
|
|
12994
13119
|
const sample = { personaKey: persona.key };
|
|
12995
|
-
const runs =
|
|
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
|
|
13017
|
-
//
|
|
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
|
-
// #
|
|
13061
|
-
//
|
|
13062
|
-
//
|
|
13063
|
-
//
|
|
13064
|
-
// in this area, or by the user
|
|
13065
|
-
|
|
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
|
-
|
|
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
|
-
// #
|
|
13161
|
-
//
|
|
13162
|
-
|
|
13163
|
-
|
|
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:
|
|
13258
|
-
//
|
|
13259
|
-
//
|
|
13260
|
-
//
|
|
13261
|
-
//
|
|
13262
|
-
|
|
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
|
-
// #
|
|
13386
|
-
//
|
|
13387
|
-
|
|
13388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14918
|
+
state.brandEditorDraftActive = false;
|
|
14919
|
+
setStatus('Saved. Applied to your Hub.', false);
|
|
14773
14920
|
tfShowBrandTeamNote(host);
|
|
14774
14921
|
})
|
|
14775
|
-
.catch(function (err) {
|
|
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();
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -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
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
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
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
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
|
|