fraim 2.0.180 → 2.0.183
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/conversation-store.js +4 -0
- package/dist/src/ai-hub/desktop-main.js +8 -0
- package/dist/src/ai-hub/preferences.js +98 -0
- package/dist/src/ai-hub/server.js +37 -0
- package/dist/src/cli/commands/add-ide.js +9 -2
- package/dist/src/cli/commands/setup.js +14 -44
- package/dist/src/cli/distribution/marketplace-bundles.js +31 -12
- package/dist/src/cli/setup/ide-detector.js +7 -2
- package/dist/src/core/config-loader.js +10 -8
- package/dist/src/core/types.js +2 -1
- package/dist/src/services/admin-service.js +1 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +6 -0
- package/public/ai-hub/script.js +228 -32
- package/public/ai-hub/styles.css +44 -0
|
@@ -68,6 +68,10 @@ class AiHubConversationStore {
|
|
|
68
68
|
constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-conversations.json')) {
|
|
69
69
|
this.stateFilePath = stateFilePath;
|
|
70
70
|
}
|
|
71
|
+
listProjectPaths() {
|
|
72
|
+
const state = this.readStore();
|
|
73
|
+
return Object.keys(state.projects || {}).map((projectPath) => normalizeProjectPath(projectPath));
|
|
74
|
+
}
|
|
71
75
|
loadProject(projectPath) {
|
|
72
76
|
const state = this.readStore();
|
|
73
77
|
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
@@ -52,6 +52,13 @@ function parseArgs(argv) {
|
|
|
52
52
|
}
|
|
53
53
|
return { projectPath, preferredPort };
|
|
54
54
|
}
|
|
55
|
+
function applyUserDataOverride() {
|
|
56
|
+
const userDataDir = process.env.FRAIM_AI_HUB_USER_DATA_DIR;
|
|
57
|
+
if (!userDataDir)
|
|
58
|
+
return;
|
|
59
|
+
fs_1.default.mkdirSync(userDataDir, { recursive: true });
|
|
60
|
+
electron_1.app.setPath('userData', userDataDir);
|
|
61
|
+
}
|
|
55
62
|
// ---------------------------------------------------------------------------
|
|
56
63
|
// Tray icon resolution — prefers bundled icon, falls back to a 1×1 empty image
|
|
57
64
|
// so the app never crashes if assets aren't present.
|
|
@@ -271,6 +278,7 @@ async function launchDesktopShell(options) {
|
|
|
271
278
|
// ---------------------------------------------------------------------------
|
|
272
279
|
async function bootstrap() {
|
|
273
280
|
const options = parseArgs(process.argv.slice(2));
|
|
281
|
+
applyUserDataOverride();
|
|
274
282
|
// Single-instance lock — if another instance is already running, focus it
|
|
275
283
|
// and exit rather than spawning a second server + window.
|
|
276
284
|
// Skip when FRAIM_AI_HUB_FAKE_HOST=1 (test mode) so Playwright can launch
|
|
@@ -4,7 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AiHubPreferencesStore = void 0;
|
|
7
|
+
exports.normalizeAiHubProjectList = normalizeAiHubProjectList;
|
|
7
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const crypto_1 = require("crypto");
|
|
8
10
|
const path_1 = __importDefault(require("path"));
|
|
9
11
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
10
12
|
const DEFAULT_CATEGORY = 'marketing';
|
|
@@ -16,7 +18,88 @@ const defaultPreferences = (projectPath) => ({
|
|
|
16
18
|
recentJobIds: [],
|
|
17
19
|
recentJobInstructions: {},
|
|
18
20
|
personaKey: null,
|
|
21
|
+
projects: normalizeAiHubProjectList([], projectPath),
|
|
19
22
|
});
|
|
23
|
+
function normalizeProjectPath(projectPath) {
|
|
24
|
+
return path_1.default.resolve(projectPath || process.cwd());
|
|
25
|
+
}
|
|
26
|
+
function canonicalProjectPath(projectPath) {
|
|
27
|
+
const normalized = normalizeProjectPath(projectPath);
|
|
28
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
29
|
+
}
|
|
30
|
+
function projectPathExists(projectPath) {
|
|
31
|
+
try {
|
|
32
|
+
return fs_1.default.statSync(projectPath).isDirectory();
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function projectIdForPath(projectPath) {
|
|
39
|
+
return `p-${(0, crypto_1.createHash)('sha1').update(canonicalProjectPath(projectPath)).digest('base64url').slice(0, 16)}`;
|
|
40
|
+
}
|
|
41
|
+
function uniqueProjectId(entry, seenIds) {
|
|
42
|
+
const fallbackId = projectIdForPath(entry.folderPath);
|
|
43
|
+
const rawId = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
44
|
+
const preferredId = rawId && rawId !== 'p-current' ? rawId : fallbackId;
|
|
45
|
+
let id = preferredId;
|
|
46
|
+
let suffix = 2;
|
|
47
|
+
while (seenIds.has(id)) {
|
|
48
|
+
id = `${fallbackId}-${suffix}`;
|
|
49
|
+
suffix += 1;
|
|
50
|
+
}
|
|
51
|
+
seenIds.add(id);
|
|
52
|
+
return id;
|
|
53
|
+
}
|
|
54
|
+
function withUniqueProjectIds(projects) {
|
|
55
|
+
const seenIds = new Set();
|
|
56
|
+
return projects.map((entry) => ({ ...entry, id: uniqueProjectId(entry, seenIds) }));
|
|
57
|
+
}
|
|
58
|
+
function normalizeProjectEntry(raw, fallbackPath) {
|
|
59
|
+
if (!raw || typeof raw !== 'object')
|
|
60
|
+
return null;
|
|
61
|
+
const value = raw;
|
|
62
|
+
const rawPath = typeof value.folderPath === 'string'
|
|
63
|
+
? value.folderPath
|
|
64
|
+
: (typeof value.path === 'string'
|
|
65
|
+
? value.path
|
|
66
|
+
: (typeof value.folder === 'string' ? value.folder : fallbackPath));
|
|
67
|
+
if (!rawPath || typeof rawPath !== 'string')
|
|
68
|
+
return null;
|
|
69
|
+
const folderPath = normalizeProjectPath(rawPath);
|
|
70
|
+
const basename = path_1.default.basename(folderPath) || folderPath;
|
|
71
|
+
const team = Array.isArray(value.team) ? value.team.filter((entry) => typeof entry === 'string') : [];
|
|
72
|
+
const rawId = typeof value.id === 'string' ? value.id.trim() : '';
|
|
73
|
+
return {
|
|
74
|
+
id: rawId || projectIdForPath(folderPath),
|
|
75
|
+
name: typeof value.name === 'string' && value.name.length > 0 ? value.name : basename,
|
|
76
|
+
folderPath,
|
|
77
|
+
intent: typeof value.intent === 'string' ? value.intent : '',
|
|
78
|
+
outcome: typeof value.outcome === 'string' ? value.outcome : '',
|
|
79
|
+
rules: typeof value.rules === 'string' ? value.rules : '',
|
|
80
|
+
brief: typeof value.brief === 'string' ? value.brief : (typeof value.intent === 'string' ? value.intent : ''),
|
|
81
|
+
team,
|
|
82
|
+
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function normalizeAiHubProjectList(projects, currentProjectPath, options = {}) {
|
|
86
|
+
const byPath = new Map();
|
|
87
|
+
const currentKey = currentProjectPath ? normalizeProjectPath(currentProjectPath) : null;
|
|
88
|
+
const add = (entry) => {
|
|
89
|
+
if (!entry)
|
|
90
|
+
return;
|
|
91
|
+
const key = normalizeProjectPath(entry.folderPath);
|
|
92
|
+
if (!options.includeMissing && key !== currentKey && !projectPathExists(key))
|
|
93
|
+
return;
|
|
94
|
+
const existing = byPath.get(key);
|
|
95
|
+
byPath.set(key, existing ? { ...existing, ...entry, folderPath: key } : { ...entry, folderPath: key });
|
|
96
|
+
};
|
|
97
|
+
if (currentProjectPath)
|
|
98
|
+
add(normalizeProjectEntry({ folderPath: currentProjectPath }, currentProjectPath));
|
|
99
|
+
for (const project of projects)
|
|
100
|
+
add(normalizeProjectEntry(project));
|
|
101
|
+
return withUniqueProjectIds(Array.from(byPath.values()));
|
|
102
|
+
}
|
|
20
103
|
class AiHubPreferencesStore {
|
|
21
104
|
constructor(stateFilePath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-state.json')) {
|
|
22
105
|
this.stateFilePath = stateFilePath;
|
|
@@ -37,6 +120,7 @@ class AiHubPreferencesStore {
|
|
|
37
120
|
: {},
|
|
38
121
|
personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
|
|
39
122
|
apiKey: typeof raw.apiKey === 'string' && raw.apiKey.length > 0 ? raw.apiKey : undefined,
|
|
123
|
+
projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath),
|
|
40
124
|
};
|
|
41
125
|
}
|
|
42
126
|
catch {
|
|
@@ -47,6 +131,20 @@ class AiHubPreferencesStore {
|
|
|
47
131
|
fs_1.default.mkdirSync(path_1.default.dirname(this.stateFilePath), { recursive: true });
|
|
48
132
|
fs_1.default.writeFileSync(this.stateFilePath, JSON.stringify(preferences, null, 2));
|
|
49
133
|
}
|
|
134
|
+
saveProjects(projectPath, projects) {
|
|
135
|
+
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
136
|
+
const preferences = this.load(normalizedProjectPath);
|
|
137
|
+
const nextProjects = normalizeAiHubProjectList(projects, normalizedProjectPath);
|
|
138
|
+
this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
|
|
139
|
+
return nextProjects;
|
|
140
|
+
}
|
|
141
|
+
mergeProjects(projectPath, projects) {
|
|
142
|
+
const normalizedProjectPath = normalizeProjectPath(projectPath);
|
|
143
|
+
const preferences = this.load(normalizedProjectPath);
|
|
144
|
+
const nextProjects = normalizeAiHubProjectList([...(preferences.projects || []), ...projects], normalizedProjectPath);
|
|
145
|
+
this.save({ ...preferences, projectPath: normalizedProjectPath, projects: nextProjects });
|
|
146
|
+
return nextProjects;
|
|
147
|
+
}
|
|
50
148
|
remember(preferences, jobId, instructions) {
|
|
51
149
|
const recentJobIds = jobId
|
|
52
150
|
? [jobId, ...preferences.recentJobIds.filter((value) => value !== jobId)].slice(0, 8)
|
|
@@ -1373,6 +1373,19 @@ class AiHubServer {
|
|
|
1373
1373
|
}
|
|
1374
1374
|
}
|
|
1375
1375
|
getHttpsPort() { return this.httpsPort; }
|
|
1376
|
+
knownProjects(projectPath, extras = []) {
|
|
1377
|
+
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1378
|
+
const preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
1379
|
+
const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
|
|
1380
|
+
return (0, preferences_1.normalizeAiHubProjectList)([
|
|
1381
|
+
...(preferences.projects || []),
|
|
1382
|
+
...conversationProjects,
|
|
1383
|
+
...extras,
|
|
1384
|
+
], normalizedProjectPath);
|
|
1385
|
+
}
|
|
1386
|
+
saveKnownProjects(projectPath, projects) {
|
|
1387
|
+
return this.preferencesStore.saveProjects(path_1.default.resolve(projectPath || this.projectPath), this.knownProjects(projectPath, projects));
|
|
1388
|
+
}
|
|
1376
1389
|
async bootstrapResponse(projectPath, apiKey) {
|
|
1377
1390
|
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1378
1391
|
const employees = this.hostRuntime.detectEmployees();
|
|
@@ -1402,6 +1415,9 @@ class AiHubServer {
|
|
|
1402
1415
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
|
|
1403
1416
|
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
|
|
1404
1417
|
const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
|
|
1418
|
+
const projects = this.knownProjects(normalizedProjectPath);
|
|
1419
|
+
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1420
|
+
this.preferencesStore.save(preferences);
|
|
1405
1421
|
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
1406
1422
|
// so the bootstrap surface (used on first paint) carries stages and
|
|
1407
1423
|
// live totals — not just the raw run state.
|
|
@@ -1418,6 +1434,7 @@ class AiHubServer {
|
|
|
1418
1434
|
personas,
|
|
1419
1435
|
subscriptionActive,
|
|
1420
1436
|
activeRun,
|
|
1437
|
+
projects,
|
|
1421
1438
|
// Issue #512 (S3) — additive manager-flow projections.
|
|
1422
1439
|
firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
|
|
1423
1440
|
teamContext: this.computeTeamContext(normalizedProjectPath),
|
|
@@ -2183,6 +2200,26 @@ class AiHubServer {
|
|
|
2183
2200
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
|
|
2184
2201
|
}
|
|
2185
2202
|
});
|
|
2203
|
+
this.app.get('/api/ai-hub/projects', (req, res) => {
|
|
2204
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2205
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
2206
|
+
: this.projectPath;
|
|
2207
|
+
return res.json({ projectPath, projects: this.knownProjects(projectPath), source: 'disk' });
|
|
2208
|
+
});
|
|
2209
|
+
this.app.put('/api/ai-hub/projects', (req, res) => {
|
|
2210
|
+
try {
|
|
2211
|
+
const body = (req.body ?? {});
|
|
2212
|
+
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2213
|
+
if (!Array.isArray(body.projects)) {
|
|
2214
|
+
return res.status(400).json({ error: 'projects array required' });
|
|
2215
|
+
}
|
|
2216
|
+
const projects = this.saveKnownProjects(projectPath, body.projects);
|
|
2217
|
+
return res.json({ projectPath, projects, source: 'disk' });
|
|
2218
|
+
}
|
|
2219
|
+
catch (error) {
|
|
2220
|
+
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2186
2223
|
this.app.post('/api/ai-hub/api-key', (req, res) => {
|
|
2187
2224
|
const { apiKey } = req.body;
|
|
2188
2225
|
if (!apiKey || typeof apiKey !== 'string')
|
|
@@ -345,7 +345,7 @@ const runAddIDE = async (options) => {
|
|
|
345
345
|
// Check if any provider tokens exist
|
|
346
346
|
const allProviderIds = await (0, provider_registry_1.getAllProviderIds)();
|
|
347
347
|
const hasAnyToken = allProviderIds.some(id => platformTokens[id]);
|
|
348
|
-
if (!hasAnyToken && !isConversationalMode) {
|
|
348
|
+
if (!hasAnyToken && !isConversationalMode && !options.skipTokenPrompts) {
|
|
349
349
|
console.log(chalk_1.default.yellow('⚠️ No provider tokens found in configuration.'));
|
|
350
350
|
// Prompt for first integrated provider as default
|
|
351
351
|
const integratedProviders = await (0, provider_registry_1.getProvidersWithCapability)('integrated');
|
|
@@ -434,7 +434,14 @@ const runAddIDE = async (options) => {
|
|
|
434
434
|
});
|
|
435
435
|
}
|
|
436
436
|
if (results.successful.length > 0) {
|
|
437
|
-
const { describeConfiguredInvocationSurfaces } = await Promise.resolve().then(() => __importStar(require('../setup/ide-global-integration')));
|
|
437
|
+
const { installSlashCommands, installGlobalRules, describeConfiguredInvocationSurfaces } = await Promise.resolve().then(() => __importStar(require('../setup/ide-global-integration')));
|
|
438
|
+
try {
|
|
439
|
+
await installSlashCommands();
|
|
440
|
+
await installGlobalRules();
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
console.log(chalk_1.default.yellow(`⚠️ IDE surface installation encountered issues: ${e.message}`));
|
|
444
|
+
}
|
|
438
445
|
const successfulIDEs = idesToConfigure.filter(ide => results.successful.includes(ide.name));
|
|
439
446
|
const invocationSummaries = describeConfiguredInvocationSurfaces(successfulIDEs);
|
|
440
447
|
console.log(chalk_1.default.blue('\n🔄 Next steps:'));
|
|
@@ -43,7 +43,7 @@ const chalk_1 = __importDefault(require("chalk"));
|
|
|
43
43
|
const prompts_1 = __importDefault(require("prompts"));
|
|
44
44
|
const fs_1 = __importDefault(require("fs"));
|
|
45
45
|
const path_1 = __importDefault(require("path"));
|
|
46
|
-
const
|
|
46
|
+
const add_ide_1 = require("./add-ide");
|
|
47
47
|
const provider_registry_1 = require("../providers/provider-registry");
|
|
48
48
|
const script_sync_utils_1 = require("../utils/script-sync-utils");
|
|
49
49
|
function parseModeOption(mode) {
|
|
@@ -252,7 +252,7 @@ const runSetup = async (options) => {
|
|
|
252
252
|
console.log(chalk_1.default.blue('\n💾 Saving global configuration...'));
|
|
253
253
|
(0, exports.saveGlobalConfig)(fraimKey, mode);
|
|
254
254
|
console.log(chalk_1.default.blue('\n🔌 Configuring MCP servers...'));
|
|
255
|
-
await (0,
|
|
255
|
+
await (0, add_ide_1.runAddIDE)({ ide: options.ide, all: !options.ide, skipTokenPrompts: true });
|
|
256
256
|
console.log(chalk_1.default.green('\n🎯 Reconfiguration complete!'));
|
|
257
257
|
console.log(chalk_1.default.cyan('\n💡 To connect platforms, run: fraim add-provider <github|gitlab|ado|jira>'));
|
|
258
258
|
return;
|
|
@@ -280,39 +280,19 @@ const runSetup = async (options) => {
|
|
|
280
280
|
// Save global configuration (key + mode only; provider tokens live in IDE MCP configs)
|
|
281
281
|
console.log(chalk_1.default.blue('💾 Saving global configuration...'));
|
|
282
282
|
(0, exports.saveGlobalConfig)(fraimKey, mode);
|
|
283
|
-
// Configure MCP servers
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
try {
|
|
291
|
-
await (0, auto_mcp_setup_1.autoConfigureMCP)(fraimKey, options.ide ? [options.ide] : undefined);
|
|
292
|
-
}
|
|
293
|
-
catch (e) {
|
|
294
|
-
console.log(chalk_1.default.yellow('⚠️ MCP configuration encountered issues'));
|
|
295
|
-
console.log(chalk_1.default.gray(' You can configure MCP manually or run setup again later\n'));
|
|
296
|
-
}
|
|
283
|
+
// Configure MCP servers and install IDE surfaces (slash commands, rules)
|
|
284
|
+
// Delegates entirely to add-ide so there is one implementation of this logic.
|
|
285
|
+
console.log(chalk_1.default.blue(isUpdate ? '\n🔄 Updating IDE MCP configurations...' : '\n🔌 Configuring MCP servers...'));
|
|
286
|
+
if (!isUpdate && mode === 'conversational') {
|
|
287
|
+
console.log(chalk_1.default.yellow('ℹ️ Conversational mode: Configuring FRAIM MCP server'));
|
|
288
|
+
console.log(chalk_1.default.gray(' FRAIM jobs will work; platform-specific features are added via fraim add-provider\n'));
|
|
297
289
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
console.log(chalk_1.default.gray(' No IDE configurations found to update'));
|
|
305
|
-
}
|
|
306
|
-
else {
|
|
307
|
-
const ideNames = installedIDEs.map(ide => ide.name);
|
|
308
|
-
await (0, auto_mcp_setup_1.autoConfigureMCP)(fraimKey, ideNames);
|
|
309
|
-
console.log(chalk_1.default.green(`✅ Updated MCP configs for: ${ideNames.join(', ')}`));
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
catch (e) {
|
|
313
|
-
console.log(chalk_1.default.yellow('⚠️ Failed to update IDE MCP configurations'));
|
|
314
|
-
console.log(chalk_1.default.gray(' You can update them manually with: fraim add-ide <ide-name>\n'));
|
|
315
|
-
}
|
|
290
|
+
try {
|
|
291
|
+
await (0, add_ide_1.runAddIDE)({ ide: options.ide, all: isUpdate || !options.ide, skipTokenPrompts: true });
|
|
292
|
+
}
|
|
293
|
+
catch (e) {
|
|
294
|
+
console.log(chalk_1.default.yellow('⚠️ MCP configuration encountered issues'));
|
|
295
|
+
console.log(chalk_1.default.gray(' You can configure MCP manually or run setup again later\n'));
|
|
316
296
|
}
|
|
317
297
|
// Sync user-level FRAIM artifacts (always, on both initial and update)
|
|
318
298
|
try {
|
|
@@ -324,16 +304,6 @@ const runSetup = async (options) => {
|
|
|
324
304
|
console.log(chalk_1.default.yellow(`⚠️ User-level content sync encountered issues: ${e.message}`));
|
|
325
305
|
console.log(chalk_1.default.gray(' You can sync later with: fraim sync --global'));
|
|
326
306
|
}
|
|
327
|
-
// Install IDE slash commands and global rules
|
|
328
|
-
try {
|
|
329
|
-
const { installSlashCommands, installGlobalRules } = await Promise.resolve().then(() => __importStar(require('../setup/ide-global-integration')));
|
|
330
|
-
console.log(chalk_1.default.blue('\n🔗 Installing IDE integrations...'));
|
|
331
|
-
await installSlashCommands();
|
|
332
|
-
await installGlobalRules();
|
|
333
|
-
}
|
|
334
|
-
catch (e) {
|
|
335
|
-
console.log(chalk_1.default.yellow(`⚠️ IDE integration encountered issues: ${e.message}`));
|
|
336
|
-
}
|
|
337
307
|
// Show mode-aware summary with clear value prop and next steps
|
|
338
308
|
console.log(chalk_1.default.green('\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
|
|
339
309
|
console.log(chalk_1.default.green(' FRAIM is ready!'));
|
|
@@ -202,7 +202,7 @@ function validateTargetMatrix(repoRoot, result) {
|
|
|
202
202
|
const relPath = path_1.default.join(TARGET_ROOT, 'marketplace-targets.json');
|
|
203
203
|
const targetMatrix = readJsonObject(repoRoot, relPath, result);
|
|
204
204
|
if (!targetMatrix) {
|
|
205
|
-
return { remoteMcpUrl: null, oauthAuthorizationServer: null, productVersion: null };
|
|
205
|
+
return { remoteMcpUrl: null, oauthAuthorizationServer: null, productVersion: null, contactEmail: null };
|
|
206
206
|
}
|
|
207
207
|
assertEqual(targetMatrix.schema, 'fraim-marketplace-targets-v1', relPath, 'schema', result);
|
|
208
208
|
assertEqual(targetMatrix.issue, 674, relPath, 'issue', result);
|
|
@@ -218,6 +218,13 @@ function validateTargetMatrix(repoRoot, result) {
|
|
|
218
218
|
if (!productVersion) {
|
|
219
219
|
addIssue(result.errors, relPath, 'product.packageVersion must be set');
|
|
220
220
|
}
|
|
221
|
+
const contactEmail = product ? stringValue(product.contactEmail) : null;
|
|
222
|
+
if (!contactEmail) {
|
|
223
|
+
addIssue(result.errors, relPath, 'product.contactEmail must be set');
|
|
224
|
+
}
|
|
225
|
+
if (contactEmail && product) {
|
|
226
|
+
assertEqual(product.support, contactEmail, relPath, 'product.support', result);
|
|
227
|
+
}
|
|
221
228
|
const packageVersion = readPackageVersion(repoRoot, result);
|
|
222
229
|
if (productVersion && packageVersion) {
|
|
223
230
|
assertEqual(productVersion, packageVersion, relPath, 'product.packageVersion', result);
|
|
@@ -229,7 +236,7 @@ function validateTargetMatrix(repoRoot, result) {
|
|
|
229
236
|
? requireHttpsUrl(remoteMcp.oauthAuthorizationServer, relPath, 'remoteMcp.oauthAuthorizationServer', result)
|
|
230
237
|
: null;
|
|
231
238
|
validateTargetEntries(repoRoot, relPath, asArray(targetMatrix.targets), result);
|
|
232
|
-
return { remoteMcpUrl, oauthAuthorizationServer, productVersion };
|
|
239
|
+
return { remoteMcpUrl, oauthAuthorizationServer, productVersion, contactEmail };
|
|
233
240
|
}
|
|
234
241
|
function validateOpenAiAssets(repoRoot, openAiRoot, result) {
|
|
235
242
|
assertFile(repoRoot, path_1.default.join(openAiRoot, 'README.md'), result);
|
|
@@ -254,6 +261,7 @@ function validateOpenAiPacketFields(packet, relPath, targetDetails, result) {
|
|
|
254
261
|
else {
|
|
255
262
|
assertEqual(app.name, 'FRAIM', relPath, 'app.name', result);
|
|
256
263
|
requireHttpsUrl(app.website, relPath, 'app.website', result);
|
|
264
|
+
assertEqual(app.support, targetDetails.contactEmail, relPath, 'app.support', result);
|
|
257
265
|
requireHttpsUrl(app.privacyPolicy, relPath, 'app.privacyPolicy', result);
|
|
258
266
|
requireHttpsUrl(app.termsOfService, relPath, 'app.termsOfService', result);
|
|
259
267
|
}
|
|
@@ -288,7 +296,7 @@ function validateOpenAiPacket(repoRoot, result, targetDetails) {
|
|
|
288
296
|
assertEqual(packet.schema, 'fraim-openai-app-submission-packet-v1', relPath, 'schema', result);
|
|
289
297
|
validateOpenAiPacketFields(packet, relPath, targetDetails, result);
|
|
290
298
|
}
|
|
291
|
-
function validatePolicyPages(repoRoot, result) {
|
|
299
|
+
function validatePolicyPages(repoRoot, targetDetails, result) {
|
|
292
300
|
for (const relPath of POLICY_PAGE_PATHS) {
|
|
293
301
|
if (!assertFile(repoRoot, relPath, result)) {
|
|
294
302
|
continue;
|
|
@@ -297,7 +305,7 @@ function validatePolicyPages(repoRoot, result) {
|
|
|
297
305
|
if (!content.includes('FRAIM')) {
|
|
298
306
|
addIssue(result.errors, relPath, 'policy page must identify FRAIM');
|
|
299
307
|
}
|
|
300
|
-
if (!content.includes(
|
|
308
|
+
if (targetDetails.contactEmail && !content.includes(targetDetails.contactEmail)) {
|
|
301
309
|
addIssue(result.errors, relPath, 'policy page must include support contact');
|
|
302
310
|
}
|
|
303
311
|
}
|
|
@@ -337,11 +345,12 @@ function validateCodexManifestInterface(repoRoot, pluginRoot, manifestRelPath, m
|
|
|
337
345
|
assertRelativeAsset(repoRoot, pluginRoot, screenshot, manifestRelPath, `interface.screenshots[${index}]`, result);
|
|
338
346
|
}
|
|
339
347
|
}
|
|
340
|
-
function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, productVersion, result) {
|
|
348
|
+
function validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, productVersion, contactEmail, result) {
|
|
341
349
|
if (!manifest)
|
|
342
350
|
return;
|
|
343
351
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
344
352
|
assertEqual(manifest.version, productVersion, manifestRelPath, 'version', result);
|
|
353
|
+
assertEqual(asObject(manifest.author)?.email, contactEmail, manifestRelPath, 'author.email', result);
|
|
345
354
|
assertEqual(manifest.skills, './skills/', manifestRelPath, 'skills', result);
|
|
346
355
|
assertEqual(manifest.mcpServers, './.mcp.json', manifestRelPath, 'mcpServers', result);
|
|
347
356
|
const manifestInterface = asObject(manifest.interface);
|
|
@@ -400,17 +409,21 @@ function validateCodexPackage(repoRoot, result, targetDetails) {
|
|
|
400
409
|
const manifest = readJsonObject(repoRoot, manifestRelPath, result);
|
|
401
410
|
const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
|
|
402
411
|
validateCodexMarketplace(marketplace, marketplaceRelPath, result);
|
|
403
|
-
validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.productVersion, result);
|
|
412
|
+
validateCodexManifest(repoRoot, pluginRoot, manifest, manifestRelPath, targetDetails.productVersion, targetDetails.contactEmail, result);
|
|
404
413
|
validateCodexMcpConfig(mcpConfig, mcpRelPath, targetDetails.remoteMcpUrl, result);
|
|
405
414
|
validateCodexSkill(repoRoot, skillRelPath, result);
|
|
406
415
|
}
|
|
407
|
-
function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, productVersion, result) {
|
|
416
|
+
function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expectedName, expectedSource, productVersion, contactEmail, result) {
|
|
408
417
|
if (!marketplace)
|
|
409
418
|
return;
|
|
410
419
|
assertEqual(marketplace.name, expectedName, marketplaceRelPath, 'name', result);
|
|
411
|
-
|
|
420
|
+
const owner = asObject(marketplace.owner);
|
|
421
|
+
if (!owner) {
|
|
412
422
|
addIssue(result.errors, marketplaceRelPath, 'owner must be an object');
|
|
413
423
|
}
|
|
424
|
+
else {
|
|
425
|
+
assertEqual(owner.email, contactEmail, marketplaceRelPath, 'owner.email', result);
|
|
426
|
+
}
|
|
414
427
|
const entries = asArray(marketplace.plugins);
|
|
415
428
|
const fraimEntry = entries
|
|
416
429
|
.map((entry) => asObject(entry))
|
|
@@ -421,6 +434,10 @@ function validateSimpleMarketplaceManifest(marketplace, marketplaceRelPath, expe
|
|
|
421
434
|
}
|
|
422
435
|
assertEqual(fraimEntry.source, expectedSource, marketplaceRelPath, 'fraim.source', result);
|
|
423
436
|
assertEqual(fraimEntry.version, productVersion, marketplaceRelPath, 'fraim.version', result);
|
|
437
|
+
const author = asObject(fraimEntry.author);
|
|
438
|
+
if (author) {
|
|
439
|
+
assertEqual(author.email, contactEmail, marketplaceRelPath, 'fraim.author.email', result);
|
|
440
|
+
}
|
|
424
441
|
}
|
|
425
442
|
function validateMcpRegistryPackage(repoRoot, result, targetDetails) {
|
|
426
443
|
const registryRoot = path_1.default.join(TARGET_ROOT, 'mcp-registry');
|
|
@@ -467,11 +484,12 @@ function validateCursorPackage(repoRoot, result, targetDetails) {
|
|
|
467
484
|
const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
|
|
468
485
|
assertFile(repoRoot, path_1.default.join(cursorRoot, 'README.md'), result);
|
|
469
486
|
assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
|
|
470
|
-
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.productVersion, result);
|
|
471
|
-
validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.productVersion, result);
|
|
487
|
+
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_CURSOR_MARKETPLACE_PATH, 'fraim-marketplace', 'marketplaces/fraim/cursor/plugins/fraim', targetDetails.productVersion, targetDetails.contactEmail, result);
|
|
488
|
+
validateSimpleMarketplaceManifest(targetMarketplace, targetMarketplaceRelPath, 'fraim-marketplace', 'plugins/fraim', targetDetails.productVersion, targetDetails.contactEmail, result);
|
|
472
489
|
if (manifest) {
|
|
473
490
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
474
491
|
assertEqual(manifest.version, targetDetails.productVersion, manifestRelPath, 'version', result);
|
|
492
|
+
assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
|
|
475
493
|
assertEqual(manifest.rules, 'rules/', manifestRelPath, 'rules', result);
|
|
476
494
|
assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
|
|
477
495
|
assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
|
|
@@ -514,10 +532,11 @@ function validateCopilotPackage(repoRoot, result, targetDetails) {
|
|
|
514
532
|
const mcpConfig = readJsonObject(repoRoot, mcpRelPath, result);
|
|
515
533
|
assertFile(repoRoot, path_1.default.join(vscodeRoot, 'README.md'), result);
|
|
516
534
|
assertFile(repoRoot, path_1.default.join(pluginRoot, 'README.md'), result);
|
|
517
|
-
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.productVersion, result);
|
|
535
|
+
validateSimpleMarketplaceManifest(rootMarketplace, ROOT_COPILOT_MARKETPLACE_PATH, 'fraim-copilot-marketplace', 'marketplaces/fraim/vscode/plugins/fraim', targetDetails.productVersion, targetDetails.contactEmail, result);
|
|
518
536
|
if (manifest) {
|
|
519
537
|
assertEqual(manifest.name, 'fraim', manifestRelPath, 'name', result);
|
|
520
538
|
assertEqual(manifest.version, targetDetails.productVersion, manifestRelPath, 'version', result);
|
|
539
|
+
assertEqual(asObject(manifest.author)?.email, targetDetails.contactEmail, manifestRelPath, 'author.email', result);
|
|
521
540
|
assertEqual(manifest.skills, 'skills/', manifestRelPath, 'skills', result);
|
|
522
541
|
assertEqual(manifest.commands, 'commands/', manifestRelPath, 'commands', result);
|
|
523
542
|
assertEqual(manifest.mcpServers, '.mcp.json', manifestRelPath, 'mcpServers', result);
|
|
@@ -559,7 +578,7 @@ function validateMarketplaceBundles(repoRoot = process.cwd()) {
|
|
|
559
578
|
assertFile(repoRoot, ACCEPTANCE_EVIDENCE_PATH, result);
|
|
560
579
|
const targetDetails = validateTargetMatrix(repoRoot, result);
|
|
561
580
|
validateOpenAiPacket(repoRoot, result, targetDetails);
|
|
562
|
-
validatePolicyPages(repoRoot, result);
|
|
581
|
+
validatePolicyPages(repoRoot, targetDetails, result);
|
|
563
582
|
validateCodexPackage(repoRoot, result, targetDetails);
|
|
564
583
|
validateMcpRegistryPackage(repoRoot, result, targetDetails);
|
|
565
584
|
validateCursorPackage(repoRoot, result, targetDetails);
|
|
@@ -161,14 +161,19 @@ exports.IDE_CONFIGS = [
|
|
|
161
161
|
},
|
|
162
162
|
{
|
|
163
163
|
name: 'Claude Desktop / Cowork',
|
|
164
|
-
configPath:
|
|
164
|
+
configPath: process.platform === 'win32'
|
|
165
|
+
? '~/AppData/Roaming/Claude/claude_desktop_config.json'
|
|
166
|
+
: process.platform === 'darwin'
|
|
167
|
+
? '~/Library/Application Support/Claude/claude_desktop_config.json'
|
|
168
|
+
: '~/.config/Claude/claude_desktop_config.json',
|
|
165
169
|
configFormat: 'json',
|
|
166
170
|
configType: 'claude',
|
|
167
171
|
invocationProfile: 'launch-phrase',
|
|
168
172
|
detectMethod: guiAppDetect(detectClaude, 'Claude'),
|
|
169
173
|
aliases: ['claude', 'claude-desktop', 'claude desktop', 'claude-cowork', 'cowork', 'claude cowork'],
|
|
170
174
|
alternativePaths: [
|
|
171
|
-
'~/
|
|
175
|
+
'~/AppData/Roaming/Claude/claude_desktop_config.json',
|
|
176
|
+
'~/Library/Application Support/Claude/claude_desktop_config.json',
|
|
172
177
|
],
|
|
173
178
|
description: 'Anthropic Claude Desktop chat and Cowork surfaces',
|
|
174
179
|
downloadUrl: 'https://claude.ai/download',
|
|
@@ -46,17 +46,18 @@ function normalizeAutomation(config) {
|
|
|
46
46
|
const support = config?.automation?.support;
|
|
47
47
|
if (!support || typeof support !== 'object')
|
|
48
48
|
return undefined;
|
|
49
|
+
const playbooks = support.playbooks && typeof support.playbooks === 'object'
|
|
50
|
+
? support.playbooks
|
|
51
|
+
: {};
|
|
49
52
|
return {
|
|
50
53
|
support: {
|
|
51
54
|
startMode: support.startMode,
|
|
52
55
|
defaultDecisionMode: support.defaultDecisionMode,
|
|
53
|
-
playbooks:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
: undefined,
|
|
56
|
+
playbooks: {
|
|
57
|
+
directory: playbooks.directory || types_1.DEFAULT_SUPPORT_PLAYBOOKS_DIRECTORY,
|
|
58
|
+
decisionCommand: playbooks.decisionCommand,
|
|
59
|
+
decisionTimeoutMs: playbooks.decisionTimeoutMs
|
|
60
|
+
},
|
|
60
61
|
actionAdapters: support.actionAdapters && typeof support.actionAdapters === 'object'
|
|
61
62
|
? support.actionAdapters
|
|
62
63
|
: undefined,
|
|
@@ -71,7 +72,8 @@ function normalizeAutomation(config) {
|
|
|
71
72
|
pollIntervalSeconds: support.queue.pollIntervalSeconds,
|
|
72
73
|
successState: support.queue.successState,
|
|
73
74
|
failureState: support.queue.failureState,
|
|
74
|
-
closeState: support.queue.closeState
|
|
75
|
+
closeState: support.queue.closeState,
|
|
76
|
+
accessScript: support.queue.accessScript
|
|
75
77
|
}
|
|
76
78
|
: undefined,
|
|
77
79
|
requestTypes: support.requestTypes && typeof support.requestTypes === 'object'
|
package/dist/src/core/types.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* TypeScript types for the workspace FRAIM config file.
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.DEFAULT_FRAIM_CONFIG = void 0;
|
|
7
|
+
exports.DEFAULT_FRAIM_CONFIG = exports.DEFAULT_SUPPORT_PLAYBOOKS_DIRECTORY = void 0;
|
|
8
|
+
exports.DEFAULT_SUPPORT_PLAYBOOKS_DIRECTORY = 'playbooks/support';
|
|
8
9
|
/**
|
|
9
10
|
* Default configuration values
|
|
10
11
|
*/
|
|
@@ -166,7 +166,7 @@ class AdminService {
|
|
|
166
166
|
});
|
|
167
167
|
const emailService = new email_service_1.EmailService();
|
|
168
168
|
const baseUrl = process.env.BASE_URL || 'https://fraimworks.ai';
|
|
169
|
-
await emailService.sendEmailCode(emailLower, `${baseUrl}
|
|
169
|
+
await emailService.sendEmailCode(emailLower, `${baseUrl}/?email=${encodeURIComponent(emailLower)}`, verificationCode, { purpose: 'request-access' });
|
|
170
170
|
console.log('[FRAIM] request_access email_code_sent', { userId: emailLower });
|
|
171
171
|
return {
|
|
172
172
|
success: true,
|
package/package.json
CHANGED
package/public/ai-hub/index.html
CHANGED
|
@@ -428,6 +428,12 @@
|
|
|
428
428
|
</div>
|
|
429
429
|
|
|
430
430
|
</div><!-- /.workspace-conv -->
|
|
431
|
+
<div class="proj-switch-mask" id="proj-switch-mask" role="status" aria-live="polite" aria-hidden="true">
|
|
432
|
+
<div class="proj-switch-panel">
|
|
433
|
+
<span class="proj-switch-spinner" aria-hidden="true"></span>
|
|
434
|
+
<span id="proj-switch-label">Loading project...</span>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
431
437
|
</div><!-- /#proj-workspace -->
|
|
432
438
|
</section><!-- /#area-projects -->
|
|
433
439
|
|
package/public/ai-hub/script.js
CHANGED
|
@@ -50,6 +50,8 @@ const state = {
|
|
|
50
50
|
_hireStripPending: null,
|
|
51
51
|
};
|
|
52
52
|
|
|
53
|
+
const bootstrapCache = new Map();
|
|
54
|
+
|
|
53
55
|
const els = {};
|
|
54
56
|
|
|
55
57
|
// ---------------------------------------------------------------------------
|
|
@@ -120,14 +122,16 @@ async function requestJson(url, options) {
|
|
|
120
122
|
return payload;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
function bootstrapCacheKey(projectPath, docUrl) {
|
|
126
|
+
return `${tfCanonicalProjectPath(projectPath || '')}::${docUrl || ''}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function cacheBootstrapPayload(bootstrap, docUrl) {
|
|
130
|
+
if (!bootstrap || !bootstrap.project || !bootstrap.project.path) return;
|
|
131
|
+
bootstrapCache.set(bootstrapCacheKey(bootstrap.project.path, docUrl), bootstrap);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function applyBootstrap(bootstrap, docUrl) {
|
|
131
135
|
state.bootstrap = bootstrap;
|
|
132
136
|
state.projectPath = bootstrap.project.path;
|
|
133
137
|
state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
|
|
@@ -162,6 +166,45 @@ async function loadBootstrap(projectPath, docUrl) {
|
|
|
162
166
|
// Issue #540: render the persona grid and manager team pool.
|
|
163
167
|
renderPersonaGrid();
|
|
164
168
|
renderManagerTeamPool();
|
|
169
|
+
cacheBootstrapPayload(bootstrap, docUrl);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchBootstrap(projectPath, docUrl) {
|
|
173
|
+
const params = new URLSearchParams();
|
|
174
|
+
if (projectPath) params.set('projectPath', projectPath);
|
|
175
|
+
if (docUrl) params.set('docUrl', docUrl);
|
|
176
|
+
const query = params.toString() ? '?' + params.toString() : '';
|
|
177
|
+
const fetchOptions = {};
|
|
178
|
+
if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
|
|
179
|
+
const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
|
|
180
|
+
cacheBootstrapPayload(bootstrap, docUrl);
|
|
181
|
+
return bootstrap;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function refreshBootstrapInBackground(projectPath, docUrl) {
|
|
185
|
+
try {
|
|
186
|
+
const bootstrap = await fetchBootstrap(projectPath, docUrl);
|
|
187
|
+
if (tfCanonicalProjectPath(state.projectPath) !== tfCanonicalProjectPath(bootstrap.project.path)) return;
|
|
188
|
+
applyBootstrap(bootstrap, docUrl);
|
|
189
|
+
await hydrateConversationsFromServer();
|
|
190
|
+
tfRefreshAfterBootstrap();
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.warn('[512] Background project refresh failed:', error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function loadBootstrap(projectPath, docUrl, options) {
|
|
197
|
+
const opts = options || {};
|
|
198
|
+
const requestedKey = bootstrapCacheKey(projectPath || state.projectPath, docUrl);
|
|
199
|
+
if (opts.preferCache && bootstrapCache.has(requestedKey)) {
|
|
200
|
+
const cached = bootstrapCache.get(requestedKey);
|
|
201
|
+
applyBootstrap(cached, docUrl);
|
|
202
|
+
if (opts.backgroundRefresh !== false) refreshBootstrapInBackground(projectPath, docUrl);
|
|
203
|
+
return cached;
|
|
204
|
+
}
|
|
205
|
+
const bootstrap = await fetchBootstrap(projectPath, docUrl);
|
|
206
|
+
applyBootstrap(bootstrap, docUrl);
|
|
207
|
+
return bootstrap;
|
|
165
208
|
}
|
|
166
209
|
|
|
167
210
|
// ---------------------------------------------------------------------------
|
|
@@ -348,6 +391,11 @@ function friendlyProjectName(p) {
|
|
|
348
391
|
return parts.slice(-2).join('/') || p;
|
|
349
392
|
}
|
|
350
393
|
|
|
394
|
+
function friendlyProjectShortName(p) {
|
|
395
|
+
const friendly = friendlyProjectName(p);
|
|
396
|
+
return friendly.split(/[\\/]/).filter(Boolean).pop() || friendly;
|
|
397
|
+
}
|
|
398
|
+
|
|
351
399
|
// ---------------------------------------------------------------------------
|
|
352
400
|
// Conversation persistence (per project, in localStorage)
|
|
353
401
|
// ---------------------------------------------------------------------------
|
|
@@ -5982,20 +6030,137 @@ function tfPersonaByKey(key) { return tfPersonas().find((p) => p.key === key) ||
|
|
|
5982
6030
|
// ---------------------------------------------------------------------------
|
|
5983
6031
|
// Persistence (projects + assignments, mirrors the conversation model)
|
|
5984
6032
|
// ---------------------------------------------------------------------------
|
|
6033
|
+
function tfCanonicalProjectPath(folderPath) {
|
|
6034
|
+
const value = String(folderPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
6035
|
+
return /^[A-Za-z]:\//.test(value) ? value.toLowerCase() : value;
|
|
6036
|
+
}
|
|
6037
|
+
|
|
6038
|
+
function tfProjectIdForPath(folderPath) {
|
|
6039
|
+
const canonical = tfCanonicalProjectPath(folderPath);
|
|
6040
|
+
let hash = 0;
|
|
6041
|
+
for (let i = 0; i < canonical.length; i++) hash = ((hash << 5) - hash + canonical.charCodeAt(i)) | 0;
|
|
6042
|
+
return 'p-' + Math.abs(hash).toString(36);
|
|
6043
|
+
}
|
|
6044
|
+
|
|
6045
|
+
function tfNormalizeProject(raw, fallbackPath) {
|
|
6046
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
6047
|
+
const folderPath = raw.folderPath || raw.path || raw.folder || fallbackPath || '';
|
|
6048
|
+
if (!folderPath) return null;
|
|
6049
|
+
const cleanPath = String(folderPath);
|
|
6050
|
+
const rawId = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
6051
|
+
return {
|
|
6052
|
+
id: rawId || tfProjectIdForPath(cleanPath),
|
|
6053
|
+
name: raw.name || friendlyProjectShortName(cleanPath),
|
|
6054
|
+
folderPath: cleanPath,
|
|
6055
|
+
intent: raw.intent || '',
|
|
6056
|
+
outcome: raw.outcome || '',
|
|
6057
|
+
rules: raw.rules || '',
|
|
6058
|
+
brief: raw.brief || raw.intent || '',
|
|
6059
|
+
team: Array.isArray(raw.team) ? raw.team.filter((key) => typeof key === 'string') : [],
|
|
6060
|
+
updatedAt: raw.updatedAt || undefined,
|
|
6061
|
+
};
|
|
6062
|
+
}
|
|
6063
|
+
|
|
6064
|
+
function tfApplyUniqueProjectIds(projects) {
|
|
6065
|
+
const seen = new Set();
|
|
6066
|
+
return (projects || []).map((project) => {
|
|
6067
|
+
const fallbackId = tfProjectIdForPath(project.folderPath || project.path || project.folder);
|
|
6068
|
+
const rawId = typeof project.id === 'string' ? project.id.trim() : '';
|
|
6069
|
+
let id = rawId && rawId !== 'p-current' ? rawId : fallbackId;
|
|
6070
|
+
let suffix = 2;
|
|
6071
|
+
while (seen.has(id)) {
|
|
6072
|
+
id = `${fallbackId}-${suffix}`;
|
|
6073
|
+
suffix += 1;
|
|
6074
|
+
}
|
|
6075
|
+
seen.add(id);
|
|
6076
|
+
return { ...project, id };
|
|
6077
|
+
});
|
|
6078
|
+
}
|
|
6079
|
+
|
|
6080
|
+
function tfMergeProjects(projects) {
|
|
6081
|
+
const merged = new Map();
|
|
6082
|
+
const add = (project) => {
|
|
6083
|
+
const normalized = tfNormalizeProject(project);
|
|
6084
|
+
if (!normalized) return;
|
|
6085
|
+
const key = tfCanonicalProjectPath(normalized.folderPath);
|
|
6086
|
+
const existing = merged.get(key);
|
|
6087
|
+
if (existing) {
|
|
6088
|
+
const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
|
|
6089
|
+
if ((!Array.isArray(normalized.team) || normalized.team.length === 0) && Array.isArray(existing.team) && existing.team.length > 0) {
|
|
6090
|
+
next.team = existing.team;
|
|
6091
|
+
}
|
|
6092
|
+
merged.set(key, next);
|
|
6093
|
+
} else {
|
|
6094
|
+
merged.set(key, normalized);
|
|
6095
|
+
}
|
|
6096
|
+
};
|
|
6097
|
+
for (const project of tf.projects || []) add(project);
|
|
6098
|
+
for (const project of projects || []) add(project);
|
|
6099
|
+
tf.projects = tfApplyUniqueProjectIds(Array.from(merged.values()));
|
|
6100
|
+
}
|
|
6101
|
+
|
|
6102
|
+
function tfProjectForPath(folderPath) {
|
|
6103
|
+
const key = tfCanonicalProjectPath(folderPath);
|
|
6104
|
+
return tf.projects.find((project) => tfCanonicalProjectPath(project.folderPath || project.path || project.folder) === key) || null;
|
|
6105
|
+
}
|
|
6106
|
+
|
|
6107
|
+
function tfEnsureCurrentProject() {
|
|
6108
|
+
if (!state.projectPath) return null;
|
|
6109
|
+
const current = tfNormalizeProject({
|
|
6110
|
+
id: tfProjectIdForPath(state.projectPath),
|
|
6111
|
+
name: friendlyProjectShortName(state.projectPath),
|
|
6112
|
+
folderPath: state.projectPath,
|
|
6113
|
+
team: tfHiredPersonas().map((p) => p.key),
|
|
6114
|
+
}, state.projectPath);
|
|
6115
|
+
tfMergeProjects([current]);
|
|
6116
|
+
const resolved = tfProjectForPath(state.projectPath);
|
|
6117
|
+
if (!tf.activeProjectId || !tf.projects.some((project) => project.id === tf.activeProjectId)) {
|
|
6118
|
+
tf.activeProjectId = resolved ? resolved.id : tf.projects[0]?.id || null;
|
|
6119
|
+
}
|
|
6120
|
+
return resolved;
|
|
6121
|
+
}
|
|
6122
|
+
|
|
5985
6123
|
function tfLoadStorage() {
|
|
5986
|
-
try {
|
|
6124
|
+
try {
|
|
6125
|
+
const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY_PROJECTS_512) || '[]');
|
|
6126
|
+
const normalized = Array.isArray(stored) ? stored.map((project) => tfNormalizeProject(project)).filter(Boolean) : [];
|
|
6127
|
+
tf.projects = tfApplyUniqueProjectIds(normalized);
|
|
6128
|
+
}
|
|
5987
6129
|
catch { tf.projects = []; }
|
|
5988
6130
|
try { tf.assignments = JSON.parse(window.localStorage.getItem(STORAGE_KEY_ASSIGNMENTS_512) || '{}'); }
|
|
5989
6131
|
catch { tf.assignments = {}; }
|
|
5990
6132
|
}
|
|
5991
6133
|
function tfPersistProjects() {
|
|
5992
6134
|
try { window.localStorage.setItem(STORAGE_KEY_PROJECTS_512, JSON.stringify(tf.projects)); } catch {}
|
|
6135
|
+
if (!state.projectPath) return;
|
|
6136
|
+
requestJson('/api/ai-hub/projects', {
|
|
6137
|
+
method: 'PUT',
|
|
6138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6139
|
+
body: JSON.stringify({ projectPath: state.projectPath, projects: tf.projects }),
|
|
6140
|
+
}).catch((error) => console.warn('[512] Could not persist projects to local disk:', error));
|
|
5993
6141
|
}
|
|
5994
6142
|
function tfPersistAssignments() {
|
|
5995
6143
|
try { window.localStorage.setItem(STORAGE_KEY_ASSIGNMENTS_512, JSON.stringify(tf.assignments)); } catch {}
|
|
5996
6144
|
}
|
|
5997
6145
|
function tfProjectAssignments(projectId) { return tf.assignments[projectId] || []; }
|
|
5998
6146
|
|
|
6147
|
+
function tfSetProjectSwitching(active, project) {
|
|
6148
|
+
const workspace = document.getElementById('proj-workspace');
|
|
6149
|
+
const mask = document.getElementById('proj-switch-mask');
|
|
6150
|
+
const label = document.getElementById('proj-switch-label');
|
|
6151
|
+
if (workspace) {
|
|
6152
|
+
workspace.classList.toggle('project-switching', !!active);
|
|
6153
|
+
workspace.setAttribute('aria-busy', active ? 'true' : 'false');
|
|
6154
|
+
}
|
|
6155
|
+
if (mask) mask.setAttribute('aria-hidden', active ? 'false' : 'true');
|
|
6156
|
+
if (label && project) {
|
|
6157
|
+
label.textContent = `Loading ${project.name || friendlyProjectShortName(project.folderPath || project.path || project.folder)}...`;
|
|
6158
|
+
}
|
|
6159
|
+
document.querySelectorAll('#proj-tabs .ptab').forEach((tab) => {
|
|
6160
|
+
if (tab.id !== 'ptab-add') tab.disabled = !!active;
|
|
6161
|
+
});
|
|
6162
|
+
}
|
|
6163
|
+
|
|
5999
6164
|
// ---------------------------------------------------------------------------
|
|
6000
6165
|
// Status dots — derived from each conversation's read-side run projection.
|
|
6001
6166
|
// red = needs you, amber = working, green = done, grey = idle/no run.
|
|
@@ -6023,9 +6188,13 @@ function tfConvForAssignment(assignment) {
|
|
|
6023
6188
|
}
|
|
6024
6189
|
// #521 R6.5: all past runs of a job across the workspace (newest first) — powers
|
|
6025
6190
|
// the run history under the Company/Manager rail jobs, same as the project tree.
|
|
6026
|
-
function tfConversationsForJob(jobId) {
|
|
6191
|
+
function tfConversationsForJob(jobId, opts) {
|
|
6027
6192
|
const out = [];
|
|
6028
|
-
|
|
6193
|
+
const projectPath = opts && opts.projectPath;
|
|
6194
|
+
const lists = projectPath
|
|
6195
|
+
? [state.conversations[projectPath] || []]
|
|
6196
|
+
: Object.values(state.conversations || {});
|
|
6197
|
+
for (const list of lists) {
|
|
6029
6198
|
for (const c of (list || [])) if (c && c.jobId === jobId) out.push(c);
|
|
6030
6199
|
}
|
|
6031
6200
|
out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
|
|
@@ -6079,7 +6248,7 @@ function tfShowArea(area) {
|
|
|
6079
6248
|
window.scrollTo(0, 0);
|
|
6080
6249
|
}
|
|
6081
6250
|
|
|
6082
|
-
function tfSelectProjectView(view, projectId) {
|
|
6251
|
+
async function tfSelectProjectView(view, projectId) {
|
|
6083
6252
|
tf.projectView = view;
|
|
6084
6253
|
if (projectId) tf.activeProjectId = projectId;
|
|
6085
6254
|
// Always return to the conversation when changing views.
|
|
@@ -6100,9 +6269,16 @@ function tfSelectProjectView(view, projectId) {
|
|
|
6100
6269
|
// When switching to a project workspace, switch the active folder so all
|
|
6101
6270
|
// runs, context reads, and learnings scope to that project's disk location.
|
|
6102
6271
|
const proj = tf.projects.find((p) => p.id === tf.activeProjectId);
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6272
|
+
const folderPath = proj && (proj.folderPath || proj.path || proj.folder);
|
|
6273
|
+
if (folderPath && tfCanonicalProjectPath(folderPath) !== tfCanonicalProjectPath(state.projectPath)) {
|
|
6274
|
+
try {
|
|
6275
|
+
tfSetProjectSwitching(true, proj);
|
|
6276
|
+
await tfSwitchProjectFolder(folderPath);
|
|
6277
|
+
} catch (e) {
|
|
6278
|
+
console.warn('[512] tfSwitchProjectFolder failed:', e);
|
|
6279
|
+
} finally {
|
|
6280
|
+
tfSetProjectSwitching(false);
|
|
6281
|
+
}
|
|
6106
6282
|
}
|
|
6107
6283
|
// #521 fix: invalidate the project-scoped context cache whenever the active
|
|
6108
6284
|
// project changes — covers new-project creation (where state.projectPath is
|
|
@@ -6417,7 +6593,7 @@ function kbMakeCard(conv) {
|
|
|
6417
6593
|
// Kanban/Cards overview is read-only. Clicking anywhere on the card navigates to the workspace.
|
|
6418
6594
|
const navigate = () => {
|
|
6419
6595
|
if (!conv.id) return;
|
|
6420
|
-
const _proj = conv.projectPath ?
|
|
6596
|
+
const _proj = conv.projectPath ? tfProjectForPath(conv.projectPath) : null;
|
|
6421
6597
|
tfSelectProjectView('workspace', _proj ? _proj.id : tf.activeProjectId);
|
|
6422
6598
|
switchToConversation(conv.id);
|
|
6423
6599
|
};
|
|
@@ -7789,7 +7965,7 @@ function tfProjectUpdatesSection() {
|
|
|
7789
7965
|
av.textContent = '🔄';
|
|
7790
7966
|
const id = document.createElement('div'); id.className = 'eh-id';
|
|
7791
7967
|
const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = 'Project Updates';
|
|
7792
|
-
const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id).length, 0);
|
|
7968
|
+
const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id, { projectPath: state.projectPath }).length, 0);
|
|
7793
7969
|
const role = document.createElement('div'); role.className = 'eh-role';
|
|
7794
7970
|
role.textContent = totalRuns ? (totalRuns + (totalRuns === 1 ? ' run' : ' runs')) : 'Onboarding & learnings';
|
|
7795
7971
|
id.appendChild(nm); id.appendChild(role);
|
|
@@ -7811,7 +7987,7 @@ function tfProjectUpdatesSection() {
|
|
|
7811
7987
|
const head = document.createElement('div'); head.className = 'pu-job-name';
|
|
7812
7988
|
head.textContent = j.ico + ' ' + j.name;
|
|
7813
7989
|
group.appendChild(head);
|
|
7814
|
-
const runs = tfConversationsForJob(j.id);
|
|
7990
|
+
const runs = tfConversationsForJob(j.id, { projectPath: state.projectPath });
|
|
7815
7991
|
if (!runs.length) {
|
|
7816
7992
|
const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet.';
|
|
7817
7993
|
group.appendChild(none);
|
|
@@ -9045,8 +9221,13 @@ async function tfCreateProject() {
|
|
|
9045
9221
|
* the server's preferences store records it so it survives restart.
|
|
9046
9222
|
*/
|
|
9047
9223
|
async function tfSwitchProjectFolder(folderPath) {
|
|
9048
|
-
|
|
9049
|
-
|
|
9224
|
+
const normalized = tfNormalizeProject({ folderPath });
|
|
9225
|
+
if (!normalized) return;
|
|
9226
|
+
if (tfCanonicalProjectPath(normalized.folderPath) === tfCanonicalProjectPath(state.projectPath)) {
|
|
9227
|
+
await hydrateConversationsFromServer();
|
|
9228
|
+
return;
|
|
9229
|
+
}
|
|
9230
|
+
state.projectPath = normalized.folderPath;
|
|
9050
9231
|
// #521 fix: the context cache (state._ctxCache) is keyed by context key only
|
|
9051
9232
|
// (projectContext/projectBrief/projectRules), NOT by project — so without this
|
|
9052
9233
|
// the new project's Brief section would render the PREVIOUS project's content
|
|
@@ -9056,7 +9237,23 @@ async function tfSwitchProjectFolder(folderPath) {
|
|
|
9056
9237
|
for (const k of ['projectContext', 'projectBrief', 'projectRules']) delete cache[k];
|
|
9057
9238
|
// loadBootstrap sends projectPath as a query param — the server records it
|
|
9058
9239
|
// in its preferences store so this folder becomes the default on next load.
|
|
9059
|
-
|
|
9240
|
+
const cachedConversations = Array.isArray(state.conversations[normalized.folderPath]);
|
|
9241
|
+
const conversationsPromise = cachedConversations ? null : hydrateConversationsFromServer();
|
|
9242
|
+
await loadBootstrap(normalized.folderPath, undefined, { preferCache: true });
|
|
9243
|
+
if (cachedConversations) {
|
|
9244
|
+
renderRail();
|
|
9245
|
+
renderActive();
|
|
9246
|
+
hydrateConversationsFromServer().catch((error) =>
|
|
9247
|
+
console.warn('[512] Could not refresh project conversations:', error)
|
|
9248
|
+
);
|
|
9249
|
+
} else {
|
|
9250
|
+
await conversationsPromise;
|
|
9251
|
+
}
|
|
9252
|
+
tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
|
|
9253
|
+
const currentProject = tfEnsureCurrentProject();
|
|
9254
|
+
if (currentProject) tf.activeProjectId = currentProject.id;
|
|
9255
|
+
tfPersistProjects();
|
|
9256
|
+
tfRefreshAfterBootstrap();
|
|
9060
9257
|
}
|
|
9061
9258
|
|
|
9062
9259
|
// ---------------------------------------------------------------------------
|
|
@@ -9078,7 +9275,7 @@ function tfOpenAssignJob(employeeKey) {
|
|
|
9078
9275
|
const filterKey = employeeKey || null;
|
|
9079
9276
|
const filterPersona = filterKey ? tfPersonaByKey(filterKey) : null;
|
|
9080
9277
|
const filterName = filterPersona ? (filterPersona.displayName || filterKey) : null;
|
|
9081
|
-
if (title) title.textContent = filterKey ? ('
|
|
9278
|
+
if (title) title.textContent = filterKey ? ('Assign a job to ' + filterName) : 'Assign a job';
|
|
9082
9279
|
|
|
9083
9280
|
const allJobs = (state.bootstrap && state.bootstrap.jobs) || [];
|
|
9084
9281
|
const personasToShow = filterKey ? hired.filter((p) => p.key === filterKey) : hired;
|
|
@@ -9132,7 +9329,7 @@ function tfOpenAssignJob(employeeKey) {
|
|
|
9132
9329
|
if (specificJobs.length > 0) {
|
|
9133
9330
|
const specLabel = document.createElement('div');
|
|
9134
9331
|
specLabel.className = 'cat-label';
|
|
9135
|
-
specLabel.textContent = (filterName || persona.displayName) + '
|
|
9332
|
+
specLabel.textContent = (filterName || persona.displayName) + "'s jobs";
|
|
9136
9333
|
listWrap.appendChild(specLabel);
|
|
9137
9334
|
specificJobs.forEach(function(job) { listWrap.appendChild(buildJobRow(av, job, persona)); });
|
|
9138
9335
|
}
|
|
@@ -9538,19 +9735,15 @@ function tfInitShell() {
|
|
|
9538
9735
|
const tabs = document.getElementById('hub-tabs');
|
|
9539
9736
|
if (tabs) tabs.hidden = false;
|
|
9540
9737
|
tfLoadStorage();
|
|
9738
|
+
tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
|
|
9541
9739
|
tfWireShell();
|
|
9542
9740
|
tfPopulateAccountMenu(); // #533: show the real account identity, not "SM"
|
|
9543
9741
|
// Seed a project from the loaded folder so the workspace reuses the existing
|
|
9544
9742
|
// conversation machinery for that real project path.
|
|
9545
|
-
|
|
9546
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
intent: '', brief: '', team: tfHiredPersonas().map((p) => p.key), path: state.projectPath,
|
|
9550
|
-
};
|
|
9551
|
-
tf.projects.push(proj);
|
|
9552
|
-
if (!tf.assignments[proj.id]) tf.assignments[proj.id] = [];
|
|
9553
|
-
tf.activeProjectId = proj.id;
|
|
9743
|
+
const currentProject = tfEnsureCurrentProject();
|
|
9744
|
+
if (currentProject && !tf.assignments[currentProject.id]) tf.assignments[currentProject.id] = [];
|
|
9745
|
+
if (currentProject) {
|
|
9746
|
+
tf.activeProjectId = currentProject.id;
|
|
9554
9747
|
tfPersistProjects();
|
|
9555
9748
|
} else if (tf.projects.length && !tf.activeProjectId) {
|
|
9556
9749
|
tf.activeProjectId = tf.projects[0].id;
|
|
@@ -9577,6 +9770,9 @@ function tfInitShell() {
|
|
|
9577
9770
|
// Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
|
|
9578
9771
|
function tfRefreshAfterBootstrap() {
|
|
9579
9772
|
if (!tf.enabled) return;
|
|
9773
|
+
tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
|
|
9774
|
+
tfEnsureCurrentProject();
|
|
9775
|
+
tfPersistProjects();
|
|
9580
9776
|
tfRenderProjectTabs();
|
|
9581
9777
|
if (tf.area === 'company') tfRenderCompany();
|
|
9582
9778
|
else if (tf.area === 'manager') tfRenderManager();
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -2915,6 +2915,50 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
2915
2915
|
display: flex;
|
|
2916
2916
|
flex: 1;
|
|
2917
2917
|
min-height: 0;
|
|
2918
|
+
position: relative;
|
|
2919
|
+
}
|
|
2920
|
+
.proj-switch-mask {
|
|
2921
|
+
position: absolute;
|
|
2922
|
+
inset: 0;
|
|
2923
|
+
z-index: 30;
|
|
2924
|
+
display: none;
|
|
2925
|
+
align-items: center;
|
|
2926
|
+
justify-content: center;
|
|
2927
|
+
background: rgba(250, 250, 250, .74);
|
|
2928
|
+
backdrop-filter: blur(2px);
|
|
2929
|
+
pointer-events: auto;
|
|
2930
|
+
}
|
|
2931
|
+
#proj-workspace.project-switching .proj-switch-mask { display: flex; }
|
|
2932
|
+
.proj-switch-panel {
|
|
2933
|
+
display: inline-flex;
|
|
2934
|
+
align-items: center;
|
|
2935
|
+
gap: 10px;
|
|
2936
|
+
min-height: 38px;
|
|
2937
|
+
max-width: min(320px, calc(100vw - 48px));
|
|
2938
|
+
padding: 9px 14px;
|
|
2939
|
+
border: 1px solid var(--line);
|
|
2940
|
+
border-radius: 8px;
|
|
2941
|
+
background: var(--surface);
|
|
2942
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, .12);
|
|
2943
|
+
color: var(--text);
|
|
2944
|
+
font-size: 13px;
|
|
2945
|
+
font-weight: 600;
|
|
2946
|
+
}
|
|
2947
|
+
.proj-switch-spinner {
|
|
2948
|
+
width: 16px;
|
|
2949
|
+
height: 16px;
|
|
2950
|
+
border: 2px solid rgba(0, 0, 0, .14);
|
|
2951
|
+
border-top-color: var(--accent);
|
|
2952
|
+
border-radius: 50%;
|
|
2953
|
+
flex: 0 0 auto;
|
|
2954
|
+
animation: proj-switch-spin .8s linear infinite;
|
|
2955
|
+
}
|
|
2956
|
+
@keyframes proj-switch-spin {
|
|
2957
|
+
to { transform: rotate(360deg); }
|
|
2958
|
+
}
|
|
2959
|
+
.ptab:disabled {
|
|
2960
|
+
cursor: wait;
|
|
2961
|
+
opacity: .72;
|
|
2918
2962
|
}
|
|
2919
2963
|
.tree-sidebar {
|
|
2920
2964
|
width: var(--tree-sidebar-width);
|