fraim-framework 2.0.182 → 2.0.184
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/hosts.js +1 -1
- package/dist/src/ai-hub/manager-turns.js +2 -2
- package/dist/src/ai-hub/preferences.js +99 -0
- package/dist/src/ai-hub/server.js +102 -21
- package/dist/src/cli/distribution/marketplace-bundles.js +47 -29
- package/dist/src/fraim/db-service.js +25 -3
- package/dist/src/routes/oauth-routes.js +40 -5
- package/dist/src/services/account-provisioning.js +38 -0
- package/dist/src/services/admin-service.js +11 -37
- package/package.json +1 -1
- package/public/ai-hub/index.html +6 -0
- package/public/ai-hub/script.js +270 -61
- package/public/ai-hub/styles.css +44 -0
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
|
// ---------------------------------------------------------------------------
|
|
@@ -620,10 +668,16 @@ function stripReviewHandoffBlocks(text) {
|
|
|
620
668
|
.trim();
|
|
621
669
|
}
|
|
622
670
|
|
|
623
|
-
function
|
|
671
|
+
function stripHubInjectedNotes(text) {
|
|
624
672
|
return String(text || '')
|
|
625
673
|
.replace(/(?:^|\n)\s*\[How to talk to me\][\s\S]*?(?=\n\s*\[[^\]\n]+\]|\n\s*#{1,6}\s|\n\s*[-*]\s|\n{2,}|$)/gi, '\n')
|
|
626
|
-
.replace(
|
|
674
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
675
|
+
.trim();
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function stripHubInjectedPromptBlocks(text) {
|
|
679
|
+
return stripHubInjectedNotes(text)
|
|
680
|
+
.replace(/(?:^|\n)\s*(?:[$/@]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
|
|
627
681
|
.replace(/\n{3,}/g, '\n\n')
|
|
628
682
|
.trim();
|
|
629
683
|
}
|
|
@@ -2580,8 +2634,8 @@ function closeTemplatePopover() {
|
|
|
2580
2634
|
}
|
|
2581
2635
|
|
|
2582
2636
|
function applyTemplateInvocation(managerJobId) {
|
|
2583
|
-
// The invocation prefix
|
|
2584
|
-
//
|
|
2637
|
+
// The server adds the invocation prefix based on the active employee. Keep
|
|
2638
|
+
// that syntax out of the editable textarea; the thread shows it after send.
|
|
2585
2639
|
// Store the job ID as a pending coaching job. The #coach-note shows a
|
|
2586
2640
|
// human-readable label; the textarea gets editable context the user can amend.
|
|
2587
2641
|
// When the user clicks Send, continueRun sends { coachingJobId, instructions }.
|
|
@@ -2590,7 +2644,7 @@ function applyTemplateInvocation(managerJobId) {
|
|
|
2590
2644
|
// for sessions that stored raw invocation text before this change).
|
|
2591
2645
|
const textarea = els['coach-text'];
|
|
2592
2646
|
textarea.value = textarea.value
|
|
2593
|
-
.replace(/(?:^|\n|\s)[
|
|
2647
|
+
.replace(/(?:^|\n|\s)[$/@]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
|
|
2594
2648
|
.replace(/\s+/g, ' ')
|
|
2595
2649
|
.trim();
|
|
2596
2650
|
textarea.focus();
|
|
@@ -2629,17 +2683,20 @@ function stripStubReference(text) {
|
|
|
2629
2683
|
}
|
|
2630
2684
|
|
|
2631
2685
|
function surfaceText(role, text, conv) {
|
|
2632
|
-
const
|
|
2686
|
+
const withoutStub = stripStubReference(text);
|
|
2687
|
+
const raw = role === 'manager'
|
|
2688
|
+
? stripHubInjectedNotes(withoutStub)
|
|
2689
|
+
: stripHubInjectedPromptBlocks(withoutStub);
|
|
2633
2690
|
if (!raw) return '';
|
|
2634
2691
|
|
|
2635
2692
|
if (role === 'manager') {
|
|
2636
|
-
const invocationOnly = raw.match(/^(?:[
|
|
2693
|
+
const invocationOnly = raw.match(/^(?:[$/@]fraim)\s+([a-z0-9-]+)\s*$/i);
|
|
2637
2694
|
if (invocationOnly) return raw;
|
|
2638
2695
|
const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
|
|
2639
|
-
const invocationWithSlug = new RegExp(`^(?:[
|
|
2696
|
+
const invocationWithSlug = new RegExp(`^(?:[$/@]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
2640
2697
|
return raw
|
|
2641
2698
|
.replace(invocationWithSlug, '')
|
|
2642
|
-
.replace(/^(?:[
|
|
2699
|
+
.replace(/^(?:[$/@]fraim)\s*/i, '')
|
|
2643
2700
|
.trim();
|
|
2644
2701
|
}
|
|
2645
2702
|
|
|
@@ -4592,9 +4649,9 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
4592
4649
|
const trimmedJob = (jobTitle || '').trim();
|
|
4593
4650
|
const raw = (instructions || '').trim();
|
|
4594
4651
|
// Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
|
|
4595
|
-
const fraimSlugMatch = raw.match(/^[
|
|
4652
|
+
const fraimSlugMatch = raw.match(/^[$/@]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
|
|
4596
4653
|
const stripped = raw
|
|
4597
|
-
.replace(/^[
|
|
4654
|
+
.replace(/^[$/@]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
|
|
4598
4655
|
.replace(/\s+/g, ' ')
|
|
4599
4656
|
.trim();
|
|
4600
4657
|
const words = stripped.split(' ').filter(Boolean).slice(0, 6);
|
|
@@ -5956,7 +6013,7 @@ const tf = {
|
|
|
5956
6013
|
area: 'projects', // company | manager | projects | brain
|
|
5957
6014
|
projectView: 'overview', // overview | workspace
|
|
5958
6015
|
activeProjectId: null,
|
|
5959
|
-
projects: [], // [{ id, name, intent, brief
|
|
6016
|
+
projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
|
|
5960
6017
|
assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
|
|
5961
6018
|
npStep: 1,
|
|
5962
6019
|
npSelectedEmployees: [],
|
|
@@ -5978,24 +6035,144 @@ function tfPersonas() { return (state.bootstrap && state.bootstrap.personas) ||
|
|
|
5978
6035
|
function tfHiredPersonas() { return tfPersonas().filter((p) => p.status === 'hired'); }
|
|
5979
6036
|
function tfAvailablePersonas() { return tfPersonas().filter((p) => p.status !== 'hired'); }
|
|
5980
6037
|
function tfPersonaByKey(key) { return tfPersonas().find((p) => p.key === key) || null; }
|
|
6038
|
+
// A project's employee roster is derived from the hired personas in the bootstrap
|
|
6039
|
+
// (DB-authoritative), NOT from per-machine local state. With the legacy-workspace
|
|
6040
|
+
// bypass every user sees the full employee catalog, and the roster is identical on
|
|
6041
|
+
// every machine the user signs in from. Deliberately not persisted to disk /
|
|
6042
|
+
// localStorage — see AiHubProjectListEntry in src/ai-hub/types.ts.
|
|
6043
|
+
function tfProjectTeamKeys() { return tfHiredPersonas().map((p) => p.key); }
|
|
5981
6044
|
|
|
5982
6045
|
// ---------------------------------------------------------------------------
|
|
5983
6046
|
// Persistence (projects + assignments, mirrors the conversation model)
|
|
5984
6047
|
// ---------------------------------------------------------------------------
|
|
6048
|
+
function tfCanonicalProjectPath(folderPath) {
|
|
6049
|
+
const value = String(folderPath || '').replace(/\\/g, '/').replace(/\/+$/, '');
|
|
6050
|
+
return /^[A-Za-z]:\//.test(value) ? value.toLowerCase() : value;
|
|
6051
|
+
}
|
|
6052
|
+
|
|
6053
|
+
function tfProjectIdForPath(folderPath) {
|
|
6054
|
+
const canonical = tfCanonicalProjectPath(folderPath);
|
|
6055
|
+
let hash = 0;
|
|
6056
|
+
for (let i = 0; i < canonical.length; i++) hash = ((hash << 5) - hash + canonical.charCodeAt(i)) | 0;
|
|
6057
|
+
return 'p-' + Math.abs(hash).toString(36);
|
|
6058
|
+
}
|
|
6059
|
+
|
|
6060
|
+
function tfNormalizeProject(raw, fallbackPath) {
|
|
6061
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
6062
|
+
const folderPath = raw.folderPath || raw.path || raw.folder || fallbackPath || '';
|
|
6063
|
+
if (!folderPath) return null;
|
|
6064
|
+
const cleanPath = String(folderPath);
|
|
6065
|
+
const rawId = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
6066
|
+
return {
|
|
6067
|
+
id: rawId || tfProjectIdForPath(cleanPath),
|
|
6068
|
+
name: raw.name || friendlyProjectShortName(cleanPath),
|
|
6069
|
+
folderPath: cleanPath,
|
|
6070
|
+
intent: raw.intent || '',
|
|
6071
|
+
outcome: raw.outcome || '',
|
|
6072
|
+
rules: raw.rules || '',
|
|
6073
|
+
brief: raw.brief || raw.intent || '',
|
|
6074
|
+
// Roster is derived from hired personas at render time (tfProjectTeamKeys),
|
|
6075
|
+
// never stored per machine. Any legacy `team` on stored state is dropped.
|
|
6076
|
+
updatedAt: raw.updatedAt || undefined,
|
|
6077
|
+
};
|
|
6078
|
+
}
|
|
6079
|
+
|
|
6080
|
+
function tfApplyUniqueProjectIds(projects) {
|
|
6081
|
+
const seen = new Set();
|
|
6082
|
+
return (projects || []).map((project) => {
|
|
6083
|
+
const fallbackId = tfProjectIdForPath(project.folderPath || project.path || project.folder);
|
|
6084
|
+
const rawId = typeof project.id === 'string' ? project.id.trim() : '';
|
|
6085
|
+
let id = rawId && rawId !== 'p-current' ? rawId : fallbackId;
|
|
6086
|
+
let suffix = 2;
|
|
6087
|
+
while (seen.has(id)) {
|
|
6088
|
+
id = `${fallbackId}-${suffix}`;
|
|
6089
|
+
suffix += 1;
|
|
6090
|
+
}
|
|
6091
|
+
seen.add(id);
|
|
6092
|
+
return { ...project, id };
|
|
6093
|
+
});
|
|
6094
|
+
}
|
|
6095
|
+
|
|
6096
|
+
function tfMergeProjects(projects) {
|
|
6097
|
+
const merged = new Map();
|
|
6098
|
+
const add = (project) => {
|
|
6099
|
+
const normalized = tfNormalizeProject(project);
|
|
6100
|
+
if (!normalized) return;
|
|
6101
|
+
const key = tfCanonicalProjectPath(normalized.folderPath);
|
|
6102
|
+
const existing = merged.get(key);
|
|
6103
|
+
if (existing) {
|
|
6104
|
+
const next = { ...existing, ...normalized, folderPath: normalized.folderPath || existing.folderPath };
|
|
6105
|
+
merged.set(key, next);
|
|
6106
|
+
} else {
|
|
6107
|
+
merged.set(key, normalized);
|
|
6108
|
+
}
|
|
6109
|
+
};
|
|
6110
|
+
for (const project of tf.projects || []) add(project);
|
|
6111
|
+
for (const project of projects || []) add(project);
|
|
6112
|
+
tf.projects = tfApplyUniqueProjectIds(Array.from(merged.values()));
|
|
6113
|
+
}
|
|
6114
|
+
|
|
6115
|
+
function tfProjectForPath(folderPath) {
|
|
6116
|
+
const key = tfCanonicalProjectPath(folderPath);
|
|
6117
|
+
return tf.projects.find((project) => tfCanonicalProjectPath(project.folderPath || project.path || project.folder) === key) || null;
|
|
6118
|
+
}
|
|
6119
|
+
|
|
6120
|
+
function tfEnsureCurrentProject() {
|
|
6121
|
+
if (!state.projectPath) return null;
|
|
6122
|
+
const current = tfNormalizeProject({
|
|
6123
|
+
id: tfProjectIdForPath(state.projectPath),
|
|
6124
|
+
name: friendlyProjectShortName(state.projectPath),
|
|
6125
|
+
folderPath: state.projectPath,
|
|
6126
|
+
}, state.projectPath);
|
|
6127
|
+
tfMergeProjects([current]);
|
|
6128
|
+
const resolved = tfProjectForPath(state.projectPath);
|
|
6129
|
+
if (!tf.activeProjectId || !tf.projects.some((project) => project.id === tf.activeProjectId)) {
|
|
6130
|
+
tf.activeProjectId = resolved ? resolved.id : tf.projects[0]?.id || null;
|
|
6131
|
+
}
|
|
6132
|
+
return resolved;
|
|
6133
|
+
}
|
|
6134
|
+
|
|
5985
6135
|
function tfLoadStorage() {
|
|
5986
|
-
try {
|
|
6136
|
+
try {
|
|
6137
|
+
const stored = JSON.parse(window.localStorage.getItem(STORAGE_KEY_PROJECTS_512) || '[]');
|
|
6138
|
+
const normalized = Array.isArray(stored) ? stored.map((project) => tfNormalizeProject(project)).filter(Boolean) : [];
|
|
6139
|
+
tf.projects = tfApplyUniqueProjectIds(normalized);
|
|
6140
|
+
}
|
|
5987
6141
|
catch { tf.projects = []; }
|
|
5988
6142
|
try { tf.assignments = JSON.parse(window.localStorage.getItem(STORAGE_KEY_ASSIGNMENTS_512) || '{}'); }
|
|
5989
6143
|
catch { tf.assignments = {}; }
|
|
5990
6144
|
}
|
|
5991
6145
|
function tfPersistProjects() {
|
|
5992
6146
|
try { window.localStorage.setItem(STORAGE_KEY_PROJECTS_512, JSON.stringify(tf.projects)); } catch {}
|
|
6147
|
+
if (!state.projectPath) return;
|
|
6148
|
+
requestJson('/api/ai-hub/projects', {
|
|
6149
|
+
method: 'PUT',
|
|
6150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6151
|
+
body: JSON.stringify({ projectPath: state.projectPath, projects: tf.projects }),
|
|
6152
|
+
}).catch((error) => console.warn('[512] Could not persist projects to local disk:', error));
|
|
5993
6153
|
}
|
|
5994
6154
|
function tfPersistAssignments() {
|
|
5995
6155
|
try { window.localStorage.setItem(STORAGE_KEY_ASSIGNMENTS_512, JSON.stringify(tf.assignments)); } catch {}
|
|
5996
6156
|
}
|
|
5997
6157
|
function tfProjectAssignments(projectId) { return tf.assignments[projectId] || []; }
|
|
5998
6158
|
|
|
6159
|
+
function tfSetProjectSwitching(active, project) {
|
|
6160
|
+
const workspace = document.getElementById('proj-workspace');
|
|
6161
|
+
const mask = document.getElementById('proj-switch-mask');
|
|
6162
|
+
const label = document.getElementById('proj-switch-label');
|
|
6163
|
+
if (workspace) {
|
|
6164
|
+
workspace.classList.toggle('project-switching', !!active);
|
|
6165
|
+
workspace.setAttribute('aria-busy', active ? 'true' : 'false');
|
|
6166
|
+
}
|
|
6167
|
+
if (mask) mask.setAttribute('aria-hidden', active ? 'false' : 'true');
|
|
6168
|
+
if (label && project) {
|
|
6169
|
+
label.textContent = `Loading ${project.name || friendlyProjectShortName(project.folderPath || project.path || project.folder)}...`;
|
|
6170
|
+
}
|
|
6171
|
+
document.querySelectorAll('#proj-tabs .ptab').forEach((tab) => {
|
|
6172
|
+
if (tab.id !== 'ptab-add') tab.disabled = !!active;
|
|
6173
|
+
});
|
|
6174
|
+
}
|
|
6175
|
+
|
|
5999
6176
|
// ---------------------------------------------------------------------------
|
|
6000
6177
|
// Status dots — derived from each conversation's read-side run projection.
|
|
6001
6178
|
// red = needs you, amber = working, green = done, grey = idle/no run.
|
|
@@ -6023,9 +6200,13 @@ function tfConvForAssignment(assignment) {
|
|
|
6023
6200
|
}
|
|
6024
6201
|
// #521 R6.5: all past runs of a job across the workspace (newest first) — powers
|
|
6025
6202
|
// the run history under the Company/Manager rail jobs, same as the project tree.
|
|
6026
|
-
function tfConversationsForJob(jobId) {
|
|
6203
|
+
function tfConversationsForJob(jobId, opts) {
|
|
6027
6204
|
const out = [];
|
|
6028
|
-
|
|
6205
|
+
const projectPath = opts && opts.projectPath;
|
|
6206
|
+
const lists = projectPath
|
|
6207
|
+
? [state.conversations[projectPath] || []]
|
|
6208
|
+
: Object.values(state.conversations || {});
|
|
6209
|
+
for (const list of lists) {
|
|
6029
6210
|
for (const c of (list || [])) if (c && c.jobId === jobId) out.push(c);
|
|
6030
6211
|
}
|
|
6031
6212
|
out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
|
|
@@ -6079,7 +6260,7 @@ function tfShowArea(area) {
|
|
|
6079
6260
|
window.scrollTo(0, 0);
|
|
6080
6261
|
}
|
|
6081
6262
|
|
|
6082
|
-
function tfSelectProjectView(view, projectId) {
|
|
6263
|
+
async function tfSelectProjectView(view, projectId) {
|
|
6083
6264
|
tf.projectView = view;
|
|
6084
6265
|
if (projectId) tf.activeProjectId = projectId;
|
|
6085
6266
|
// Always return to the conversation when changing views.
|
|
@@ -6100,9 +6281,16 @@ function tfSelectProjectView(view, projectId) {
|
|
|
6100
6281
|
// When switching to a project workspace, switch the active folder so all
|
|
6101
6282
|
// runs, context reads, and learnings scope to that project's disk location.
|
|
6102
6283
|
const proj = tf.projects.find((p) => p.id === tf.activeProjectId);
|
|
6103
|
-
|
|
6104
|
-
|
|
6105
|
-
|
|
6284
|
+
const folderPath = proj && (proj.folderPath || proj.path || proj.folder);
|
|
6285
|
+
if (folderPath && tfCanonicalProjectPath(folderPath) !== tfCanonicalProjectPath(state.projectPath)) {
|
|
6286
|
+
try {
|
|
6287
|
+
tfSetProjectSwitching(true, proj);
|
|
6288
|
+
await tfSwitchProjectFolder(folderPath);
|
|
6289
|
+
} catch (e) {
|
|
6290
|
+
console.warn('[512] tfSwitchProjectFolder failed:', e);
|
|
6291
|
+
} finally {
|
|
6292
|
+
tfSetProjectSwitching(false);
|
|
6293
|
+
}
|
|
6106
6294
|
}
|
|
6107
6295
|
// #521 fix: invalidate the project-scoped context cache whenever the active
|
|
6108
6296
|
// project changes — covers new-project creation (where state.projectPath is
|
|
@@ -6235,7 +6423,7 @@ function tfRenderOverview() {
|
|
|
6235
6423
|
const team = document.createElement('div');
|
|
6236
6424
|
team.className = 'proj-team';
|
|
6237
6425
|
const assigned = tfProjectAssignments(proj.id);
|
|
6238
|
-
const empKeys = Array.from(new Set([...(
|
|
6426
|
+
const empKeys = Array.from(new Set([...tfProjectTeamKeys(), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
|
|
6239
6427
|
empKeys.slice(0, 6).forEach((key, i) => {
|
|
6240
6428
|
const persona = tfPersonaByKey(key);
|
|
6241
6429
|
const av = tfAvatarFor(persona ? persona.displayName : key, i);
|
|
@@ -6417,7 +6605,7 @@ function kbMakeCard(conv) {
|
|
|
6417
6605
|
// Kanban/Cards overview is read-only. Clicking anywhere on the card navigates to the workspace.
|
|
6418
6606
|
const navigate = () => {
|
|
6419
6607
|
if (!conv.id) return;
|
|
6420
|
-
const _proj = conv.projectPath ?
|
|
6608
|
+
const _proj = conv.projectPath ? tfProjectForPath(conv.projectPath) : null;
|
|
6421
6609
|
tfSelectProjectView('workspace', _proj ? _proj.id : tf.activeProjectId);
|
|
6422
6610
|
switchToConversation(conv.id);
|
|
6423
6611
|
};
|
|
@@ -6537,17 +6725,16 @@ function tfRenderTree() {
|
|
|
6537
6725
|
const projectId = tf.activeProjectId;
|
|
6538
6726
|
const assigned = tfProjectAssignments(projectId);
|
|
6539
6727
|
const project = tf.projects.find((p) => p.id === projectId);
|
|
6540
|
-
const onProject = (
|
|
6728
|
+
const onProject = tfProjectTeamKeys();
|
|
6541
6729
|
// Issue #540 R8: include personas on the manager's team so delegate buttons
|
|
6542
6730
|
// appear in the workspace tree even when the persona hasn't been assigned yet.
|
|
6543
6731
|
const managerTeamPersonaKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey);
|
|
6544
6732
|
const empKeys = Array.from(new Set([...onProject, ...assigned.map((a) => a.employeeKey).filter(Boolean), ...managerTeamPersonaKeys]))
|
|
6545
|
-
// #538 follow-up: reconcile against real entitlements.
|
|
6546
|
-
//
|
|
6547
|
-
//
|
|
6548
|
-
//
|
|
6549
|
-
//
|
|
6550
|
-
// personas and genuinely custom employees (no persona record).
|
|
6733
|
+
// #538 follow-up: reconcile against real entitlements. `assigned` job records
|
|
6734
|
+
// and manager-team keys can reference personas that are no longer hired; drop
|
|
6735
|
+
// any key that resolves to a known persona that is NOT hired. Keep hired
|
|
6736
|
+
// personas and genuinely custom employees (no persona record). onProject is
|
|
6737
|
+
// already hired-only (tfProjectTeamKeys), so this is a no-op for it.
|
|
6551
6738
|
.filter((key) => { const p = tfPersonaByKey(key); return !p || p.status === 'hired'; });
|
|
6552
6739
|
const managerTeamKeySet = new Set(managerTeamPersonaKeys);
|
|
6553
6740
|
|
|
@@ -6815,9 +7002,12 @@ async function loadDeployments() {
|
|
|
6815
7002
|
const list = document.getElementById('proj-deployments-list');
|
|
6816
7003
|
if (!list) return;
|
|
6817
7004
|
try {
|
|
7005
|
+
const params = new URLSearchParams();
|
|
7006
|
+
if (state.projectPath) params.set('projectPath', state.projectPath);
|
|
7007
|
+
const suffix = params.toString() ? '?' + params.toString() : '';
|
|
6818
7008
|
const [schResp, whResp] = await Promise.all([
|
|
6819
|
-
fetch('/api/ai-hub/schedules'),
|
|
6820
|
-
fetch('/api/ai-hub/webhooks'),
|
|
7009
|
+
fetch('/api/ai-hub/schedules' + suffix),
|
|
7010
|
+
fetch('/api/ai-hub/webhooks' + suffix),
|
|
6821
7011
|
]);
|
|
6822
7012
|
const schedules = schResp.ok ? await schResp.json() : [];
|
|
6823
7013
|
const webhooks = whResp.ok ? await whResp.json() : [];
|
|
@@ -7157,7 +7347,7 @@ async function saveScheduleDeployment() {
|
|
|
7157
7347
|
const resp = await fetch(url, {
|
|
7158
7348
|
method,
|
|
7159
7349
|
headers: { 'Content-Type': 'application/json' },
|
|
7160
|
-
body: JSON.stringify({ label, jobId, hostId, cronExpr, instructions: instructions || undefined }),
|
|
7350
|
+
body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }),
|
|
7161
7351
|
});
|
|
7162
7352
|
if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
|
|
7163
7353
|
_editingDepId = null;
|
|
@@ -7180,7 +7370,7 @@ async function saveWebhookDeployment() {
|
|
|
7180
7370
|
const resp = await fetch(url, {
|
|
7181
7371
|
method,
|
|
7182
7372
|
headers: { 'Content-Type': 'application/json' },
|
|
7183
|
-
body: JSON.stringify({ label, jobId, hostId, instructions: instructions || undefined }),
|
|
7373
|
+
body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }),
|
|
7184
7374
|
});
|
|
7185
7375
|
if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
|
|
7186
7376
|
if (!isEdit) {
|
|
@@ -7789,7 +7979,7 @@ function tfProjectUpdatesSection() {
|
|
|
7789
7979
|
av.textContent = '🔄';
|
|
7790
7980
|
const id = document.createElement('div'); id.className = 'eh-id';
|
|
7791
7981
|
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);
|
|
7982
|
+
const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id, { projectPath: state.projectPath }).length, 0);
|
|
7793
7983
|
const role = document.createElement('div'); role.className = 'eh-role';
|
|
7794
7984
|
role.textContent = totalRuns ? (totalRuns + (totalRuns === 1 ? ' run' : ' runs')) : 'Onboarding & learnings';
|
|
7795
7985
|
id.appendChild(nm); id.appendChild(role);
|
|
@@ -7811,7 +8001,7 @@ function tfProjectUpdatesSection() {
|
|
|
7811
8001
|
const head = document.createElement('div'); head.className = 'pu-job-name';
|
|
7812
8002
|
head.textContent = j.ico + ' ' + j.name;
|
|
7813
8003
|
group.appendChild(head);
|
|
7814
|
-
const runs = tfConversationsForJob(j.id);
|
|
8004
|
+
const runs = tfConversationsForJob(j.id, { projectPath: state.projectPath });
|
|
7815
8005
|
if (!runs.length) {
|
|
7816
8006
|
const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet.';
|
|
7817
8007
|
group.appendChild(none);
|
|
@@ -9024,7 +9214,7 @@ async function tfCreateProject() {
|
|
|
9024
9214
|
intent && `Project intent: ${intent}`,
|
|
9025
9215
|
val('np-outcome') && `Success looks like: ${val('np-outcome')}`,
|
|
9026
9216
|
val('np-rules') && `Rules & guardrails: ${val('np-rules')}`,
|
|
9027
|
-
`Team: ${(
|
|
9217
|
+
`Team: ${tfProjectTeamKeys().join(', ') || 'no employees assigned yet'}`,
|
|
9028
9218
|
].filter(Boolean).join('\n\n');
|
|
9029
9219
|
|
|
9030
9220
|
if (onboardingContext) {
|
|
@@ -9045,8 +9235,13 @@ async function tfCreateProject() {
|
|
|
9045
9235
|
* the server's preferences store records it so it survives restart.
|
|
9046
9236
|
*/
|
|
9047
9237
|
async function tfSwitchProjectFolder(folderPath) {
|
|
9048
|
-
|
|
9049
|
-
|
|
9238
|
+
const normalized = tfNormalizeProject({ folderPath });
|
|
9239
|
+
if (!normalized) return;
|
|
9240
|
+
if (tfCanonicalProjectPath(normalized.folderPath) === tfCanonicalProjectPath(state.projectPath)) {
|
|
9241
|
+
await hydrateConversationsFromServer();
|
|
9242
|
+
return;
|
|
9243
|
+
}
|
|
9244
|
+
state.projectPath = normalized.folderPath;
|
|
9050
9245
|
// #521 fix: the context cache (state._ctxCache) is keyed by context key only
|
|
9051
9246
|
// (projectContext/projectBrief/projectRules), NOT by project — so without this
|
|
9052
9247
|
// the new project's Brief section would render the PREVIOUS project's content
|
|
@@ -9056,7 +9251,23 @@ async function tfSwitchProjectFolder(folderPath) {
|
|
|
9056
9251
|
for (const k of ['projectContext', 'projectBrief', 'projectRules']) delete cache[k];
|
|
9057
9252
|
// loadBootstrap sends projectPath as a query param — the server records it
|
|
9058
9253
|
// in its preferences store so this folder becomes the default on next load.
|
|
9059
|
-
|
|
9254
|
+
const cachedConversations = Array.isArray(state.conversations[normalized.folderPath]);
|
|
9255
|
+
const conversationsPromise = cachedConversations ? null : hydrateConversationsFromServer();
|
|
9256
|
+
await loadBootstrap(normalized.folderPath, undefined, { preferCache: true });
|
|
9257
|
+
if (cachedConversations) {
|
|
9258
|
+
renderRail();
|
|
9259
|
+
renderActive();
|
|
9260
|
+
hydrateConversationsFromServer().catch((error) =>
|
|
9261
|
+
console.warn('[512] Could not refresh project conversations:', error)
|
|
9262
|
+
);
|
|
9263
|
+
} else {
|
|
9264
|
+
await conversationsPromise;
|
|
9265
|
+
}
|
|
9266
|
+
tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
|
|
9267
|
+
const currentProject = tfEnsureCurrentProject();
|
|
9268
|
+
if (currentProject) tf.activeProjectId = currentProject.id;
|
|
9269
|
+
tfPersistProjects();
|
|
9270
|
+
tfRefreshAfterBootstrap();
|
|
9060
9271
|
}
|
|
9061
9272
|
|
|
9062
9273
|
// ---------------------------------------------------------------------------
|
|
@@ -9078,7 +9289,7 @@ function tfOpenAssignJob(employeeKey) {
|
|
|
9078
9289
|
const filterKey = employeeKey || null;
|
|
9079
9290
|
const filterPersona = filterKey ? tfPersonaByKey(filterKey) : null;
|
|
9080
9291
|
const filterName = filterPersona ? (filterPersona.displayName || filterKey) : null;
|
|
9081
|
-
if (title) title.textContent = filterKey ? ('
|
|
9292
|
+
if (title) title.textContent = filterKey ? ('Assign a job to ' + filterName) : 'Assign a job';
|
|
9082
9293
|
|
|
9083
9294
|
const allJobs = (state.bootstrap && state.bootstrap.jobs) || [];
|
|
9084
9295
|
const personasToShow = filterKey ? hired.filter((p) => p.key === filterKey) : hired;
|
|
@@ -9132,7 +9343,7 @@ function tfOpenAssignJob(employeeKey) {
|
|
|
9132
9343
|
if (specificJobs.length > 0) {
|
|
9133
9344
|
const specLabel = document.createElement('div');
|
|
9134
9345
|
specLabel.className = 'cat-label';
|
|
9135
|
-
specLabel.textContent = (filterName || persona.displayName) + '
|
|
9346
|
+
specLabel.textContent = (filterName || persona.displayName) + "'s jobs";
|
|
9136
9347
|
listWrap.appendChild(specLabel);
|
|
9137
9348
|
specificJobs.forEach(function(job) { listWrap.appendChild(buildJobRow(av, job, persona)); });
|
|
9138
9349
|
}
|
|
@@ -9189,10 +9400,9 @@ function tfAssignJobToEmployee(employeeKey, job) {
|
|
|
9189
9400
|
if (!projectId) { tfCloseAssignJob(); return; }
|
|
9190
9401
|
if (!tf.assignments[projectId]) tf.assignments[projectId] = [];
|
|
9191
9402
|
tf.assignments[projectId].push({ id: 'a-' + Date.now(), employeeKey, jobName: job.title || job.id, jobId: job.id });
|
|
9192
|
-
|
|
9193
|
-
|
|
9403
|
+
// No longer mutate a stored project.team: the roster is derived from hired
|
|
9404
|
+
// personas (tfProjectTeamKeys) and every employee is always available.
|
|
9194
9405
|
tfPersistAssignments();
|
|
9195
|
-
tfPersistProjects();
|
|
9196
9406
|
tfCloseAssignJob();
|
|
9197
9407
|
tfRenderTree();
|
|
9198
9408
|
tfRenderProjectTabs();
|
|
@@ -9211,7 +9421,7 @@ function tfOpenAddEmp(preselectKey) {
|
|
|
9211
9421
|
const sub = document.getElementById('ae-sub');
|
|
9212
9422
|
if (!modal || !body) return;
|
|
9213
9423
|
body.innerHTML = '';
|
|
9214
|
-
const onProject = (
|
|
9424
|
+
const onProject = tfProjectTeamKeys();
|
|
9215
9425
|
const onProjectPersonas = onProject.map(tfPersonaByKey).filter(Boolean);
|
|
9216
9426
|
if (onProjectPersonas.length) {
|
|
9217
9427
|
const label = document.createElement('div');
|
|
@@ -9538,19 +9748,15 @@ function tfInitShell() {
|
|
|
9538
9748
|
const tabs = document.getElementById('hub-tabs');
|
|
9539
9749
|
if (tabs) tabs.hidden = false;
|
|
9540
9750
|
tfLoadStorage();
|
|
9751
|
+
tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
|
|
9541
9752
|
tfWireShell();
|
|
9542
9753
|
tfPopulateAccountMenu(); // #533: show the real account identity, not "SM"
|
|
9543
9754
|
// Seed a project from the loaded folder so the workspace reuses the existing
|
|
9544
9755
|
// 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;
|
|
9756
|
+
const currentProject = tfEnsureCurrentProject();
|
|
9757
|
+
if (currentProject && !tf.assignments[currentProject.id]) tf.assignments[currentProject.id] = [];
|
|
9758
|
+
if (currentProject) {
|
|
9759
|
+
tf.activeProjectId = currentProject.id;
|
|
9554
9760
|
tfPersistProjects();
|
|
9555
9761
|
} else if (tf.projects.length && !tf.activeProjectId) {
|
|
9556
9762
|
tf.activeProjectId = tf.projects[0].id;
|
|
@@ -9577,6 +9783,9 @@ function tfInitShell() {
|
|
|
9577
9783
|
// Refresh shell-derived UI after a bootstrap reload (e.g. project picked).
|
|
9578
9784
|
function tfRefreshAfterBootstrap() {
|
|
9579
9785
|
if (!tf.enabled) return;
|
|
9786
|
+
tfMergeProjects(state.bootstrap && Array.isArray(state.bootstrap.projects) ? state.bootstrap.projects : []);
|
|
9787
|
+
tfEnsureCurrentProject();
|
|
9788
|
+
tfPersistProjects();
|
|
9580
9789
|
tfRenderProjectTabs();
|
|
9581
9790
|
if (tf.area === 'company') tfRenderCompany();
|
|
9582
9791
|
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);
|