fraim-framework 2.0.183 → 2.0.185
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/desktop-main.js +2 -10
- 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 +3 -2
- package/dist/src/ai-hub/remote-hub-gateway.js +88 -0
- package/dist/src/ai-hub/server.js +119 -184
- package/dist/src/api/ai-hub/manager-team.js +93 -0
- package/dist/src/cli/distribution/marketplace-bundles.js +26 -27
- 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 +10 -36
- package/package.json +1 -1
- package/public/ai-hub/index.html +77 -90
- package/public/ai-hub/review.css +4 -2
- package/public/ai-hub/script.js +526 -562
- package/public/ai-hub/styles.css +151 -35
package/public/ai-hub/script.js
CHANGED
|
@@ -668,10 +668,16 @@ function stripReviewHandoffBlocks(text) {
|
|
|
668
668
|
.trim();
|
|
669
669
|
}
|
|
670
670
|
|
|
671
|
-
function
|
|
671
|
+
function stripHubInjectedNotes(text) {
|
|
672
672
|
return String(text || '')
|
|
673
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')
|
|
674
|
-
.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')
|
|
675
681
|
.replace(/\n{3,}/g, '\n\n')
|
|
676
682
|
.trim();
|
|
677
683
|
}
|
|
@@ -833,11 +839,9 @@ function renderRail() {
|
|
|
833
839
|
renderTeamRoster();
|
|
834
840
|
els['conv-list'].innerHTML = '';
|
|
835
841
|
// R4: filter by selected persona when one is active.
|
|
836
|
-
// #
|
|
837
|
-
// sleep-on-learnings)
|
|
838
|
-
//
|
|
839
|
-
const inWorkspace = !!document.querySelector('#proj-workspace .workspace-conv');
|
|
840
|
-
const projectUpdateJobs = new Set(['project-onboarding', 'sleep-on-learnings']);
|
|
842
|
+
// #693 R1: the "Project Updates" section was retired; project-lifecycle runs
|
|
843
|
+
// (project-onboarding + sleep-on-learnings) now surface in the Runs list under
|
|
844
|
+
// their employee like any other run, so their history stays reachable.
|
|
841
845
|
const allProjectConversations = projectConversations();
|
|
842
846
|
const managedChildren = allProjectConversations.filter(isManagedDelegationChild);
|
|
843
847
|
const childrenByManager = new Map();
|
|
@@ -850,7 +854,6 @@ function renderRail() {
|
|
|
850
854
|
const list = allProjectConversations.filter((conv) =>
|
|
851
855
|
(!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
|
|
852
856
|
!isManagedDelegationChild(conv) &&
|
|
853
|
-
!(inWorkspace && projectUpdateJobs.has(conv.jobId)) &&
|
|
854
857
|
!AREA_SCOPED_JOBS.has(conv.jobId)
|
|
855
858
|
);
|
|
856
859
|
|
|
@@ -880,6 +883,31 @@ function renderRail() {
|
|
|
880
883
|
groups.get(key).conversations.push(conv);
|
|
881
884
|
}
|
|
882
885
|
|
|
886
|
+
// #693 R1 (PR round 2): this Runs list is the single employees section in the
|
|
887
|
+
// workspace tree, so include every project employee — team, manager-team, and
|
|
888
|
+
// assigned — even before they have a run, so you can assign/delegate to them.
|
|
889
|
+
const tfProjId = (typeof tf !== 'undefined' && tf) ? tf.activeProjectId : null;
|
|
890
|
+
const managerTeamKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey).filter(Boolean);
|
|
891
|
+
// merge: master resolves the tree's employees via tfProjectTeamKeys() (hired personas),
|
|
892
|
+
// not project.team; align on it so every hired employee appears in the single section.
|
|
893
|
+
const projectEmployeeKeys = new Set([
|
|
894
|
+
...(typeof tfProjectTeamKeys === 'function' ? tfProjectTeamKeys() : []),
|
|
895
|
+
...(tfProjId && typeof tfProjectAssignments === 'function' ? tfProjectAssignments(tfProjId).map((a) => a.employeeKey).filter(Boolean) : []),
|
|
896
|
+
...managerTeamKeys,
|
|
897
|
+
]);
|
|
898
|
+
for (const key of projectEmployeeKeys) {
|
|
899
|
+
if (groups.has(key)) continue;
|
|
900
|
+
const p = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
|
|
901
|
+
if (p && p.status !== 'hired') continue; // reconcile against real entitlements (#538 follow-up)
|
|
902
|
+
groups.set(key, {
|
|
903
|
+
key,
|
|
904
|
+
label: p ? p.displayName : key,
|
|
905
|
+
detail: p ? (p.role || 'AI Employee') : '',
|
|
906
|
+
sample: p ? { personaKey: key } : { agentName: key },
|
|
907
|
+
conversations: [],
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
883
911
|
function buildRunButton(conv, options = {}) {
|
|
884
912
|
const btn = document.createElement('button');
|
|
885
913
|
btn.type = 'button';
|
|
@@ -993,9 +1021,33 @@ function renderRail() {
|
|
|
993
1021
|
summary.appendChild(avatar);
|
|
994
1022
|
summary.appendChild(copy);
|
|
995
1023
|
summary.appendChild(addBtn);
|
|
1024
|
+
// #693 R1 (PR round 2): hire-a-human-manager (#538) affordance, ported from the
|
|
1025
|
+
// retired emp-hero list. The old per-employee "Delegate" button is intentionally
|
|
1026
|
+
// omitted — it did the same openPalette('/persona') as the "+" above, so it was
|
|
1027
|
+
// redundant and only squeezed the employee-name column.
|
|
1028
|
+
if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[group.key]) {
|
|
1029
|
+
const hireBtn = document.createElement('button');
|
|
1030
|
+
hireBtn.type = 'button';
|
|
1031
|
+
hireBtn.className = 'eh-hire-manager';
|
|
1032
|
+
hireBtn.textContent = '🧑💼';
|
|
1033
|
+
hireBtn.title = 'Find a human manager for ' + group.label;
|
|
1034
|
+
hireBtn.setAttribute('aria-label', 'Find a human manager for ' + group.label);
|
|
1035
|
+
hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(group.key); });
|
|
1036
|
+
summary.appendChild(hireBtn);
|
|
1037
|
+
}
|
|
996
1038
|
summary.appendChild(count);
|
|
997
1039
|
details.appendChild(summary);
|
|
998
|
-
|
|
1040
|
+
if (group.conversations.length) {
|
|
1041
|
+
details.appendChild(buildGroupList(group.conversations));
|
|
1042
|
+
} else {
|
|
1043
|
+
const emptyWrap = document.createElement('div');
|
|
1044
|
+
emptyWrap.className = 'conv-employee-list';
|
|
1045
|
+
const em = document.createElement('div');
|
|
1046
|
+
em.className = 'eh-empty';
|
|
1047
|
+
em.textContent = 'No runs yet — + to assign a job.';
|
|
1048
|
+
emptyWrap.appendChild(em);
|
|
1049
|
+
details.appendChild(emptyWrap);
|
|
1050
|
+
}
|
|
999
1051
|
els['conv-list'].appendChild(details);
|
|
1000
1052
|
}
|
|
1001
1053
|
|
|
@@ -1010,9 +1062,11 @@ function renderRail() {
|
|
|
1010
1062
|
wcDetails.open = true;
|
|
1011
1063
|
const wcSummary = document.createElement('summary');
|
|
1012
1064
|
wcSummary.className = 'conv-employee-tab';
|
|
1013
|
-
//
|
|
1065
|
+
// #693.8: watercooler icon (replaces the blank dashed placeholder).
|
|
1014
1066
|
const wcAvatar = document.createElement('span');
|
|
1015
|
-
wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--
|
|
1067
|
+
wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--watercooler';
|
|
1068
|
+
wcAvatar.setAttribute('aria-hidden', 'true');
|
|
1069
|
+
wcAvatar.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2.5" width="10" height="6" rx="1.2"></rect><path d="M8.5 8.5h7v9a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2z"></path><line x1="9.5" y1="21.5" x2="14.5" y2="21.5"></line></svg>';
|
|
1016
1070
|
const wcCopy = document.createElement('span');
|
|
1017
1071
|
wcCopy.className = 'conv-employee-tab-copy';
|
|
1018
1072
|
const wcLabel = document.createElement('strong');
|
|
@@ -2628,8 +2682,8 @@ function closeTemplatePopover() {
|
|
|
2628
2682
|
}
|
|
2629
2683
|
|
|
2630
2684
|
function applyTemplateInvocation(managerJobId) {
|
|
2631
|
-
// The invocation prefix
|
|
2632
|
-
//
|
|
2685
|
+
// The server adds the invocation prefix based on the active employee. Keep
|
|
2686
|
+
// that syntax out of the editable textarea; the thread shows it after send.
|
|
2633
2687
|
// Store the job ID as a pending coaching job. The #coach-note shows a
|
|
2634
2688
|
// human-readable label; the textarea gets editable context the user can amend.
|
|
2635
2689
|
// When the user clicks Send, continueRun sends { coachingJobId, instructions }.
|
|
@@ -2638,7 +2692,7 @@ function applyTemplateInvocation(managerJobId) {
|
|
|
2638
2692
|
// for sessions that stored raw invocation text before this change).
|
|
2639
2693
|
const textarea = els['coach-text'];
|
|
2640
2694
|
textarea.value = textarea.value
|
|
2641
|
-
.replace(/(?:^|\n|\s)[
|
|
2695
|
+
.replace(/(?:^|\n|\s)[$/@]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
|
|
2642
2696
|
.replace(/\s+/g, ' ')
|
|
2643
2697
|
.trim();
|
|
2644
2698
|
textarea.focus();
|
|
@@ -2677,17 +2731,20 @@ function stripStubReference(text) {
|
|
|
2677
2731
|
}
|
|
2678
2732
|
|
|
2679
2733
|
function surfaceText(role, text, conv) {
|
|
2680
|
-
const
|
|
2734
|
+
const withoutStub = stripStubReference(text);
|
|
2735
|
+
const raw = role === 'manager'
|
|
2736
|
+
? stripHubInjectedNotes(withoutStub)
|
|
2737
|
+
: stripHubInjectedPromptBlocks(withoutStub);
|
|
2681
2738
|
if (!raw) return '';
|
|
2682
2739
|
|
|
2683
2740
|
if (role === 'manager') {
|
|
2684
|
-
const invocationOnly = raw.match(/^(?:[
|
|
2741
|
+
const invocationOnly = raw.match(/^(?:[$/@]fraim)\s+([a-z0-9-]+)\s*$/i);
|
|
2685
2742
|
if (invocationOnly) return raw;
|
|
2686
2743
|
const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
|
|
2687
|
-
const invocationWithSlug = new RegExp(`^(?:[
|
|
2744
|
+
const invocationWithSlug = new RegExp(`^(?:[$/@]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
2688
2745
|
return raw
|
|
2689
2746
|
.replace(invocationWithSlug, '')
|
|
2690
|
-
.replace(/^(?:[
|
|
2747
|
+
.replace(/^(?:[$/@]fraim)\s*/i, '')
|
|
2691
2748
|
.trim();
|
|
2692
2749
|
}
|
|
2693
2750
|
|
|
@@ -2980,7 +3037,6 @@ function convAwaitingReview(conv) {
|
|
|
2980
3037
|
// output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
|
|
2981
3038
|
// stakeholder-status-reporting) may legitimately surface artifacts for human review.
|
|
2982
3039
|
if (delegationLedgerForConversation(conv)) return false;
|
|
2983
|
-
if (conv.status === 'completed' && Array.isArray(conv.artifacts) && conv.artifacts.length > 0) return true;
|
|
2984
3040
|
// #521: a plain completed turn is NOT awaiting review — the approve/reject card
|
|
2985
3041
|
// only belongs when the employee actually submits for review (a review_handoff,
|
|
2986
3042
|
// emitted by the submit phase). A turn that just finished (a question, a pause
|
|
@@ -3045,20 +3101,22 @@ function normalizeReviewHandoff(raw) {
|
|
|
3045
3101
|
if (targetType === 'pull_request') {
|
|
3046
3102
|
const url = safeHttpUrl(rawTarget && rawTarget.url);
|
|
3047
3103
|
if (!url) return null;
|
|
3104
|
+
if (artifacts.length > 0) return null;
|
|
3048
3105
|
return {
|
|
3049
3106
|
reviewRequired: true,
|
|
3050
3107
|
reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
|
|
3051
|
-
artifacts,
|
|
3108
|
+
artifacts: [],
|
|
3052
3109
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
3053
3110
|
feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'pull_request_comments',
|
|
3054
3111
|
};
|
|
3055
3112
|
}
|
|
3056
3113
|
|
|
3057
|
-
|
|
3114
|
+
const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
|
|
3115
|
+
if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
|
|
3058
3116
|
return {
|
|
3059
3117
|
reviewRequired: true,
|
|
3060
3118
|
reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length || 1} artifact${artifacts.length === 1 ? '' : 's'}` },
|
|
3061
|
-
artifacts,
|
|
3119
|
+
artifacts: fileArtifacts,
|
|
3062
3120
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
3063
3121
|
feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'inline',
|
|
3064
3122
|
};
|
|
@@ -3750,7 +3808,7 @@ async function handleArtifactAction(conv, action) {
|
|
|
3750
3808
|
'Please resend the review handoff contract as valid JSON inside <review_handoff>...</review_handoff>.',
|
|
3751
3809
|
'Do not rely on prose.',
|
|
3752
3810
|
'Include reviewRequired, reviewTarget, artifacts, summary, and feedbackMode.',
|
|
3753
|
-
'Use reviewTarget.type "pull_request" only
|
|
3811
|
+
'Use reviewTarget.type "pull_request" with only the PR URL and no artifacts, or "artifact_set" with exact local file paths and no PR URL.',
|
|
3754
3812
|
].join(' ');
|
|
3755
3813
|
if (conv && conv.sessionId) {
|
|
3756
3814
|
els['coach-text'].value = message;
|
|
@@ -3945,17 +4003,14 @@ function wirePopovers() {
|
|
|
3945
4003
|
|
|
3946
4004
|
if (e.key === 'Escape') {
|
|
3947
4005
|
const cpModal = document.getElementById('cp-modal');
|
|
3948
|
-
const
|
|
3949
|
-
const webhookModal = document.getElementById('dep-webhook-modal');
|
|
4006
|
+
const assignModal = document.getElementById('dep-assignment-modal');
|
|
3950
4007
|
const aomModal = document.getElementById('area-onboard-modal');
|
|
3951
4008
|
if (aomModal && !aomModal.hidden) {
|
|
3952
4009
|
tfCloseAreaOnboardModal();
|
|
3953
4010
|
} else if (cpModal && !cpModal.hidden) {
|
|
3954
4011
|
closePalette();
|
|
3955
|
-
} else if (
|
|
3956
|
-
|
|
3957
|
-
} else if (webhookModal && !webhookModal.hidden) {
|
|
3958
|
-
webhookModal.hidden = true;
|
|
4012
|
+
} else if (assignModal && !assignModal.hidden) {
|
|
4013
|
+
assignModal.hidden = true;
|
|
3959
4014
|
} else if (els['modal'].classList.contains('open')) {
|
|
3960
4015
|
closeModal();
|
|
3961
4016
|
} else {
|
|
@@ -4136,13 +4191,37 @@ function renderCpRows(searchText) {
|
|
|
4136
4191
|
});
|
|
4137
4192
|
}
|
|
4138
4193
|
|
|
4139
|
-
//
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4194
|
+
// #693 R5: agent picker — choose the AI agent for this launch.
|
|
4195
|
+
renderCpAgentPicker();
|
|
4196
|
+
}
|
|
4197
|
+
|
|
4198
|
+
// #693 R5: render the command-palette AI-agent picker. Lists only agents
|
|
4199
|
+
// available on the machine as selectable; unavailable agents are shown disabled
|
|
4200
|
+
// with an install hint. Defaults to the saved agent (state.cpEmployee); if that
|
|
4201
|
+
// agent is not installed, falls back to the first available one.
|
|
4202
|
+
function renderCpAgentPicker() {
|
|
4203
|
+
const picker = document.getElementById('cp-agent-picker');
|
|
4204
|
+
if (!picker) return;
|
|
4205
|
+
picker.innerHTML = '';
|
|
4206
|
+
const employees = (state.bootstrap && state.bootstrap.employees) || [];
|
|
4207
|
+
const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
|
|
4208
|
+
const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
|
|
4209
|
+
if (!curOk) {
|
|
4210
|
+
const firstAvail = list.find((e) => e.available !== false);
|
|
4211
|
+
if (firstAvail) state.cpEmployee = firstAvail.id;
|
|
4212
|
+
}
|
|
4213
|
+
for (const e of list) {
|
|
4214
|
+
const pill = document.createElement('button');
|
|
4215
|
+
pill.type = 'button';
|
|
4216
|
+
pill.className = 'cp-agent-pill' + (e.id === state.cpEmployee ? ' on' : '');
|
|
4217
|
+
pill.textContent = e.label + (e.available === false ? ' · install' : '');
|
|
4218
|
+
if (e.available === false) {
|
|
4219
|
+
pill.disabled = true;
|
|
4220
|
+
pill.title = e.label + ' is not installed on this machine';
|
|
4221
|
+
} else {
|
|
4222
|
+
pill.addEventListener('click', () => { state.cpEmployee = e.id; renderCpAgentPicker(); });
|
|
4223
|
+
}
|
|
4224
|
+
picker.appendChild(pill);
|
|
4146
4225
|
}
|
|
4147
4226
|
}
|
|
4148
4227
|
|
|
@@ -4640,9 +4719,9 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
4640
4719
|
const trimmedJob = (jobTitle || '').trim();
|
|
4641
4720
|
const raw = (instructions || '').trim();
|
|
4642
4721
|
// Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
|
|
4643
|
-
const fraimSlugMatch = raw.match(/^[
|
|
4722
|
+
const fraimSlugMatch = raw.match(/^[$/@]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
|
|
4644
4723
|
const stripped = raw
|
|
4645
|
-
.replace(/^[
|
|
4724
|
+
.replace(/^[$/@]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
|
|
4646
4725
|
.replace(/\s+/g, ' ')
|
|
4647
4726
|
.trim();
|
|
4648
4727
|
const words = stripped.split(' ').filter(Boolean).slice(0, 6);
|
|
@@ -4910,29 +4989,25 @@ function foldRunIntoConversation(conv, run) {
|
|
|
4910
4989
|
conv.personaKey = run.personaKey;
|
|
4911
4990
|
}
|
|
4912
4991
|
const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
|
|
4913
|
-
if (runHandoff)
|
|
4914
|
-
|
|
4992
|
+
if (runHandoff) {
|
|
4993
|
+
conv.reviewHandoff = runHandoff;
|
|
4994
|
+
} else if (run.reviewHandoff) {
|
|
4995
|
+
conv.reviewHandoff = run.reviewHandoff;
|
|
4996
|
+
} else {
|
|
4997
|
+
conv.reviewHandoff = null;
|
|
4998
|
+
}
|
|
4915
4999
|
const runDelegation = normalizeDelegationLedger(run.delegation);
|
|
4916
5000
|
if (runDelegation) conv.delegation = runDelegation;
|
|
4917
5001
|
else delegationLedgerForConversation(conv);
|
|
4918
5002
|
if (Array.isArray(run.artifacts)) {
|
|
4919
5003
|
conv.artifacts = run.artifacts;
|
|
5004
|
+
} else {
|
|
5005
|
+
conv.artifacts = [];
|
|
4920
5006
|
}
|
|
4921
5007
|
// Update status.
|
|
4922
5008
|
if (run.status === 'completed') conv.status = 'completed';
|
|
4923
5009
|
else if (run.status === 'failed') conv.status = 'failed';
|
|
4924
5010
|
else conv.status = 'running';
|
|
4925
|
-
// Fallback for server responses that do not include structured artifacts.
|
|
4926
|
-
// The browser only parses output text when that structured field is absent.
|
|
4927
|
-
if (!Array.isArray(run.artifacts)) {
|
|
4928
|
-
for (const e of conv.events) {
|
|
4929
|
-
for (const found of extractArtifacts(e.text)) {
|
|
4930
|
-
if (!conv.artifacts.some((a) => a.name === found.name && a.where === found.where)) {
|
|
4931
|
-
conv.artifacts.push(found);
|
|
4932
|
-
}
|
|
4933
|
-
}
|
|
4934
|
-
}
|
|
4935
|
-
}
|
|
4936
5011
|
conv.lastUpdatedAt = Date.now();
|
|
4937
5012
|
}
|
|
4938
5013
|
|
|
@@ -4951,42 +5026,6 @@ function foldCompareRunIntoConversation(conv, compareRun) {
|
|
|
4951
5026
|
};
|
|
4952
5027
|
}
|
|
4953
5028
|
|
|
4954
|
-
// Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
|
|
4955
|
-
// raw learnings, evidence dumps, mock files), not deliverables the
|
|
4956
|
-
// manager should be drawn to. Excluding them keeps the artifact callout
|
|
4957
|
-
// meaningful — it should mean "the employee produced this file for you".
|
|
4958
|
-
const ARTIFACT_EXCLUDE_RE = /(^|\/)(retrospectives|evidence|learnings|mocks|raw|archive)\//i;
|
|
4959
|
-
function extractArtifact(text) {
|
|
4960
|
-
return extractArtifacts(text)[0] || null;
|
|
4961
|
-
}
|
|
4962
|
-
|
|
4963
|
-
function extractArtifacts(text) {
|
|
4964
|
-
if (!text) return [];
|
|
4965
|
-
const artifacts = [];
|
|
4966
|
-
const seen = new Set();
|
|
4967
|
-
const matches = String(text)
|
|
4968
|
-
.split(/[\s`"'()<>{}\[\],;:]+/)
|
|
4969
|
-
.filter((token) => /^(docs|public|src|tests)\//.test(token));
|
|
4970
|
-
for (const candidate of matches) {
|
|
4971
|
-
const fullPath = candidate.replace(/[.)]+$/g, '');
|
|
4972
|
-
if (!/\.[A-Za-z0-9]+$/.test(fullPath)) continue;
|
|
4973
|
-
if (ARTIFACT_EXCLUDE_RE.test(fullPath)) continue;
|
|
4974
|
-
const segments = fullPath.split('/');
|
|
4975
|
-
const name = segments[segments.length - 1];
|
|
4976
|
-
const where = segments.slice(0, -1).join('/') + '/';
|
|
4977
|
-
// Store the absolute path so the agent can re-read the artifact (and any
|
|
4978
|
-
// .docx export written alongside it) without guessing the project root.
|
|
4979
|
-
const absPath = state.projectPath
|
|
4980
|
-
? state.projectPath.replace(/\\/g, '/').replace(/\/$/, '') + '/' + fullPath
|
|
4981
|
-
: null;
|
|
4982
|
-
const key = absPath || fullPath;
|
|
4983
|
-
if (seen.has(key)) continue;
|
|
4984
|
-
seen.add(key);
|
|
4985
|
-
artifacts.push({ name, where, path: absPath });
|
|
4986
|
-
}
|
|
4987
|
-
return artifacts;
|
|
4988
|
-
}
|
|
4989
|
-
|
|
4990
5029
|
function startPolling() {
|
|
4991
5030
|
if (state.pollHandle) window.clearInterval(state.pollHandle);
|
|
4992
5031
|
state.pollHandle = window.setInterval(async () => {
|
|
@@ -6004,7 +6043,7 @@ const tf = {
|
|
|
6004
6043
|
area: 'projects', // company | manager | projects | brain
|
|
6005
6044
|
projectView: 'overview', // overview | workspace
|
|
6006
6045
|
activeProjectId: null,
|
|
6007
|
-
projects: [], // [{ id, name, intent, brief
|
|
6046
|
+
projects: [], // [{ id, name, intent, brief }] — roster derived from hired personas, not stored
|
|
6008
6047
|
assignments: {}, // { [projectId]: [{ id, employeeKey, jobName, jobId }] }
|
|
6009
6048
|
npStep: 1,
|
|
6010
6049
|
npSelectedEmployees: [],
|
|
@@ -6026,6 +6065,12 @@ function tfPersonas() { return (state.bootstrap && state.bootstrap.personas) ||
|
|
|
6026
6065
|
function tfHiredPersonas() { return tfPersonas().filter((p) => p.status === 'hired'); }
|
|
6027
6066
|
function tfAvailablePersonas() { return tfPersonas().filter((p) => p.status !== 'hired'); }
|
|
6028
6067
|
function tfPersonaByKey(key) { return tfPersonas().find((p) => p.key === key) || null; }
|
|
6068
|
+
// A project's employee roster is derived from the hired personas in the bootstrap
|
|
6069
|
+
// (DB-authoritative), NOT from per-machine local state. With the legacy-workspace
|
|
6070
|
+
// bypass every user sees the full employee catalog, and the roster is identical on
|
|
6071
|
+
// every machine the user signs in from. Deliberately not persisted to disk /
|
|
6072
|
+
// localStorage — see AiHubProjectListEntry in src/ai-hub/types.ts.
|
|
6073
|
+
function tfProjectTeamKeys() { return tfHiredPersonas().map((p) => p.key); }
|
|
6029
6074
|
|
|
6030
6075
|
// ---------------------------------------------------------------------------
|
|
6031
6076
|
// Persistence (projects + assignments, mirrors the conversation model)
|
|
@@ -6056,7 +6101,8 @@ function tfNormalizeProject(raw, fallbackPath) {
|
|
|
6056
6101
|
outcome: raw.outcome || '',
|
|
6057
6102
|
rules: raw.rules || '',
|
|
6058
6103
|
brief: raw.brief || raw.intent || '',
|
|
6059
|
-
|
|
6104
|
+
// Roster is derived from hired personas at render time (tfProjectTeamKeys),
|
|
6105
|
+
// never stored per machine. Any legacy `team` on stored state is dropped.
|
|
6060
6106
|
updatedAt: raw.updatedAt || undefined,
|
|
6061
6107
|
};
|
|
6062
6108
|
}
|
|
@@ -6086,9 +6132,6 @@ function tfMergeProjects(projects) {
|
|
|
6086
6132
|
const existing = merged.get(key);
|
|
6087
6133
|
if (existing) {
|
|
6088
6134
|
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
6135
|
merged.set(key, next);
|
|
6093
6136
|
} else {
|
|
6094
6137
|
merged.set(key, normalized);
|
|
@@ -6110,7 +6153,6 @@ function tfEnsureCurrentProject() {
|
|
|
6110
6153
|
id: tfProjectIdForPath(state.projectPath),
|
|
6111
6154
|
name: friendlyProjectShortName(state.projectPath),
|
|
6112
6155
|
folderPath: state.projectPath,
|
|
6113
|
-
team: tfHiredPersonas().map((p) => p.key),
|
|
6114
6156
|
}, state.projectPath);
|
|
6115
6157
|
tfMergeProjects([current]);
|
|
6116
6158
|
const resolved = tfProjectForPath(state.projectPath);
|
|
@@ -6411,7 +6453,7 @@ function tfRenderOverview() {
|
|
|
6411
6453
|
const team = document.createElement('div');
|
|
6412
6454
|
team.className = 'proj-team';
|
|
6413
6455
|
const assigned = tfProjectAssignments(proj.id);
|
|
6414
|
-
const empKeys = Array.from(new Set([...(
|
|
6456
|
+
const empKeys = Array.from(new Set([...tfProjectTeamKeys(), ...assigned.map((a) => a.employeeKey).filter(Boolean)]));
|
|
6415
6457
|
empKeys.slice(0, 6).forEach((key, i) => {
|
|
6416
6458
|
const persona = tfPersonaByKey(key);
|
|
6417
6459
|
const av = tfAvatarFor(persona ? persona.displayName : key, i);
|
|
@@ -6683,10 +6725,10 @@ function tfRenderTree() {
|
|
|
6683
6725
|
});
|
|
6684
6726
|
host.appendChild(briefNav);
|
|
6685
6727
|
host.appendChild(tfTreeSep());
|
|
6686
|
-
// #
|
|
6687
|
-
//
|
|
6688
|
-
|
|
6689
|
-
|
|
6728
|
+
// #693 R1: the standalone "Project Updates" accordion is retired. Its two
|
|
6729
|
+
// lifecycle actions now live where their output lands — Run Project Onboarding
|
|
6730
|
+
// in the Brief section and Sleep on learnings in the Learnings section
|
|
6731
|
+
// (see tfRenderProjectContextTop). No separate tree section.
|
|
6690
6732
|
// The legacy standalone "+ New job" entry (#new-conv-btn → openModal → the
|
|
6691
6733
|
// #job-catalog/#next1/#instructions/#start chain) lives in the old `.rail`,
|
|
6692
6734
|
// which the shell suppresses (display:none). Relocate the live node to the
|
|
@@ -6699,127 +6741,20 @@ function tfRenderTree() {
|
|
|
6699
6741
|
host.appendChild(newConvBtn);
|
|
6700
6742
|
host.appendChild(tfTreeSep());
|
|
6701
6743
|
}
|
|
6702
|
-
//
|
|
6703
|
-
//
|
|
6704
|
-
//
|
|
6705
|
-
//
|
|
6706
|
-
// keeps targeting the same cached #conv-list node wherever it now lives.
|
|
6744
|
+
// #693 R1 (PR round 2): the per-employee "Runs" accordions (renderRail's
|
|
6745
|
+
// conv-employee-group) ARE the single employees section — they list every
|
|
6746
|
+
// project employee with that employee's runs nested under their accordion.
|
|
6747
|
+
// Relocate that section into the tree; the redundant emp-hero list is retired.
|
|
6707
6748
|
const convList = (typeof els !== 'undefined' && els['conv-list']) || document.getElementById('conv-list');
|
|
6708
6749
|
const runsSection = convList && convList.closest('.rail-section--runs');
|
|
6709
6750
|
if (runsSection) {
|
|
6710
6751
|
runsSection.classList.add('tree-runs');
|
|
6711
6752
|
host.appendChild(runsSection);
|
|
6712
6753
|
}
|
|
6713
|
-
|
|
6714
|
-
|
|
6715
|
-
|
|
6716
|
-
|
|
6717
|
-
// Issue #540 R8: include personas on the manager's team so delegate buttons
|
|
6718
|
-
// appear in the workspace tree even when the persona hasn't been assigned yet.
|
|
6719
|
-
const managerTeamPersonaKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey);
|
|
6720
|
-
const empKeys = Array.from(new Set([...onProject, ...assigned.map((a) => a.employeeKey).filter(Boolean), ...managerTeamPersonaKeys]))
|
|
6721
|
-
// #538 follow-up: reconcile against real entitlements. The localStorage
|
|
6722
|
-
// project.team can hold members who are no longer hired (or were seeded
|
|
6723
|
-
// before an entitlement change), which made un-hired personas like swen
|
|
6724
|
-
// appear in a workspace where only beza/pam are actually hired. Drop any
|
|
6725
|
-
// key that resolves to a known persona that is NOT hired; keep hired
|
|
6726
|
-
// personas and genuinely custom employees (no persona record).
|
|
6727
|
-
.filter((key) => { const p = tfPersonaByKey(key); return !p || p.status === 'hired'; });
|
|
6728
|
-
const managerTeamKeySet = new Set(managerTeamPersonaKeys);
|
|
6729
|
-
|
|
6730
|
-
empKeys.forEach((key, i) => {
|
|
6731
|
-
const persona = tfPersonaByKey(key);
|
|
6732
|
-
const name = persona ? persona.displayName : key;
|
|
6733
|
-
const role = (persona && persona.role) || '';
|
|
6734
|
-
const av = tfAvatarFor(name, i);
|
|
6735
|
-
// R1 (#521): each employee is a large hero accordion — the team are the
|
|
6736
|
-
// heroes of the workspace, not a thin nav list. Their jobs nest inside.
|
|
6737
|
-
const details = document.createElement('details');
|
|
6738
|
-
details.className = 'emp-hero';
|
|
6739
|
-
details.dataset.accKey = 'emp:' + key; // #533 R5: stable key for open-state preservation
|
|
6740
|
-
details.open = true;
|
|
6741
|
-
const summary = document.createElement('summary');
|
|
6742
|
-
summary.title = name;
|
|
6743
|
-
const avEl = document.createElement('div');
|
|
6744
|
-
avEl.className = 'eh-av';
|
|
6745
|
-
avEl.style.background = av.color;
|
|
6746
|
-
avEl.textContent = av.badge;
|
|
6747
|
-
const idWrap = document.createElement('div');
|
|
6748
|
-
idWrap.className = 'eh-id';
|
|
6749
|
-
const nameEl = document.createElement('div');
|
|
6750
|
-
nameEl.className = 'eh-name';
|
|
6751
|
-
nameEl.textContent = name;
|
|
6752
|
-
idWrap.appendChild(nameEl);
|
|
6753
|
-
if (role) {
|
|
6754
|
-
const roleEl = document.createElement('div');
|
|
6755
|
-
roleEl.className = 'eh-role';
|
|
6756
|
-
roleEl.textContent = role;
|
|
6757
|
-
idWrap.appendChild(roleEl);
|
|
6758
|
-
}
|
|
6759
|
-
const addBtn = document.createElement('button');
|
|
6760
|
-
addBtn.type = 'button';
|
|
6761
|
-
addBtn.className = 'eh-add';
|
|
6762
|
-
addBtn.textContent = '+';
|
|
6763
|
-
addBtn.title = 'Assign a job to ' + name;
|
|
6764
|
-
addBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); tfOpenAssignJob(key); });
|
|
6765
|
-
// Issue #538 — per-employee entry point: hire a human manager for this role.
|
|
6766
|
-
let hireBtn = null;
|
|
6767
|
-
if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[key]) {
|
|
6768
|
-
hireBtn = document.createElement('button');
|
|
6769
|
-
hireBtn.type = 'button';
|
|
6770
|
-
hireBtn.className = 'eh-hire-manager';
|
|
6771
|
-
hireBtn.textContent = '🧑💼';
|
|
6772
|
-
hireBtn.title = 'Find a human manager for ' + name;
|
|
6773
|
-
hireBtn.setAttribute('aria-label', 'Find a human manager for ' + name);
|
|
6774
|
-
hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(key); });
|
|
6775
|
-
}
|
|
6776
|
-
const empDot = tfEmployeeDot(projectId, key);
|
|
6777
|
-
const dot = document.createElement('span');
|
|
6778
|
-
dot.className = 'eh-dot t-dot dot-' + empDot;
|
|
6779
|
-
dot.style.background = 'var(--' + empDot + ')';
|
|
6780
|
-
dot.title = tfDotTitle(empDot);
|
|
6781
|
-
const chev = document.createElement('span');
|
|
6782
|
-
chev.className = 'eh-chev';
|
|
6783
|
-
chev.textContent = '▸';
|
|
6784
|
-
summary.appendChild(avEl);
|
|
6785
|
-
summary.appendChild(idWrap);
|
|
6786
|
-
if (hireBtn) summary.appendChild(hireBtn);
|
|
6787
|
-
summary.appendChild(addBtn);
|
|
6788
|
-
// Issue #540 R8: delegate button for personas on the manager's team.
|
|
6789
|
-
if (managerTeamKeySet.has(key)) {
|
|
6790
|
-
summary.appendChild(renderDelegateButton(key));
|
|
6791
|
-
}
|
|
6792
|
-
summary.appendChild(dot);
|
|
6793
|
-
summary.appendChild(chev);
|
|
6794
|
-
details.appendChild(summary);
|
|
6795
|
-
|
|
6796
|
-
const body = document.createElement('div');
|
|
6797
|
-
body.className = 'eh-jobs';
|
|
6798
|
-
const empJobs = assigned.filter((x) => x.employeeKey === key);
|
|
6799
|
-
if (empJobs.length === 0) {
|
|
6800
|
-
const none = document.createElement('div');
|
|
6801
|
-
none.className = 'eh-empty';
|
|
6802
|
-
none.textContent = 'No jobs yet — + to assign one.';
|
|
6803
|
-
body.appendChild(none);
|
|
6804
|
-
}
|
|
6805
|
-
for (const a of empJobs) {
|
|
6806
|
-
const jobRow = document.createElement('div');
|
|
6807
|
-
jobRow.className = 'tree-job';
|
|
6808
|
-
const jobDot = tfAssignmentDot(a);
|
|
6809
|
-
const label = document.createElement('span');
|
|
6810
|
-
label.className = 'tj-label';
|
|
6811
|
-
label.textContent = a.jobName;
|
|
6812
|
-
const jd = document.createElement('span');
|
|
6813
|
-
jd.className = 'tj-dot dot-' + jobDot;
|
|
6814
|
-
jd.style.background = 'var(--' + jobDot + ')';
|
|
6815
|
-
jd.title = tfDotTitle(jobDot);
|
|
6816
|
-
jobRow.appendChild(label); jobRow.appendChild(jd);
|
|
6817
|
-
jobRow.addEventListener('click', () => tfSelectAssignmentConversation(a));
|
|
6818
|
-
body.appendChild(jobRow);
|
|
6819
|
-
}
|
|
6820
|
-
details.appendChild(body);
|
|
6821
|
-
host.appendChild(details);
|
|
6822
|
-
});
|
|
6754
|
+
// #693 R1 (PR round 2): the redundant emp-hero employee list was removed. The
|
|
6755
|
+
// relocated Runs section above (renderRail's conv-employee-group) is now the
|
|
6756
|
+
// single employees section — every project employee, with their runs nested
|
|
6757
|
+
// under their own accordion, plus per-employee assign/hire-manager.
|
|
6823
6758
|
|
|
6824
6759
|
// #521 R5/R6.4: the project brief and learnings moved OUT of the tree into the
|
|
6825
6760
|
// collapsible top sections (#proj-brief-acc / #proj-learnings-acc), rendered by
|
|
@@ -6920,6 +6855,37 @@ function tfRenderProjectContextTop() {
|
|
|
6920
6855
|
// #533 #4: project-LEVEL manager learnings (repo-local) live in the project tab.
|
|
6921
6856
|
tfRenderLearningsList(learnHost, 'manager', 'project', 'Nothing here yet — what your team learns about working with you on THIS project (preferences, patterns to avoid, validated approaches) appears here. Add one with + Add learning.');
|
|
6922
6857
|
}
|
|
6858
|
+
// #693 R1: the retired "Project Updates" section's actions now live where their
|
|
6859
|
+
// output lands — Run Project Onboarding in the Brief section, Sleep on learnings
|
|
6860
|
+
// in the Learnings section.
|
|
6861
|
+
tfEnsureAccAction('proj-brief-acc', '🚀', 'Run Project Onboarding', () => tfOpenOnboardInput('run'));
|
|
6862
|
+
tfEnsureAccAction('proj-learnings-acc', '🧠', 'Sleep on learnings', () => tfRunShareLearnings('project'));
|
|
6863
|
+
}
|
|
6864
|
+
|
|
6865
|
+
// #693 R1: insert (idempotently) a single lifecycle-action button at the top of a
|
|
6866
|
+
// ctx accordion's body. Used to place onboarding/sleep actions inside the Brief,
|
|
6867
|
+
// Learnings, and Company/Manager context sections they populate.
|
|
6868
|
+
function tfEnsureAccAction(accId, ico, label, onClick) {
|
|
6869
|
+
const acc = document.getElementById(accId);
|
|
6870
|
+
if (!acc) return;
|
|
6871
|
+
const body = acc.querySelector('.ctx-acc-body');
|
|
6872
|
+
if (!body) return;
|
|
6873
|
+
let row = body.querySelector(':scope > .ctx-acc-action-row');
|
|
6874
|
+
if (!row) {
|
|
6875
|
+
row = document.createElement('div');
|
|
6876
|
+
row.className = 'ctx-acc-action-row';
|
|
6877
|
+
body.insertBefore(row, body.firstChild);
|
|
6878
|
+
}
|
|
6879
|
+
// Rebuild the button so repeated renders don't stack duplicates or stale handlers.
|
|
6880
|
+
row.innerHTML = '';
|
|
6881
|
+
const btn = document.createElement('button');
|
|
6882
|
+
btn.type = 'button';
|
|
6883
|
+
btn.className = 'ctx-acc-action';
|
|
6884
|
+
const ic = document.createElement('span'); ic.className = 'caa-ico'; ic.textContent = ico;
|
|
6885
|
+
const tx = document.createElement('span'); tx.textContent = label;
|
|
6886
|
+
btn.appendChild(ic); btn.appendChild(tx);
|
|
6887
|
+
btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick(); });
|
|
6888
|
+
row.appendChild(btn);
|
|
6923
6889
|
}
|
|
6924
6890
|
|
|
6925
6891
|
// #521: the Brief section is the project's real captured context + rules — the two
|
|
@@ -6991,9 +6957,12 @@ async function loadDeployments() {
|
|
|
6991
6957
|
const list = document.getElementById('proj-deployments-list');
|
|
6992
6958
|
if (!list) return;
|
|
6993
6959
|
try {
|
|
6960
|
+
const params = new URLSearchParams();
|
|
6961
|
+
if (state.projectPath) params.set('projectPath', state.projectPath);
|
|
6962
|
+
const suffix = params.toString() ? '?' + params.toString() : '';
|
|
6994
6963
|
const [schResp, whResp] = await Promise.all([
|
|
6995
|
-
fetch('/api/ai-hub/schedules'),
|
|
6996
|
-
fetch('/api/ai-hub/webhooks'),
|
|
6964
|
+
fetch('/api/ai-hub/schedules' + suffix),
|
|
6965
|
+
fetch('/api/ai-hub/webhooks' + suffix),
|
|
6997
6966
|
]);
|
|
6998
6967
|
const schedules = schResp.ok ? await schResp.json() : [];
|
|
6999
6968
|
const webhooks = whResp.ok ? await whResp.json() : [];
|
|
@@ -7085,119 +7054,181 @@ function cronToHuman(expr) {
|
|
|
7085
7054
|
return expr;
|
|
7086
7055
|
}
|
|
7087
7056
|
|
|
7057
|
+
// #693 R3 (PR round 2): render assignments grouped by the EMPLOYEE the job maps to
|
|
7058
|
+
// (job.requiredPersonaKey), not the AI agent, reusing the Runs section's
|
|
7059
|
+
// conv-employee-group accordion so the two sections look and behave the same.
|
|
7088
7060
|
function renderDeploymentList(container, deployments) {
|
|
7089
7061
|
container.innerHTML = '';
|
|
7090
7062
|
if (!deployments.length) {
|
|
7091
7063
|
const empty = document.createElement('p');
|
|
7092
7064
|
empty.className = 'dep-empty';
|
|
7093
|
-
empty.textContent = 'No assignments yet. Use +
|
|
7065
|
+
empty.textContent = 'No assignments yet. Use + New Assignment above to schedule a job or trigger it from an external system.';
|
|
7094
7066
|
container.appendChild(empty);
|
|
7095
7067
|
return;
|
|
7096
7068
|
}
|
|
7069
|
+
const jobs = state.bootstrap?.jobs ?? [];
|
|
7070
|
+
const UNASSIGNED = '__unassigned__';
|
|
7071
|
+
// The employee is determined by the job: its requiredPersonaKey. Fall back to an
|
|
7072
|
+
// "Unassigned" group for generic jobs with no required specialist.
|
|
7073
|
+
const personaKeyForDep = (dep) => {
|
|
7074
|
+
const j = jobs.find((x) => x.id === dep.jobId);
|
|
7075
|
+
return (j && j.requiredPersonaKey) || UNASSIGNED;
|
|
7076
|
+
};
|
|
7077
|
+
const groups = new Map();
|
|
7097
7078
|
for (const dep of deployments) {
|
|
7098
|
-
const
|
|
7099
|
-
|
|
7100
|
-
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
const
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
if (human !== dep.cronExpr) cron.title = dep.cronExpr;
|
|
7122
|
-
card.appendChild(cron);
|
|
7123
|
-
}
|
|
7124
|
-
|
|
7125
|
-
const employee = (state.bootstrap?.employees || []).find((e) => e.id === dep.hostId);
|
|
7126
|
-
if (employee) {
|
|
7127
|
-
const empRow = document.createElement('div');
|
|
7128
|
-
empRow.className = 'dep-detail dep-detail--emp';
|
|
7129
|
-
empRow.textContent = employee.label;
|
|
7130
|
-
card.appendChild(empRow);
|
|
7079
|
+
const key = personaKeyForDep(dep);
|
|
7080
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
7081
|
+
groups.get(key).push(dep);
|
|
7082
|
+
}
|
|
7083
|
+
for (const [personaKey, deps] of groups) {
|
|
7084
|
+
const persona = personaKey === UNASSIGNED ? null : tfPersonaByKey(personaKey);
|
|
7085
|
+
const empLabel = persona ? persona.displayName : 'Unassigned';
|
|
7086
|
+
const empDetail = persona ? (persona.role || 'AI Employee') : 'No specialist required';
|
|
7087
|
+
const details = document.createElement('details');
|
|
7088
|
+
details.className = 'conv-employee-group';
|
|
7089
|
+
details.open = true;
|
|
7090
|
+
const summary = document.createElement('summary');
|
|
7091
|
+
summary.className = 'conv-employee-tab';
|
|
7092
|
+
// DiceBear when the employee has an avatar; else an initials chip.
|
|
7093
|
+
let avatar;
|
|
7094
|
+
if (persona && persona.avatarUrl) {
|
|
7095
|
+
avatar = document.createElement('img');
|
|
7096
|
+
avatar.className = 'conv-employee-avatar';
|
|
7097
|
+
avatar.src = persona.avatarUrl; avatar.alt = empLabel;
|
|
7098
|
+
} else {
|
|
7099
|
+
avatar = document.createElement('span');
|
|
7100
|
+
avatar.className = 'conv-employee-avatar';
|
|
7101
|
+
avatar.textContent = initialBadge(empLabel);
|
|
7131
7102
|
}
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
7145
|
-
|
|
7146
|
-
|
|
7103
|
+
const copy = document.createElement('span');
|
|
7104
|
+
copy.className = 'conv-employee-tab-copy';
|
|
7105
|
+
const label = document.createElement('strong');
|
|
7106
|
+
label.className = 'conv-employee-tab-label';
|
|
7107
|
+
label.textContent = empLabel;
|
|
7108
|
+
const detail = document.createElement('small');
|
|
7109
|
+
detail.className = 'conv-employee-tab-detail';
|
|
7110
|
+
detail.textContent = empDetail;
|
|
7111
|
+
copy.appendChild(label); copy.appendChild(detail);
|
|
7112
|
+
const count = document.createElement('span');
|
|
7113
|
+
count.className = 'conv-employee-tab-count';
|
|
7114
|
+
count.textContent = String(deps.length);
|
|
7115
|
+
const addBtn = document.createElement('button');
|
|
7116
|
+
addBtn.type = 'button';
|
|
7117
|
+
addBtn.className = 'conv-employee-add';
|
|
7118
|
+
addBtn.textContent = '+';
|
|
7119
|
+
addBtn.title = 'New assignment for ' + empLabel;
|
|
7120
|
+
addBtn.setAttribute('aria-label', 'New assignment for ' + empLabel);
|
|
7121
|
+
addBtn.addEventListener('click', (e) => {
|
|
7122
|
+
e.preventDefault(); e.stopPropagation();
|
|
7123
|
+
openDeploymentModal();
|
|
7124
|
+
// Pre-select a job that maps to this employee so the grouping stays consistent.
|
|
7125
|
+
if (persona) {
|
|
7126
|
+
const sel = document.getElementById('dep-job');
|
|
7127
|
+
const match = jobs.find((x) => x.requiredPersonaKey === personaKey);
|
|
7128
|
+
if (sel && match && Array.from(sel.options).some((o) => o.value === match.id)) sel.value = match.id;
|
|
7129
|
+
}
|
|
7130
|
+
});
|
|
7131
|
+
summary.appendChild(avatar);
|
|
7132
|
+
summary.appendChild(copy);
|
|
7133
|
+
summary.appendChild(addBtn);
|
|
7134
|
+
summary.appendChild(count);
|
|
7135
|
+
details.appendChild(summary);
|
|
7136
|
+
const list = document.createElement('div');
|
|
7137
|
+
list.className = 'conv-employee-list';
|
|
7138
|
+
for (const dep of deps) {
|
|
7139
|
+
list.appendChild(buildDeploymentRow(dep));
|
|
7140
|
+
if (dep.inboundUrl) list.appendChild(buildInboundUrlRow(dep));
|
|
7147
7141
|
}
|
|
7142
|
+
details.appendChild(list);
|
|
7143
|
+
container.appendChild(details);
|
|
7144
|
+
}
|
|
7145
|
+
}
|
|
7148
7146
|
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7147
|
+
// #693 R3: a single assignment rendered as a Runs-style row.
|
|
7148
|
+
function buildDeploymentRow(dep) {
|
|
7149
|
+
const row = document.createElement('div');
|
|
7150
|
+
// #693 R3: a dedicated class (not .conv-item) so run-list logic never treats an
|
|
7151
|
+
// assignment as a run; styled to match the Runs rows via CSS.
|
|
7152
|
+
row.className = 'dep-row';
|
|
7153
|
+
const body = document.createElement('span');
|
|
7154
|
+
body.className = 'conv-body';
|
|
7155
|
+
const title = document.createElement('span');
|
|
7156
|
+
title.className = 'dep-row-title';
|
|
7157
|
+
title.textContent = dep.label;
|
|
7158
|
+
body.appendChild(title);
|
|
7159
|
+
const meta = document.createElement('span');
|
|
7160
|
+
meta.className = 'assign-row-meta';
|
|
7161
|
+
const badge = document.createElement('span');
|
|
7162
|
+
badge.className = 'assign-badge assign-badge--' + (dep.type === 'scheduled' ? 'scheduled' : 'triggered');
|
|
7163
|
+
badge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
|
|
7164
|
+
meta.appendChild(badge);
|
|
7165
|
+
const detailText = document.createElement('span');
|
|
7166
|
+
if (dep.type === 'scheduled' && dep.cronExpr) {
|
|
7167
|
+
const human = cronToHuman(dep.cronExpr);
|
|
7168
|
+
detailText.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
|
|
7169
|
+
if (human !== dep.cronExpr) detailText.title = dep.cronExpr;
|
|
7170
|
+
} else {
|
|
7171
|
+
detailText.textContent = 'Inbound webhook';
|
|
7173
7172
|
}
|
|
7173
|
+
meta.appendChild(detailText);
|
|
7174
|
+
body.appendChild(meta);
|
|
7175
|
+
row.appendChild(body);
|
|
7176
|
+
const actions = document.createElement('span');
|
|
7177
|
+
actions.className = 'dep-row-actions';
|
|
7178
|
+
const editBtn = document.createElement('button');
|
|
7179
|
+
editBtn.type = 'button'; editBtn.className = 'dep-edit-btn'; editBtn.textContent = 'Edit'; editBtn.title = 'Edit this assignment';
|
|
7180
|
+
editBtn.addEventListener('click', () => openDeploymentModal(dep));
|
|
7181
|
+
const delBtn = document.createElement('button');
|
|
7182
|
+
delBtn.type = 'button'; delBtn.className = 'dep-del-btn'; delBtn.textContent = 'Remove'; delBtn.title = 'Remove this assignment';
|
|
7183
|
+
delBtn.addEventListener('click', async () => {
|
|
7184
|
+
const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
|
|
7185
|
+
await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
|
|
7186
|
+
await loadDeployments();
|
|
7187
|
+
});
|
|
7188
|
+
actions.appendChild(editBtn); actions.appendChild(delBtn);
|
|
7189
|
+
row.appendChild(actions);
|
|
7190
|
+
return row;
|
|
7191
|
+
}
|
|
7192
|
+
|
|
7193
|
+
// #693 R3: inbound-URL sub-row for triggered assignments (kept from the old card).
|
|
7194
|
+
function buildInboundUrlRow(dep) {
|
|
7195
|
+
const urlRow = document.createElement('div');
|
|
7196
|
+
// #693 R3: self-contained class — do NOT add .dep-detail (its width:100% plus this
|
|
7197
|
+
// row's left margin overflows the container and forces a horizontal scrollbar).
|
|
7198
|
+
urlRow.className = 'dep-inbound-sub';
|
|
7199
|
+
const urlCode = document.createElement('code');
|
|
7200
|
+
urlCode.textContent = dep.inboundUrl;
|
|
7201
|
+
urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
|
|
7202
|
+
const copyBtn = document.createElement('button');
|
|
7203
|
+
copyBtn.type = 'button';
|
|
7204
|
+
copyBtn.className = 'hm-copy-btn';
|
|
7205
|
+
copyBtn.textContent = 'Copy';
|
|
7206
|
+
copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
|
|
7207
|
+
urlRow.appendChild(urlCode);
|
|
7208
|
+
urlRow.appendChild(copyBtn);
|
|
7209
|
+
return urlRow;
|
|
7174
7210
|
}
|
|
7175
7211
|
|
|
7176
7212
|
function initDeploymentButtons() {
|
|
7177
|
-
|
|
7178
|
-
const
|
|
7179
|
-
if (
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
const
|
|
7184
|
-
|
|
7185
|
-
if (
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
if (schSave) schSave.addEventListener('click', saveScheduleDeployment);
|
|
7197
|
-
const whSave = document.getElementById('dep-wh-save-btn');
|
|
7198
|
-
if (whSave) whSave.addEventListener('click', saveWebhookDeployment);
|
|
7199
|
-
|
|
7200
|
-
// Preset chips
|
|
7213
|
+
// #693 R2: one entry point opens the consolidated assignment modal.
|
|
7214
|
+
const addBtn = document.getElementById('dep-add-btn');
|
|
7215
|
+
if (addBtn) addBtn.addEventListener('click', () => openDeploymentModal());
|
|
7216
|
+
|
|
7217
|
+
const closeModal = () => { const m = document.getElementById('dep-assignment-modal'); if (m) m.hidden = true; };
|
|
7218
|
+
const close = document.getElementById('dep-assign-close');
|
|
7219
|
+
const cancel = document.getElementById('dep-cancel-btn');
|
|
7220
|
+
if (close) close.addEventListener('click', closeModal);
|
|
7221
|
+
if (cancel) cancel.addEventListener('click', closeModal);
|
|
7222
|
+
|
|
7223
|
+
const save = document.getElementById('dep-save-btn');
|
|
7224
|
+
if (save) save.addEventListener('click', saveDeployment);
|
|
7225
|
+
|
|
7226
|
+
// Scheduled / Triggered segmented toggle.
|
|
7227
|
+
document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((btn) => {
|
|
7228
|
+
btn.addEventListener('click', () => setDepType(btn.dataset.depType));
|
|
7229
|
+
});
|
|
7230
|
+
|
|
7231
|
+
// Preset chips (scheduled block).
|
|
7201
7232
|
document.querySelectorAll('#dep-sch-presets .dep-preset-chip').forEach(chip => {
|
|
7202
7233
|
chip.addEventListener('click', () => applySchPreset(chip.dataset.preset));
|
|
7203
7234
|
});
|
|
@@ -7243,131 +7274,131 @@ function initDeploymentButtons() {
|
|
|
7243
7274
|
});
|
|
7244
7275
|
}
|
|
7245
7276
|
|
|
7246
|
-
|
|
7247
|
-
|
|
7277
|
+
// #693 R2: current assignment type in the consolidated modal.
|
|
7278
|
+
let _depType = 'scheduled';
|
|
7279
|
+
function setDepType(type) {
|
|
7280
|
+
_depType = (type === 'triggered') ? 'triggered' : 'scheduled';
|
|
7281
|
+
document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((b) => {
|
|
7282
|
+
b.classList.toggle('on', b.dataset.depType === _depType);
|
|
7283
|
+
});
|
|
7284
|
+
const sched = document.getElementById('dep-sched-block');
|
|
7285
|
+
const trig = document.getElementById('dep-trig-block');
|
|
7286
|
+
if (sched) sched.hidden = _depType !== 'scheduled';
|
|
7287
|
+
if (trig) trig.hidden = _depType !== 'triggered';
|
|
7288
|
+
}
|
|
7289
|
+
|
|
7290
|
+
// #693 R2/R5: populate the AI Agent select with only agents available on the
|
|
7291
|
+
// machine. Mirrors the existing employee-select behavior — unavailable agents are
|
|
7292
|
+
// shown disabled rather than as selectable options.
|
|
7293
|
+
function populateAgentSelect(sel, currentHostId) {
|
|
7294
|
+
sel.innerHTML = '';
|
|
7248
7295
|
const employees = state.bootstrap?.employees ?? [];
|
|
7296
|
+
const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
|
|
7297
|
+
for (const e of list) {
|
|
7298
|
+
const opt = document.createElement('option');
|
|
7299
|
+
opt.value = e.id;
|
|
7300
|
+
opt.textContent = e.label + (e.available === false ? ' (not installed)' : '');
|
|
7301
|
+
if (e.available === false) opt.disabled = true;
|
|
7302
|
+
sel.appendChild(opt);
|
|
7303
|
+
}
|
|
7304
|
+
const avail = list.filter((e) => e.available !== false);
|
|
7305
|
+
const pick = (currentHostId && list.some((e) => e.id === currentHostId && e.available !== false))
|
|
7306
|
+
? currentHostId
|
|
7307
|
+
: (avail[0]?.id || list[0]?.id);
|
|
7308
|
+
if (pick) sel.value = pick;
|
|
7309
|
+
}
|
|
7310
|
+
|
|
7311
|
+
// #693 R2: open the single consolidated assignment modal. `dep` present = edit;
|
|
7312
|
+
// its .type locks the segmented control. New assignments default to Scheduled.
|
|
7313
|
+
function openDeploymentModal(dep) {
|
|
7314
|
+
const jobs = state.bootstrap?.jobs ?? [];
|
|
7249
7315
|
const editing = dep != null;
|
|
7250
7316
|
_editingDepId = editing ? dep.id : null;
|
|
7251
7317
|
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7256
|
-
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7318
|
+
const jobSel = document.getElementById('dep-job');
|
|
7319
|
+
jobSel.innerHTML = '';
|
|
7320
|
+
for (const j of jobs) {
|
|
7321
|
+
const opt = document.createElement('option');
|
|
7322
|
+
opt.value = j.id; opt.textContent = j.title;
|
|
7323
|
+
jobSel.appendChild(opt);
|
|
7324
|
+
}
|
|
7325
|
+
if (dep?.jobId) jobSel.value = dep.jobId;
|
|
7326
|
+
|
|
7327
|
+
populateAgentSelect(document.getElementById('dep-agent'), dep?.hostId || 'claude');
|
|
7328
|
+
document.getElementById('dep-label').value = dep?.label ?? '';
|
|
7329
|
+
document.getElementById('dep-instructions').value = dep?.instructions ?? '';
|
|
7330
|
+
const errEl = document.getElementById('dep-error');
|
|
7331
|
+
if (errEl) errEl.hidden = true;
|
|
7332
|
+
const inbound = document.getElementById('dep-wh-inbound-row');
|
|
7333
|
+
if (inbound) inbound.hidden = true;
|
|
7334
|
+
|
|
7335
|
+
setDepType(editing ? (dep.type === 'scheduled' ? 'scheduled' : 'triggered') : 'scheduled');
|
|
7336
|
+
|
|
7337
|
+
// Scheduled fields: detect preset and pre-fill time/day.
|
|
7338
|
+
const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
|
|
7339
|
+
if (preset && preset !== 'custom' && dep?.cronExpr) {
|
|
7340
|
+
const parts = dep.cronExpr.trim().split(/\s+/);
|
|
7341
|
+
const m = parseInt(parts[0], 10) || 0;
|
|
7342
|
+
const h = parseInt(parts[1], 10) || 9;
|
|
7343
|
+
document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
|
7344
|
+
if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
|
|
7345
|
+
document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
|
|
7346
|
+
} else if (preset === 'custom' && dep?.cronExpr) {
|
|
7347
|
+
document.getElementById('dep-sch-cron').value = dep.cronExpr;
|
|
7348
|
+
const cronPreview = document.getElementById('dep-sch-cron-preview');
|
|
7349
|
+
if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
|
|
7350
|
+
} else {
|
|
7351
|
+
document.getElementById('dep-sch-cron').value = '';
|
|
7352
|
+
document.getElementById('dep-sch-time').value = '09:00';
|
|
7261
7353
|
}
|
|
7354
|
+
applySchPreset(preset || 'daily');
|
|
7262
7355
|
|
|
7263
|
-
|
|
7264
|
-
|
|
7265
|
-
|
|
7266
|
-
const list = employees.length ? employees : fallback;
|
|
7267
|
-
for (const e of list) {
|
|
7268
|
-
const opt = document.createElement('option');
|
|
7269
|
-
opt.value = e.id;
|
|
7270
|
-
opt.textContent = e.label;
|
|
7271
|
-
sel.appendChild(opt);
|
|
7272
|
-
}
|
|
7273
|
-
if (currentHostId) sel.value = currentHostId;
|
|
7274
|
-
}
|
|
7275
|
-
|
|
7276
|
-
if (type === 'schedule') {
|
|
7277
|
-
const modal = document.getElementById('dep-schedule-modal');
|
|
7278
|
-
document.getElementById('dep-sch-modal-title').textContent = editing ? 'Edit Scheduled Assignment' : 'New Scheduled Assignment';
|
|
7279
|
-
document.getElementById('dep-sch-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
|
|
7280
|
-
populateJobs(document.getElementById('dep-sch-job'), dep?.jobId);
|
|
7281
|
-
populateEmployees(document.getElementById('dep-sch-host'), dep?.hostId || 'claude');
|
|
7282
|
-
document.getElementById('dep-sch-error').hidden = true;
|
|
7283
|
-
document.getElementById('dep-sch-label').value = dep?.label ?? '';
|
|
7284
|
-
document.getElementById('dep-sch-instructions').value = dep?.instructions ?? '';
|
|
7285
|
-
// Detect preset and pre-fill time/day
|
|
7286
|
-
const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
|
|
7287
|
-
if (preset && preset !== 'custom' && dep?.cronExpr) {
|
|
7288
|
-
const parts = dep.cronExpr.trim().split(/\s+/);
|
|
7289
|
-
const m = parseInt(parts[0], 10) || 0;
|
|
7290
|
-
const h = parseInt(parts[1], 10) || 9;
|
|
7291
|
-
document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
|
7292
|
-
if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
|
|
7293
|
-
document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
|
|
7294
|
-
} else if (preset === 'custom' && dep?.cronExpr) {
|
|
7295
|
-
document.getElementById('dep-sch-cron').value = dep.cronExpr;
|
|
7296
|
-
const cronPreview = document.getElementById('dep-sch-cron-preview');
|
|
7297
|
-
if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
|
|
7298
|
-
} else {
|
|
7299
|
-
document.getElementById('dep-sch-cron').value = '';
|
|
7300
|
-
document.getElementById('dep-sch-time').value = '09:00';
|
|
7301
|
-
}
|
|
7302
|
-
applySchPreset(preset || 'daily');
|
|
7303
|
-
modal.hidden = false;
|
|
7304
|
-
} else {
|
|
7305
|
-
const modal = document.getElementById('dep-webhook-modal');
|
|
7306
|
-
document.getElementById('dep-wh-modal-title').textContent = editing ? 'Edit Triggered Assignment' : 'New Triggered Assignment';
|
|
7307
|
-
document.getElementById('dep-wh-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
|
|
7308
|
-
populateJobs(document.getElementById('dep-wh-job'), dep?.jobId);
|
|
7309
|
-
populateEmployees(document.getElementById('dep-wh-host'), dep?.hostId || 'claude');
|
|
7310
|
-
document.getElementById('dep-wh-error').hidden = true;
|
|
7311
|
-
document.getElementById('dep-wh-inbound-row').hidden = true;
|
|
7312
|
-
document.getElementById('dep-wh-label').value = dep?.label ?? '';
|
|
7313
|
-
document.getElementById('dep-wh-instructions').value = dep?.instructions ?? '';
|
|
7314
|
-
modal.hidden = false;
|
|
7315
|
-
}
|
|
7316
|
-
}
|
|
7317
|
-
|
|
7318
|
-
async function saveScheduleDeployment() {
|
|
7319
|
-
const label = document.getElementById('dep-sch-label').value.trim();
|
|
7320
|
-
const jobId = document.getElementById('dep-sch-job').value;
|
|
7321
|
-
const hostId = document.getElementById('dep-sch-host').value;
|
|
7322
|
-
const isCustom = _activeSchPreset === 'custom';
|
|
7323
|
-
const cronExpr = isCustom
|
|
7324
|
-
? document.getElementById('dep-sch-cron').value.trim()
|
|
7325
|
-
: (document.getElementById('dep-sch-cron-preset').value.trim() || '');
|
|
7326
|
-
const instructions = document.getElementById('dep-sch-instructions').value.trim();
|
|
7327
|
-
const errEl = document.getElementById('dep-sch-error');
|
|
7328
|
-
if (!label || !cronExpr) { errEl.textContent = 'Label and schedule are required.'; errEl.hidden = false; return; }
|
|
7329
|
-
const isEdit = _editingDepId !== null;
|
|
7330
|
-
const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
|
|
7331
|
-
const method = isEdit ? 'PUT' : 'POST';
|
|
7332
|
-
try {
|
|
7333
|
-
const resp = await fetch(url, {
|
|
7334
|
-
method,
|
|
7335
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7336
|
-
body: JSON.stringify({ label, jobId, hostId, cronExpr, instructions: instructions || undefined }),
|
|
7337
|
-
});
|
|
7338
|
-
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; }
|
|
7339
|
-
_editingDepId = null;
|
|
7340
|
-
document.getElementById('dep-schedule-modal').hidden = true;
|
|
7341
|
-
await loadDeployments();
|
|
7342
|
-
} catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
|
|
7356
|
+
document.getElementById('dep-assign-title').textContent = editing ? 'Edit assignment' : 'New assignment';
|
|
7357
|
+
document.getElementById('dep-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
|
|
7358
|
+
document.getElementById('dep-assignment-modal').hidden = false;
|
|
7343
7359
|
}
|
|
7344
7360
|
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
const
|
|
7348
|
-
const
|
|
7349
|
-
const
|
|
7350
|
-
const
|
|
7351
|
-
|
|
7361
|
+
// #693 R2: one save path routes to the schedule or webhook endpoint by type.
|
|
7362
|
+
async function saveDeployment() {
|
|
7363
|
+
const errEl = document.getElementById('dep-error');
|
|
7364
|
+
const label = document.getElementById('dep-label').value.trim();
|
|
7365
|
+
const jobId = document.getElementById('dep-job').value;
|
|
7366
|
+
const hostId = document.getElementById('dep-agent').value;
|
|
7367
|
+
const instructions = document.getElementById('dep-instructions').value.trim();
|
|
7352
7368
|
const isEdit = _editingDepId !== null;
|
|
7353
|
-
|
|
7354
|
-
|
|
7355
|
-
|
|
7356
|
-
const
|
|
7357
|
-
|
|
7358
|
-
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
|
|
7362
|
-
|
|
7363
|
-
|
|
7364
|
-
|
|
7365
|
-
|
|
7366
|
-
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
}
|
|
7369
|
+
if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
|
|
7370
|
+
|
|
7371
|
+
if (_depType === 'scheduled') {
|
|
7372
|
+
const isCustom = _activeSchPreset === 'custom';
|
|
7373
|
+
const cronExpr = isCustom
|
|
7374
|
+
? document.getElementById('dep-sch-cron').value.trim()
|
|
7375
|
+
: (document.getElementById('dep-sch-cron-preset').value.trim() || '');
|
|
7376
|
+
if (!cronExpr) { errEl.textContent = 'A schedule is required.'; errEl.hidden = false; return; }
|
|
7377
|
+
const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
|
|
7378
|
+
try {
|
|
7379
|
+
// merge: keep master's projectPath association on the request body.
|
|
7380
|
+
const resp = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }) });
|
|
7381
|
+
if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
|
|
7382
|
+
_editingDepId = null;
|
|
7383
|
+
document.getElementById('dep-assignment-modal').hidden = true;
|
|
7384
|
+
await loadDeployments();
|
|
7385
|
+
} catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
|
|
7386
|
+
} else {
|
|
7387
|
+
const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
|
|
7388
|
+
try {
|
|
7389
|
+
const resp = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }) });
|
|
7390
|
+
if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
|
|
7391
|
+
if (!isEdit) {
|
|
7392
|
+
const dep = await resp.json();
|
|
7393
|
+
document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
|
|
7394
|
+
document.getElementById('dep-wh-inbound-row').hidden = false;
|
|
7395
|
+
} else {
|
|
7396
|
+
document.getElementById('dep-assignment-modal').hidden = true;
|
|
7397
|
+
}
|
|
7398
|
+
_editingDepId = null;
|
|
7399
|
+
await loadDeployments();
|
|
7400
|
+
} catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
|
|
7401
|
+
}
|
|
7371
7402
|
}
|
|
7372
7403
|
|
|
7373
7404
|
// ---------------------------------------------------------------------------
|
|
@@ -7891,123 +7922,12 @@ function tfContextRow(key, label, placeholder, rerender) {
|
|
|
7891
7922
|
|
|
7892
7923
|
return row;
|
|
7893
7924
|
}
|
|
7894
|
-
// #
|
|
7895
|
-
// runs — the SAME paradigm as the project tree (employee → job runs). The summary
|
|
7896
|
-
// is the job (icon, name, run count, + to run again); the body lists prior runs,
|
|
7897
|
-
// each clicking through to that run's conversation.
|
|
7898
|
-
function tfAreaRailJob(ico, jobId, name, sub, onRun) {
|
|
7899
|
-
const details = document.createElement('details');
|
|
7900
|
-
details.className = 'emp-hero area-rail-acc';
|
|
7901
|
-
details.open = true;
|
|
7902
|
-
const summary = document.createElement('summary');
|
|
7903
|
-
const av = document.createElement('div');
|
|
7904
|
-
av.className = 'eh-av job-ico';
|
|
7905
|
-
av.style.borderRadius = '9px';
|
|
7906
|
-
av.style.background = 'var(--accent-soft)';
|
|
7907
|
-
av.style.color = 'var(--accent)';
|
|
7908
|
-
av.textContent = ico;
|
|
7909
|
-
const id = document.createElement('div'); id.className = 'eh-id';
|
|
7910
|
-
const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = name;
|
|
7911
|
-
const runs = tfConversationsForJob(jobId);
|
|
7912
|
-
const role = document.createElement('div'); role.className = 'eh-role';
|
|
7913
|
-
role.textContent = runs.length ? (runs.length + (runs.length === 1 ? ' past run' : ' past runs')) : sub;
|
|
7914
|
-
id.appendChild(nm); id.appendChild(role);
|
|
7915
|
-
const add = document.createElement('button');
|
|
7916
|
-
add.type = 'button'; add.className = 'eh-add'; add.textContent = '+'; add.title = 'Run ' + name + ' again';
|
|
7917
|
-
add.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onRun(); });
|
|
7918
|
-
const chev = document.createElement('span'); chev.className = 'eh-chev'; chev.textContent = '▸';
|
|
7919
|
-
summary.appendChild(av); summary.appendChild(id); summary.appendChild(add); summary.appendChild(chev);
|
|
7920
|
-
details.appendChild(summary);
|
|
7921
|
-
const body = document.createElement('div'); body.className = 'eh-jobs';
|
|
7922
|
-
if (!runs.length) {
|
|
7923
|
-
const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet — + to run it.';
|
|
7924
|
-
body.appendChild(none);
|
|
7925
|
-
} else {
|
|
7926
|
-
for (const c of runs) {
|
|
7927
|
-
const row = document.createElement('div'); row.className = 'tree-job';
|
|
7928
|
-
const label = document.createElement('span'); label.className = 'tj-label'; label.textContent = c.title || c.jobTitle || name;
|
|
7929
|
-
const dotCls = (typeof conversationStateDotClass === 'function') ? conversationStateDotClass(c) : 'grey';
|
|
7930
|
-
const dot = document.createElement('span'); dot.className = 'tj-dot dot-' + dotCls; dot.style.background = 'var(--' + dotCls + ')';
|
|
7931
|
-
row.appendChild(label); row.appendChild(dot);
|
|
7932
|
-
tfAttachRunDelete(row, c, { tree: true }); // #533 R6: delete an area-rail job run
|
|
7933
|
-
row.addEventListener('click', () => { if (typeof switchToConversation === 'function') switchToConversation(c.id); });
|
|
7934
|
-
body.appendChild(row);
|
|
7935
|
-
}
|
|
7936
|
-
}
|
|
7937
|
-
details.appendChild(body);
|
|
7938
|
-
return details;
|
|
7939
|
-
}
|
|
7925
|
+
// #693 R1 (PR round 2): tfAreaRailJob removed with the Company/Manager job rails.
|
|
7940
7926
|
|
|
7941
|
-
// #
|
|
7942
|
-
//
|
|
7943
|
-
//
|
|
7944
|
-
//
|
|
7945
|
-
function tfProjectUpdatesSection() {
|
|
7946
|
-
const JOBS = [
|
|
7947
|
-
{ id: 'project-onboarding', ico: '🚀', name: 'Project Onboarding', run: () => tfStartProjectOnboarding() },
|
|
7948
|
-
{ id: 'sleep-on-learnings', ico: '🧠', name: 'Sleep on learnings', run: () => tfRunShareLearnings('project') },
|
|
7949
|
-
];
|
|
7950
|
-
const details = document.createElement('details');
|
|
7951
|
-
// NB: deliberately NOT `.emp-hero` — that class is the employee-hero selector
|
|
7952
|
-
// the tree's per-employee assign-job affordance keys off; Project Updates reuses
|
|
7953
|
-
// the same visual chrome via `.proj-updates-acc` grouped CSS instead.
|
|
7954
|
-
details.className = 'proj-updates-acc';
|
|
7955
|
-
details.id = 'proj-updates-acc';
|
|
7956
|
-
details.dataset.accKey = 'proj-updates'; // #533 R5: preserve open-state across status re-renders
|
|
7957
|
-
details.open = true;
|
|
7958
|
-
|
|
7959
|
-
const summary = document.createElement('summary');
|
|
7960
|
-
const av = document.createElement('div');
|
|
7961
|
-
av.className = 'eh-av job-ico';
|
|
7962
|
-
av.style.borderRadius = '9px';
|
|
7963
|
-
av.style.background = 'var(--accent-soft)';
|
|
7964
|
-
av.style.color = 'var(--accent)';
|
|
7965
|
-
av.textContent = '🔄';
|
|
7966
|
-
const id = document.createElement('div'); id.className = 'eh-id';
|
|
7967
|
-
const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = 'Project Updates';
|
|
7968
|
-
const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id, { projectPath: state.projectPath }).length, 0);
|
|
7969
|
-
const role = document.createElement('div'); role.className = 'eh-role';
|
|
7970
|
-
role.textContent = totalRuns ? (totalRuns + (totalRuns === 1 ? ' run' : ' runs')) : 'Onboarding & learnings';
|
|
7971
|
-
id.appendChild(nm); id.appendChild(role);
|
|
7972
|
-
|
|
7973
|
-
const add = document.createElement('button');
|
|
7974
|
-
add.type = 'button'; add.className = 'eh-add'; add.textContent = '+'; add.title = 'Run a project update';
|
|
7975
|
-
add.addEventListener('click', (e) => {
|
|
7976
|
-
e.preventDefault(); e.stopPropagation();
|
|
7977
|
-
openPalette();
|
|
7978
|
-
});
|
|
7979
|
-
|
|
7980
|
-
const chev = document.createElement('span'); chev.className = 'eh-chev'; chev.textContent = '▸';
|
|
7981
|
-
summary.appendChild(av); summary.appendChild(id); summary.appendChild(add); summary.appendChild(chev);
|
|
7982
|
-
details.appendChild(summary);
|
|
7983
|
-
|
|
7984
|
-
const body = document.createElement('div'); body.className = 'eh-jobs';
|
|
7985
|
-
JOBS.forEach((j) => {
|
|
7986
|
-
const group = document.createElement('div'); group.className = 'pu-job';
|
|
7987
|
-
const head = document.createElement('div'); head.className = 'pu-job-name';
|
|
7988
|
-
head.textContent = j.ico + ' ' + j.name;
|
|
7989
|
-
group.appendChild(head);
|
|
7990
|
-
const runs = tfConversationsForJob(j.id, { projectPath: state.projectPath });
|
|
7991
|
-
if (!runs.length) {
|
|
7992
|
-
const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet.';
|
|
7993
|
-
group.appendChild(none);
|
|
7994
|
-
} else {
|
|
7995
|
-
runs.forEach((c) => {
|
|
7996
|
-
const row = document.createElement('div'); row.className = 'tree-job';
|
|
7997
|
-
const rl = document.createElement('span'); rl.className = 'tj-label'; rl.textContent = c.title || c.jobTitle || j.name;
|
|
7998
|
-
const dotCls = (typeof conversationStateDotClass === 'function') ? conversationStateDotClass(c) : 'grey';
|
|
7999
|
-
const dot = document.createElement('span'); dot.className = 'tj-dot dot-' + dotCls; dot.style.background = 'var(--' + dotCls + ')';
|
|
8000
|
-
row.appendChild(rl); row.appendChild(dot);
|
|
8001
|
-
tfAttachRunDelete(row, c, { tree: true }); // #533 R6: delete a project-update run
|
|
8002
|
-
row.addEventListener('click', () => { if (typeof switchToConversation === 'function') switchToConversation(c.id); });
|
|
8003
|
-
group.appendChild(row);
|
|
8004
|
-
});
|
|
8005
|
-
}
|
|
8006
|
-
body.appendChild(group);
|
|
8007
|
-
});
|
|
8008
|
-
details.appendChild(body);
|
|
8009
|
-
return details;
|
|
8010
|
-
}
|
|
7927
|
+
// #693 R1: tfProjectUpdatesSection was removed. The "Project Updates" tree
|
|
7928
|
+
// accordion is retired; its Run Project Onboarding and Sleep on learnings actions
|
|
7929
|
+
// now live inside the Brief and Learnings sections (see tfEnsureAccAction, called
|
|
7930
|
+
// from tfRenderProjectContextTop).
|
|
8011
7931
|
|
|
8012
7932
|
// #594 R2: render a conversation panel (topline + messages + coach input) into an
|
|
8013
7933
|
// area-level container so org/manager jobs are viewable inside Company/Manager tabs.
|
|
@@ -8142,13 +8062,9 @@ function tfRenderCompany() {
|
|
|
8142
8062
|
infoBtn.textContent = '📋 Company Info';
|
|
8143
8063
|
infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
|
|
8144
8064
|
rail.appendChild(infoBtn);
|
|
8145
|
-
|
|
8146
|
-
|
|
8147
|
-
|
|
8148
|
-
rail.appendChild(tfAreaRailJob('🧠', 'organizational-learning-synthesis', 'organizational-learning-synthesis', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis()));
|
|
8149
|
-
const note = document.createElement('div'); note.className = 'area-rail-note';
|
|
8150
|
-
note.textContent = "Company-wide work lives here — it applies across every project, so it's not in any project's job list. (sleep-on-learnings runs at the project level.)";
|
|
8151
|
-
rail.appendChild(note);
|
|
8065
|
+
// #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
|
|
8066
|
+
// now run from the section they populate — Run Organization Onboarding in
|
|
8067
|
+
// "Context & rules", Synthesize company learnings in "Company learnings".
|
|
8152
8068
|
}
|
|
8153
8069
|
const profile = document.getElementById('company-profile');
|
|
8154
8070
|
const learn = document.getElementById('company-learnings');
|
|
@@ -8204,6 +8120,11 @@ function tfRenderCompany() {
|
|
|
8204
8120
|
if (learn) {
|
|
8205
8121
|
tfRenderLearningsList(learn, 'org', 'org', 'Nothing here yet — org-wide learnings that apply on every project. Run organizational-learning-synthesis (in Company jobs, left), or add one with + Add learning.');
|
|
8206
8122
|
}
|
|
8123
|
+
// #693 R1: lifecycle actions live in the sections they populate (mirrors the
|
|
8124
|
+
// Projects tab): onboarding in Context & rules, learnings synthesis in Company learnings.
|
|
8125
|
+
tfEnsureAccAction('company-ctx-acc', '🏢', 'Run Organization Onboarding', () => tfStartOrgOnboarding());
|
|
8126
|
+
// #693 R1 (PR round 2): Company's learnings job is organizational-learning-synthesis, not sleep-on-learnings.
|
|
8127
|
+
tfEnsureAccAction('company-learn-acc', '🧠', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis());
|
|
8207
8128
|
tfMaybeRenderOrgPushBanner();
|
|
8208
8129
|
// Show the full coaching conversation panel when there is an active org conv;
|
|
8209
8130
|
// otherwise show the info view (accordions). This uses the shared .page element
|
|
@@ -8293,12 +8214,8 @@ function tfRenderManager() {
|
|
|
8293
8214
|
infoBtn.textContent = '📋 Manager Info';
|
|
8294
8215
|
infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
|
|
8295
8216
|
rail.appendChild(infoBtn);
|
|
8296
|
-
|
|
8297
|
-
|
|
8298
|
-
rail.appendChild(tfAreaRailJob('🤝', 'manager-agreements', 'manager-agreements', 'Set up / update how you work', () => tfStartManagerOnboarding()));
|
|
8299
|
-
const note = document.createElement('div'); note.className = 'area-rail-note';
|
|
8300
|
-
note.textContent = "manager-agreements is about you, not a single project — so it isn't in any project's job list. You coach in project conversations; what the team learns about you surfaces here.";
|
|
8301
|
-
rail.appendChild(note);
|
|
8217
|
+
// #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
|
|
8218
|
+
// Manager agreements now runs from the "Context & rules" section it populates.
|
|
8302
8219
|
}
|
|
8303
8220
|
const profile = document.getElementById('manager-profile');
|
|
8304
8221
|
const learn = document.getElementById('manager-learnings');
|
|
@@ -8361,6 +8278,12 @@ function tfRenderManager() {
|
|
|
8361
8278
|
// the manager-coaching learnings the team has captured for you.
|
|
8362
8279
|
tfRenderLearningsList(reverse, 'reverse', 'machine', 'Nothing here yet — as your team works with you, coaching on what you can improve on as a manager appears here. Add one with + Add learning.');
|
|
8363
8280
|
}
|
|
8281
|
+
// #693 R1: lifecycle actions live in the sections they populate (mirrors the
|
|
8282
|
+
// Projects tab): manager agreements in Context & rules, learnings in Manager learnings.
|
|
8283
|
+
tfEnsureAccAction('manager-ctx-acc', '🤝', 'Set up manager agreements', () => tfStartManagerOnboarding());
|
|
8284
|
+
// #693 R1 (PR round 2): Manager has no learnings-synthesis job — its learnings
|
|
8285
|
+
// surface here from project sleep-on-learnings promoted to manager scope — so no
|
|
8286
|
+
// run-action button in the Manager learnings section.
|
|
8364
8287
|
// Issue #540 R4-R7: render manager team pool on every manager-tab activation.
|
|
8365
8288
|
renderManagerTeamPool();
|
|
8366
8289
|
// Show the full coaching conversation panel when there is an active manager conv;
|
|
@@ -8869,6 +8792,47 @@ function tfCloseAccountMenu() {
|
|
|
8869
8792
|
if (menu) menu.classList.remove('open');
|
|
8870
8793
|
}
|
|
8871
8794
|
|
|
8795
|
+
// ---------------------------------------------------------------------------
|
|
8796
|
+
// Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
|
|
8797
|
+
// sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
|
|
8798
|
+
// OS preference; here we reflect it in the toggle control and persist changes.
|
|
8799
|
+
// Same-origin key sharing keeps /account and /analytics in sync on their next load.
|
|
8800
|
+
// ---------------------------------------------------------------------------
|
|
8801
|
+
function tfCurrentTheme() {
|
|
8802
|
+
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
8803
|
+
}
|
|
8804
|
+
function tfSyncThemeToggle(theme) {
|
|
8805
|
+
const btn = document.getElementById('am-theme-toggle');
|
|
8806
|
+
if (!btn) return;
|
|
8807
|
+
const on = theme === 'dark';
|
|
8808
|
+
btn.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
8809
|
+
const sub = document.getElementById('am-theme-sub');
|
|
8810
|
+
if (sub) sub.textContent = on ? 'On' : 'Off';
|
|
8811
|
+
const ico = document.getElementById('am-theme-ico');
|
|
8812
|
+
if (ico) ico.textContent = on ? '🌙' : '☀️';
|
|
8813
|
+
}
|
|
8814
|
+
function tfApplyTheme(theme, persist) {
|
|
8815
|
+
const t = theme === 'dark' ? 'dark' : 'light';
|
|
8816
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
8817
|
+
if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
|
|
8818
|
+
tfSyncThemeToggle(t);
|
|
8819
|
+
// Keep any same-origin embedded surface (e.g. an analytics iframe) in sync live.
|
|
8820
|
+
try {
|
|
8821
|
+
document.querySelectorAll('iframe').forEach((f) => {
|
|
8822
|
+
if (f.contentWindow) f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, location.origin);
|
|
8823
|
+
});
|
|
8824
|
+
} catch (e) {}
|
|
8825
|
+
}
|
|
8826
|
+
function tfToggleTheme(e) {
|
|
8827
|
+
if (e) e.stopPropagation();
|
|
8828
|
+
tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
|
|
8829
|
+
}
|
|
8830
|
+
function tfWireThemeToggle() {
|
|
8831
|
+
const btn = document.getElementById('am-theme-toggle');
|
|
8832
|
+
if (btn) btn.addEventListener('click', tfToggleTheme);
|
|
8833
|
+
tfSyncThemeToggle(tfCurrentTheme());
|
|
8834
|
+
}
|
|
8835
|
+
|
|
8872
8836
|
// Per-job config for the area onboarding pre-flight modal.
|
|
8873
8837
|
const AREA_ONBOARD_CONFIG = {
|
|
8874
8838
|
'organization-onboarding': {
|
|
@@ -9200,7 +9164,7 @@ async function tfCreateProject() {
|
|
|
9200
9164
|
intent && `Project intent: ${intent}`,
|
|
9201
9165
|
val('np-outcome') && `Success looks like: ${val('np-outcome')}`,
|
|
9202
9166
|
val('np-rules') && `Rules & guardrails: ${val('np-rules')}`,
|
|
9203
|
-
`Team: ${(
|
|
9167
|
+
`Team: ${tfProjectTeamKeys().join(', ') || 'no employees assigned yet'}`,
|
|
9204
9168
|
].filter(Boolean).join('\n\n');
|
|
9205
9169
|
|
|
9206
9170
|
if (onboardingContext) {
|
|
@@ -9386,10 +9350,9 @@ function tfAssignJobToEmployee(employeeKey, job) {
|
|
|
9386
9350
|
if (!projectId) { tfCloseAssignJob(); return; }
|
|
9387
9351
|
if (!tf.assignments[projectId]) tf.assignments[projectId] = [];
|
|
9388
9352
|
tf.assignments[projectId].push({ id: 'a-' + Date.now(), employeeKey, jobName: job.title || job.id, jobId: job.id });
|
|
9389
|
-
|
|
9390
|
-
|
|
9353
|
+
// No longer mutate a stored project.team: the roster is derived from hired
|
|
9354
|
+
// personas (tfProjectTeamKeys) and every employee is always available.
|
|
9391
9355
|
tfPersistAssignments();
|
|
9392
|
-
tfPersistProjects();
|
|
9393
9356
|
tfCloseAssignJob();
|
|
9394
9357
|
tfRenderTree();
|
|
9395
9358
|
tfRenderProjectTabs();
|
|
@@ -9408,7 +9371,7 @@ function tfOpenAddEmp(preselectKey) {
|
|
|
9408
9371
|
const sub = document.getElementById('ae-sub');
|
|
9409
9372
|
if (!modal || !body) return;
|
|
9410
9373
|
body.innerHTML = '';
|
|
9411
|
-
const onProject = (
|
|
9374
|
+
const onProject = tfProjectTeamKeys();
|
|
9412
9375
|
const onProjectPersonas = onProject.map(tfPersonaByKey).filter(Boolean);
|
|
9413
9376
|
if (onProjectPersonas.length) {
|
|
9414
9377
|
const label = document.createElement('div');
|
|
@@ -9578,6 +9541,7 @@ function tfWireShell() {
|
|
|
9578
9541
|
}
|
|
9579
9542
|
const avatar = document.getElementById('avatar-btn');
|
|
9580
9543
|
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
9544
|
+
tfWireThemeToggle();
|
|
9581
9545
|
const brainItem = document.getElementById('am-brain');
|
|
9582
9546
|
if (brainItem) brainItem.addEventListener('click', () => { tfCloseAccountMenu(); tfShowArea('brain'); });
|
|
9583
9547
|
const signout = document.getElementById('am-signout');
|