fraim-framework 2.0.184 → 2.0.186
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 +127 -15
- package/dist/src/ai-hub/openclaw-bridge.js +17 -6
- package/dist/src/ai-hub/remote-hub-gateway.js +88 -0
- package/dist/src/ai-hub/server.js +189 -191
- package/dist/src/api/ai-hub/manager-team.js +93 -0
- 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 +733 -553
- package/public/ai-hub/styles.css +162 -35
package/public/ai-hub/script.js
CHANGED
|
@@ -16,6 +16,9 @@ const TREE_WIDTH_MAX = 380;
|
|
|
16
16
|
const TREE_WIDTH_DEFAULT = 216;
|
|
17
17
|
const PAGE_SCOPED_JOBS = new Set(['organization-onboarding', 'manager-agreements', 'project-onboarding', 'organizational-learning-synthesis']);
|
|
18
18
|
// Jobs scoped to the Company/Manager area — never shown in the Projects workspace rail.
|
|
19
|
+
// These are the truly area-only jobs. Issue #702: persona jobs (e.g. Ashley's) are NOT
|
|
20
|
+
// listed here — a persona can run a job from a project OR from the Manager tab, and a
|
|
21
|
+
// run's placement is decided per-invocation by conv.invokedArea, not by job id.
|
|
19
22
|
const AREA_SCOPED_JOBS = new Set(['organization-onboarding', 'organizational-learning-synthesis', 'manager-agreements']);
|
|
20
23
|
|
|
21
24
|
const state = {
|
|
@@ -509,9 +512,10 @@ async function bgRefreshConversations() {
|
|
|
509
512
|
existing.push(conv);
|
|
510
513
|
changed = true;
|
|
511
514
|
} else {
|
|
512
|
-
// Update
|
|
515
|
+
// Update known conversations when the server projection changes. This
|
|
516
|
+
// includes job/persona recognition for running ad-hoc jobs.
|
|
513
517
|
const idx = existing.findIndex((c) => c.id === conv.id);
|
|
514
|
-
if (idx !== -1 && existing[idx]
|
|
518
|
+
if (idx !== -1 && conversationProjectionChanged(existing[idx], conv)) {
|
|
515
519
|
existing[idx] = conv;
|
|
516
520
|
changed = true;
|
|
517
521
|
}
|
|
@@ -533,7 +537,7 @@ function stopBgConvPoll() {
|
|
|
533
537
|
if (_bgConvPollHandle) { window.clearInterval(_bgConvPollHandle); _bgConvPollHandle = null; }
|
|
534
538
|
}
|
|
535
539
|
|
|
536
|
-
function
|
|
540
|
+
function needsDelegatedConversationRefresh() {
|
|
537
541
|
const active = activeConversation();
|
|
538
542
|
if (!active) return false;
|
|
539
543
|
if (isManagedDelegationChild(active) && active.status === 'running') return true;
|
|
@@ -548,12 +552,49 @@ function needsConversationRefresh() {
|
|
|
548
552
|
});
|
|
549
553
|
}
|
|
550
554
|
|
|
555
|
+
function needsConversationRefresh() {
|
|
556
|
+
return runningProjectConversations().length > 0 || needsDelegatedConversationRefresh();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
async function refreshRunningConversations() {
|
|
560
|
+
const running = runningProjectConversations();
|
|
561
|
+
if (!running.length) return false;
|
|
562
|
+
let changed = false;
|
|
563
|
+
for (const conv of running) {
|
|
564
|
+
try {
|
|
565
|
+
const run = await requestJson(`/api/ai-hub/runs/${encodeURIComponent(conv.runId)}`);
|
|
566
|
+
foldRunIntoConversation(conv, run);
|
|
567
|
+
upsertConversation(conv);
|
|
568
|
+
changed = true;
|
|
569
|
+
} catch (_) {
|
|
570
|
+
// The run may have just completed and left the in-memory registry before
|
|
571
|
+
// the conversation store hydrated. The next conversation refresh catches it.
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (changed) {
|
|
575
|
+
renderRail();
|
|
576
|
+
renderActive();
|
|
577
|
+
if (tf.area === 'company') tfRenderCompany();
|
|
578
|
+
else if (tf.area === 'manager') tfRenderManager();
|
|
579
|
+
}
|
|
580
|
+
return changed;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function refreshConversationProjections() {
|
|
584
|
+
await refreshRunningConversations();
|
|
585
|
+
if (needsDelegatedConversationRefresh()) {
|
|
586
|
+
await hydrateConversationsFromServer();
|
|
587
|
+
} else {
|
|
588
|
+
syncConversationRefreshPolling();
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
551
592
|
function syncConversationRefreshPolling() {
|
|
552
593
|
const shouldPoll = needsConversationRefresh();
|
|
553
594
|
if (shouldPoll && !state.conversationRefreshHandle) {
|
|
554
595
|
state.conversationRefreshHandle = window.setInterval(() => {
|
|
555
|
-
|
|
556
|
-
console.warn('Could not refresh
|
|
596
|
+
refreshConversationProjections().catch((error) =>
|
|
597
|
+
console.warn('Could not refresh conversations:', error));
|
|
557
598
|
}, 1500);
|
|
558
599
|
} else if (!shouldPoll && state.conversationRefreshHandle) {
|
|
559
600
|
window.clearInterval(state.conversationRefreshHandle);
|
|
@@ -578,6 +619,32 @@ function projectConversations() {
|
|
|
578
619
|
return state.conversations[key] || [];
|
|
579
620
|
}
|
|
580
621
|
|
|
622
|
+
function conversationProjectionChanged(existing, incoming) {
|
|
623
|
+
if (!existing || !incoming) return true;
|
|
624
|
+
const fields = [
|
|
625
|
+
'jobId',
|
|
626
|
+
'jobTitle',
|
|
627
|
+
'personaKey',
|
|
628
|
+
'runId',
|
|
629
|
+
'sessionId',
|
|
630
|
+
'status',
|
|
631
|
+
'managedReviewStatus',
|
|
632
|
+
'compareRunId',
|
|
633
|
+
'sourceTrigger',
|
|
634
|
+
];
|
|
635
|
+
if (fields.some((field) => (existing[field] ?? null) !== (incoming[field] ?? null))) return true;
|
|
636
|
+
const existingMessages = Array.isArray(existing.messages) ? existing.messages.length : 0;
|
|
637
|
+
const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages.length : 0;
|
|
638
|
+
if (existingMessages !== incomingMessages) return true;
|
|
639
|
+
const existingEvents = Array.isArray(existing.events) ? existing.events.length : 0;
|
|
640
|
+
const incomingEvents = Array.isArray(incoming.events) ? incoming.events.length : 0;
|
|
641
|
+
return existingEvents !== incomingEvents;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function runningProjectConversations() {
|
|
645
|
+
return projectConversations().filter((conv) => conv && conv.status === 'running' && conv.runId);
|
|
646
|
+
}
|
|
647
|
+
|
|
581
648
|
function setProjectConversations(list) {
|
|
582
649
|
const key = state.projectPath || '';
|
|
583
650
|
state.conversations[key] = list;
|
|
@@ -839,11 +906,9 @@ function renderRail() {
|
|
|
839
906
|
renderTeamRoster();
|
|
840
907
|
els['conv-list'].innerHTML = '';
|
|
841
908
|
// R4: filter by selected persona when one is active.
|
|
842
|
-
// #
|
|
843
|
-
// sleep-on-learnings)
|
|
844
|
-
//
|
|
845
|
-
const inWorkspace = !!document.querySelector('#proj-workspace .workspace-conv');
|
|
846
|
-
const projectUpdateJobs = new Set(['project-onboarding', 'sleep-on-learnings']);
|
|
909
|
+
// #693 R1: the "Project Updates" section was retired; project-lifecycle runs
|
|
910
|
+
// (project-onboarding + sleep-on-learnings) now surface in the Runs list under
|
|
911
|
+
// their employee like any other run, so their history stays reachable.
|
|
847
912
|
const allProjectConversations = projectConversations();
|
|
848
913
|
const managedChildren = allProjectConversations.filter(isManagedDelegationChild);
|
|
849
914
|
const childrenByManager = new Map();
|
|
@@ -856,8 +921,9 @@ function renderRail() {
|
|
|
856
921
|
const list = allProjectConversations.filter((conv) =>
|
|
857
922
|
(!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
|
|
858
923
|
!isManagedDelegationChild(conv) &&
|
|
859
|
-
!
|
|
860
|
-
|
|
924
|
+
!AREA_SCOPED_JOBS.has(conv.jobId) &&
|
|
925
|
+
// #702: runs invoked from the Manager/Company tab surface there, not in a project.
|
|
926
|
+
conv.invokedArea !== 'manager' && conv.invokedArea !== 'company'
|
|
861
927
|
);
|
|
862
928
|
|
|
863
929
|
// Issue #550: Two-path routing for ad-hoc (freeform) conversations.
|
|
@@ -886,6 +952,31 @@ function renderRail() {
|
|
|
886
952
|
groups.get(key).conversations.push(conv);
|
|
887
953
|
}
|
|
888
954
|
|
|
955
|
+
// #693 R1 (PR round 2): this Runs list is the single employees section in the
|
|
956
|
+
// workspace tree, so include every project employee — team, manager-team, and
|
|
957
|
+
// assigned — even before they have a run, so you can assign/delegate to them.
|
|
958
|
+
const tfProjId = (typeof tf !== 'undefined' && tf) ? tf.activeProjectId : null;
|
|
959
|
+
const managerTeamKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey).filter(Boolean);
|
|
960
|
+
// merge: master resolves the tree's employees via tfProjectTeamKeys() (hired personas),
|
|
961
|
+
// not project.team; align on it so every hired employee appears in the single section.
|
|
962
|
+
const projectEmployeeKeys = new Set([
|
|
963
|
+
...(typeof tfProjectTeamKeys === 'function' ? tfProjectTeamKeys() : []),
|
|
964
|
+
...(tfProjId && typeof tfProjectAssignments === 'function' ? tfProjectAssignments(tfProjId).map((a) => a.employeeKey).filter(Boolean) : []),
|
|
965
|
+
...managerTeamKeys,
|
|
966
|
+
]);
|
|
967
|
+
for (const key of projectEmployeeKeys) {
|
|
968
|
+
if (groups.has(key)) continue;
|
|
969
|
+
const p = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
|
|
970
|
+
if (p && p.status !== 'hired') continue; // reconcile against real entitlements (#538 follow-up)
|
|
971
|
+
groups.set(key, {
|
|
972
|
+
key,
|
|
973
|
+
label: p ? p.displayName : key,
|
|
974
|
+
detail: p ? (p.role || 'AI Employee') : '',
|
|
975
|
+
sample: p ? { personaKey: key } : { agentName: key },
|
|
976
|
+
conversations: [],
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
889
980
|
function buildRunButton(conv, options = {}) {
|
|
890
981
|
const btn = document.createElement('button');
|
|
891
982
|
btn.type = 'button';
|
|
@@ -999,9 +1090,33 @@ function renderRail() {
|
|
|
999
1090
|
summary.appendChild(avatar);
|
|
1000
1091
|
summary.appendChild(copy);
|
|
1001
1092
|
summary.appendChild(addBtn);
|
|
1093
|
+
// #693 R1 (PR round 2): hire-a-human-manager (#538) affordance, ported from the
|
|
1094
|
+
// retired emp-hero list. The old per-employee "Delegate" button is intentionally
|
|
1095
|
+
// omitted — it did the same openPalette('/persona') as the "+" above, so it was
|
|
1096
|
+
// redundant and only squeezed the employee-name column.
|
|
1097
|
+
if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[group.key]) {
|
|
1098
|
+
const hireBtn = document.createElement('button');
|
|
1099
|
+
hireBtn.type = 'button';
|
|
1100
|
+
hireBtn.className = 'eh-hire-manager';
|
|
1101
|
+
hireBtn.textContent = '🧑💼';
|
|
1102
|
+
hireBtn.title = 'Find a human manager for ' + group.label;
|
|
1103
|
+
hireBtn.setAttribute('aria-label', 'Find a human manager for ' + group.label);
|
|
1104
|
+
hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(group.key); });
|
|
1105
|
+
summary.appendChild(hireBtn);
|
|
1106
|
+
}
|
|
1002
1107
|
summary.appendChild(count);
|
|
1003
1108
|
details.appendChild(summary);
|
|
1004
|
-
|
|
1109
|
+
if (group.conversations.length) {
|
|
1110
|
+
details.appendChild(buildGroupList(group.conversations));
|
|
1111
|
+
} else {
|
|
1112
|
+
const emptyWrap = document.createElement('div');
|
|
1113
|
+
emptyWrap.className = 'conv-employee-list';
|
|
1114
|
+
const em = document.createElement('div');
|
|
1115
|
+
em.className = 'eh-empty';
|
|
1116
|
+
em.textContent = 'No runs yet — + to assign a job.';
|
|
1117
|
+
emptyWrap.appendChild(em);
|
|
1118
|
+
details.appendChild(emptyWrap);
|
|
1119
|
+
}
|
|
1005
1120
|
els['conv-list'].appendChild(details);
|
|
1006
1121
|
}
|
|
1007
1122
|
|
|
@@ -1016,9 +1131,11 @@ function renderRail() {
|
|
|
1016
1131
|
wcDetails.open = true;
|
|
1017
1132
|
const wcSummary = document.createElement('summary');
|
|
1018
1133
|
wcSummary.className = 'conv-employee-tab';
|
|
1019
|
-
//
|
|
1134
|
+
// #693.8: watercooler icon (replaces the blank dashed placeholder).
|
|
1020
1135
|
const wcAvatar = document.createElement('span');
|
|
1021
|
-
wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--
|
|
1136
|
+
wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--watercooler';
|
|
1137
|
+
wcAvatar.setAttribute('aria-hidden', 'true');
|
|
1138
|
+
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>';
|
|
1022
1139
|
const wcCopy = document.createElement('span');
|
|
1023
1140
|
wcCopy.className = 'conv-employee-tab-copy';
|
|
1024
1141
|
const wcLabel = document.createElement('strong');
|
|
@@ -1986,11 +2103,29 @@ function renderTracker(conv) {
|
|
|
1986
2103
|
|
|
1987
2104
|
const stages = Array.isArray(conv.run.stages) ? conv.run.stages : [];
|
|
1988
2105
|
if (stages.length === 0) {
|
|
1989
|
-
|
|
2106
|
+
// Issue #711 — adhoc runs (jobId '__freeform__') declare no phases, so
|
|
2107
|
+
// there is no pizza tracker. We still surface the run totals (tokens,
|
|
2108
|
+
// cost, duration) that the server derives for every run. Keep the tracker
|
|
2109
|
+
// container visible in "totals-only" mode so renderTotals has a place to
|
|
2110
|
+
// render; otherwise hide the whole section.
|
|
2111
|
+
if (conv.run.totals) {
|
|
2112
|
+
tracker.hidden = false;
|
|
2113
|
+
tracker.classList.add('tracker--totals-only');
|
|
2114
|
+
tracker.classList.remove('tracker-compact');
|
|
2115
|
+
const rowsHost = els['tracker-rows'];
|
|
2116
|
+
if (rowsHost) rowsHost.innerHTML = '';
|
|
2117
|
+
const activeLabel = tracker.querySelector('.tracker-active-label');
|
|
2118
|
+
if (activeLabel) activeLabel.textContent = '';
|
|
2119
|
+
const note = els['tracker-note'];
|
|
2120
|
+
if (note) { note.hidden = true; note.textContent = ''; }
|
|
2121
|
+
} else {
|
|
2122
|
+
tracker.hidden = true;
|
|
2123
|
+
}
|
|
1990
2124
|
renderedTrackerKey = null;
|
|
1991
2125
|
return;
|
|
1992
2126
|
}
|
|
1993
2127
|
|
|
2128
|
+
tracker.classList.remove('tracker--totals-only');
|
|
1994
2129
|
tracker.hidden = false;
|
|
1995
2130
|
tracker.dataset.stageCount = String(stages.length);
|
|
1996
2131
|
const rowCapacity = trackerRowCapacity(tracker, stages);
|
|
@@ -2989,7 +3124,6 @@ function convAwaitingReview(conv) {
|
|
|
2989
3124
|
// output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
|
|
2990
3125
|
// stakeholder-status-reporting) may legitimately surface artifacts for human review.
|
|
2991
3126
|
if (delegationLedgerForConversation(conv)) return false;
|
|
2992
|
-
if (conv.status === 'completed' && Array.isArray(conv.artifacts) && conv.artifacts.length > 0) return true;
|
|
2993
3127
|
// #521: a plain completed turn is NOT awaiting review — the approve/reject card
|
|
2994
3128
|
// only belongs when the employee actually submits for review (a review_handoff,
|
|
2995
3129
|
// emitted by the submit phase). A turn that just finished (a question, a pause
|
|
@@ -3054,20 +3188,22 @@ function normalizeReviewHandoff(raw) {
|
|
|
3054
3188
|
if (targetType === 'pull_request') {
|
|
3055
3189
|
const url = safeHttpUrl(rawTarget && rawTarget.url);
|
|
3056
3190
|
if (!url) return null;
|
|
3191
|
+
if (artifacts.length > 0) return null;
|
|
3057
3192
|
return {
|
|
3058
3193
|
reviewRequired: true,
|
|
3059
3194
|
reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
|
|
3060
|
-
artifacts,
|
|
3195
|
+
artifacts: [],
|
|
3061
3196
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
3062
3197
|
feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'pull_request_comments',
|
|
3063
3198
|
};
|
|
3064
3199
|
}
|
|
3065
3200
|
|
|
3066
|
-
|
|
3201
|
+
const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
|
|
3202
|
+
if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
|
|
3067
3203
|
return {
|
|
3068
3204
|
reviewRequired: true,
|
|
3069
3205
|
reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length || 1} artifact${artifacts.length === 1 ? '' : 's'}` },
|
|
3070
|
-
artifacts,
|
|
3206
|
+
artifacts: fileArtifacts,
|
|
3071
3207
|
summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
|
|
3072
3208
|
feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'inline',
|
|
3073
3209
|
};
|
|
@@ -3759,7 +3895,7 @@ async function handleArtifactAction(conv, action) {
|
|
|
3759
3895
|
'Please resend the review handoff contract as valid JSON inside <review_handoff>...</review_handoff>.',
|
|
3760
3896
|
'Do not rely on prose.',
|
|
3761
3897
|
'Include reviewRequired, reviewTarget, artifacts, summary, and feedbackMode.',
|
|
3762
|
-
'Use reviewTarget.type "pull_request" only
|
|
3898
|
+
'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.',
|
|
3763
3899
|
].join(' ');
|
|
3764
3900
|
if (conv && conv.sessionId) {
|
|
3765
3901
|
els['coach-text'].value = message;
|
|
@@ -3954,17 +4090,14 @@ function wirePopovers() {
|
|
|
3954
4090
|
|
|
3955
4091
|
if (e.key === 'Escape') {
|
|
3956
4092
|
const cpModal = document.getElementById('cp-modal');
|
|
3957
|
-
const
|
|
3958
|
-
const webhookModal = document.getElementById('dep-webhook-modal');
|
|
4093
|
+
const assignModal = document.getElementById('dep-assignment-modal');
|
|
3959
4094
|
const aomModal = document.getElementById('area-onboard-modal');
|
|
3960
4095
|
if (aomModal && !aomModal.hidden) {
|
|
3961
4096
|
tfCloseAreaOnboardModal();
|
|
3962
4097
|
} else if (cpModal && !cpModal.hidden) {
|
|
3963
4098
|
closePalette();
|
|
3964
|
-
} else if (
|
|
3965
|
-
|
|
3966
|
-
} else if (webhookModal && !webhookModal.hidden) {
|
|
3967
|
-
webhookModal.hidden = true;
|
|
4099
|
+
} else if (assignModal && !assignModal.hidden) {
|
|
4100
|
+
assignModal.hidden = true;
|
|
3968
4101
|
} else if (els['modal'].classList.contains('open')) {
|
|
3969
4102
|
closeModal();
|
|
3970
4103
|
} else {
|
|
@@ -4145,13 +4278,37 @@ function renderCpRows(searchText) {
|
|
|
4145
4278
|
});
|
|
4146
4279
|
}
|
|
4147
4280
|
|
|
4148
|
-
//
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
|
|
4153
|
-
|
|
4154
|
-
|
|
4281
|
+
// #693 R5: agent picker — choose the AI agent for this launch.
|
|
4282
|
+
renderCpAgentPicker();
|
|
4283
|
+
}
|
|
4284
|
+
|
|
4285
|
+
// #693 R5: render the command-palette AI-agent picker. Lists only agents
|
|
4286
|
+
// available on the machine as selectable; unavailable agents are shown disabled
|
|
4287
|
+
// with an install hint. Defaults to the saved agent (state.cpEmployee); if that
|
|
4288
|
+
// agent is not installed, falls back to the first available one.
|
|
4289
|
+
function renderCpAgentPicker() {
|
|
4290
|
+
const picker = document.getElementById('cp-agent-picker');
|
|
4291
|
+
if (!picker) return;
|
|
4292
|
+
picker.innerHTML = '';
|
|
4293
|
+
const employees = (state.bootstrap && state.bootstrap.employees) || [];
|
|
4294
|
+
const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
|
|
4295
|
+
const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
|
|
4296
|
+
if (!curOk) {
|
|
4297
|
+
const firstAvail = list.find((e) => e.available !== false);
|
|
4298
|
+
if (firstAvail) state.cpEmployee = firstAvail.id;
|
|
4299
|
+
}
|
|
4300
|
+
for (const e of list) {
|
|
4301
|
+
const pill = document.createElement('button');
|
|
4302
|
+
pill.type = 'button';
|
|
4303
|
+
pill.className = 'cp-agent-pill' + (e.id === state.cpEmployee ? ' on' : '');
|
|
4304
|
+
pill.textContent = e.label + (e.available === false ? ' · install' : '');
|
|
4305
|
+
if (e.available === false) {
|
|
4306
|
+
pill.disabled = true;
|
|
4307
|
+
pill.title = e.label + ' is not installed on this machine';
|
|
4308
|
+
} else {
|
|
4309
|
+
pill.addEventListener('click', () => { state.cpEmployee = e.id; renderCpAgentPicker(); });
|
|
4310
|
+
}
|
|
4311
|
+
picker.appendChild(pill);
|
|
4155
4312
|
}
|
|
4156
4313
|
}
|
|
4157
4314
|
|
|
@@ -4683,7 +4840,7 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
4683
4840
|
// The browser sends raw manager instructions. AI Hub normalizes assigned
|
|
4684
4841
|
// FRAIM jobs into host-facing invocations so the Hub UI and channel callers
|
|
4685
4842
|
// share one start/continue contract.
|
|
4686
|
-
async function startRun(job, instructions, employeeId, preassignedConvId) {
|
|
4843
|
+
async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
|
|
4687
4844
|
const isFreeform = job.id === '__freeform__';
|
|
4688
4845
|
// In task-pane/extension mode: get a fresh selection snapshot and prepend
|
|
4689
4846
|
// document context to the instructions so the agent knows what the user is
|
|
@@ -4722,6 +4879,10 @@ async function startRun(job, instructions, employeeId, preassignedConvId) {
|
|
|
4722
4879
|
compareRun: null,
|
|
4723
4880
|
// Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
|
|
4724
4881
|
wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
|
|
4882
|
+
// Issue #702: remember which area this run was launched from so its run surfaces
|
|
4883
|
+
// where it was invoked (project-invoked → that project; manager-invoked → Manager tab).
|
|
4884
|
+
// Defaults to the current area.
|
|
4885
|
+
invokedArea: invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects',
|
|
4725
4886
|
};
|
|
4726
4887
|
upsertConversation(conv);
|
|
4727
4888
|
state.activeId = conv.id;
|
|
@@ -4888,6 +5049,18 @@ async function continueRun(text) {
|
|
|
4888
5049
|
}
|
|
4889
5050
|
|
|
4890
5051
|
function foldRunIntoConversation(conv, run) {
|
|
5052
|
+
// Issue #710: server run identity is canonical. The browser may have
|
|
5053
|
+
// created this conversation as provisional __freeform__ state, but every
|
|
5054
|
+
// server snapshot can promote it to a real catalog job.
|
|
5055
|
+
if (run.jobId) {
|
|
5056
|
+
conv.jobId = run.jobId;
|
|
5057
|
+
}
|
|
5058
|
+
if (run.jobTitle !== undefined && run.jobTitle !== null) {
|
|
5059
|
+
conv.jobTitle = run.jobTitle;
|
|
5060
|
+
}
|
|
5061
|
+
if (run.personaKey !== undefined) {
|
|
5062
|
+
conv.personaKey = run.personaKey;
|
|
5063
|
+
}
|
|
4891
5064
|
// Issue #347: keep the latest server snapshot under conv.run so the
|
|
4892
5065
|
// tracker / totals renderers can read it without re-doing work. Stages,
|
|
4893
5066
|
// currentPhase, phaseHistory, and totals are all server-derived per
|
|
@@ -4897,6 +5070,7 @@ function foldRunIntoConversation(conv, run) {
|
|
|
4897
5070
|
currentPhase: run.currentPhase || null,
|
|
4898
5071
|
phaseHistory: run.phaseHistory || [],
|
|
4899
5072
|
totals: run.totals || null,
|
|
5073
|
+
runDiscriminant: run.runDiscriminant || null,
|
|
4900
5074
|
};
|
|
4901
5075
|
// Replace the conversation's events with the run's events (single source of truth on the server).
|
|
4902
5076
|
// If this conversation was restarted on a different agent, prepend the prior run's events so
|
|
@@ -4914,34 +5088,26 @@ function foldRunIntoConversation(conv, run) {
|
|
|
4914
5088
|
conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
|
|
4915
5089
|
// Track session for resumption.
|
|
4916
5090
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
4917
|
-
// R4: persist the persona key from the server-side run record.
|
|
4918
|
-
if (run.personaKey !== undefined && conv.personaKey == null) {
|
|
4919
|
-
conv.personaKey = run.personaKey;
|
|
4920
|
-
}
|
|
4921
5091
|
const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
|
|
4922
|
-
if (runHandoff)
|
|
4923
|
-
|
|
5092
|
+
if (runHandoff) {
|
|
5093
|
+
conv.reviewHandoff = runHandoff;
|
|
5094
|
+
} else if (run.reviewHandoff) {
|
|
5095
|
+
conv.reviewHandoff = run.reviewHandoff;
|
|
5096
|
+
} else {
|
|
5097
|
+
conv.reviewHandoff = null;
|
|
5098
|
+
}
|
|
4924
5099
|
const runDelegation = normalizeDelegationLedger(run.delegation);
|
|
4925
5100
|
if (runDelegation) conv.delegation = runDelegation;
|
|
4926
5101
|
else delegationLedgerForConversation(conv);
|
|
4927
5102
|
if (Array.isArray(run.artifacts)) {
|
|
4928
5103
|
conv.artifacts = run.artifacts;
|
|
5104
|
+
} else {
|
|
5105
|
+
conv.artifacts = [];
|
|
4929
5106
|
}
|
|
4930
5107
|
// Update status.
|
|
4931
5108
|
if (run.status === 'completed') conv.status = 'completed';
|
|
4932
5109
|
else if (run.status === 'failed') conv.status = 'failed';
|
|
4933
5110
|
else conv.status = 'running';
|
|
4934
|
-
// Fallback for server responses that do not include structured artifacts.
|
|
4935
|
-
// The browser only parses output text when that structured field is absent.
|
|
4936
|
-
if (!Array.isArray(run.artifacts)) {
|
|
4937
|
-
for (const e of conv.events) {
|
|
4938
|
-
for (const found of extractArtifacts(e.text)) {
|
|
4939
|
-
if (!conv.artifacts.some((a) => a.name === found.name && a.where === found.where)) {
|
|
4940
|
-
conv.artifacts.push(found);
|
|
4941
|
-
}
|
|
4942
|
-
}
|
|
4943
|
-
}
|
|
4944
|
-
}
|
|
4945
5111
|
conv.lastUpdatedAt = Date.now();
|
|
4946
5112
|
}
|
|
4947
5113
|
|
|
@@ -4960,42 +5126,6 @@ function foldCompareRunIntoConversation(conv, compareRun) {
|
|
|
4960
5126
|
};
|
|
4961
5127
|
}
|
|
4962
5128
|
|
|
4963
|
-
// Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
|
|
4964
|
-
// raw learnings, evidence dumps, mock files), not deliverables the
|
|
4965
|
-
// manager should be drawn to. Excluding them keeps the artifact callout
|
|
4966
|
-
// meaningful — it should mean "the employee produced this file for you".
|
|
4967
|
-
const ARTIFACT_EXCLUDE_RE = /(^|\/)(retrospectives|evidence|learnings|mocks|raw|archive)\//i;
|
|
4968
|
-
function extractArtifact(text) {
|
|
4969
|
-
return extractArtifacts(text)[0] || null;
|
|
4970
|
-
}
|
|
4971
|
-
|
|
4972
|
-
function extractArtifacts(text) {
|
|
4973
|
-
if (!text) return [];
|
|
4974
|
-
const artifacts = [];
|
|
4975
|
-
const seen = new Set();
|
|
4976
|
-
const matches = String(text)
|
|
4977
|
-
.split(/[\s`"'()<>{}\[\],;:]+/)
|
|
4978
|
-
.filter((token) => /^(docs|public|src|tests)\//.test(token));
|
|
4979
|
-
for (const candidate of matches) {
|
|
4980
|
-
const fullPath = candidate.replace(/[.)]+$/g, '');
|
|
4981
|
-
if (!/\.[A-Za-z0-9]+$/.test(fullPath)) continue;
|
|
4982
|
-
if (ARTIFACT_EXCLUDE_RE.test(fullPath)) continue;
|
|
4983
|
-
const segments = fullPath.split('/');
|
|
4984
|
-
const name = segments[segments.length - 1];
|
|
4985
|
-
const where = segments.slice(0, -1).join('/') + '/';
|
|
4986
|
-
// Store the absolute path so the agent can re-read the artifact (and any
|
|
4987
|
-
// .docx export written alongside it) without guessing the project root.
|
|
4988
|
-
const absPath = state.projectPath
|
|
4989
|
-
? state.projectPath.replace(/\\/g, '/').replace(/\/$/, '') + '/' + fullPath
|
|
4990
|
-
: null;
|
|
4991
|
-
const key = absPath || fullPath;
|
|
4992
|
-
if (seen.has(key)) continue;
|
|
4993
|
-
seen.add(key);
|
|
4994
|
-
artifacts.push({ name, where, path: absPath });
|
|
4995
|
-
}
|
|
4996
|
-
return artifacts;
|
|
4997
|
-
}
|
|
4998
|
-
|
|
4999
5129
|
function startPolling() {
|
|
5000
5130
|
if (state.pollHandle) window.clearInterval(state.pollHandle);
|
|
5001
5131
|
state.pollHandle = window.setInterval(async () => {
|
|
@@ -6203,11 +6333,18 @@ function tfConvForAssignment(assignment) {
|
|
|
6203
6333
|
function tfConversationsForJob(jobId, opts) {
|
|
6204
6334
|
const out = [];
|
|
6205
6335
|
const projectPath = opts && opts.projectPath;
|
|
6336
|
+
// #702: optional invokedArea filter so the Manager rail shows only manager-invoked
|
|
6337
|
+
// runs of a persona job (project-invoked runs of the same job stay in the project).
|
|
6338
|
+
const invokedArea = opts && opts.invokedArea;
|
|
6206
6339
|
const lists = projectPath
|
|
6207
6340
|
? [state.conversations[projectPath] || []]
|
|
6208
6341
|
: Object.values(state.conversations || {});
|
|
6209
6342
|
for (const list of lists) {
|
|
6210
|
-
for (const c of (list || []))
|
|
6343
|
+
for (const c of (list || [])) {
|
|
6344
|
+
if (!c || c.jobId !== jobId) continue;
|
|
6345
|
+
if (invokedArea && (c.invokedArea || 'projects') !== invokedArea) continue;
|
|
6346
|
+
out.push(c);
|
|
6347
|
+
}
|
|
6211
6348
|
}
|
|
6212
6349
|
out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
|
|
6213
6350
|
return out;
|
|
@@ -6695,10 +6832,10 @@ function tfRenderTree() {
|
|
|
6695
6832
|
});
|
|
6696
6833
|
host.appendChild(briefNav);
|
|
6697
6834
|
host.appendChild(tfTreeSep());
|
|
6698
|
-
// #
|
|
6699
|
-
//
|
|
6700
|
-
|
|
6701
|
-
|
|
6835
|
+
// #693 R1: the standalone "Project Updates" accordion is retired. Its two
|
|
6836
|
+
// lifecycle actions now live where their output lands — Run Project Onboarding
|
|
6837
|
+
// in the Brief section and Sleep on learnings in the Learnings section
|
|
6838
|
+
// (see tfRenderProjectContextTop). No separate tree section.
|
|
6702
6839
|
// The legacy standalone "+ New job" entry (#new-conv-btn → openModal → the
|
|
6703
6840
|
// #job-catalog/#next1/#instructions/#start chain) lives in the old `.rail`,
|
|
6704
6841
|
// which the shell suppresses (display:none). Relocate the live node to the
|
|
@@ -6711,126 +6848,20 @@ function tfRenderTree() {
|
|
|
6711
6848
|
host.appendChild(newConvBtn);
|
|
6712
6849
|
host.appendChild(tfTreeSep());
|
|
6713
6850
|
}
|
|
6714
|
-
//
|
|
6715
|
-
//
|
|
6716
|
-
//
|
|
6717
|
-
//
|
|
6718
|
-
// keeps targeting the same cached #conv-list node wherever it now lives.
|
|
6851
|
+
// #693 R1 (PR round 2): the per-employee "Runs" accordions (renderRail's
|
|
6852
|
+
// conv-employee-group) ARE the single employees section — they list every
|
|
6853
|
+
// project employee with that employee's runs nested under their accordion.
|
|
6854
|
+
// Relocate that section into the tree; the redundant emp-hero list is retired.
|
|
6719
6855
|
const convList = (typeof els !== 'undefined' && els['conv-list']) || document.getElementById('conv-list');
|
|
6720
6856
|
const runsSection = convList && convList.closest('.rail-section--runs');
|
|
6721
6857
|
if (runsSection) {
|
|
6722
6858
|
runsSection.classList.add('tree-runs');
|
|
6723
6859
|
host.appendChild(runsSection);
|
|
6724
6860
|
}
|
|
6725
|
-
|
|
6726
|
-
|
|
6727
|
-
|
|
6728
|
-
|
|
6729
|
-
// Issue #540 R8: include personas on the manager's team so delegate buttons
|
|
6730
|
-
// appear in the workspace tree even when the persona hasn't been assigned yet.
|
|
6731
|
-
const managerTeamPersonaKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey);
|
|
6732
|
-
const empKeys = Array.from(new Set([...onProject, ...assigned.map((a) => a.employeeKey).filter(Boolean), ...managerTeamPersonaKeys]))
|
|
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.
|
|
6738
|
-
.filter((key) => { const p = tfPersonaByKey(key); return !p || p.status === 'hired'; });
|
|
6739
|
-
const managerTeamKeySet = new Set(managerTeamPersonaKeys);
|
|
6740
|
-
|
|
6741
|
-
empKeys.forEach((key, i) => {
|
|
6742
|
-
const persona = tfPersonaByKey(key);
|
|
6743
|
-
const name = persona ? persona.displayName : key;
|
|
6744
|
-
const role = (persona && persona.role) || '';
|
|
6745
|
-
const av = tfAvatarFor(name, i);
|
|
6746
|
-
// R1 (#521): each employee is a large hero accordion — the team are the
|
|
6747
|
-
// heroes of the workspace, not a thin nav list. Their jobs nest inside.
|
|
6748
|
-
const details = document.createElement('details');
|
|
6749
|
-
details.className = 'emp-hero';
|
|
6750
|
-
details.dataset.accKey = 'emp:' + key; // #533 R5: stable key for open-state preservation
|
|
6751
|
-
details.open = true;
|
|
6752
|
-
const summary = document.createElement('summary');
|
|
6753
|
-
summary.title = name;
|
|
6754
|
-
const avEl = document.createElement('div');
|
|
6755
|
-
avEl.className = 'eh-av';
|
|
6756
|
-
avEl.style.background = av.color;
|
|
6757
|
-
avEl.textContent = av.badge;
|
|
6758
|
-
const idWrap = document.createElement('div');
|
|
6759
|
-
idWrap.className = 'eh-id';
|
|
6760
|
-
const nameEl = document.createElement('div');
|
|
6761
|
-
nameEl.className = 'eh-name';
|
|
6762
|
-
nameEl.textContent = name;
|
|
6763
|
-
idWrap.appendChild(nameEl);
|
|
6764
|
-
if (role) {
|
|
6765
|
-
const roleEl = document.createElement('div');
|
|
6766
|
-
roleEl.className = 'eh-role';
|
|
6767
|
-
roleEl.textContent = role;
|
|
6768
|
-
idWrap.appendChild(roleEl);
|
|
6769
|
-
}
|
|
6770
|
-
const addBtn = document.createElement('button');
|
|
6771
|
-
addBtn.type = 'button';
|
|
6772
|
-
addBtn.className = 'eh-add';
|
|
6773
|
-
addBtn.textContent = '+';
|
|
6774
|
-
addBtn.title = 'Assign a job to ' + name;
|
|
6775
|
-
addBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); tfOpenAssignJob(key); });
|
|
6776
|
-
// Issue #538 — per-employee entry point: hire a human manager for this role.
|
|
6777
|
-
let hireBtn = null;
|
|
6778
|
-
if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[key]) {
|
|
6779
|
-
hireBtn = document.createElement('button');
|
|
6780
|
-
hireBtn.type = 'button';
|
|
6781
|
-
hireBtn.className = 'eh-hire-manager';
|
|
6782
|
-
hireBtn.textContent = '🧑💼';
|
|
6783
|
-
hireBtn.title = 'Find a human manager for ' + name;
|
|
6784
|
-
hireBtn.setAttribute('aria-label', 'Find a human manager for ' + name);
|
|
6785
|
-
hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(key); });
|
|
6786
|
-
}
|
|
6787
|
-
const empDot = tfEmployeeDot(projectId, key);
|
|
6788
|
-
const dot = document.createElement('span');
|
|
6789
|
-
dot.className = 'eh-dot t-dot dot-' + empDot;
|
|
6790
|
-
dot.style.background = 'var(--' + empDot + ')';
|
|
6791
|
-
dot.title = tfDotTitle(empDot);
|
|
6792
|
-
const chev = document.createElement('span');
|
|
6793
|
-
chev.className = 'eh-chev';
|
|
6794
|
-
chev.textContent = '▸';
|
|
6795
|
-
summary.appendChild(avEl);
|
|
6796
|
-
summary.appendChild(idWrap);
|
|
6797
|
-
if (hireBtn) summary.appendChild(hireBtn);
|
|
6798
|
-
summary.appendChild(addBtn);
|
|
6799
|
-
// Issue #540 R8: delegate button for personas on the manager's team.
|
|
6800
|
-
if (managerTeamKeySet.has(key)) {
|
|
6801
|
-
summary.appendChild(renderDelegateButton(key));
|
|
6802
|
-
}
|
|
6803
|
-
summary.appendChild(dot);
|
|
6804
|
-
summary.appendChild(chev);
|
|
6805
|
-
details.appendChild(summary);
|
|
6806
|
-
|
|
6807
|
-
const body = document.createElement('div');
|
|
6808
|
-
body.className = 'eh-jobs';
|
|
6809
|
-
const empJobs = assigned.filter((x) => x.employeeKey === key);
|
|
6810
|
-
if (empJobs.length === 0) {
|
|
6811
|
-
const none = document.createElement('div');
|
|
6812
|
-
none.className = 'eh-empty';
|
|
6813
|
-
none.textContent = 'No jobs yet — + to assign one.';
|
|
6814
|
-
body.appendChild(none);
|
|
6815
|
-
}
|
|
6816
|
-
for (const a of empJobs) {
|
|
6817
|
-
const jobRow = document.createElement('div');
|
|
6818
|
-
jobRow.className = 'tree-job';
|
|
6819
|
-
const jobDot = tfAssignmentDot(a);
|
|
6820
|
-
const label = document.createElement('span');
|
|
6821
|
-
label.className = 'tj-label';
|
|
6822
|
-
label.textContent = a.jobName;
|
|
6823
|
-
const jd = document.createElement('span');
|
|
6824
|
-
jd.className = 'tj-dot dot-' + jobDot;
|
|
6825
|
-
jd.style.background = 'var(--' + jobDot + ')';
|
|
6826
|
-
jd.title = tfDotTitle(jobDot);
|
|
6827
|
-
jobRow.appendChild(label); jobRow.appendChild(jd);
|
|
6828
|
-
jobRow.addEventListener('click', () => tfSelectAssignmentConversation(a));
|
|
6829
|
-
body.appendChild(jobRow);
|
|
6830
|
-
}
|
|
6831
|
-
details.appendChild(body);
|
|
6832
|
-
host.appendChild(details);
|
|
6833
|
-
});
|
|
6861
|
+
// #693 R1 (PR round 2): the redundant emp-hero employee list was removed. The
|
|
6862
|
+
// relocated Runs section above (renderRail's conv-employee-group) is now the
|
|
6863
|
+
// single employees section — every project employee, with their runs nested
|
|
6864
|
+
// under their own accordion, plus per-employee assign/hire-manager.
|
|
6834
6865
|
|
|
6835
6866
|
// #521 R5/R6.4: the project brief and learnings moved OUT of the tree into the
|
|
6836
6867
|
// collapsible top sections (#proj-brief-acc / #proj-learnings-acc), rendered by
|
|
@@ -6931,6 +6962,37 @@ function tfRenderProjectContextTop() {
|
|
|
6931
6962
|
// #533 #4: project-LEVEL manager learnings (repo-local) live in the project tab.
|
|
6932
6963
|
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.');
|
|
6933
6964
|
}
|
|
6965
|
+
// #693 R1: the retired "Project Updates" section's actions now live where their
|
|
6966
|
+
// output lands — Run Project Onboarding in the Brief section, Sleep on learnings
|
|
6967
|
+
// in the Learnings section.
|
|
6968
|
+
tfEnsureAccAction('proj-brief-acc', '🚀', 'Run Project Onboarding', () => tfOpenOnboardInput('run'));
|
|
6969
|
+
tfEnsureAccAction('proj-learnings-acc', '🧠', 'Sleep on learnings', () => tfRunShareLearnings('project'));
|
|
6970
|
+
}
|
|
6971
|
+
|
|
6972
|
+
// #693 R1: insert (idempotently) a single lifecycle-action button at the top of a
|
|
6973
|
+
// ctx accordion's body. Used to place onboarding/sleep actions inside the Brief,
|
|
6974
|
+
// Learnings, and Company/Manager context sections they populate.
|
|
6975
|
+
function tfEnsureAccAction(accId, ico, label, onClick) {
|
|
6976
|
+
const acc = document.getElementById(accId);
|
|
6977
|
+
if (!acc) return;
|
|
6978
|
+
const body = acc.querySelector('.ctx-acc-body');
|
|
6979
|
+
if (!body) return;
|
|
6980
|
+
let row = body.querySelector(':scope > .ctx-acc-action-row');
|
|
6981
|
+
if (!row) {
|
|
6982
|
+
row = document.createElement('div');
|
|
6983
|
+
row.className = 'ctx-acc-action-row';
|
|
6984
|
+
body.insertBefore(row, body.firstChild);
|
|
6985
|
+
}
|
|
6986
|
+
// Rebuild the button so repeated renders don't stack duplicates or stale handlers.
|
|
6987
|
+
row.innerHTML = '';
|
|
6988
|
+
const btn = document.createElement('button');
|
|
6989
|
+
btn.type = 'button';
|
|
6990
|
+
btn.className = 'ctx-acc-action';
|
|
6991
|
+
const ic = document.createElement('span'); ic.className = 'caa-ico'; ic.textContent = ico;
|
|
6992
|
+
const tx = document.createElement('span'); tx.textContent = label;
|
|
6993
|
+
btn.appendChild(ic); btn.appendChild(tx);
|
|
6994
|
+
btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick(); });
|
|
6995
|
+
row.appendChild(btn);
|
|
6934
6996
|
}
|
|
6935
6997
|
|
|
6936
6998
|
// #521: the Brief section is the project's real captured context + rules — the two
|
|
@@ -7099,119 +7161,181 @@ function cronToHuman(expr) {
|
|
|
7099
7161
|
return expr;
|
|
7100
7162
|
}
|
|
7101
7163
|
|
|
7164
|
+
// #693 R3 (PR round 2): render assignments grouped by the EMPLOYEE the job maps to
|
|
7165
|
+
// (job.requiredPersonaKey), not the AI agent, reusing the Runs section's
|
|
7166
|
+
// conv-employee-group accordion so the two sections look and behave the same.
|
|
7102
7167
|
function renderDeploymentList(container, deployments) {
|
|
7103
7168
|
container.innerHTML = '';
|
|
7104
7169
|
if (!deployments.length) {
|
|
7105
7170
|
const empty = document.createElement('p');
|
|
7106
7171
|
empty.className = 'dep-empty';
|
|
7107
|
-
empty.textContent = 'No assignments yet. Use +
|
|
7172
|
+
empty.textContent = 'No assignments yet. Use + New Assignment above to schedule a job or trigger it from an external system.';
|
|
7108
7173
|
container.appendChild(empty);
|
|
7109
7174
|
return;
|
|
7110
7175
|
}
|
|
7176
|
+
const jobs = state.bootstrap?.jobs ?? [];
|
|
7177
|
+
const UNASSIGNED = '__unassigned__';
|
|
7178
|
+
// The employee is determined by the job: its requiredPersonaKey. Fall back to an
|
|
7179
|
+
// "Unassigned" group for generic jobs with no required specialist.
|
|
7180
|
+
const personaKeyForDep = (dep) => {
|
|
7181
|
+
const j = jobs.find((x) => x.id === dep.jobId);
|
|
7182
|
+
return (j && j.requiredPersonaKey) || UNASSIGNED;
|
|
7183
|
+
};
|
|
7184
|
+
const groups = new Map();
|
|
7111
7185
|
for (const dep of deployments) {
|
|
7112
|
-
const
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
7116
|
-
|
|
7117
|
-
|
|
7118
|
-
|
|
7119
|
-
const
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
if (human !== dep.cronExpr) cron.title = dep.cronExpr;
|
|
7136
|
-
card.appendChild(cron);
|
|
7137
|
-
}
|
|
7138
|
-
|
|
7139
|
-
const employee = (state.bootstrap?.employees || []).find((e) => e.id === dep.hostId);
|
|
7140
|
-
if (employee) {
|
|
7141
|
-
const empRow = document.createElement('div');
|
|
7142
|
-
empRow.className = 'dep-detail dep-detail--emp';
|
|
7143
|
-
empRow.textContent = employee.label;
|
|
7144
|
-
card.appendChild(empRow);
|
|
7186
|
+
const key = personaKeyForDep(dep);
|
|
7187
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
7188
|
+
groups.get(key).push(dep);
|
|
7189
|
+
}
|
|
7190
|
+
for (const [personaKey, deps] of groups) {
|
|
7191
|
+
const persona = personaKey === UNASSIGNED ? null : tfPersonaByKey(personaKey);
|
|
7192
|
+
const empLabel = persona ? persona.displayName : 'Unassigned';
|
|
7193
|
+
const empDetail = persona ? (persona.role || 'AI Employee') : 'No specialist required';
|
|
7194
|
+
const details = document.createElement('details');
|
|
7195
|
+
details.className = 'conv-employee-group';
|
|
7196
|
+
details.open = true;
|
|
7197
|
+
const summary = document.createElement('summary');
|
|
7198
|
+
summary.className = 'conv-employee-tab';
|
|
7199
|
+
// DiceBear when the employee has an avatar; else an initials chip.
|
|
7200
|
+
let avatar;
|
|
7201
|
+
if (persona && persona.avatarUrl) {
|
|
7202
|
+
avatar = document.createElement('img');
|
|
7203
|
+
avatar.className = 'conv-employee-avatar';
|
|
7204
|
+
avatar.src = persona.avatarUrl; avatar.alt = empLabel;
|
|
7205
|
+
} else {
|
|
7206
|
+
avatar = document.createElement('span');
|
|
7207
|
+
avatar.className = 'conv-employee-avatar';
|
|
7208
|
+
avatar.textContent = initialBadge(empLabel);
|
|
7145
7209
|
}
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7210
|
+
const copy = document.createElement('span');
|
|
7211
|
+
copy.className = 'conv-employee-tab-copy';
|
|
7212
|
+
const label = document.createElement('strong');
|
|
7213
|
+
label.className = 'conv-employee-tab-label';
|
|
7214
|
+
label.textContent = empLabel;
|
|
7215
|
+
const detail = document.createElement('small');
|
|
7216
|
+
detail.className = 'conv-employee-tab-detail';
|
|
7217
|
+
detail.textContent = empDetail;
|
|
7218
|
+
copy.appendChild(label); copy.appendChild(detail);
|
|
7219
|
+
const count = document.createElement('span');
|
|
7220
|
+
count.className = 'conv-employee-tab-count';
|
|
7221
|
+
count.textContent = String(deps.length);
|
|
7222
|
+
const addBtn = document.createElement('button');
|
|
7223
|
+
addBtn.type = 'button';
|
|
7224
|
+
addBtn.className = 'conv-employee-add';
|
|
7225
|
+
addBtn.textContent = '+';
|
|
7226
|
+
addBtn.title = 'New assignment for ' + empLabel;
|
|
7227
|
+
addBtn.setAttribute('aria-label', 'New assignment for ' + empLabel);
|
|
7228
|
+
addBtn.addEventListener('click', (e) => {
|
|
7229
|
+
e.preventDefault(); e.stopPropagation();
|
|
7230
|
+
openDeploymentModal();
|
|
7231
|
+
// Pre-select a job that maps to this employee so the grouping stays consistent.
|
|
7232
|
+
if (persona) {
|
|
7233
|
+
const sel = document.getElementById('dep-job');
|
|
7234
|
+
const match = jobs.find((x) => x.requiredPersonaKey === personaKey);
|
|
7235
|
+
if (sel && match && Array.from(sel.options).some((o) => o.value === match.id)) sel.value = match.id;
|
|
7236
|
+
}
|
|
7237
|
+
});
|
|
7238
|
+
summary.appendChild(avatar);
|
|
7239
|
+
summary.appendChild(copy);
|
|
7240
|
+
summary.appendChild(addBtn);
|
|
7241
|
+
summary.appendChild(count);
|
|
7242
|
+
details.appendChild(summary);
|
|
7243
|
+
const list = document.createElement('div');
|
|
7244
|
+
list.className = 'conv-employee-list';
|
|
7245
|
+
for (const dep of deps) {
|
|
7246
|
+
list.appendChild(buildDeploymentRow(dep));
|
|
7247
|
+
if (dep.inboundUrl) list.appendChild(buildInboundUrlRow(dep));
|
|
7161
7248
|
}
|
|
7249
|
+
details.appendChild(list);
|
|
7250
|
+
container.appendChild(details);
|
|
7251
|
+
}
|
|
7252
|
+
}
|
|
7162
7253
|
|
|
7163
|
-
|
|
7164
|
-
|
|
7165
|
-
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
|
|
7169
|
-
|
|
7170
|
-
|
|
7171
|
-
|
|
7172
|
-
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7254
|
+
// #693 R3: a single assignment rendered as a Runs-style row.
|
|
7255
|
+
function buildDeploymentRow(dep) {
|
|
7256
|
+
const row = document.createElement('div');
|
|
7257
|
+
// #693 R3: a dedicated class (not .conv-item) so run-list logic never treats an
|
|
7258
|
+
// assignment as a run; styled to match the Runs rows via CSS.
|
|
7259
|
+
row.className = 'dep-row';
|
|
7260
|
+
const body = document.createElement('span');
|
|
7261
|
+
body.className = 'conv-body';
|
|
7262
|
+
const title = document.createElement('span');
|
|
7263
|
+
title.className = 'dep-row-title';
|
|
7264
|
+
title.textContent = dep.label;
|
|
7265
|
+
body.appendChild(title);
|
|
7266
|
+
const meta = document.createElement('span');
|
|
7267
|
+
meta.className = 'assign-row-meta';
|
|
7268
|
+
const badge = document.createElement('span');
|
|
7269
|
+
badge.className = 'assign-badge assign-badge--' + (dep.type === 'scheduled' ? 'scheduled' : 'triggered');
|
|
7270
|
+
badge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
|
|
7271
|
+
meta.appendChild(badge);
|
|
7272
|
+
const detailText = document.createElement('span');
|
|
7273
|
+
if (dep.type === 'scheduled' && dep.cronExpr) {
|
|
7274
|
+
const human = cronToHuman(dep.cronExpr);
|
|
7275
|
+
detailText.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
|
|
7276
|
+
if (human !== dep.cronExpr) detailText.title = dep.cronExpr;
|
|
7277
|
+
} else {
|
|
7278
|
+
detailText.textContent = 'Inbound webhook';
|
|
7187
7279
|
}
|
|
7280
|
+
meta.appendChild(detailText);
|
|
7281
|
+
body.appendChild(meta);
|
|
7282
|
+
row.appendChild(body);
|
|
7283
|
+
const actions = document.createElement('span');
|
|
7284
|
+
actions.className = 'dep-row-actions';
|
|
7285
|
+
const editBtn = document.createElement('button');
|
|
7286
|
+
editBtn.type = 'button'; editBtn.className = 'dep-edit-btn'; editBtn.textContent = 'Edit'; editBtn.title = 'Edit this assignment';
|
|
7287
|
+
editBtn.addEventListener('click', () => openDeploymentModal(dep));
|
|
7288
|
+
const delBtn = document.createElement('button');
|
|
7289
|
+
delBtn.type = 'button'; delBtn.className = 'dep-del-btn'; delBtn.textContent = 'Remove'; delBtn.title = 'Remove this assignment';
|
|
7290
|
+
delBtn.addEventListener('click', async () => {
|
|
7291
|
+
const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
|
|
7292
|
+
await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
|
|
7293
|
+
await loadDeployments();
|
|
7294
|
+
});
|
|
7295
|
+
actions.appendChild(editBtn); actions.appendChild(delBtn);
|
|
7296
|
+
row.appendChild(actions);
|
|
7297
|
+
return row;
|
|
7298
|
+
}
|
|
7299
|
+
|
|
7300
|
+
// #693 R3: inbound-URL sub-row for triggered assignments (kept from the old card).
|
|
7301
|
+
function buildInboundUrlRow(dep) {
|
|
7302
|
+
const urlRow = document.createElement('div');
|
|
7303
|
+
// #693 R3: self-contained class — do NOT add .dep-detail (its width:100% plus this
|
|
7304
|
+
// row's left margin overflows the container and forces a horizontal scrollbar).
|
|
7305
|
+
urlRow.className = 'dep-inbound-sub';
|
|
7306
|
+
const urlCode = document.createElement('code');
|
|
7307
|
+
urlCode.textContent = dep.inboundUrl;
|
|
7308
|
+
urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
|
|
7309
|
+
const copyBtn = document.createElement('button');
|
|
7310
|
+
copyBtn.type = 'button';
|
|
7311
|
+
copyBtn.className = 'hm-copy-btn';
|
|
7312
|
+
copyBtn.textContent = 'Copy';
|
|
7313
|
+
copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
|
|
7314
|
+
urlRow.appendChild(urlCode);
|
|
7315
|
+
urlRow.appendChild(copyBtn);
|
|
7316
|
+
return urlRow;
|
|
7188
7317
|
}
|
|
7189
7318
|
|
|
7190
7319
|
function initDeploymentButtons() {
|
|
7191
|
-
|
|
7192
|
-
const
|
|
7193
|
-
if (
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
const
|
|
7198
|
-
|
|
7199
|
-
if (
|
|
7200
|
-
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
if (schSave) schSave.addEventListener('click', saveScheduleDeployment);
|
|
7211
|
-
const whSave = document.getElementById('dep-wh-save-btn');
|
|
7212
|
-
if (whSave) whSave.addEventListener('click', saveWebhookDeployment);
|
|
7213
|
-
|
|
7214
|
-
// Preset chips
|
|
7320
|
+
// #693 R2: one entry point opens the consolidated assignment modal.
|
|
7321
|
+
const addBtn = document.getElementById('dep-add-btn');
|
|
7322
|
+
if (addBtn) addBtn.addEventListener('click', () => openDeploymentModal());
|
|
7323
|
+
|
|
7324
|
+
const closeModal = () => { const m = document.getElementById('dep-assignment-modal'); if (m) m.hidden = true; };
|
|
7325
|
+
const close = document.getElementById('dep-assign-close');
|
|
7326
|
+
const cancel = document.getElementById('dep-cancel-btn');
|
|
7327
|
+
if (close) close.addEventListener('click', closeModal);
|
|
7328
|
+
if (cancel) cancel.addEventListener('click', closeModal);
|
|
7329
|
+
|
|
7330
|
+
const save = document.getElementById('dep-save-btn');
|
|
7331
|
+
if (save) save.addEventListener('click', saveDeployment);
|
|
7332
|
+
|
|
7333
|
+
// Scheduled / Triggered segmented toggle.
|
|
7334
|
+
document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((btn) => {
|
|
7335
|
+
btn.addEventListener('click', () => setDepType(btn.dataset.depType));
|
|
7336
|
+
});
|
|
7337
|
+
|
|
7338
|
+
// Preset chips (scheduled block).
|
|
7215
7339
|
document.querySelectorAll('#dep-sch-presets .dep-preset-chip').forEach(chip => {
|
|
7216
7340
|
chip.addEventListener('click', () => applySchPreset(chip.dataset.preset));
|
|
7217
7341
|
});
|
|
@@ -7257,131 +7381,131 @@ function initDeploymentButtons() {
|
|
|
7257
7381
|
});
|
|
7258
7382
|
}
|
|
7259
7383
|
|
|
7260
|
-
|
|
7261
|
-
|
|
7384
|
+
// #693 R2: current assignment type in the consolidated modal.
|
|
7385
|
+
let _depType = 'scheduled';
|
|
7386
|
+
function setDepType(type) {
|
|
7387
|
+
_depType = (type === 'triggered') ? 'triggered' : 'scheduled';
|
|
7388
|
+
document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((b) => {
|
|
7389
|
+
b.classList.toggle('on', b.dataset.depType === _depType);
|
|
7390
|
+
});
|
|
7391
|
+
const sched = document.getElementById('dep-sched-block');
|
|
7392
|
+
const trig = document.getElementById('dep-trig-block');
|
|
7393
|
+
if (sched) sched.hidden = _depType !== 'scheduled';
|
|
7394
|
+
if (trig) trig.hidden = _depType !== 'triggered';
|
|
7395
|
+
}
|
|
7396
|
+
|
|
7397
|
+
// #693 R2/R5: populate the AI Agent select with only agents available on the
|
|
7398
|
+
// machine. Mirrors the existing employee-select behavior — unavailable agents are
|
|
7399
|
+
// shown disabled rather than as selectable options.
|
|
7400
|
+
function populateAgentSelect(sel, currentHostId) {
|
|
7401
|
+
sel.innerHTML = '';
|
|
7262
7402
|
const employees = state.bootstrap?.employees ?? [];
|
|
7403
|
+
const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
|
|
7404
|
+
for (const e of list) {
|
|
7405
|
+
const opt = document.createElement('option');
|
|
7406
|
+
opt.value = e.id;
|
|
7407
|
+
opt.textContent = e.label + (e.available === false ? ' (not installed)' : '');
|
|
7408
|
+
if (e.available === false) opt.disabled = true;
|
|
7409
|
+
sel.appendChild(opt);
|
|
7410
|
+
}
|
|
7411
|
+
const avail = list.filter((e) => e.available !== false);
|
|
7412
|
+
const pick = (currentHostId && list.some((e) => e.id === currentHostId && e.available !== false))
|
|
7413
|
+
? currentHostId
|
|
7414
|
+
: (avail[0]?.id || list[0]?.id);
|
|
7415
|
+
if (pick) sel.value = pick;
|
|
7416
|
+
}
|
|
7417
|
+
|
|
7418
|
+
// #693 R2: open the single consolidated assignment modal. `dep` present = edit;
|
|
7419
|
+
// its .type locks the segmented control. New assignments default to Scheduled.
|
|
7420
|
+
function openDeploymentModal(dep) {
|
|
7421
|
+
const jobs = state.bootstrap?.jobs ?? [];
|
|
7263
7422
|
const editing = dep != null;
|
|
7264
7423
|
_editingDepId = editing ? dep.id : null;
|
|
7265
7424
|
|
|
7266
|
-
|
|
7267
|
-
|
|
7268
|
-
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7273
|
-
|
|
7274
|
-
|
|
7425
|
+
const jobSel = document.getElementById('dep-job');
|
|
7426
|
+
jobSel.innerHTML = '';
|
|
7427
|
+
for (const j of jobs) {
|
|
7428
|
+
const opt = document.createElement('option');
|
|
7429
|
+
opt.value = j.id; opt.textContent = j.title;
|
|
7430
|
+
jobSel.appendChild(opt);
|
|
7431
|
+
}
|
|
7432
|
+
if (dep?.jobId) jobSel.value = dep.jobId;
|
|
7433
|
+
|
|
7434
|
+
populateAgentSelect(document.getElementById('dep-agent'), dep?.hostId || 'claude');
|
|
7435
|
+
document.getElementById('dep-label').value = dep?.label ?? '';
|
|
7436
|
+
document.getElementById('dep-instructions').value = dep?.instructions ?? '';
|
|
7437
|
+
const errEl = document.getElementById('dep-error');
|
|
7438
|
+
if (errEl) errEl.hidden = true;
|
|
7439
|
+
const inbound = document.getElementById('dep-wh-inbound-row');
|
|
7440
|
+
if (inbound) inbound.hidden = true;
|
|
7441
|
+
|
|
7442
|
+
setDepType(editing ? (dep.type === 'scheduled' ? 'scheduled' : 'triggered') : 'scheduled');
|
|
7443
|
+
|
|
7444
|
+
// Scheduled fields: detect preset and pre-fill time/day.
|
|
7445
|
+
const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
|
|
7446
|
+
if (preset && preset !== 'custom' && dep?.cronExpr) {
|
|
7447
|
+
const parts = dep.cronExpr.trim().split(/\s+/);
|
|
7448
|
+
const m = parseInt(parts[0], 10) || 0;
|
|
7449
|
+
const h = parseInt(parts[1], 10) || 9;
|
|
7450
|
+
document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
|
7451
|
+
if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
|
|
7452
|
+
document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
|
|
7453
|
+
} else if (preset === 'custom' && dep?.cronExpr) {
|
|
7454
|
+
document.getElementById('dep-sch-cron').value = dep.cronExpr;
|
|
7455
|
+
const cronPreview = document.getElementById('dep-sch-cron-preview');
|
|
7456
|
+
if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
|
|
7457
|
+
} else {
|
|
7458
|
+
document.getElementById('dep-sch-cron').value = '';
|
|
7459
|
+
document.getElementById('dep-sch-time').value = '09:00';
|
|
7275
7460
|
}
|
|
7461
|
+
applySchPreset(preset || 'daily');
|
|
7276
7462
|
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
const list = employees.length ? employees : fallback;
|
|
7281
|
-
for (const e of list) {
|
|
7282
|
-
const opt = document.createElement('option');
|
|
7283
|
-
opt.value = e.id;
|
|
7284
|
-
opt.textContent = e.label;
|
|
7285
|
-
sel.appendChild(opt);
|
|
7286
|
-
}
|
|
7287
|
-
if (currentHostId) sel.value = currentHostId;
|
|
7288
|
-
}
|
|
7289
|
-
|
|
7290
|
-
if (type === 'schedule') {
|
|
7291
|
-
const modal = document.getElementById('dep-schedule-modal');
|
|
7292
|
-
document.getElementById('dep-sch-modal-title').textContent = editing ? 'Edit Scheduled Assignment' : 'New Scheduled Assignment';
|
|
7293
|
-
document.getElementById('dep-sch-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
|
|
7294
|
-
populateJobs(document.getElementById('dep-sch-job'), dep?.jobId);
|
|
7295
|
-
populateEmployees(document.getElementById('dep-sch-host'), dep?.hostId || 'claude');
|
|
7296
|
-
document.getElementById('dep-sch-error').hidden = true;
|
|
7297
|
-
document.getElementById('dep-sch-label').value = dep?.label ?? '';
|
|
7298
|
-
document.getElementById('dep-sch-instructions').value = dep?.instructions ?? '';
|
|
7299
|
-
// Detect preset and pre-fill time/day
|
|
7300
|
-
const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
|
|
7301
|
-
if (preset && preset !== 'custom' && dep?.cronExpr) {
|
|
7302
|
-
const parts = dep.cronExpr.trim().split(/\s+/);
|
|
7303
|
-
const m = parseInt(parts[0], 10) || 0;
|
|
7304
|
-
const h = parseInt(parts[1], 10) || 9;
|
|
7305
|
-
document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
|
7306
|
-
if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
|
|
7307
|
-
document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
|
|
7308
|
-
} else if (preset === 'custom' && dep?.cronExpr) {
|
|
7309
|
-
document.getElementById('dep-sch-cron').value = dep.cronExpr;
|
|
7310
|
-
const cronPreview = document.getElementById('dep-sch-cron-preview');
|
|
7311
|
-
if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
|
|
7312
|
-
} else {
|
|
7313
|
-
document.getElementById('dep-sch-cron').value = '';
|
|
7314
|
-
document.getElementById('dep-sch-time').value = '09:00';
|
|
7315
|
-
}
|
|
7316
|
-
applySchPreset(preset || 'daily');
|
|
7317
|
-
modal.hidden = false;
|
|
7318
|
-
} else {
|
|
7319
|
-
const modal = document.getElementById('dep-webhook-modal');
|
|
7320
|
-
document.getElementById('dep-wh-modal-title').textContent = editing ? 'Edit Triggered Assignment' : 'New Triggered Assignment';
|
|
7321
|
-
document.getElementById('dep-wh-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
|
|
7322
|
-
populateJobs(document.getElementById('dep-wh-job'), dep?.jobId);
|
|
7323
|
-
populateEmployees(document.getElementById('dep-wh-host'), dep?.hostId || 'claude');
|
|
7324
|
-
document.getElementById('dep-wh-error').hidden = true;
|
|
7325
|
-
document.getElementById('dep-wh-inbound-row').hidden = true;
|
|
7326
|
-
document.getElementById('dep-wh-label').value = dep?.label ?? '';
|
|
7327
|
-
document.getElementById('dep-wh-instructions').value = dep?.instructions ?? '';
|
|
7328
|
-
modal.hidden = false;
|
|
7329
|
-
}
|
|
7330
|
-
}
|
|
7331
|
-
|
|
7332
|
-
async function saveScheduleDeployment() {
|
|
7333
|
-
const label = document.getElementById('dep-sch-label').value.trim();
|
|
7334
|
-
const jobId = document.getElementById('dep-sch-job').value;
|
|
7335
|
-
const hostId = document.getElementById('dep-sch-host').value;
|
|
7336
|
-
const isCustom = _activeSchPreset === 'custom';
|
|
7337
|
-
const cronExpr = isCustom
|
|
7338
|
-
? document.getElementById('dep-sch-cron').value.trim()
|
|
7339
|
-
: (document.getElementById('dep-sch-cron-preset').value.trim() || '');
|
|
7340
|
-
const instructions = document.getElementById('dep-sch-instructions').value.trim();
|
|
7341
|
-
const errEl = document.getElementById('dep-sch-error');
|
|
7342
|
-
if (!label || !cronExpr) { errEl.textContent = 'Label and schedule are required.'; errEl.hidden = false; return; }
|
|
7343
|
-
const isEdit = _editingDepId !== null;
|
|
7344
|
-
const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
|
|
7345
|
-
const method = isEdit ? 'PUT' : 'POST';
|
|
7346
|
-
try {
|
|
7347
|
-
const resp = await fetch(url, {
|
|
7348
|
-
method,
|
|
7349
|
-
headers: { 'Content-Type': 'application/json' },
|
|
7350
|
-
body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }),
|
|
7351
|
-
});
|
|
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; }
|
|
7353
|
-
_editingDepId = null;
|
|
7354
|
-
document.getElementById('dep-schedule-modal').hidden = true;
|
|
7355
|
-
await loadDeployments();
|
|
7356
|
-
} catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
|
|
7463
|
+
document.getElementById('dep-assign-title').textContent = editing ? 'Edit assignment' : 'New assignment';
|
|
7464
|
+
document.getElementById('dep-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
|
|
7465
|
+
document.getElementById('dep-assignment-modal').hidden = false;
|
|
7357
7466
|
}
|
|
7358
7467
|
|
|
7359
|
-
|
|
7360
|
-
|
|
7361
|
-
const
|
|
7362
|
-
const
|
|
7363
|
-
const
|
|
7364
|
-
const
|
|
7365
|
-
|
|
7468
|
+
// #693 R2: one save path routes to the schedule or webhook endpoint by type.
|
|
7469
|
+
async function saveDeployment() {
|
|
7470
|
+
const errEl = document.getElementById('dep-error');
|
|
7471
|
+
const label = document.getElementById('dep-label').value.trim();
|
|
7472
|
+
const jobId = document.getElementById('dep-job').value;
|
|
7473
|
+
const hostId = document.getElementById('dep-agent').value;
|
|
7474
|
+
const instructions = document.getElementById('dep-instructions').value.trim();
|
|
7366
7475
|
const isEdit = _editingDepId !== null;
|
|
7367
|
-
|
|
7368
|
-
|
|
7369
|
-
|
|
7370
|
-
const
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
|
|
7377
|
-
|
|
7378
|
-
|
|
7379
|
-
|
|
7380
|
-
|
|
7381
|
-
|
|
7382
|
-
|
|
7383
|
-
|
|
7384
|
-
}
|
|
7476
|
+
if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
|
|
7477
|
+
|
|
7478
|
+
if (_depType === 'scheduled') {
|
|
7479
|
+
const isCustom = _activeSchPreset === 'custom';
|
|
7480
|
+
const cronExpr = isCustom
|
|
7481
|
+
? document.getElementById('dep-sch-cron').value.trim()
|
|
7482
|
+
: (document.getElementById('dep-sch-cron-preset').value.trim() || '');
|
|
7483
|
+
if (!cronExpr) { errEl.textContent = 'A schedule is required.'; errEl.hidden = false; return; }
|
|
7484
|
+
const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
|
|
7485
|
+
try {
|
|
7486
|
+
// merge: keep master's projectPath association on the request body.
|
|
7487
|
+
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 }) });
|
|
7488
|
+
if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
|
|
7489
|
+
_editingDepId = null;
|
|
7490
|
+
document.getElementById('dep-assignment-modal').hidden = true;
|
|
7491
|
+
await loadDeployments();
|
|
7492
|
+
} catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
|
|
7493
|
+
} else {
|
|
7494
|
+
const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
|
|
7495
|
+
try {
|
|
7496
|
+
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 }) });
|
|
7497
|
+
if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
|
|
7498
|
+
if (!isEdit) {
|
|
7499
|
+
const dep = await resp.json();
|
|
7500
|
+
document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
|
|
7501
|
+
document.getElementById('dep-wh-inbound-row').hidden = false;
|
|
7502
|
+
} else {
|
|
7503
|
+
document.getElementById('dep-assignment-modal').hidden = true;
|
|
7504
|
+
}
|
|
7505
|
+
_editingDepId = null;
|
|
7506
|
+
await loadDeployments();
|
|
7507
|
+
} catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
|
|
7508
|
+
}
|
|
7385
7509
|
}
|
|
7386
7510
|
|
|
7387
7511
|
// ---------------------------------------------------------------------------
|
|
@@ -7905,123 +8029,12 @@ function tfContextRow(key, label, placeholder, rerender) {
|
|
|
7905
8029
|
|
|
7906
8030
|
return row;
|
|
7907
8031
|
}
|
|
7908
|
-
// #
|
|
7909
|
-
// runs — the SAME paradigm as the project tree (employee → job runs). The summary
|
|
7910
|
-
// is the job (icon, name, run count, + to run again); the body lists prior runs,
|
|
7911
|
-
// each clicking through to that run's conversation.
|
|
7912
|
-
function tfAreaRailJob(ico, jobId, name, sub, onRun) {
|
|
7913
|
-
const details = document.createElement('details');
|
|
7914
|
-
details.className = 'emp-hero area-rail-acc';
|
|
7915
|
-
details.open = true;
|
|
7916
|
-
const summary = document.createElement('summary');
|
|
7917
|
-
const av = document.createElement('div');
|
|
7918
|
-
av.className = 'eh-av job-ico';
|
|
7919
|
-
av.style.borderRadius = '9px';
|
|
7920
|
-
av.style.background = 'var(--accent-soft)';
|
|
7921
|
-
av.style.color = 'var(--accent)';
|
|
7922
|
-
av.textContent = ico;
|
|
7923
|
-
const id = document.createElement('div'); id.className = 'eh-id';
|
|
7924
|
-
const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = name;
|
|
7925
|
-
const runs = tfConversationsForJob(jobId);
|
|
7926
|
-
const role = document.createElement('div'); role.className = 'eh-role';
|
|
7927
|
-
role.textContent = runs.length ? (runs.length + (runs.length === 1 ? ' past run' : ' past runs')) : sub;
|
|
7928
|
-
id.appendChild(nm); id.appendChild(role);
|
|
7929
|
-
const add = document.createElement('button');
|
|
7930
|
-
add.type = 'button'; add.className = 'eh-add'; add.textContent = '+'; add.title = 'Run ' + name + ' again';
|
|
7931
|
-
add.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onRun(); });
|
|
7932
|
-
const chev = document.createElement('span'); chev.className = 'eh-chev'; chev.textContent = '▸';
|
|
7933
|
-
summary.appendChild(av); summary.appendChild(id); summary.appendChild(add); summary.appendChild(chev);
|
|
7934
|
-
details.appendChild(summary);
|
|
7935
|
-
const body = document.createElement('div'); body.className = 'eh-jobs';
|
|
7936
|
-
if (!runs.length) {
|
|
7937
|
-
const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet — + to run it.';
|
|
7938
|
-
body.appendChild(none);
|
|
7939
|
-
} else {
|
|
7940
|
-
for (const c of runs) {
|
|
7941
|
-
const row = document.createElement('div'); row.className = 'tree-job';
|
|
7942
|
-
const label = document.createElement('span'); label.className = 'tj-label'; label.textContent = c.title || c.jobTitle || name;
|
|
7943
|
-
const dotCls = (typeof conversationStateDotClass === 'function') ? conversationStateDotClass(c) : 'grey';
|
|
7944
|
-
const dot = document.createElement('span'); dot.className = 'tj-dot dot-' + dotCls; dot.style.background = 'var(--' + dotCls + ')';
|
|
7945
|
-
row.appendChild(label); row.appendChild(dot);
|
|
7946
|
-
tfAttachRunDelete(row, c, { tree: true }); // #533 R6: delete an area-rail job run
|
|
7947
|
-
row.addEventListener('click', () => { if (typeof switchToConversation === 'function') switchToConversation(c.id); });
|
|
7948
|
-
body.appendChild(row);
|
|
7949
|
-
}
|
|
7950
|
-
}
|
|
7951
|
-
details.appendChild(body);
|
|
7952
|
-
return details;
|
|
7953
|
-
}
|
|
8032
|
+
// #693 R1 (PR round 2): tfAreaRailJob removed with the Company/Manager job rails.
|
|
7954
8033
|
|
|
7955
|
-
// #
|
|
7956
|
-
//
|
|
7957
|
-
//
|
|
7958
|
-
//
|
|
7959
|
-
function tfProjectUpdatesSection() {
|
|
7960
|
-
const JOBS = [
|
|
7961
|
-
{ id: 'project-onboarding', ico: '🚀', name: 'Project Onboarding', run: () => tfStartProjectOnboarding() },
|
|
7962
|
-
{ id: 'sleep-on-learnings', ico: '🧠', name: 'Sleep on learnings', run: () => tfRunShareLearnings('project') },
|
|
7963
|
-
];
|
|
7964
|
-
const details = document.createElement('details');
|
|
7965
|
-
// NB: deliberately NOT `.emp-hero` — that class is the employee-hero selector
|
|
7966
|
-
// the tree's per-employee assign-job affordance keys off; Project Updates reuses
|
|
7967
|
-
// the same visual chrome via `.proj-updates-acc` grouped CSS instead.
|
|
7968
|
-
details.className = 'proj-updates-acc';
|
|
7969
|
-
details.id = 'proj-updates-acc';
|
|
7970
|
-
details.dataset.accKey = 'proj-updates'; // #533 R5: preserve open-state across status re-renders
|
|
7971
|
-
details.open = true;
|
|
7972
|
-
|
|
7973
|
-
const summary = document.createElement('summary');
|
|
7974
|
-
const av = document.createElement('div');
|
|
7975
|
-
av.className = 'eh-av job-ico';
|
|
7976
|
-
av.style.borderRadius = '9px';
|
|
7977
|
-
av.style.background = 'var(--accent-soft)';
|
|
7978
|
-
av.style.color = 'var(--accent)';
|
|
7979
|
-
av.textContent = '🔄';
|
|
7980
|
-
const id = document.createElement('div'); id.className = 'eh-id';
|
|
7981
|
-
const nm = document.createElement('div'); nm.className = 'eh-name'; nm.style.fontSize = '14px'; nm.textContent = 'Project Updates';
|
|
7982
|
-
const totalRuns = JOBS.reduce((n, j) => n + tfConversationsForJob(j.id, { projectPath: state.projectPath }).length, 0);
|
|
7983
|
-
const role = document.createElement('div'); role.className = 'eh-role';
|
|
7984
|
-
role.textContent = totalRuns ? (totalRuns + (totalRuns === 1 ? ' run' : ' runs')) : 'Onboarding & learnings';
|
|
7985
|
-
id.appendChild(nm); id.appendChild(role);
|
|
7986
|
-
|
|
7987
|
-
const add = document.createElement('button');
|
|
7988
|
-
add.type = 'button'; add.className = 'eh-add'; add.textContent = '+'; add.title = 'Run a project update';
|
|
7989
|
-
add.addEventListener('click', (e) => {
|
|
7990
|
-
e.preventDefault(); e.stopPropagation();
|
|
7991
|
-
openPalette();
|
|
7992
|
-
});
|
|
7993
|
-
|
|
7994
|
-
const chev = document.createElement('span'); chev.className = 'eh-chev'; chev.textContent = '▸';
|
|
7995
|
-
summary.appendChild(av); summary.appendChild(id); summary.appendChild(add); summary.appendChild(chev);
|
|
7996
|
-
details.appendChild(summary);
|
|
7997
|
-
|
|
7998
|
-
const body = document.createElement('div'); body.className = 'eh-jobs';
|
|
7999
|
-
JOBS.forEach((j) => {
|
|
8000
|
-
const group = document.createElement('div'); group.className = 'pu-job';
|
|
8001
|
-
const head = document.createElement('div'); head.className = 'pu-job-name';
|
|
8002
|
-
head.textContent = j.ico + ' ' + j.name;
|
|
8003
|
-
group.appendChild(head);
|
|
8004
|
-
const runs = tfConversationsForJob(j.id, { projectPath: state.projectPath });
|
|
8005
|
-
if (!runs.length) {
|
|
8006
|
-
const none = document.createElement('div'); none.className = 'eh-empty'; none.textContent = 'No runs yet.';
|
|
8007
|
-
group.appendChild(none);
|
|
8008
|
-
} else {
|
|
8009
|
-
runs.forEach((c) => {
|
|
8010
|
-
const row = document.createElement('div'); row.className = 'tree-job';
|
|
8011
|
-
const rl = document.createElement('span'); rl.className = 'tj-label'; rl.textContent = c.title || c.jobTitle || j.name;
|
|
8012
|
-
const dotCls = (typeof conversationStateDotClass === 'function') ? conversationStateDotClass(c) : 'grey';
|
|
8013
|
-
const dot = document.createElement('span'); dot.className = 'tj-dot dot-' + dotCls; dot.style.background = 'var(--' + dotCls + ')';
|
|
8014
|
-
row.appendChild(rl); row.appendChild(dot);
|
|
8015
|
-
tfAttachRunDelete(row, c, { tree: true }); // #533 R6: delete a project-update run
|
|
8016
|
-
row.addEventListener('click', () => { if (typeof switchToConversation === 'function') switchToConversation(c.id); });
|
|
8017
|
-
group.appendChild(row);
|
|
8018
|
-
});
|
|
8019
|
-
}
|
|
8020
|
-
body.appendChild(group);
|
|
8021
|
-
});
|
|
8022
|
-
details.appendChild(body);
|
|
8023
|
-
return details;
|
|
8024
|
-
}
|
|
8034
|
+
// #693 R1: tfProjectUpdatesSection was removed. The "Project Updates" tree
|
|
8035
|
+
// accordion is retired; its Run Project Onboarding and Sleep on learnings actions
|
|
8036
|
+
// now live inside the Brief and Learnings sections (see tfEnsureAccAction, called
|
|
8037
|
+
// from tfRenderProjectContextTop).
|
|
8025
8038
|
|
|
8026
8039
|
// #594 R2: render a conversation panel (topline + messages + coach input) into an
|
|
8027
8040
|
// area-level container so org/manager jobs are viewable inside Company/Manager tabs.
|
|
@@ -8103,10 +8116,12 @@ function tfActiveOrgConv() {
|
|
|
8103
8116
|
);
|
|
8104
8117
|
}
|
|
8105
8118
|
|
|
8106
|
-
// #594 R2: return the
|
|
8119
|
+
// #594 R2: return the manager-scoped conversation to show in Manager area.
|
|
8120
|
+
// #702: manager-tab runs are those invoked from the manager area (any persona job),
|
|
8121
|
+
// plus the area-only manager-agreements job (older runs predate invokedArea). An active
|
|
8122
|
+
// manager-invoked run surfaces in the Manager conversation host (Coach panel included).
|
|
8107
8123
|
function tfActiveMgrConv() {
|
|
8108
|
-
const
|
|
8109
|
-
const convs = Object.values(state.conversations || {}).flat().filter((c) => mgrJobs.has(c.jobId));
|
|
8124
|
+
const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (c.invokedArea === 'manager' || c.jobId === 'manager-agreements'));
|
|
8110
8125
|
return (
|
|
8111
8126
|
convs.find((c) => c.status === 'running') ||
|
|
8112
8127
|
convs.find((c) => c.status === 'waiting') ||
|
|
@@ -8115,6 +8130,93 @@ function tfActiveMgrConv() {
|
|
|
8115
8130
|
);
|
|
8116
8131
|
}
|
|
8117
8132
|
|
|
8133
|
+
// #702: is a persona hired (has a company seat) so its manager-tab jobs should show?
|
|
8134
|
+
// (MANAGER_ASHLEY_JOBS is defined once at the top of this file as the single source of truth.)
|
|
8135
|
+
function tfIsPersonaHired(personaKey) {
|
|
8136
|
+
const personas = (state.bootstrap && state.bootstrap.personas) || [];
|
|
8137
|
+
const p = personas.find((x) => x && x.key === personaKey);
|
|
8138
|
+
return !!(p && (p.status === 'hired' || (p.seatCount || 0) > 0));
|
|
8139
|
+
}
|
|
8140
|
+
|
|
8141
|
+
// #702: launch a persona job from the Manager tab. Reuses the area pre-flight modal +
|
|
8142
|
+
// run routing (targetArea 'manager'), so the run opens in #manager-conv-host with the
|
|
8143
|
+
// Coach panel and is tagged invokedArea='manager' — exactly like manager-agreements.
|
|
8144
|
+
function tfStartManagerPersonaJob(jobId) {
|
|
8145
|
+
tfOpenAreaOnboardModal(jobId, 'Run ' + jobId + ' across the projects I manage and my calendar.', 'manager');
|
|
8146
|
+
}
|
|
8147
|
+
|
|
8148
|
+
// #702: run-item button matching the Projects rail (conv-item), for the manager
|
|
8149
|
+
// employee group. Mirrors the core of renderRail's buildRunButton so runs look
|
|
8150
|
+
// identical on both tabs.
|
|
8151
|
+
function tfBuildManagerRunItem(conv) {
|
|
8152
|
+
const btn = document.createElement('button');
|
|
8153
|
+
btn.className = 'conv-item';
|
|
8154
|
+
btn.type = 'button';
|
|
8155
|
+
btn.dataset.conv = conv.id;
|
|
8156
|
+
const body = document.createElement('span'); body.className = 'conv-body';
|
|
8157
|
+
const title = document.createElement('span'); title.className = 'conv-title';
|
|
8158
|
+
title.textContent = conv.title || conv.jobTitle || conv.jobId || 'Run';
|
|
8159
|
+
body.appendChild(title); btn.appendChild(body);
|
|
8160
|
+
const dotClass = conversationStateDotClass(conv);
|
|
8161
|
+
const dot = document.createElement('span'); dot.className = 'state-dot conv-state-dot dot-' + dotClass;
|
|
8162
|
+
if (typeof tfDotTitle === 'function') dot.title = tfDotTitle(dotClass);
|
|
8163
|
+
btn.appendChild(dot);
|
|
8164
|
+
const status = document.createElement('span'); status.className = 'conv-status';
|
|
8165
|
+
status.textContent = conversationStateLabel(conv);
|
|
8166
|
+
status.classList.add(conversationUiState(conv));
|
|
8167
|
+
btn.appendChild(status);
|
|
8168
|
+
tfAttachRunDelete(btn, conv, { tree: false });
|
|
8169
|
+
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
8170
|
+
return btn;
|
|
8171
|
+
}
|
|
8172
|
+
|
|
8173
|
+
// #702: render a hired persona as an employee group on the Manager rail — the SAME
|
|
8174
|
+
// visual as the Projects rail employee groups (avatar + name + role + count + "+" that
|
|
8175
|
+
// opens the job palette), with the persona's MANAGER-invoked runs listed under it.
|
|
8176
|
+
// Keeps a persona looking like an employee, consistent across tabs.
|
|
8177
|
+
function tfRenderManagerPersonaGroup(rail, persona) {
|
|
8178
|
+
const sample = { personaKey: persona.key };
|
|
8179
|
+
const runs = Object.values(state.conversations || {}).flat()
|
|
8180
|
+
.filter((c) => c && c.personaKey === persona.key && c.invokedArea === 'manager')
|
|
8181
|
+
.sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
|
|
8182
|
+
const details = document.createElement('details');
|
|
8183
|
+
details.className = 'conv-employee-group';
|
|
8184
|
+
details.open = true;
|
|
8185
|
+
const summary = document.createElement('summary');
|
|
8186
|
+
summary.className = 'conv-employee-tab';
|
|
8187
|
+
const avatar = buildConversationEmployeeAvatar(sample, 'conv-employee-avatar');
|
|
8188
|
+
const copy = document.createElement('span'); copy.className = 'conv-employee-tab-copy';
|
|
8189
|
+
const label = document.createElement('strong'); label.className = 'conv-employee-tab-label';
|
|
8190
|
+
label.textContent = persona.displayName || persona.key;
|
|
8191
|
+
const detail = document.createElement('small'); detail.className = 'conv-employee-tab-detail';
|
|
8192
|
+
detail.textContent = persona.role || 'AI Employee';
|
|
8193
|
+
copy.appendChild(label); copy.appendChild(detail);
|
|
8194
|
+
const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'conv-employee-add';
|
|
8195
|
+
addBtn.textContent = '+';
|
|
8196
|
+
addBtn.title = 'Launch a job for ' + (persona.displayName || persona.key);
|
|
8197
|
+
addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
|
|
8198
|
+
addBtn.addEventListener('click', (e) => {
|
|
8199
|
+
e.preventDefault(); e.stopPropagation();
|
|
8200
|
+
// Launch from the Manager tab: palette prefiltered to this persona. Runs started
|
|
8201
|
+
// while the manager tab is active default to invokedArea='manager'.
|
|
8202
|
+
openPalette({ employeeId: (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude', prefixSearch: '/' + persona.key });
|
|
8203
|
+
});
|
|
8204
|
+
const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
|
|
8205
|
+
count.textContent = String(runs.length);
|
|
8206
|
+
summary.appendChild(avatar); summary.appendChild(copy); summary.appendChild(addBtn); summary.appendChild(count);
|
|
8207
|
+
details.appendChild(summary);
|
|
8208
|
+
const listDiv = document.createElement('div'); listDiv.className = 'conv-employee-list';
|
|
8209
|
+
if (!runs.length) {
|
|
8210
|
+
const hint = document.createElement('div'); hint.className = 'eh-empty';
|
|
8211
|
+
hint.textContent = 'No runs yet — + to launch a job.';
|
|
8212
|
+
listDiv.appendChild(hint);
|
|
8213
|
+
} else {
|
|
8214
|
+
for (const c of runs) listDiv.appendChild(tfBuildManagerRunItem(c));
|
|
8215
|
+
}
|
|
8216
|
+
details.appendChild(listDiv);
|
|
8217
|
+
rail.appendChild(details);
|
|
8218
|
+
}
|
|
8219
|
+
|
|
8118
8220
|
// Move the shared .page conversation panel into the specified area's workspace-conv host.
|
|
8119
8221
|
// All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
|
|
8120
8222
|
// because the area-conv-host elements carry the workspace-conv class.
|
|
@@ -8156,13 +8258,9 @@ function tfRenderCompany() {
|
|
|
8156
8258
|
infoBtn.textContent = '📋 Company Info';
|
|
8157
8259
|
infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
|
|
8158
8260
|
rail.appendChild(infoBtn);
|
|
8159
|
-
|
|
8160
|
-
|
|
8161
|
-
|
|
8162
|
-
rail.appendChild(tfAreaRailJob('🧠', 'organizational-learning-synthesis', 'organizational-learning-synthesis', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis()));
|
|
8163
|
-
const note = document.createElement('div'); note.className = 'area-rail-note';
|
|
8164
|
-
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.)";
|
|
8165
|
-
rail.appendChild(note);
|
|
8261
|
+
// #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
|
|
8262
|
+
// now run from the section they populate — Run Organization Onboarding in
|
|
8263
|
+
// "Context & rules", Synthesize company learnings in "Company learnings".
|
|
8166
8264
|
}
|
|
8167
8265
|
const profile = document.getElementById('company-profile');
|
|
8168
8266
|
const learn = document.getElementById('company-learnings');
|
|
@@ -8218,6 +8316,11 @@ function tfRenderCompany() {
|
|
|
8218
8316
|
if (learn) {
|
|
8219
8317
|
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.');
|
|
8220
8318
|
}
|
|
8319
|
+
// #693 R1: lifecycle actions live in the sections they populate (mirrors the
|
|
8320
|
+
// Projects tab): onboarding in Context & rules, learnings synthesis in Company learnings.
|
|
8321
|
+
tfEnsureAccAction('company-ctx-acc', '🏢', 'Run Organization Onboarding', () => tfStartOrgOnboarding());
|
|
8322
|
+
// #693 R1 (PR round 2): Company's learnings job is organizational-learning-synthesis, not sleep-on-learnings.
|
|
8323
|
+
tfEnsureAccAction('company-learn-acc', '🧠', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis());
|
|
8221
8324
|
tfMaybeRenderOrgPushBanner();
|
|
8222
8325
|
// Show the full coaching conversation panel when there is an active org conv;
|
|
8223
8326
|
// otherwise show the info view (accordions). This uses the shared .page element
|
|
@@ -8307,12 +8410,32 @@ function tfRenderManager() {
|
|
|
8307
8410
|
infoBtn.textContent = '📋 Manager Info';
|
|
8308
8411
|
infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
|
|
8309
8412
|
rail.appendChild(infoBtn);
|
|
8310
|
-
|
|
8311
|
-
|
|
8312
|
-
|
|
8313
|
-
|
|
8314
|
-
|
|
8315
|
-
|
|
8413
|
+
// #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
|
|
8414
|
+
// Manager agreements now runs from the "Context & rules" section it populates.
|
|
8415
|
+
// Issue #702: for each hired persona with catalog jobs, render an EMPLOYEE GROUP on
|
|
8416
|
+
// the Manager rail — the same avatar + name + runs + "+" presentation as the Projects
|
|
8417
|
+
// rail (tfRenderManagerPersonaGroup reuses conv-employee-group), so a persona looks
|
|
8418
|
+
// identical on both tabs. Data-driven from bootstrap job metadata (job.requiredPersonaKey),
|
|
8419
|
+
// not a hardcoded list. Runs launched here are tagged invokedArea='manager'; the same
|
|
8420
|
+
// employee's job launched inside a project stays in that project.
|
|
8421
|
+
const mgrPersonas = (state.bootstrap && state.bootstrap.personas) || [];
|
|
8422
|
+
const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
|
|
8423
|
+
let anyMgrEmployee = false;
|
|
8424
|
+
for (const persona of mgrPersonas) {
|
|
8425
|
+
if (!tfIsPersonaHired(persona.key)) continue;
|
|
8426
|
+
if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
|
|
8427
|
+
if (!anyMgrEmployee) {
|
|
8428
|
+
const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
|
|
8429
|
+
rail.appendChild(head);
|
|
8430
|
+
anyMgrEmployee = true;
|
|
8431
|
+
}
|
|
8432
|
+
tfRenderManagerPersonaGroup(rail, persona);
|
|
8433
|
+
}
|
|
8434
|
+
if (anyMgrEmployee) {
|
|
8435
|
+
const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
|
|
8436
|
+
pNote.textContent = "These employees work across every project you manage. Launch a job here (+) for cross-project work — its run stays on this tab; the same employee's job launched inside a project stays there. Coach and verify like any employee.";
|
|
8437
|
+
rail.appendChild(pNote);
|
|
8438
|
+
}
|
|
8316
8439
|
}
|
|
8317
8440
|
const profile = document.getElementById('manager-profile');
|
|
8318
8441
|
const learn = document.getElementById('manager-learnings');
|
|
@@ -8375,6 +8498,12 @@ function tfRenderManager() {
|
|
|
8375
8498
|
// the manager-coaching learnings the team has captured for you.
|
|
8376
8499
|
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.');
|
|
8377
8500
|
}
|
|
8501
|
+
// #693 R1: lifecycle actions live in the sections they populate (mirrors the
|
|
8502
|
+
// Projects tab): manager agreements in Context & rules, learnings in Manager learnings.
|
|
8503
|
+
tfEnsureAccAction('manager-ctx-acc', '🤝', 'Set up manager agreements', () => tfStartManagerOnboarding());
|
|
8504
|
+
// #693 R1 (PR round 2): Manager has no learnings-synthesis job — its learnings
|
|
8505
|
+
// surface here from project sleep-on-learnings promoted to manager scope — so no
|
|
8506
|
+
// run-action button in the Manager learnings section.
|
|
8378
8507
|
// Issue #540 R4-R7: render manager team pool on every manager-tab activation.
|
|
8379
8508
|
renderManagerTeamPool();
|
|
8380
8509
|
// Show the full coaching conversation panel when there is an active manager conv;
|
|
@@ -8883,6 +9012,47 @@ function tfCloseAccountMenu() {
|
|
|
8883
9012
|
if (menu) menu.classList.remove('open');
|
|
8884
9013
|
}
|
|
8885
9014
|
|
|
9015
|
+
// ---------------------------------------------------------------------------
|
|
9016
|
+
// Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
|
|
9017
|
+
// sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
|
|
9018
|
+
// OS preference; here we reflect it in the toggle control and persist changes.
|
|
9019
|
+
// Same-origin key sharing keeps /account and /analytics in sync on their next load.
|
|
9020
|
+
// ---------------------------------------------------------------------------
|
|
9021
|
+
function tfCurrentTheme() {
|
|
9022
|
+
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
|
|
9023
|
+
}
|
|
9024
|
+
function tfSyncThemeToggle(theme) {
|
|
9025
|
+
const btn = document.getElementById('am-theme-toggle');
|
|
9026
|
+
if (!btn) return;
|
|
9027
|
+
const on = theme === 'dark';
|
|
9028
|
+
btn.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
9029
|
+
const sub = document.getElementById('am-theme-sub');
|
|
9030
|
+
if (sub) sub.textContent = on ? 'On' : 'Off';
|
|
9031
|
+
const ico = document.getElementById('am-theme-ico');
|
|
9032
|
+
if (ico) ico.textContent = on ? '🌙' : '☀️';
|
|
9033
|
+
}
|
|
9034
|
+
function tfApplyTheme(theme, persist) {
|
|
9035
|
+
const t = theme === 'dark' ? 'dark' : 'light';
|
|
9036
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
9037
|
+
if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
|
|
9038
|
+
tfSyncThemeToggle(t);
|
|
9039
|
+
// Keep any same-origin embedded surface (e.g. an analytics iframe) in sync live.
|
|
9040
|
+
try {
|
|
9041
|
+
document.querySelectorAll('iframe').forEach((f) => {
|
|
9042
|
+
if (f.contentWindow) f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, location.origin);
|
|
9043
|
+
});
|
|
9044
|
+
} catch (e) {}
|
|
9045
|
+
}
|
|
9046
|
+
function tfToggleTheme(e) {
|
|
9047
|
+
if (e) e.stopPropagation();
|
|
9048
|
+
tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
|
|
9049
|
+
}
|
|
9050
|
+
function tfWireThemeToggle() {
|
|
9051
|
+
const btn = document.getElementById('am-theme-toggle');
|
|
9052
|
+
if (btn) btn.addEventListener('click', tfToggleTheme);
|
|
9053
|
+
tfSyncThemeToggle(tfCurrentTheme());
|
|
9054
|
+
}
|
|
9055
|
+
|
|
8886
9056
|
// Per-job config for the area onboarding pre-flight modal.
|
|
8887
9057
|
const AREA_ONBOARD_CONFIG = {
|
|
8888
9058
|
'organization-onboarding': {
|
|
@@ -8897,6 +9067,15 @@ const AREA_ONBOARD_CONFIG = {
|
|
|
8897
9067
|
title: 'Manager agreements',
|
|
8898
9068
|
desc: 'FRAIM will learn how you work and write manager_context.md and manager_rules.md. Add any specific direction below.',
|
|
8899
9069
|
},
|
|
9070
|
+
// Issue #702: Ashley's manager-tab jobs.
|
|
9071
|
+
'executive-assistant': {
|
|
9072
|
+
title: 'Ashley — what needs you today',
|
|
9073
|
+
desc: "Ashley reviews the projects you manage plus your calendar and briefs you on what needs your attention. Add any specific focus below.",
|
|
9074
|
+
},
|
|
9075
|
+
'weekly-operating-review': {
|
|
9076
|
+
title: 'Ashley — weekly operating review',
|
|
9077
|
+
desc: 'Ashley produces your weekly portfolio operating brief. Add any specific focus areas below.',
|
|
9078
|
+
},
|
|
8900
9079
|
};
|
|
8901
9080
|
|
|
8902
9081
|
function tfOpenAreaOnboardModal(jobId, baseMessage, area) {
|
|
@@ -8942,7 +9121,7 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
|
|
|
8942
9121
|
const finalMessage = (userContext && userContext.trim()) ? userContext.trim() : jobMessage;
|
|
8943
9122
|
if (job && typeof startRun === 'function') {
|
|
8944
9123
|
if (targetArea) tfShowArea(targetArea);
|
|
8945
|
-
await startRun(job, finalMessage, employeeId);
|
|
9124
|
+
await startRun(job, finalMessage, employeeId, undefined, targetArea);
|
|
8946
9125
|
// After run creation refresh the area panel so the conversation becomes visible.
|
|
8947
9126
|
if (targetArea === 'company') tfRenderCompany();
|
|
8948
9127
|
else if (targetArea === 'manager') tfRenderManager();
|
|
@@ -9591,6 +9770,7 @@ function tfWireShell() {
|
|
|
9591
9770
|
}
|
|
9592
9771
|
const avatar = document.getElementById('avatar-btn');
|
|
9593
9772
|
if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
|
|
9773
|
+
tfWireThemeToggle();
|
|
9594
9774
|
const brainItem = document.getElementById('am-brain');
|
|
9595
9775
|
if (brainItem) brainItem.addEventListener('click', () => { tfCloseAccountMenu(); tfShowArea('brain'); });
|
|
9596
9776
|
const signout = document.getElementById('am-signout');
|