fraim-framework 2.0.182 → 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.
@@ -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')
@@ -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('support@fraimworks.ai')) {
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
- if (!asObject(marketplace.owner)) {
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);
@@ -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}/get-started`, verificationCode, { purpose: 'request-access' });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-framework",
3
- "version": "2.0.182",
3
+ "version": "2.0.183",
4
4
  "description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -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
 
@@ -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
- async function loadBootstrap(projectPath, docUrl) {
124
- const params = new URLSearchParams();
125
- if (projectPath) params.set('projectPath', projectPath);
126
- if (docUrl) params.set('docUrl', docUrl);
127
- const query = params.toString() ? '?' + params.toString() : '';
128
- const fetchOptions = {};
129
- if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
130
- const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
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 { tf.projects = JSON.parse(window.localStorage.getItem(STORAGE_KEY_PROJECTS_512) || '[]'); }
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
- for (const list of Object.values(state.conversations || {})) {
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
- if (proj && proj.folderPath && proj.folderPath !== state.projectPath) {
6104
- tfSwitchProjectFolder(proj.folderPath).catch((e) =>
6105
- console.warn('[512] tfSwitchProjectFolder failed:', e));
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 ? tf.projects.find((p) => p.folderPath === conv.projectPath) : null;
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
- if (!folderPath || folderPath === state.projectPath) return;
9049
- state.projectPath = folderPath;
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
- await loadBootstrap(folderPath);
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 ? ('Jobs for ' + filterName) : 'Assign a job';
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) + 's specialty';
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
- if (state.projectPath && !tf.projects.length) {
9546
- const proj = {
9547
- id: 'p-current',
9548
- name: friendlyProjectName(state.projectPath),
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();
@@ -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);