fraim-framework 2.0.184 → 2.0.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -839,11 +839,9 @@ function renderRail() {
839
839
  renderTeamRoster();
840
840
  els['conv-list'].innerHTML = '';
841
841
  // 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']);
842
+ // #693 R1: the "Project Updates" section was retired; project-lifecycle runs
843
+ // (project-onboarding + sleep-on-learnings) now surface in the Runs list under
844
+ // their employee like any other run, so their history stays reachable.
847
845
  const allProjectConversations = projectConversations();
848
846
  const managedChildren = allProjectConversations.filter(isManagedDelegationChild);
849
847
  const childrenByManager = new Map();
@@ -856,7 +854,6 @@ function renderRail() {
856
854
  const list = allProjectConversations.filter((conv) =>
857
855
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
858
856
  !isManagedDelegationChild(conv) &&
859
- !(inWorkspace && projectUpdateJobs.has(conv.jobId)) &&
860
857
  !AREA_SCOPED_JOBS.has(conv.jobId)
861
858
  );
862
859
 
@@ -886,6 +883,31 @@ function renderRail() {
886
883
  groups.get(key).conversations.push(conv);
887
884
  }
888
885
 
886
+ // #693 R1 (PR round 2): this Runs list is the single employees section in the
887
+ // workspace tree, so include every project employee — team, manager-team, and
888
+ // assigned — even before they have a run, so you can assign/delegate to them.
889
+ const tfProjId = (typeof tf !== 'undefined' && tf) ? tf.activeProjectId : null;
890
+ const managerTeamKeys = (state.bootstrap?.managerTeam || []).map((e) => e.personaKey).filter(Boolean);
891
+ // merge: master resolves the tree's employees via tfProjectTeamKeys() (hired personas),
892
+ // not project.team; align on it so every hired employee appears in the single section.
893
+ const projectEmployeeKeys = new Set([
894
+ ...(typeof tfProjectTeamKeys === 'function' ? tfProjectTeamKeys() : []),
895
+ ...(tfProjId && typeof tfProjectAssignments === 'function' ? tfProjectAssignments(tfProjId).map((a) => a.employeeKey).filter(Boolean) : []),
896
+ ...managerTeamKeys,
897
+ ]);
898
+ for (const key of projectEmployeeKeys) {
899
+ if (groups.has(key)) continue;
900
+ const p = typeof tfPersonaByKey === 'function' ? tfPersonaByKey(key) : null;
901
+ if (p && p.status !== 'hired') continue; // reconcile against real entitlements (#538 follow-up)
902
+ groups.set(key, {
903
+ key,
904
+ label: p ? p.displayName : key,
905
+ detail: p ? (p.role || 'AI Employee') : '',
906
+ sample: p ? { personaKey: key } : { agentName: key },
907
+ conversations: [],
908
+ });
909
+ }
910
+
889
911
  function buildRunButton(conv, options = {}) {
890
912
  const btn = document.createElement('button');
891
913
  btn.type = 'button';
@@ -999,9 +1021,33 @@ function renderRail() {
999
1021
  summary.appendChild(avatar);
1000
1022
  summary.appendChild(copy);
1001
1023
  summary.appendChild(addBtn);
1024
+ // #693 R1 (PR round 2): hire-a-human-manager (#538) affordance, ported from the
1025
+ // retired emp-hero list. The old per-employee "Delegate" button is intentionally
1026
+ // omitted — it did the same openPalette('/persona') as the "+" above, so it was
1027
+ // redundant and only squeezed the employee-name column.
1028
+ if (state.bootstrap && state.bootstrap.managerHiring && state.bootstrap.managerHiring.roles && state.bootstrap.managerHiring.roles[group.key]) {
1029
+ const hireBtn = document.createElement('button');
1030
+ hireBtn.type = 'button';
1031
+ hireBtn.className = 'eh-hire-manager';
1032
+ hireBtn.textContent = '🧑‍💼';
1033
+ hireBtn.title = 'Find a human manager for ' + group.label;
1034
+ hireBtn.setAttribute('aria-label', 'Find a human manager for ' + group.label);
1035
+ hireBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openHireManagerModal(group.key); });
1036
+ summary.appendChild(hireBtn);
1037
+ }
1002
1038
  summary.appendChild(count);
1003
1039
  details.appendChild(summary);
1004
- details.appendChild(buildGroupList(group.conversations));
1040
+ if (group.conversations.length) {
1041
+ details.appendChild(buildGroupList(group.conversations));
1042
+ } else {
1043
+ const emptyWrap = document.createElement('div');
1044
+ emptyWrap.className = 'conv-employee-list';
1045
+ const em = document.createElement('div');
1046
+ em.className = 'eh-empty';
1047
+ em.textContent = 'No runs yet — + to assign a job.';
1048
+ emptyWrap.appendChild(em);
1049
+ details.appendChild(emptyWrap);
1050
+ }
1005
1051
  els['conv-list'].appendChild(details);
1006
1052
  }
1007
1053
 
@@ -1016,9 +1062,11 @@ function renderRail() {
1016
1062
  wcDetails.open = true;
1017
1063
  const wcSummary = document.createElement('summary');
1018
1064
  wcSummary.className = 'conv-employee-tab';
1019
- // R4: dashed-border avatar placeholder no image or initials.
1065
+ // #693.8: watercooler icon (replaces the blank dashed placeholder).
1020
1066
  const wcAvatar = document.createElement('span');
1021
- wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--adhoc';
1067
+ wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--watercooler';
1068
+ wcAvatar.setAttribute('aria-hidden', 'true');
1069
+ wcAvatar.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2.5" width="10" height="6" rx="1.2"></rect><path d="M8.5 8.5h7v9a2 2 0 0 1-2 2h-3a2 2 0 0 1-2-2z"></path><line x1="9.5" y1="21.5" x2="14.5" y2="21.5"></line></svg>';
1022
1070
  const wcCopy = document.createElement('span');
1023
1071
  wcCopy.className = 'conv-employee-tab-copy';
1024
1072
  const wcLabel = document.createElement('strong');
@@ -2989,7 +3037,6 @@ function convAwaitingReview(conv) {
2989
3037
  // output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
2990
3038
  // stakeholder-status-reporting) may legitimately surface artifacts for human review.
2991
3039
  if (delegationLedgerForConversation(conv)) return false;
2992
- if (conv.status === 'completed' && Array.isArray(conv.artifacts) && conv.artifacts.length > 0) return true;
2993
3040
  // #521: a plain completed turn is NOT awaiting review — the approve/reject card
2994
3041
  // only belongs when the employee actually submits for review (a review_handoff,
2995
3042
  // emitted by the submit phase). A turn that just finished (a question, a pause
@@ -3054,20 +3101,22 @@ function normalizeReviewHandoff(raw) {
3054
3101
  if (targetType === 'pull_request') {
3055
3102
  const url = safeHttpUrl(rawTarget && rawTarget.url);
3056
3103
  if (!url) return null;
3104
+ if (artifacts.length > 0) return null;
3057
3105
  return {
3058
3106
  reviewRequired: true,
3059
3107
  reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
3060
- artifacts,
3108
+ artifacts: [],
3061
3109
  summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
3062
3110
  feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'pull_request_comments',
3063
3111
  };
3064
3112
  }
3065
3113
 
3066
- if (targetType === 'artifact_set' && artifacts.length > 0) {
3114
+ const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
3115
+ if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
3067
3116
  return {
3068
3117
  reviewRequired: true,
3069
3118
  reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length || 1} artifact${artifacts.length === 1 ? '' : 's'}` },
3070
- artifacts,
3119
+ artifacts: fileArtifacts,
3071
3120
  summary: typeof raw.summary === 'string' ? raw.summary.trim() : '',
3072
3121
  feedbackMode: typeof raw.feedbackMode === 'string' ? raw.feedbackMode.trim() : 'inline',
3073
3122
  };
@@ -3759,7 +3808,7 @@ async function handleArtifactAction(conv, action) {
3759
3808
  'Please resend the review handoff contract as valid JSON inside <review_handoff>...</review_handoff>.',
3760
3809
  'Do not rely on prose.',
3761
3810
  '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.',
3811
+ 'Use reviewTarget.type "pull_request" with only the PR URL and no artifacts, or "artifact_set" with exact local file paths and no PR URL.',
3763
3812
  ].join(' ');
3764
3813
  if (conv && conv.sessionId) {
3765
3814
  els['coach-text'].value = message;
@@ -3954,17 +4003,14 @@ function wirePopovers() {
3954
4003
 
3955
4004
  if (e.key === 'Escape') {
3956
4005
  const cpModal = document.getElementById('cp-modal');
3957
- const schedModal = document.getElementById('dep-schedule-modal');
3958
- const webhookModal = document.getElementById('dep-webhook-modal');
4006
+ const assignModal = document.getElementById('dep-assignment-modal');
3959
4007
  const aomModal = document.getElementById('area-onboard-modal');
3960
4008
  if (aomModal && !aomModal.hidden) {
3961
4009
  tfCloseAreaOnboardModal();
3962
4010
  } else if (cpModal && !cpModal.hidden) {
3963
4011
  closePalette();
3964
- } else if (schedModal && !schedModal.hidden) {
3965
- schedModal.hidden = true;
3966
- } else if (webhookModal && !webhookModal.hidden) {
3967
- webhookModal.hidden = true;
4012
+ } else if (assignModal && !assignModal.hidden) {
4013
+ assignModal.hidden = true;
3968
4014
  } else if (els['modal'].classList.contains('open')) {
3969
4015
  closeModal();
3970
4016
  } else {
@@ -4145,13 +4191,37 @@ function renderCpRows(searchText) {
4145
4191
  });
4146
4192
  }
4147
4193
 
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');
4194
+ // #693 R5: agent picker — choose the AI agent for this launch.
4195
+ renderCpAgentPicker();
4196
+ }
4197
+
4198
+ // #693 R5: render the command-palette AI-agent picker. Lists only agents
4199
+ // available on the machine as selectable; unavailable agents are shown disabled
4200
+ // with an install hint. Defaults to the saved agent (state.cpEmployee); if that
4201
+ // agent is not installed, falls back to the first available one.
4202
+ function renderCpAgentPicker() {
4203
+ const picker = document.getElementById('cp-agent-picker');
4204
+ if (!picker) return;
4205
+ picker.innerHTML = '';
4206
+ const employees = (state.bootstrap && state.bootstrap.employees) || [];
4207
+ const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
4208
+ const curOk = list.some((e) => e.id === state.cpEmployee && e.available !== false);
4209
+ if (!curOk) {
4210
+ const firstAvail = list.find((e) => e.available !== false);
4211
+ if (firstAvail) state.cpEmployee = firstAvail.id;
4212
+ }
4213
+ for (const e of list) {
4214
+ const pill = document.createElement('button');
4215
+ pill.type = 'button';
4216
+ pill.className = 'cp-agent-pill' + (e.id === state.cpEmployee ? ' on' : '');
4217
+ pill.textContent = e.label + (e.available === false ? ' · install' : '');
4218
+ if (e.available === false) {
4219
+ pill.disabled = true;
4220
+ pill.title = e.label + ' is not installed on this machine';
4221
+ } else {
4222
+ pill.addEventListener('click', () => { state.cpEmployee = e.id; renderCpAgentPicker(); });
4223
+ }
4224
+ picker.appendChild(pill);
4155
4225
  }
4156
4226
  }
4157
4227
 
@@ -4919,29 +4989,25 @@ function foldRunIntoConversation(conv, run) {
4919
4989
  conv.personaKey = run.personaKey;
4920
4990
  }
4921
4991
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
4922
- if (runHandoff) conv.reviewHandoff = runHandoff;
4923
- else reviewHandoffForConversation(conv);
4992
+ if (runHandoff) {
4993
+ conv.reviewHandoff = runHandoff;
4994
+ } else if (run.reviewHandoff) {
4995
+ conv.reviewHandoff = run.reviewHandoff;
4996
+ } else {
4997
+ conv.reviewHandoff = null;
4998
+ }
4924
4999
  const runDelegation = normalizeDelegationLedger(run.delegation);
4925
5000
  if (runDelegation) conv.delegation = runDelegation;
4926
5001
  else delegationLedgerForConversation(conv);
4927
5002
  if (Array.isArray(run.artifacts)) {
4928
5003
  conv.artifacts = run.artifacts;
5004
+ } else {
5005
+ conv.artifacts = [];
4929
5006
  }
4930
5007
  // Update status.
4931
5008
  if (run.status === 'completed') conv.status = 'completed';
4932
5009
  else if (run.status === 'failed') conv.status = 'failed';
4933
5010
  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
5011
  conv.lastUpdatedAt = Date.now();
4946
5012
  }
4947
5013
 
@@ -4960,42 +5026,6 @@ function foldCompareRunIntoConversation(conv, compareRun) {
4960
5026
  };
4961
5027
  }
4962
5028
 
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
5029
  function startPolling() {
5000
5030
  if (state.pollHandle) window.clearInterval(state.pollHandle);
5001
5031
  state.pollHandle = window.setInterval(async () => {
@@ -6695,10 +6725,10 @@ function tfRenderTree() {
6695
6725
  });
6696
6726
  host.appendChild(briefNav);
6697
6727
  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());
6728
+ // #693 R1: the standalone "Project Updates" accordion is retired. Its two
6729
+ // lifecycle actions now live where their output lands Run Project Onboarding
6730
+ // in the Brief section and Sleep on learnings in the Learnings section
6731
+ // (see tfRenderProjectContextTop). No separate tree section.
6702
6732
  // The legacy standalone "+ New job" entry (#new-conv-btn → openModal → the
6703
6733
  // #job-catalog/#next1/#instructions/#start chain) lives in the old `.rail`,
6704
6734
  // which the shell suppresses (display:none). Relocate the live node to the
@@ -6711,126 +6741,20 @@ function tfRenderTree() {
6711
6741
  host.appendChild(newConvBtn);
6712
6742
  host.appendChild(tfTreeSep());
6713
6743
  }
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.
6744
+ // #693 R1 (PR round 2): the per-employee "Runs" accordions (renderRail's
6745
+ // conv-employee-group) ARE the single employees section they list every
6746
+ // project employee with that employee's runs nested under their accordion.
6747
+ // Relocate that section into the tree; the redundant emp-hero list is retired.
6719
6748
  const convList = (typeof els !== 'undefined' && els['conv-list']) || document.getElementById('conv-list');
6720
6749
  const runsSection = convList && convList.closest('.rail-section--runs');
6721
6750
  if (runsSection) {
6722
6751
  runsSection.classList.add('tree-runs');
6723
6752
  host.appendChild(runsSection);
6724
6753
  }
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
- });
6754
+ // #693 R1 (PR round 2): the redundant emp-hero employee list was removed. The
6755
+ // relocated Runs section above (renderRail's conv-employee-group) is now the
6756
+ // single employees section — every project employee, with their runs nested
6757
+ // under their own accordion, plus per-employee assign/hire-manager.
6834
6758
 
6835
6759
  // #521 R5/R6.4: the project brief and learnings moved OUT of the tree into the
6836
6760
  // collapsible top sections (#proj-brief-acc / #proj-learnings-acc), rendered by
@@ -6931,6 +6855,37 @@ function tfRenderProjectContextTop() {
6931
6855
  // #533 #4: project-LEVEL manager learnings (repo-local) live in the project tab.
6932
6856
  tfRenderLearningsList(learnHost, 'manager', 'project', 'Nothing here yet — what your team learns about working with you on THIS project (preferences, patterns to avoid, validated approaches) appears here. Add one with + Add learning.');
6933
6857
  }
6858
+ // #693 R1: the retired "Project Updates" section's actions now live where their
6859
+ // output lands — Run Project Onboarding in the Brief section, Sleep on learnings
6860
+ // in the Learnings section.
6861
+ tfEnsureAccAction('proj-brief-acc', '🚀', 'Run Project Onboarding', () => tfOpenOnboardInput('run'));
6862
+ tfEnsureAccAction('proj-learnings-acc', '🧠', 'Sleep on learnings', () => tfRunShareLearnings('project'));
6863
+ }
6864
+
6865
+ // #693 R1: insert (idempotently) a single lifecycle-action button at the top of a
6866
+ // ctx accordion's body. Used to place onboarding/sleep actions inside the Brief,
6867
+ // Learnings, and Company/Manager context sections they populate.
6868
+ function tfEnsureAccAction(accId, ico, label, onClick) {
6869
+ const acc = document.getElementById(accId);
6870
+ if (!acc) return;
6871
+ const body = acc.querySelector('.ctx-acc-body');
6872
+ if (!body) return;
6873
+ let row = body.querySelector(':scope > .ctx-acc-action-row');
6874
+ if (!row) {
6875
+ row = document.createElement('div');
6876
+ row.className = 'ctx-acc-action-row';
6877
+ body.insertBefore(row, body.firstChild);
6878
+ }
6879
+ // Rebuild the button so repeated renders don't stack duplicates or stale handlers.
6880
+ row.innerHTML = '';
6881
+ const btn = document.createElement('button');
6882
+ btn.type = 'button';
6883
+ btn.className = 'ctx-acc-action';
6884
+ const ic = document.createElement('span'); ic.className = 'caa-ico'; ic.textContent = ico;
6885
+ const tx = document.createElement('span'); tx.textContent = label;
6886
+ btn.appendChild(ic); btn.appendChild(tx);
6887
+ btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); onClick(); });
6888
+ row.appendChild(btn);
6934
6889
  }
6935
6890
 
6936
6891
  // #521: the Brief section is the project's real captured context + rules — the two
@@ -7099,119 +7054,181 @@ function cronToHuman(expr) {
7099
7054
  return expr;
7100
7055
  }
7101
7056
 
7057
+ // #693 R3 (PR round 2): render assignments grouped by the EMPLOYEE the job maps to
7058
+ // (job.requiredPersonaKey), not the AI agent, reusing the Runs section's
7059
+ // conv-employee-group accordion so the two sections look and behave the same.
7102
7060
  function renderDeploymentList(container, deployments) {
7103
7061
  container.innerHTML = '';
7104
7062
  if (!deployments.length) {
7105
7063
  const empty = document.createElement('p');
7106
7064
  empty.className = 'dep-empty';
7107
- empty.textContent = 'No assignments yet. Use + Schedule or + Trigger above to set up scheduled and triggered assignments.';
7065
+ empty.textContent = 'No assignments yet. Use + New Assignment above to schedule a job or trigger it from an external system.';
7108
7066
  container.appendChild(empty);
7109
7067
  return;
7110
7068
  }
7069
+ const jobs = state.bootstrap?.jobs ?? [];
7070
+ const UNASSIGNED = '__unassigned__';
7071
+ // The employee is determined by the job: its requiredPersonaKey. Fall back to an
7072
+ // "Unassigned" group for generic jobs with no required specialist.
7073
+ const personaKeyForDep = (dep) => {
7074
+ const j = jobs.find((x) => x.id === dep.jobId);
7075
+ return (j && j.requiredPersonaKey) || UNASSIGNED;
7076
+ };
7077
+ const groups = new Map();
7111
7078
  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);
7079
+ const key = personaKeyForDep(dep);
7080
+ if (!groups.has(key)) groups.set(key, []);
7081
+ groups.get(key).push(dep);
7082
+ }
7083
+ for (const [personaKey, deps] of groups) {
7084
+ const persona = personaKey === UNASSIGNED ? null : tfPersonaByKey(personaKey);
7085
+ const empLabel = persona ? persona.displayName : 'Unassigned';
7086
+ const empDetail = persona ? (persona.role || 'AI Employee') : 'No specialist required';
7087
+ const details = document.createElement('details');
7088
+ details.className = 'conv-employee-group';
7089
+ details.open = true;
7090
+ const summary = document.createElement('summary');
7091
+ summary.className = 'conv-employee-tab';
7092
+ // DiceBear when the employee has an avatar; else an initials chip.
7093
+ let avatar;
7094
+ if (persona && persona.avatarUrl) {
7095
+ avatar = document.createElement('img');
7096
+ avatar.className = 'conv-employee-avatar';
7097
+ avatar.src = persona.avatarUrl; avatar.alt = empLabel;
7098
+ } else {
7099
+ avatar = document.createElement('span');
7100
+ avatar.className = 'conv-employee-avatar';
7101
+ avatar.textContent = initialBadge(empLabel);
7145
7102
  }
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);
7103
+ const copy = document.createElement('span');
7104
+ copy.className = 'conv-employee-tab-copy';
7105
+ const label = document.createElement('strong');
7106
+ label.className = 'conv-employee-tab-label';
7107
+ label.textContent = empLabel;
7108
+ const detail = document.createElement('small');
7109
+ detail.className = 'conv-employee-tab-detail';
7110
+ detail.textContent = empDetail;
7111
+ copy.appendChild(label); copy.appendChild(detail);
7112
+ const count = document.createElement('span');
7113
+ count.className = 'conv-employee-tab-count';
7114
+ count.textContent = String(deps.length);
7115
+ const addBtn = document.createElement('button');
7116
+ addBtn.type = 'button';
7117
+ addBtn.className = 'conv-employee-add';
7118
+ addBtn.textContent = '+';
7119
+ addBtn.title = 'New assignment for ' + empLabel;
7120
+ addBtn.setAttribute('aria-label', 'New assignment for ' + empLabel);
7121
+ addBtn.addEventListener('click', (e) => {
7122
+ e.preventDefault(); e.stopPropagation();
7123
+ openDeploymentModal();
7124
+ // Pre-select a job that maps to this employee so the grouping stays consistent.
7125
+ if (persona) {
7126
+ const sel = document.getElementById('dep-job');
7127
+ const match = jobs.find((x) => x.requiredPersonaKey === personaKey);
7128
+ if (sel && match && Array.from(sel.options).some((o) => o.value === match.id)) sel.value = match.id;
7129
+ }
7130
+ });
7131
+ summary.appendChild(avatar);
7132
+ summary.appendChild(copy);
7133
+ summary.appendChild(addBtn);
7134
+ summary.appendChild(count);
7135
+ details.appendChild(summary);
7136
+ const list = document.createElement('div');
7137
+ list.className = 'conv-employee-list';
7138
+ for (const dep of deps) {
7139
+ list.appendChild(buildDeploymentRow(dep));
7140
+ if (dep.inboundUrl) list.appendChild(buildInboundUrlRow(dep));
7161
7141
  }
7142
+ details.appendChild(list);
7143
+ container.appendChild(details);
7144
+ }
7145
+ }
7162
7146
 
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);
7147
+ // #693 R3: a single assignment rendered as a Runs-style row.
7148
+ function buildDeploymentRow(dep) {
7149
+ const row = document.createElement('div');
7150
+ // #693 R3: a dedicated class (not .conv-item) so run-list logic never treats an
7151
+ // assignment as a run; styled to match the Runs rows via CSS.
7152
+ row.className = 'dep-row';
7153
+ const body = document.createElement('span');
7154
+ body.className = 'conv-body';
7155
+ const title = document.createElement('span');
7156
+ title.className = 'dep-row-title';
7157
+ title.textContent = dep.label;
7158
+ body.appendChild(title);
7159
+ const meta = document.createElement('span');
7160
+ meta.className = 'assign-row-meta';
7161
+ const badge = document.createElement('span');
7162
+ badge.className = 'assign-badge assign-badge--' + (dep.type === 'scheduled' ? 'scheduled' : 'triggered');
7163
+ badge.textContent = dep.type === 'scheduled' ? 'Scheduled' : 'Triggered';
7164
+ meta.appendChild(badge);
7165
+ const detailText = document.createElement('span');
7166
+ if (dep.type === 'scheduled' && dep.cronExpr) {
7167
+ const human = cronToHuman(dep.cronExpr);
7168
+ detailText.textContent = human !== dep.cronExpr ? human : dep.cronExpr;
7169
+ if (human !== dep.cronExpr) detailText.title = dep.cronExpr;
7170
+ } else {
7171
+ detailText.textContent = 'Inbound webhook';
7187
7172
  }
7173
+ meta.appendChild(detailText);
7174
+ body.appendChild(meta);
7175
+ row.appendChild(body);
7176
+ const actions = document.createElement('span');
7177
+ actions.className = 'dep-row-actions';
7178
+ const editBtn = document.createElement('button');
7179
+ editBtn.type = 'button'; editBtn.className = 'dep-edit-btn'; editBtn.textContent = 'Edit'; editBtn.title = 'Edit this assignment';
7180
+ editBtn.addEventListener('click', () => openDeploymentModal(dep));
7181
+ const delBtn = document.createElement('button');
7182
+ delBtn.type = 'button'; delBtn.className = 'dep-del-btn'; delBtn.textContent = 'Remove'; delBtn.title = 'Remove this assignment';
7183
+ delBtn.addEventListener('click', async () => {
7184
+ const endpoint = dep.type === 'scheduled' ? 'schedules' : 'webhooks';
7185
+ await fetch('/api/ai-hub/' + endpoint + '/' + dep.id, { method: 'DELETE' });
7186
+ await loadDeployments();
7187
+ });
7188
+ actions.appendChild(editBtn); actions.appendChild(delBtn);
7189
+ row.appendChild(actions);
7190
+ return row;
7191
+ }
7192
+
7193
+ // #693 R3: inbound-URL sub-row for triggered assignments (kept from the old card).
7194
+ function buildInboundUrlRow(dep) {
7195
+ const urlRow = document.createElement('div');
7196
+ // #693 R3: self-contained class — do NOT add .dep-detail (its width:100% plus this
7197
+ // row's left margin overflows the container and forces a horizontal scrollbar).
7198
+ urlRow.className = 'dep-inbound-sub';
7199
+ const urlCode = document.createElement('code');
7200
+ urlCode.textContent = dep.inboundUrl;
7201
+ urlCode.className = 'dep-inbound-url dep-inbound-url--inline';
7202
+ const copyBtn = document.createElement('button');
7203
+ copyBtn.type = 'button';
7204
+ copyBtn.className = 'hm-copy-btn';
7205
+ copyBtn.textContent = 'Copy';
7206
+ copyBtn.addEventListener('click', () => { navigator.clipboard.writeText(dep.inboundUrl).then(() => { copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1500); }).catch(() => {}); });
7207
+ urlRow.appendChild(urlCode);
7208
+ urlRow.appendChild(copyBtn);
7209
+ return urlRow;
7188
7210
  }
7189
7211
 
7190
7212
  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
7213
+ // #693 R2: one entry point opens the consolidated assignment modal.
7214
+ const addBtn = document.getElementById('dep-add-btn');
7215
+ if (addBtn) addBtn.addEventListener('click', () => openDeploymentModal());
7216
+
7217
+ const closeModal = () => { const m = document.getElementById('dep-assignment-modal'); if (m) m.hidden = true; };
7218
+ const close = document.getElementById('dep-assign-close');
7219
+ const cancel = document.getElementById('dep-cancel-btn');
7220
+ if (close) close.addEventListener('click', closeModal);
7221
+ if (cancel) cancel.addEventListener('click', closeModal);
7222
+
7223
+ const save = document.getElementById('dep-save-btn');
7224
+ if (save) save.addEventListener('click', saveDeployment);
7225
+
7226
+ // Scheduled / Triggered segmented toggle.
7227
+ document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((btn) => {
7228
+ btn.addEventListener('click', () => setDepType(btn.dataset.depType));
7229
+ });
7230
+
7231
+ // Preset chips (scheduled block).
7215
7232
  document.querySelectorAll('#dep-sch-presets .dep-preset-chip').forEach(chip => {
7216
7233
  chip.addEventListener('click', () => applySchPreset(chip.dataset.preset));
7217
7234
  });
@@ -7257,131 +7274,131 @@ function initDeploymentButtons() {
7257
7274
  });
7258
7275
  }
7259
7276
 
7260
- function openDeploymentModal(type, dep) {
7261
- const jobs = state.bootstrap?.jobs ?? [];
7277
+ // #693 R2: current assignment type in the consolidated modal.
7278
+ let _depType = 'scheduled';
7279
+ function setDepType(type) {
7280
+ _depType = (type === 'triggered') ? 'triggered' : 'scheduled';
7281
+ document.querySelectorAll('#dep-type-seg .dep-type-btn').forEach((b) => {
7282
+ b.classList.toggle('on', b.dataset.depType === _depType);
7283
+ });
7284
+ const sched = document.getElementById('dep-sched-block');
7285
+ const trig = document.getElementById('dep-trig-block');
7286
+ if (sched) sched.hidden = _depType !== 'scheduled';
7287
+ if (trig) trig.hidden = _depType !== 'triggered';
7288
+ }
7289
+
7290
+ // #693 R2/R5: populate the AI Agent select with only agents available on the
7291
+ // machine. Mirrors the existing employee-select behavior — unavailable agents are
7292
+ // shown disabled rather than as selectable options.
7293
+ function populateAgentSelect(sel, currentHostId) {
7294
+ sel.innerHTML = '';
7262
7295
  const employees = state.bootstrap?.employees ?? [];
7296
+ const list = employees.length ? employees : [{ id: 'claude', label: 'Claude', available: true }];
7297
+ for (const e of list) {
7298
+ const opt = document.createElement('option');
7299
+ opt.value = e.id;
7300
+ opt.textContent = e.label + (e.available === false ? ' (not installed)' : '');
7301
+ if (e.available === false) opt.disabled = true;
7302
+ sel.appendChild(opt);
7303
+ }
7304
+ const avail = list.filter((e) => e.available !== false);
7305
+ const pick = (currentHostId && list.some((e) => e.id === currentHostId && e.available !== false))
7306
+ ? currentHostId
7307
+ : (avail[0]?.id || list[0]?.id);
7308
+ if (pick) sel.value = pick;
7309
+ }
7310
+
7311
+ // #693 R2: open the single consolidated assignment modal. `dep` present = edit;
7312
+ // its .type locks the segmented control. New assignments default to Scheduled.
7313
+ function openDeploymentModal(dep) {
7314
+ const jobs = state.bootstrap?.jobs ?? [];
7263
7315
  const editing = dep != null;
7264
7316
  _editingDepId = editing ? dep.id : null;
7265
7317
 
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;
7318
+ const jobSel = document.getElementById('dep-job');
7319
+ jobSel.innerHTML = '';
7320
+ for (const j of jobs) {
7321
+ const opt = document.createElement('option');
7322
+ opt.value = j.id; opt.textContent = j.title;
7323
+ jobSel.appendChild(opt);
7324
+ }
7325
+ if (dep?.jobId) jobSel.value = dep.jobId;
7326
+
7327
+ populateAgentSelect(document.getElementById('dep-agent'), dep?.hostId || 'claude');
7328
+ document.getElementById('dep-label').value = dep?.label ?? '';
7329
+ document.getElementById('dep-instructions').value = dep?.instructions ?? '';
7330
+ const errEl = document.getElementById('dep-error');
7331
+ if (errEl) errEl.hidden = true;
7332
+ const inbound = document.getElementById('dep-wh-inbound-row');
7333
+ if (inbound) inbound.hidden = true;
7334
+
7335
+ setDepType(editing ? (dep.type === 'scheduled' ? 'scheduled' : 'triggered') : 'scheduled');
7336
+
7337
+ // Scheduled fields: detect preset and pre-fill time/day.
7338
+ const preset = dep?.cronExpr ? detectPreset(dep.cronExpr) : null;
7339
+ if (preset && preset !== 'custom' && dep?.cronExpr) {
7340
+ const parts = dep.cronExpr.trim().split(/\s+/);
7341
+ const m = parseInt(parts[0], 10) || 0;
7342
+ const h = parseInt(parts[1], 10) || 9;
7343
+ document.getElementById('dep-sch-time').value = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
7344
+ if (preset === 'weekly') document.getElementById('dep-sch-day').value = parts[4];
7345
+ document.getElementById('dep-sch-cron-preset').value = dep.cronExpr;
7346
+ } else if (preset === 'custom' && dep?.cronExpr) {
7347
+ document.getElementById('dep-sch-cron').value = dep.cronExpr;
7348
+ const cronPreview = document.getElementById('dep-sch-cron-preview');
7349
+ if (cronPreview) cronPreview.textContent = cronToHuman(dep.cronExpr);
7350
+ } else {
7351
+ document.getElementById('dep-sch-cron').value = '';
7352
+ document.getElementById('dep-sch-time').value = '09:00';
7275
7353
  }
7354
+ applySchPreset(preset || 'daily');
7276
7355
 
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; }
7356
+ document.getElementById('dep-assign-title').textContent = editing ? 'Edit assignment' : 'New assignment';
7357
+ document.getElementById('dep-save-btn').textContent = editing ? 'Save changes' : 'Add assignment';
7358
+ document.getElementById('dep-assignment-modal').hidden = false;
7357
7359
  }
7358
7360
 
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; }
7361
+ // #693 R2: one save path routes to the schedule or webhook endpoint by type.
7362
+ async function saveDeployment() {
7363
+ const errEl = document.getElementById('dep-error');
7364
+ const label = document.getElementById('dep-label').value.trim();
7365
+ const jobId = document.getElementById('dep-job').value;
7366
+ const hostId = document.getElementById('dep-agent').value;
7367
+ const instructions = document.getElementById('dep-instructions').value.trim();
7366
7368
  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; }
7369
+ if (!label) { errEl.textContent = 'Name is required.'; errEl.hidden = false; return; }
7370
+
7371
+ if (_depType === 'scheduled') {
7372
+ const isCustom = _activeSchPreset === 'custom';
7373
+ const cronExpr = isCustom
7374
+ ? document.getElementById('dep-sch-cron').value.trim()
7375
+ : (document.getElementById('dep-sch-cron-preset').value.trim() || '');
7376
+ if (!cronExpr) { errEl.textContent = 'A schedule is required.'; errEl.hidden = false; return; }
7377
+ const url = isEdit ? '/api/ai-hub/schedules/' + _editingDepId : '/api/ai-hub/schedules';
7378
+ try {
7379
+ // merge: keep master's projectPath association on the request body.
7380
+ const resp = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, cronExpr, instructions: instructions || undefined }) });
7381
+ if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
7382
+ _editingDepId = null;
7383
+ document.getElementById('dep-assignment-modal').hidden = true;
7384
+ await loadDeployments();
7385
+ } catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
7386
+ } else {
7387
+ const url = isEdit ? '/api/ai-hub/webhooks/' + _editingDepId : '/api/ai-hub/webhooks';
7388
+ try {
7389
+ const resp = await fetch(url, { method: isEdit ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ label, jobId, projectPath: state.projectPath || undefined, hostId, instructions: instructions || undefined }) });
7390
+ if (!resp.ok) { const e = await resp.json().catch(() => ({})); errEl.textContent = e.error || 'Failed to save assignment.'; errEl.hidden = false; return; }
7391
+ if (!isEdit) {
7392
+ const dep = await resp.json();
7393
+ document.getElementById('dep-wh-inbound-url').textContent = dep.inboundUrl;
7394
+ document.getElementById('dep-wh-inbound-row').hidden = false;
7395
+ } else {
7396
+ document.getElementById('dep-assignment-modal').hidden = true;
7397
+ }
7398
+ _editingDepId = null;
7399
+ await loadDeployments();
7400
+ } catch { errEl.textContent = 'Network error - is the Hub running?'; errEl.hidden = false; }
7401
+ }
7385
7402
  }
7386
7403
 
7387
7404
  // ---------------------------------------------------------------------------
@@ -7905,123 +7922,12 @@ function tfContextRow(key, label, placeholder, rerender) {
7905
7922
 
7906
7923
  return row;
7907
7924
  }
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
- }
7925
+ // #693 R1 (PR round 2): tfAreaRailJob removed with the Company/Manager job rails.
7954
7926
 
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
- }
7927
+ // #693 R1: tfProjectUpdatesSection was removed. The "Project Updates" tree
7928
+ // accordion is retired; its Run Project Onboarding and Sleep on learnings actions
7929
+ // now live inside the Brief and Learnings sections (see tfEnsureAccAction, called
7930
+ // from tfRenderProjectContextTop).
8025
7931
 
8026
7932
  // #594 R2: render a conversation panel (topline + messages + coach input) into an
8027
7933
  // area-level container so org/manager jobs are viewable inside Company/Manager tabs.
@@ -8156,13 +8062,9 @@ function tfRenderCompany() {
8156
8062
  infoBtn.textContent = '📋 Company Info';
8157
8063
  infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
8158
8064
  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);
8065
+ // #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
8066
+ // now run from the section they populate — Run Organization Onboarding in
8067
+ // "Context & rules", Synthesize company learnings in "Company learnings".
8166
8068
  }
8167
8069
  const profile = document.getElementById('company-profile');
8168
8070
  const learn = document.getElementById('company-learnings');
@@ -8218,6 +8120,11 @@ function tfRenderCompany() {
8218
8120
  if (learn) {
8219
8121
  tfRenderLearningsList(learn, 'org', 'org', 'Nothing here yet — org-wide learnings that apply on every project. Run organizational-learning-synthesis (in Company jobs, left), or add one with + Add learning.');
8220
8122
  }
8123
+ // #693 R1: lifecycle actions live in the sections they populate (mirrors the
8124
+ // Projects tab): onboarding in Context & rules, learnings synthesis in Company learnings.
8125
+ tfEnsureAccAction('company-ctx-acc', '🏢', 'Run Organization Onboarding', () => tfStartOrgOnboarding());
8126
+ // #693 R1 (PR round 2): Company's learnings job is organizational-learning-synthesis, not sleep-on-learnings.
8127
+ tfEnsureAccAction('company-learn-acc', '🧠', 'Synthesize company learnings', () => tfStartOrgLearningSynthesis());
8221
8128
  tfMaybeRenderOrgPushBanner();
8222
8129
  // Show the full coaching conversation panel when there is an active org conv;
8223
8130
  // otherwise show the info view (accordions). This uses the shared .page element
@@ -8307,12 +8214,8 @@ function tfRenderManager() {
8307
8214
  infoBtn.textContent = '📋 Manager Info';
8308
8215
  infoBtn.addEventListener('click', () => tfToggleAreaView('manager', 'info'));
8309
8216
  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);
8217
+ // #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
8218
+ // Manager agreements now runs from the "Context & rules" section it populates.
8316
8219
  }
8317
8220
  const profile = document.getElementById('manager-profile');
8318
8221
  const learn = document.getElementById('manager-learnings');
@@ -8375,6 +8278,12 @@ function tfRenderManager() {
8375
8278
  // the manager-coaching learnings the team has captured for you.
8376
8279
  tfRenderLearningsList(reverse, 'reverse', 'machine', 'Nothing here yet — as your team works with you, coaching on what you can improve on as a manager appears here. Add one with + Add learning.');
8377
8280
  }
8281
+ // #693 R1: lifecycle actions live in the sections they populate (mirrors the
8282
+ // Projects tab): manager agreements in Context & rules, learnings in Manager learnings.
8283
+ tfEnsureAccAction('manager-ctx-acc', '🤝', 'Set up manager agreements', () => tfStartManagerOnboarding());
8284
+ // #693 R1 (PR round 2): Manager has no learnings-synthesis job — its learnings
8285
+ // surface here from project sleep-on-learnings promoted to manager scope — so no
8286
+ // run-action button in the Manager learnings section.
8378
8287
  // Issue #540 R4-R7: render manager team pool on every manager-tab activation.
8379
8288
  renderManagerTeamPool();
8380
8289
  // Show the full coaching conversation panel when there is an active manager conv;
@@ -8883,6 +8792,47 @@ function tfCloseAccountMenu() {
8883
8792
  if (menu) menu.classList.remove('open');
8884
8793
  }
8885
8794
 
8795
+ // ---------------------------------------------------------------------------
8796
+ // Theme (#700): explicit light/dark with an in-app toggle. The <head> bootstrap
8797
+ // sets [data-theme] pre-paint (no flash) from localStorage['fraim-theme'] or the
8798
+ // OS preference; here we reflect it in the toggle control and persist changes.
8799
+ // Same-origin key sharing keeps /account and /analytics in sync on their next load.
8800
+ // ---------------------------------------------------------------------------
8801
+ function tfCurrentTheme() {
8802
+ return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
8803
+ }
8804
+ function tfSyncThemeToggle(theme) {
8805
+ const btn = document.getElementById('am-theme-toggle');
8806
+ if (!btn) return;
8807
+ const on = theme === 'dark';
8808
+ btn.setAttribute('aria-checked', on ? 'true' : 'false');
8809
+ const sub = document.getElementById('am-theme-sub');
8810
+ if (sub) sub.textContent = on ? 'On' : 'Off';
8811
+ const ico = document.getElementById('am-theme-ico');
8812
+ if (ico) ico.textContent = on ? '🌙' : '☀️';
8813
+ }
8814
+ function tfApplyTheme(theme, persist) {
8815
+ const t = theme === 'dark' ? 'dark' : 'light';
8816
+ document.documentElement.setAttribute('data-theme', t);
8817
+ if (persist) { try { localStorage.setItem('fraim-theme', t); } catch (e) {} }
8818
+ tfSyncThemeToggle(t);
8819
+ // Keep any same-origin embedded surface (e.g. an analytics iframe) in sync live.
8820
+ try {
8821
+ document.querySelectorAll('iframe').forEach((f) => {
8822
+ if (f.contentWindow) f.contentWindow.postMessage({ type: 'fraim-theme-change', theme: t }, location.origin);
8823
+ });
8824
+ } catch (e) {}
8825
+ }
8826
+ function tfToggleTheme(e) {
8827
+ if (e) e.stopPropagation();
8828
+ tfApplyTheme(tfCurrentTheme() === 'dark' ? 'light' : 'dark', true);
8829
+ }
8830
+ function tfWireThemeToggle() {
8831
+ const btn = document.getElementById('am-theme-toggle');
8832
+ if (btn) btn.addEventListener('click', tfToggleTheme);
8833
+ tfSyncThemeToggle(tfCurrentTheme());
8834
+ }
8835
+
8886
8836
  // Per-job config for the area onboarding pre-flight modal.
8887
8837
  const AREA_ONBOARD_CONFIG = {
8888
8838
  'organization-onboarding': {
@@ -9591,6 +9541,7 @@ function tfWireShell() {
9591
9541
  }
9592
9542
  const avatar = document.getElementById('avatar-btn');
9593
9543
  if (avatar) avatar.addEventListener('click', tfToggleAccountMenu);
9544
+ tfWireThemeToggle();
9594
9545
  const brainItem = document.getElementById('am-brain');
9595
9546
  if (brainItem) brainItem.addEventListener('click', () => { tfCloseAccountMenu(); tfShowArea('brain'); });
9596
9547
  const signout = document.getElementById('am-signout');