fraim 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.
@@ -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 status/messages on known conversations (e.g. running completed)
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].status !== conv.status) {
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 needsConversationRefresh() {
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
- hydrateConversationsFromServer().catch((error) =>
556
- console.warn('Could not refresh delegated conversations:', error));
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
- // #521: in the project workspace, project-lifecycle jobs (onboarding +
843
- // sleep-on-learnings) have their own "Project Updates" home, so keep them out
844
- // of the general Runs list to avoid showing the same run twice.
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
- !(inWorkspace && projectUpdateJobs.has(conv.jobId)) &&
860
- !AREA_SCOPED_JOBS.has(conv.jobId)
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
- details.appendChild(buildGroupList(group.conversations));
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
- // R4: dashed-border avatar placeholder no image or initials.
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--adhoc';
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
- tracker.hidden = true;
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
- if (targetType === 'artifact_set' && artifacts.length > 0) {
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 for a real PR URL, or "artifact_set" for one or more review files.',
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 schedModal = document.getElementById('dep-schedule-modal');
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 (schedModal && !schedModal.hidden) {
3965
- schedModal.hidden = true;
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
- // Update employee footer
4149
- const footer = document.getElementById('cp-employee-name');
4150
- if (footer) {
4151
- const emp = state.bootstrap && state.bootstrap.employees
4152
- ? state.bootstrap.employees.find((e) => e.id === (state.cpEmployee || 'claude'))
4153
- : null;
4154
- footer.textContent = emp ? emp.label : (state.cpEmployee || 'claude');
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) conv.reviewHandoff = runHandoff;
4923
- else reviewHandoffForConversation(conv);
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 || [])) if (c && c.jobId === jobId) out.push(c);
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
- // #521: "Project Updates" the project-lifecycle jobs (project onboarding +
6699
- // sleep-on-learnings) grouped in one accordion with run history, above New job.
6700
- host.appendChild(tfProjectUpdatesSection());
6701
- host.appendChild(tfTreeSep());
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
- // The legacy conversation switcher (#conv-list .conv-item buttons, rendered
6715
- // by renderRail) also lives in the suppressed rail. Relocate the whole Runs
6716
- // section so standalone conversations stay switchable the contract behind
6717
- // #429 R6 (thread-collapse persists across conversation switches). renderRail
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
- const projectId = tf.activeProjectId;
6726
- const assigned = tfProjectAssignments(projectId);
6727
- const project = tf.projects.find((p) => p.id === projectId);
6728
- const onProject = tfProjectTeamKeys();
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 + Schedule or + Trigger above to set up scheduled and triggered assignments.';
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 card = document.createElement('div');
7113
- card.className = 'dep-card';
7114
- const top = document.createElement('div');
7115
- top.className = 'dep-card-top';
7116
- const icon = document.createElement('span');
7117
- icon.className = 'dep-type-icon';
7118
- icon.textContent = dep.type === 'scheduled' ? '⏱' : '🔗';
7119
- const label = document.createElement('span');
7120
- label.className = 'dep-label';
7121
- label.textContent = dep.label;
7122
- const typeBadge = document.createElement('span');
7123
- typeBadge.className = 'dep-type-badge dep-type-badge--' + dep.type;
7124
- typeBadge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
7125
- top.appendChild(icon);
7126
- top.appendChild(label);
7127
- top.appendChild(typeBadge);
7128
- card.appendChild(top);
7129
-
7130
- if (dep.cronExpr) {
7131
- const cron = document.createElement('div');
7132
- cron.className = 'dep-detail';
7133
- const human = cronToHuman(dep.cronExpr);
7134
- cron.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
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
- if (dep.inboundUrl) {
7148
- const urlRow = document.createElement('div');
7149
- urlRow.className = 'dep-detail dep-detail--url';
7150
- const urlCode = document.createElement('code');
7151
- urlCode.textContent = dep.inboundUrl;
7152
- urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
7153
- const copyBtn = document.createElement('button');
7154
- copyBtn.type = 'button';
7155
- copyBtn.className = 'hm-copy-btn';
7156
- copyBtn.textContent = 'Copy';
7157
- copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
7158
- urlRow.appendChild(urlCode);
7159
- urlRow.appendChild(copyBtn);
7160
- card.appendChild(urlRow);
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
- const cardActions = document.createElement('div');
7164
- cardActions.className = 'dep-card-actions';
7165
-
7166
- const editBtn = document.createElement('button');
7167
- editBtn.type = 'button';
7168
- editBtn.className = 'dep-edit-btn';
7169
- editBtn.title = 'Edit this assignment';
7170
- editBtn.textContent = 'Edit';
7171
- editBtn.addEventListener('click', () => { openDeploymentModal(dep.type === 'scheduled' ? 'schedule' : 'webhook', dep); });
7172
- cardActions.appendChild(editBtn);
7173
-
7174
- const delBtn = document.createElement('button');
7175
- delBtn.type = 'button';
7176
- delBtn.className = 'dep-del-btn';
7177
- delBtn.title = 'Remove this assignment';
7178
- delBtn.textContent = 'Remove';
7179
- delBtn.addEventListener('click', async () => {
7180
- const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
7181
- await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
7182
- await loadDeployments();
7183
- });
7184
- cardActions.appendChild(delBtn);
7185
- card.appendChild(cardActions);
7186
- container.appendChild(card);
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
- const addSchBtn = document.getElementById('dep-add-schedule-btn');
7192
- const addWhBtn = document.getElementById('dep-add-webhook-btn');
7193
- if (addSchBtn) addSchBtn.addEventListener('click', () => openDeploymentModal('schedule'));
7194
- if (addWhBtn) addWhBtn.addEventListener('click', () => openDeploymentModal('webhook'));
7195
-
7196
- // Close buttons
7197
- const schClose = document.getElementById('dep-schedule-close');
7198
- const whClose = document.getElementById('dep-webhook-close');
7199
- if (schClose) schClose.addEventListener('click', () => { document.getElementById('dep-schedule-modal').hidden = true; });
7200
- if (whClose) whClose.addEventListener('click', () => { document.getElementById('dep-webhook-modal').hidden = true; });
7201
-
7202
- // Cancel buttons
7203
- const schCancel = document.getElementById('dep-sch-cancel-btn');
7204
- const whCancel = document.getElementById('dep-wh-cancel-btn');
7205
- if (schCancel) schCancel.addEventListener('click', () => { document.getElementById('dep-schedule-modal').hidden = true; });
7206
- if (whCancel) whCancel.addEventListener('click', () => { document.getElementById('dep-webhook-modal').hidden = true; });
7207
-
7208
- // Save buttons
7209
- const schSave = document.getElementById('dep-sch-save-btn');
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
- function openDeploymentModal(type, dep) {
7261
- const jobs = state.bootstrap?.jobs ?? [];
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
- function populateJobs(sel, currentJobId) {
7267
- sel.innerHTML = '';
7268
- for (const j of jobs) {
7269
- const opt = document.createElement('option');
7270
- opt.value = j.id;
7271
- opt.textContent = j.title;
7272
- sel.appendChild(opt);
7273
- }
7274
- if (currentJobId) sel.value = currentJobId;
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
- function populateEmployees(sel, currentHostId) {
7278
- sel.innerHTML = '';
7279
- const fallback = [{ id: 'claude', label: 'Claude' }, { id: 'codex', label: 'Codex' }, { id: 'gemini', label: 'Gemini' }, { id: 'copilot', label: 'Copilot' }];
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
- async function saveWebhookDeployment() {
7360
- const label = document.getElementById('dep-wh-label').value.trim();
7361
- const jobId = document.getElementById('dep-wh-job').value;
7362
- const hostId = document.getElementById('dep-wh-host').value;
7363
- const instructions = document.getElementById('dep-wh-instructions').value.trim();
7364
- const errEl = document.getElementById('dep-wh-error');
7365
- if (!label) { errEl.textContent = 'Label is required.'; errEl.hidden = false; return; }
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
- const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
7368
- const method = isEdit ? 'PUT' : 'POST';
7369
- try {
7370
- const resp = await fetch(url, {
7371
- method,
7372
- headers: { 'Content-Type': 'application/json' },
7373
- body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }),
7374
- });
7375
- if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || (isEdit ? 'Failed to update assignment.' : 'Failed to create assignment.'); errEl.hidden = false; return; }
7376
- if (!isEdit) {
7377
- const dep = await resp.json();
7378
- document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
7379
- document.getElementById('dep-wh-inbound-row').hidden = false;
7380
- }
7381
- _editingDepId = null;
7382
- if (isEdit) document.getElementById('dep-webhook-modal').hidden = true;
7383
- await loadDeployments();
7384
- } catch { errEl.textContent = 'Network error — is the Hub running?'; errEl.hidden = false; }
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
- // #521 R6/R6.5: a Company/Manager rail job is an accordion that nests its past
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
- // #521: "Project Updates" — one accordion grouping the project-lifecycle jobs
7956
- // (project-onboarding + sleep-on-learnings) with their run history. The header
7957
- // "+" opens a small menu to run either; the body lists each job and its past
7958
- // runs (click a run to open it). Sits between Project Info and "+ New job".
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 org-scoped manager-agreements conversation to show in Manager area.
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 mgrJobs = new Set(['manager-agreements']);
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
- const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Company jobs';
8160
- rail.appendChild(head);
8161
- rail.appendChild(tfAreaRailJob('🏢', 'organization-onboarding', 'Organization onboarding', 'Set up / update org context & rules', () => tfStartOrgOnboarding()));
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
- const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Manager jobs';
8311
- rail.appendChild(head);
8312
- rail.appendChild(tfAreaRailJob('🤝', 'manager-agreements', 'manager-agreements', 'Set up / update how you work', () => tfStartManagerOnboarding()));
8313
- const note = document.createElement('div'); note.className = 'area-rail-note';
8314
- note.textContent = "manager-agreements is about you, not a single project — so it isn't in any project's job list. You coach in project conversations; what the team learns about you surfaces here.";
8315
- rail.appendChild(note);
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');