fraim-framework 2.0.185 → 2.0.187

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,7 +16,25 @@ 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']);
23
+ // Issue #708: project-independent conversation scope buckets (match server sentinels).
24
+ const MANAGER_CONV_KEY = '@manager';
25
+ const COMPANY_CONV_KEY = '@company';
26
+ // A conversation's scope: prefer the persisted `scope`, fall back to the legacy `invokedArea`.
27
+ function convScope(conv) {
28
+ const s = conv && (conv.scope || conv.invokedArea);
29
+ return (s === 'manager' || s === 'company') ? s : 'project';
30
+ }
31
+ // Which state.conversations bucket a conversation belongs to.
32
+ function convBucketKey(conv) {
33
+ const s = convScope(conv);
34
+ if (s === 'manager') return MANAGER_CONV_KEY;
35
+ if (s === 'company') return COMPANY_CONV_KEY;
36
+ return (conv && typeof conv.projectPath === 'string' && conv.projectPath) ? conv.projectPath : state.projectPath;
37
+ }
20
38
 
21
39
  const state = {
22
40
  bootstrap: null,
@@ -117,7 +135,11 @@ async function requestJson(url, options) {
117
135
  if (!response.ok) {
118
136
  const errVal = payload && payload.error;
119
137
  const message = (errVal && typeof errVal === 'object' ? errVal.message : errVal) || `Request failed (${response.status}).`;
120
- throw new Error(message);
138
+ const error = new Error(message);
139
+ // #719: callers branch on the HTTP status (e.g. DELETE 404 already-removed vs
140
+ // 409 sequencing error) — carry it on the thrown error.
141
+ error.status = response.status;
142
+ throw error;
121
143
  }
122
144
  return payload;
123
145
  }
@@ -444,16 +466,30 @@ function projectConversationPayload() {
444
466
  }
445
467
 
446
468
  function scheduleConversationDiskPersist() {
447
- if (!state.projectPath) return;
469
+ // Issue #708: no early-return on missing projectPath — manager/company scope buckets
470
+ // must persist even when no project is selected (their whole point is project-independence).
448
471
  if (state.conversationPersistTimer) window.clearTimeout(state.conversationPersistTimer);
449
472
  state.conversationPersistTimer = window.setTimeout(async () => {
450
473
  state.conversationPersistTimer = null;
451
474
  try {
452
- await requestJson('/api/ai-hub/conversations', {
453
- method: 'PUT',
454
- headers: { 'Content-Type': 'application/json' },
455
- body: JSON.stringify(projectConversationPayload()),
456
- });
475
+ if (state.projectPath) {
476
+ await requestJson('/api/ai-hub/conversations', {
477
+ method: 'PUT',
478
+ headers: { 'Content-Type': 'application/json' },
479
+ body: JSON.stringify(projectConversationPayload()),
480
+ });
481
+ }
482
+ // Issue #708: persist scope buckets (manager/company) to their project-independent
483
+ // home so their runs survive project switches/removal and reload correctly.
484
+ for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
485
+ const bucket = state.conversations[key];
486
+ if (!Array.isArray(bucket)) continue;
487
+ await requestJson('/api/ai-hub/conversations', {
488
+ method: 'PUT',
489
+ headers: { 'Content-Type': 'application/json' },
490
+ body: JSON.stringify({ scope, activeId: null, conversations: bucket }),
491
+ });
492
+ }
457
493
  state.conversationDiskAvailable = true;
458
494
  } catch (error) {
459
495
  state.conversationDiskAvailable = false;
@@ -468,7 +504,28 @@ function persistConversations(options) {
468
504
  }
469
505
 
470
506
  async function hydrateConversationsFromServer() {
471
- if (!state.projectPath) return;
507
+ // Issue #708: load the project-independent scope buckets (manager/company) first, so
508
+ // manager runs are present regardless of whether a project is selected.
509
+ try {
510
+ for (const [scope, key] of [['manager', MANAGER_CONV_KEY], ['company', COMPANY_CONV_KEY]]) {
511
+ const scopePayload = await requestJson(`/api/ai-hub/conversations?scope=${scope}`);
512
+ const scopeConvs = Array.isArray(scopePayload.conversations) ? scopePayload.conversations : [];
513
+ for (const conv of scopeConvs) normalizeGeminiConversationMessages(conv);
514
+ // Only materialize a scope bucket when it has content (or already exists). Creating
515
+ // empty @manager/@company buckets on every project hydrate pollutes state.conversations
516
+ // and, because they land ahead of the project bucket, breaks the "project bucket is the
517
+ // primary key" invariant that project-scoped rendering relies on (#550 Watercooler).
518
+ if (scopeConvs.length || Array.isArray(state.conversations[key])) {
519
+ state.conversations[key] = scopeConvs;
520
+ }
521
+ }
522
+ } catch (error) {
523
+ console.warn('Could not hydrate manager/company conversations:', error);
524
+ }
525
+ if (!state.projectPath) {
526
+ if (typeof tfRenderManager === 'function' && typeof tf !== 'undefined' && tf.area === 'manager') tfRenderManager();
527
+ return;
528
+ }
472
529
  try {
473
530
  const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
474
531
  const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
@@ -509,9 +566,10 @@ async function bgRefreshConversations() {
509
566
  existing.push(conv);
510
567
  changed = true;
511
568
  } else {
512
- // Update status/messages on known conversations (e.g. running completed)
569
+ // Update known conversations when the server projection changes. This
570
+ // includes job/persona recognition for running ad-hoc jobs.
513
571
  const idx = existing.findIndex((c) => c.id === conv.id);
514
- if (idx !== -1 && existing[idx].status !== conv.status) {
572
+ if (idx !== -1 && conversationProjectionChanged(existing[idx], conv)) {
515
573
  existing[idx] = conv;
516
574
  changed = true;
517
575
  }
@@ -533,7 +591,7 @@ function stopBgConvPoll() {
533
591
  if (_bgConvPollHandle) { window.clearInterval(_bgConvPollHandle); _bgConvPollHandle = null; }
534
592
  }
535
593
 
536
- function needsConversationRefresh() {
594
+ function needsDelegatedConversationRefresh() {
537
595
  const active = activeConversation();
538
596
  if (!active) return false;
539
597
  if (isManagedDelegationChild(active) && active.status === 'running') return true;
@@ -548,12 +606,49 @@ function needsConversationRefresh() {
548
606
  });
549
607
  }
550
608
 
609
+ function needsConversationRefresh() {
610
+ return runningProjectConversations().length > 0 || needsDelegatedConversationRefresh();
611
+ }
612
+
613
+ async function refreshRunningConversations() {
614
+ const running = runningProjectConversations();
615
+ if (!running.length) return false;
616
+ let changed = false;
617
+ for (const conv of running) {
618
+ try {
619
+ const run = await requestJson(`/api/ai-hub/runs/${encodeURIComponent(conv.runId)}`);
620
+ foldRunIntoConversation(conv, run);
621
+ upsertConversation(conv);
622
+ changed = true;
623
+ } catch (_) {
624
+ // The run may have just completed and left the in-memory registry before
625
+ // the conversation store hydrated. The next conversation refresh catches it.
626
+ }
627
+ }
628
+ if (changed) {
629
+ renderRail();
630
+ renderActive();
631
+ if (tf.area === 'company') tfRenderCompany();
632
+ else if (tf.area === 'manager') tfRenderManager();
633
+ }
634
+ return changed;
635
+ }
636
+
637
+ async function refreshConversationProjections() {
638
+ await refreshRunningConversations();
639
+ if (needsDelegatedConversationRefresh()) {
640
+ await hydrateConversationsFromServer();
641
+ } else {
642
+ syncConversationRefreshPolling();
643
+ }
644
+ }
645
+
551
646
  function syncConversationRefreshPolling() {
552
647
  const shouldPoll = needsConversationRefresh();
553
648
  if (shouldPoll && !state.conversationRefreshHandle) {
554
649
  state.conversationRefreshHandle = window.setInterval(() => {
555
- hydrateConversationsFromServer().catch((error) =>
556
- console.warn('Could not refresh delegated conversations:', error));
650
+ refreshConversationProjections().catch((error) =>
651
+ console.warn('Could not refresh conversations:', error));
557
652
  }, 1500);
558
653
  } else if (!shouldPoll && state.conversationRefreshHandle) {
559
654
  window.clearInterval(state.conversationRefreshHandle);
@@ -578,6 +673,32 @@ function projectConversations() {
578
673
  return state.conversations[key] || [];
579
674
  }
580
675
 
676
+ function conversationProjectionChanged(existing, incoming) {
677
+ if (!existing || !incoming) return true;
678
+ const fields = [
679
+ 'jobId',
680
+ 'jobTitle',
681
+ 'personaKey',
682
+ 'runId',
683
+ 'sessionId',
684
+ 'status',
685
+ 'managedReviewStatus',
686
+ 'compareRunId',
687
+ 'sourceTrigger',
688
+ ];
689
+ if (fields.some((field) => (existing[field] ?? null) !== (incoming[field] ?? null))) return true;
690
+ const existingMessages = Array.isArray(existing.messages) ? existing.messages.length : 0;
691
+ const incomingMessages = Array.isArray(incoming.messages) ? incoming.messages.length : 0;
692
+ if (existingMessages !== incomingMessages) return true;
693
+ const existingEvents = Array.isArray(existing.events) ? existing.events.length : 0;
694
+ const incomingEvents = Array.isArray(incoming.events) ? incoming.events.length : 0;
695
+ return existingEvents !== incomingEvents;
696
+ }
697
+
698
+ function runningProjectConversations() {
699
+ return projectConversations().filter((conv) => conv && conv.status === 'running' && conv.runId);
700
+ }
701
+
581
702
  function setProjectConversations(list) {
582
703
  const key = state.projectPath || '';
583
704
  state.conversations[key] = list;
@@ -743,12 +864,15 @@ function syncConversationPanels(conv, switchedConv) {
743
864
  }
744
865
 
745
866
  function upsertConversation(conv) {
746
- const list = projectConversations().slice();
867
+ // Issue #708: route to the conversation's scope bucket (manager/company → sentinel
868
+ // key; project → its project bucket) so manager runs have a project-independent home.
869
+ const key = convBucketKey(conv);
870
+ const list = (state.conversations[key] || []).slice();
747
871
  const idx = list.findIndex((c) => c.id === conv.id);
748
872
  if (idx >= 0) list[idx] = conv;
749
873
  else list.unshift(conv);
750
- setProjectConversations(list);
751
- persistConversations();
874
+ state.conversations[key] = list;
875
+ persistConversations({ scope: convScope(conv) });
752
876
  }
753
877
 
754
878
  function newConversationId() {
@@ -854,7 +978,9 @@ function renderRail() {
854
978
  const list = allProjectConversations.filter((conv) =>
855
979
  (!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey) &&
856
980
  !isManagedDelegationChild(conv) &&
857
- !AREA_SCOPED_JOBS.has(conv.jobId)
981
+ !AREA_SCOPED_JOBS.has(conv.jobId) &&
982
+ // #702/#708: manager/company-scoped runs surface on those tabs, not in a project.
983
+ convScope(conv) === 'project'
858
984
  );
859
985
 
860
986
  // Issue #550: Two-path routing for ad-hoc (freeform) conversations.
@@ -2034,11 +2160,29 @@ function renderTracker(conv) {
2034
2160
 
2035
2161
  const stages = Array.isArray(conv.run.stages) ? conv.run.stages : [];
2036
2162
  if (stages.length === 0) {
2037
- tracker.hidden = true;
2163
+ // Issue #711 — adhoc runs (jobId '__freeform__') declare no phases, so
2164
+ // there is no pizza tracker. We still surface the run totals (tokens,
2165
+ // cost, duration) that the server derives for every run. Keep the tracker
2166
+ // container visible in "totals-only" mode so renderTotals has a place to
2167
+ // render; otherwise hide the whole section.
2168
+ if (conv.run.totals) {
2169
+ tracker.hidden = false;
2170
+ tracker.classList.add('tracker--totals-only');
2171
+ tracker.classList.remove('tracker-compact');
2172
+ const rowsHost = els['tracker-rows'];
2173
+ if (rowsHost) rowsHost.innerHTML = '';
2174
+ const activeLabel = tracker.querySelector('.tracker-active-label');
2175
+ if (activeLabel) activeLabel.textContent = '';
2176
+ const note = els['tracker-note'];
2177
+ if (note) { note.hidden = true; note.textContent = ''; }
2178
+ } else {
2179
+ tracker.hidden = true;
2180
+ }
2038
2181
  renderedTrackerKey = null;
2039
2182
  return;
2040
2183
  }
2041
2184
 
2185
+ tracker.classList.remove('tracker--totals-only');
2042
2186
  tracker.hidden = false;
2043
2187
  tracker.dataset.stageCount = String(stages.length);
2044
2188
  const rowCapacity = trackerRowCapacity(tracker, stages);
@@ -4753,7 +4897,7 @@ function deriveTitle(jobTitle, instructions) {
4753
4897
  // The browser sends raw manager instructions. AI Hub normalizes assigned
4754
4898
  // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
4755
4899
  // share one start/continue contract.
4756
- async function startRun(job, instructions, employeeId, preassignedConvId) {
4900
+ async function startRun(job, instructions, employeeId, preassignedConvId, invokedArea) {
4757
4901
  const isFreeform = job.id === '__freeform__';
4758
4902
  // In task-pane/extension mode: get a fresh selection snapshot and prepend
4759
4903
  // document context to the instructions so the agent knows what the user is
@@ -4792,6 +4936,16 @@ async function startRun(job, instructions, employeeId, preassignedConvId) {
4792
4936
  compareRun: null,
4793
4937
  // Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
4794
4938
  wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
4939
+ // Issue #702: remember which area this run was launched from so its run surfaces
4940
+ // where it was invoked (project-invoked → that project; manager-invoked → Manager tab).
4941
+ // Defaults to the current area.
4942
+ invokedArea: invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects',
4943
+ // Issue #708: persisted scope drives which store bucket the run lives in
4944
+ // (manager/company get a project-independent home). Derived from invocation area.
4945
+ scope: (function () {
4946
+ const a = invokedArea || (typeof tf !== 'undefined' && tf.area) || 'projects';
4947
+ return (a === 'manager' || a === 'company') ? a : 'project';
4948
+ })(),
4795
4949
  };
4796
4950
  upsertConversation(conv);
4797
4951
  state.activeId = conv.id;
@@ -4958,6 +5112,18 @@ async function continueRun(text) {
4958
5112
  }
4959
5113
 
4960
5114
  function foldRunIntoConversation(conv, run) {
5115
+ // Issue #710: server run identity is canonical. The browser may have
5116
+ // created this conversation as provisional __freeform__ state, but every
5117
+ // server snapshot can promote it to a real catalog job.
5118
+ if (run.jobId) {
5119
+ conv.jobId = run.jobId;
5120
+ }
5121
+ if (run.jobTitle !== undefined && run.jobTitle !== null) {
5122
+ conv.jobTitle = run.jobTitle;
5123
+ }
5124
+ if (run.personaKey !== undefined) {
5125
+ conv.personaKey = run.personaKey;
5126
+ }
4961
5127
  // Issue #347: keep the latest server snapshot under conv.run so the
4962
5128
  // tracker / totals renderers can read it without re-doing work. Stages,
4963
5129
  // currentPhase, phaseHistory, and totals are all server-derived per
@@ -4967,6 +5133,7 @@ function foldRunIntoConversation(conv, run) {
4967
5133
  currentPhase: run.currentPhase || null,
4968
5134
  phaseHistory: run.phaseHistory || [],
4969
5135
  totals: run.totals || null,
5136
+ runDiscriminant: run.runDiscriminant || null,
4970
5137
  };
4971
5138
  // Replace the conversation's events with the run's events (single source of truth on the server).
4972
5139
  // If this conversation was restarted on a different agent, prepend the prior run's events so
@@ -4984,10 +5151,6 @@ function foldRunIntoConversation(conv, run) {
4984
5151
  conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
4985
5152
  // Track session for resumption.
4986
5153
  if (run.sessionId) conv.sessionId = run.sessionId;
4987
- // R4: persist the persona key from the server-side run record.
4988
- if (run.personaKey !== undefined && conv.personaKey == null) {
4989
- conv.personaKey = run.personaKey;
4990
- }
4991
5154
  const runHandoff = normalizeReviewHandoff(run.reviewHandoff);
4992
5155
  if (runHandoff) {
4993
5156
  conv.reviewHandoff = runHandoff;
@@ -6233,11 +6396,18 @@ function tfConvForAssignment(assignment) {
6233
6396
  function tfConversationsForJob(jobId, opts) {
6234
6397
  const out = [];
6235
6398
  const projectPath = opts && opts.projectPath;
6399
+ // #702: optional invokedArea filter so the Manager rail shows only manager-invoked
6400
+ // runs of a persona job (project-invoked runs of the same job stay in the project).
6401
+ const invokedArea = opts && opts.invokedArea;
6236
6402
  const lists = projectPath
6237
6403
  ? [state.conversations[projectPath] || []]
6238
6404
  : Object.values(state.conversations || {});
6239
6405
  for (const list of lists) {
6240
- for (const c of (list || [])) if (c && c.jobId === jobId) out.push(c);
6406
+ for (const c of (list || [])) {
6407
+ if (!c || c.jobId !== jobId) continue;
6408
+ if (invokedArea && (c.invokedArea || 'projects') !== invokedArea) continue;
6409
+ out.push(c);
6410
+ }
6241
6411
  }
6242
6412
  out.sort((a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0));
6243
6413
  return out;
@@ -6497,6 +6667,9 @@ function tfRenderOverview() {
6497
6667
  }
6498
6668
  card.appendChild(team);
6499
6669
  card.addEventListener('click', () => tfSelectProjectView('workspace', proj.id));
6670
+ // #719 R1/R1b: hover/focus-revealed remove control — Overview cards only, and
6671
+ // only while another project remains (the Hub always keeps at least one).
6672
+ if (tf.projects.length > 1) tfAttachProjectRemove(card, proj);
6500
6673
  grid.appendChild(card);
6501
6674
  }
6502
6675
  const addCard = document.createElement('div');
@@ -6509,6 +6682,167 @@ function tfRenderOverview() {
6509
6682
  tfSwitchOverviewView(storedView || (tf.projects.length >= 3 ? 'kanban' : 'cards'));
6510
6683
  }
6511
6684
 
6685
+ // ---------------------------------------------------------------------------
6686
+ // Issue #719: remove a project from the Hub (Overview card 🗑 + inline confirm)
6687
+ // ---------------------------------------------------------------------------
6688
+
6689
+ // The card-positioned sibling of tfAttachRunDelete: a hover/focus-revealed 🗑 in
6690
+ // the card's top-right. Keyboard-operable (tabindex + Enter/Space) per R11.
6691
+ function tfAttachProjectRemove(cardEl, proj) {
6692
+ if (!cardEl || !proj) return;
6693
+ const del = document.createElement('span');
6694
+ del.className = 'proj-del';
6695
+ del.textContent = '🗑';
6696
+ del.title = 'Remove this project from the Hub';
6697
+ del.setAttribute('role', 'button');
6698
+ del.setAttribute('aria-label', 'Remove project from Hub');
6699
+ del.tabIndex = 0;
6700
+ del.addEventListener('click', (e) => {
6701
+ e.preventDefault();
6702
+ e.stopPropagation();
6703
+ tfConfirmProjectRemove(proj, cardEl);
6704
+ });
6705
+ del.addEventListener('keydown', (e) => {
6706
+ if (e.key !== 'Enter' && e.key !== ' ') return;
6707
+ e.preventDefault();
6708
+ e.stopPropagation();
6709
+ tfConfirmProjectRemove(proj, cardEl);
6710
+ });
6711
+ cardEl.appendChild(del);
6712
+ }
6713
+
6714
+ function tfCloseProjectConfirm() {
6715
+ document.querySelectorAll('.del-confirm').forEach((n) => n.remove());
6716
+ document.removeEventListener('keydown', tfProjectConfirmEscape, true);
6717
+ }
6718
+ function tfProjectConfirmEscape(e) {
6719
+ if (e.key === 'Escape') tfCloseProjectConfirm();
6720
+ }
6721
+
6722
+ // Inline confirm anchored inside the card — the run-delete idiom (tfConfirmRunDelete)
6723
+ // with the project scope-of-loss copy. Loads run/schedule state from the server so
6724
+ // the copy can state the schedule count (R6) and block removal while runs are in
6725
+ // progress (R7). Nothing is deleted until the confirm button is clicked (C1).
6726
+ function tfConfirmProjectRemove(proj, cardEl) {
6727
+ tfCloseProjectConfirm();
6728
+ const box = document.createElement('div');
6729
+ box.className = 'del-confirm';
6730
+ // Any click inside the confirm must not bubble into the card's open-workspace handler.
6731
+ box.addEventListener('click', (e) => e.stopPropagation());
6732
+ const msg = document.createElement('div');
6733
+ msg.textContent = 'Remove "' + (proj.name || 'this project') + '" from the Hub? Its run history, job assignments, and Hub settings are forgotten. The project’s files on disk are untouched.';
6734
+ const detail = document.createElement('div');
6735
+ detail.className = 'dc-detail';
6736
+ detail.textContent = 'Checking for runs and schedules…';
6737
+ const row = document.createElement('div'); row.className = 'dc-row';
6738
+ const del = document.createElement('button'); del.className = 'dc-del'; del.type = 'button'; del.textContent = 'Remove project';
6739
+ del.disabled = true; // enabled once the run/schedule check resolves (C1/R7)
6740
+ del.addEventListener('click', (e) => {
6741
+ e.stopPropagation();
6742
+ if (del.disabled) return;
6743
+ tfCloseProjectConfirm();
6744
+ tfRemoveProject(proj);
6745
+ });
6746
+ const cancel = document.createElement('button'); cancel.className = 'dc-cancel'; cancel.type = 'button'; cancel.textContent = 'Cancel';
6747
+ cancel.addEventListener('click', (e) => { e.stopPropagation(); tfCloseProjectConfirm(); });
6748
+ row.appendChild(del); row.appendChild(cancel);
6749
+ box.appendChild(msg); box.appendChild(detail); box.appendChild(row);
6750
+ cardEl.appendChild(box);
6751
+ document.addEventListener('keydown', tfProjectConfirmEscape, true);
6752
+
6753
+ const encodedPath = encodeURIComponent(proj.folderPath || '');
6754
+ Promise.all([
6755
+ requestJson('/api/ai-hub/conversations?projectPath=' + encodedPath).catch(() => null),
6756
+ requestJson('/api/ai-hub/schedules?projectPath=' + encodedPath).catch(() => []),
6757
+ requestJson('/api/ai-hub/webhooks?projectPath=' + encodedPath).catch(() => []),
6758
+ ]).then(([convPayload, schedules, webhooks]) => {
6759
+ if (!box.isConnected) return;
6760
+ const serverConvs = (convPayload && Array.isArray(convPayload.conversations)) ? convPayload.conversations : [];
6761
+ const cachedConvs = state.conversations[proj.folderPath] || [];
6762
+ const running = [...serverConvs, ...cachedConvs].some((c) => c && c.status === 'running');
6763
+ if (running) {
6764
+ // R7: never forget in-flight work — block until the runs finish or are stopped.
6765
+ detail.textContent = 'Runs are in progress in this project. Stop them or wait for them to finish before removing.';
6766
+ del.disabled = true;
6767
+ return;
6768
+ }
6769
+ const scheduleCount = Array.isArray(schedules) ? schedules.length : 0;
6770
+ const webhookCount = Array.isArray(webhooks) ? webhooks.length : 0;
6771
+ const parts = [];
6772
+ if (scheduleCount) parts.push(scheduleCount + ' scheduled deployment' + (scheduleCount === 1 ? '' : 's'));
6773
+ if (webhookCount) parts.push(webhookCount + ' webhook' + (webhookCount === 1 ? '' : 's'));
6774
+ detail.textContent = parts.length ? (parts.join(' and ') + ' will also be removed.') : '';
6775
+ if (!parts.length) detail.remove();
6776
+ del.disabled = false;
6777
+ });
6778
+ }
6779
+
6780
+ // R10: adopt a server projects list as the authoritative tf.projects — a REPLACE,
6781
+ // not a tfMergeProjects union, so a later tfPersistProjects PUT cannot re-add a
6782
+ // removed project from stale in-memory state.
6783
+ function tfAdoptServerProjects(projects) {
6784
+ const normalized = (Array.isArray(projects) ? projects : [])
6785
+ .map((project) => tfNormalizeProject(project))
6786
+ .filter(Boolean);
6787
+ tf.projects = tfApplyUniqueProjectIds(normalized);
6788
+ }
6789
+
6790
+ async function tfRemoveProject(proj) {
6791
+ const removedCanonical = tfCanonicalProjectPath(proj.folderPath || '');
6792
+ // R5/D4: never delete the workspace we are standing in — the server re-injects
6793
+ // the current projectPath on every load, so switch to another project first.
6794
+ if (removedCanonical === tfCanonicalProjectPath(state.projectPath)) {
6795
+ const next = tf.projects.find((p) => p.id !== proj.id && tfCanonicalProjectPath(p.folderPath || '') !== removedCanonical);
6796
+ if (!next) { showStatus('Cannot remove the only project in the Hub.', true); return; }
6797
+ try {
6798
+ await tfSwitchProjectFolder(next.folderPath);
6799
+ tf.activeProjectId = next.id;
6800
+ } catch (e) {
6801
+ showStatus('Could not switch to another project first; nothing was removed.', true);
6802
+ return;
6803
+ }
6804
+ }
6805
+
6806
+ let payload;
6807
+ try {
6808
+ payload = await requestJson(
6809
+ '/api/ai-hub/projects/' + encodeURIComponent(proj.id) + '?projectPath=' + encodeURIComponent(state.projectPath),
6810
+ { method: 'DELETE' },
6811
+ );
6812
+ } catch (error) {
6813
+ if (error && error.status === 404) {
6814
+ // Already removed elsewhere (double-click / second tab): treat as success
6815
+ // for rendering and adopt the server's current list.
6816
+ try { payload = { projects: (await requestJson('/api/ai-hub/projects?projectPath=' + encodeURIComponent(state.projectPath))).projects }; }
6817
+ catch { payload = { projects: tf.projects.filter((p) => p.id !== proj.id) }; }
6818
+ showStatus('That project was already removed.', false);
6819
+ } else if (error && error.status === 409) {
6820
+ showStatus('Switch to another project first, then remove this one.', true);
6821
+ return;
6822
+ } else {
6823
+ // Server unreachable or rejected: no optimistic client mutation.
6824
+ showStatus('Could not remove the project: ' + (error && error.message ? error.message : 'request failed.'), true);
6825
+ return;
6826
+ }
6827
+ }
6828
+
6829
+ // R3/R10: drop every client layer, adopting the server list as authoritative.
6830
+ tfAdoptServerProjects(payload && payload.projects);
6831
+ delete tf.assignments[proj.id];
6832
+ tfPersistAssignments();
6833
+ for (const key of Object.keys(state.conversations || {})) {
6834
+ if (tfCanonicalProjectPath(key) === removedCanonical) delete state.conversations[key];
6835
+ }
6836
+ if (!tf.projects.some((p) => p.id === tf.activeProjectId)) {
6837
+ tf.activeProjectId = tf.projects[0] ? tf.projects[0].id : null;
6838
+ }
6839
+ tfPersistProjects();
6840
+ tfRenderProjectTabs();
6841
+ tfRenderOverview();
6842
+ if (typeof renderRail === 'function') renderRail();
6843
+ showStatus('"' + (proj.name || 'Project') + '" was removed from the Hub. Its files on disk are untouched.', false);
6844
+ }
6845
+
6512
6846
  // ---------------------------------------------------------------------------
6513
6847
  // Issue #671: Kanban view for Projects Overview
6514
6848
  // ---------------------------------------------------------------------------
@@ -8009,10 +8343,12 @@ function tfActiveOrgConv() {
8009
8343
  );
8010
8344
  }
8011
8345
 
8012
- // #594 R2: return the org-scoped manager-agreements conversation to show in Manager area.
8346
+ // #594 R2: return the manager-scoped conversation to show in Manager area.
8347
+ // #702: manager-tab runs are those invoked from the manager area (any persona job),
8348
+ // plus the area-only manager-agreements job (older runs predate invokedArea). An active
8349
+ // manager-invoked run surfaces in the Manager conversation host (Coach panel included).
8013
8350
  function tfActiveMgrConv() {
8014
- const mgrJobs = new Set(['manager-agreements']);
8015
- const convs = Object.values(state.conversations || {}).flat().filter((c) => mgrJobs.has(c.jobId));
8351
+ const convs = Object.values(state.conversations || {}).flat().filter((c) => c && (convScope(c) === 'manager' || c.jobId === 'manager-agreements'));
8016
8352
  return (
8017
8353
  convs.find((c) => c.status === 'running') ||
8018
8354
  convs.find((c) => c.status === 'waiting') ||
@@ -8021,6 +8357,93 @@ function tfActiveMgrConv() {
8021
8357
  );
8022
8358
  }
8023
8359
 
8360
+ // #702: is a persona hired (has a company seat) so its manager-tab jobs should show?
8361
+ // (MANAGER_ASHLEY_JOBS is defined once at the top of this file as the single source of truth.)
8362
+ function tfIsPersonaHired(personaKey) {
8363
+ const personas = (state.bootstrap && state.bootstrap.personas) || [];
8364
+ const p = personas.find((x) => x && x.key === personaKey);
8365
+ return !!(p && (p.status === 'hired' || (p.seatCount || 0) > 0));
8366
+ }
8367
+
8368
+ // #702: launch a persona job from the Manager tab. Reuses the area pre-flight modal +
8369
+ // run routing (targetArea 'manager'), so the run opens in #manager-conv-host with the
8370
+ // Coach panel and is tagged invokedArea='manager' — exactly like manager-agreements.
8371
+ function tfStartManagerPersonaJob(jobId) {
8372
+ tfOpenAreaOnboardModal(jobId, 'Run ' + jobId + ' across the projects I manage and my calendar.', 'manager');
8373
+ }
8374
+
8375
+ // #702: run-item button matching the Projects rail (conv-item), for the manager
8376
+ // employee group. Mirrors the core of renderRail's buildRunButton so runs look
8377
+ // identical on both tabs.
8378
+ function tfBuildManagerRunItem(conv) {
8379
+ const btn = document.createElement('button');
8380
+ btn.className = 'conv-item';
8381
+ btn.type = 'button';
8382
+ btn.dataset.conv = conv.id;
8383
+ const body = document.createElement('span'); body.className = 'conv-body';
8384
+ const title = document.createElement('span'); title.className = 'conv-title';
8385
+ title.textContent = conv.title || conv.jobTitle || conv.jobId || 'Run';
8386
+ body.appendChild(title); btn.appendChild(body);
8387
+ const dotClass = conversationStateDotClass(conv);
8388
+ const dot = document.createElement('span'); dot.className = 'state-dot conv-state-dot dot-' + dotClass;
8389
+ if (typeof tfDotTitle === 'function') dot.title = tfDotTitle(dotClass);
8390
+ btn.appendChild(dot);
8391
+ const status = document.createElement('span'); status.className = 'conv-status';
8392
+ status.textContent = conversationStateLabel(conv);
8393
+ status.classList.add(conversationUiState(conv));
8394
+ btn.appendChild(status);
8395
+ tfAttachRunDelete(btn, conv, { tree: false });
8396
+ btn.addEventListener('click', () => switchToConversation(conv.id));
8397
+ return btn;
8398
+ }
8399
+
8400
+ // #702: render a hired persona as an employee group on the Manager rail — the SAME
8401
+ // visual as the Projects rail employee groups (avatar + name + role + count + "+" that
8402
+ // opens the job palette), with the persona's MANAGER-invoked runs listed under it.
8403
+ // Keeps a persona looking like an employee, consistent across tabs.
8404
+ function tfRenderManagerPersonaGroup(rail, persona) {
8405
+ const sample = { personaKey: persona.key };
8406
+ const runs = Object.values(state.conversations || {}).flat()
8407
+ .filter((c) => c && c.personaKey === persona.key && convScope(c) === 'manager')
8408
+ .sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0));
8409
+ const details = document.createElement('details');
8410
+ details.className = 'conv-employee-group';
8411
+ details.open = true;
8412
+ const summary = document.createElement('summary');
8413
+ summary.className = 'conv-employee-tab';
8414
+ const avatar = buildConversationEmployeeAvatar(sample, 'conv-employee-avatar');
8415
+ const copy = document.createElement('span'); copy.className = 'conv-employee-tab-copy';
8416
+ const label = document.createElement('strong'); label.className = 'conv-employee-tab-label';
8417
+ label.textContent = persona.displayName || persona.key;
8418
+ const detail = document.createElement('small'); detail.className = 'conv-employee-tab-detail';
8419
+ detail.textContent = persona.role || 'AI Employee';
8420
+ copy.appendChild(label); copy.appendChild(detail);
8421
+ const addBtn = document.createElement('button'); addBtn.type = 'button'; addBtn.className = 'conv-employee-add';
8422
+ addBtn.textContent = '+';
8423
+ addBtn.title = 'Launch a job for ' + (persona.displayName || persona.key);
8424
+ addBtn.setAttribute('aria-label', 'Launch a job for ' + (persona.displayName || persona.key));
8425
+ addBtn.addEventListener('click', (e) => {
8426
+ e.preventDefault(); e.stopPropagation();
8427
+ // Launch from the Manager tab: palette prefiltered to this persona. Runs started
8428
+ // while the manager tab is active default to invokedArea='manager'.
8429
+ openPalette({ employeeId: (state.bootstrap && state.bootstrap.preferences && state.bootstrap.preferences.employeeId) || 'claude', prefixSearch: '/' + persona.key });
8430
+ });
8431
+ const count = document.createElement('span'); count.className = 'conv-employee-tab-count';
8432
+ count.textContent = String(runs.length);
8433
+ summary.appendChild(avatar); summary.appendChild(copy); summary.appendChild(addBtn); summary.appendChild(count);
8434
+ details.appendChild(summary);
8435
+ const listDiv = document.createElement('div'); listDiv.className = 'conv-employee-list';
8436
+ if (!runs.length) {
8437
+ const hint = document.createElement('div'); hint.className = 'eh-empty';
8438
+ hint.textContent = 'No runs yet — + to launch a job.';
8439
+ listDiv.appendChild(hint);
8440
+ } else {
8441
+ for (const c of runs) listDiv.appendChild(tfBuildManagerRunItem(c));
8442
+ }
8443
+ details.appendChild(listDiv);
8444
+ rail.appendChild(details);
8445
+ }
8446
+
8024
8447
  // Move the shared .page conversation panel into the specified area's workspace-conv host.
8025
8448
  // All .workspace-conv CSS rules (header/rail hidden, conv fills space) apply automatically
8026
8449
  // because the area-conv-host elements carry the workspace-conv class.
@@ -8216,6 +8639,30 @@ function tfRenderManager() {
8216
8639
  rail.appendChild(infoBtn);
8217
8640
  // #693 R1 (PR round 2): the "Manager jobs" launcher list is retired.
8218
8641
  // Manager agreements now runs from the "Context & rules" section it populates.
8642
+ // Issue #702: for each hired persona with catalog jobs, render an EMPLOYEE GROUP on
8643
+ // the Manager rail — the same avatar + name + runs + "+" presentation as the Projects
8644
+ // rail (tfRenderManagerPersonaGroup reuses conv-employee-group), so a persona looks
8645
+ // identical on both tabs. Data-driven from bootstrap job metadata (job.requiredPersonaKey),
8646
+ // not a hardcoded list. Runs launched here are tagged invokedArea='manager'; the same
8647
+ // employee's job launched inside a project stays in that project.
8648
+ const mgrPersonas = (state.bootstrap && state.bootstrap.personas) || [];
8649
+ const catalogJobs = (state.bootstrap && state.bootstrap.jobs) || [];
8650
+ let anyMgrEmployee = false;
8651
+ for (const persona of mgrPersonas) {
8652
+ if (!tfIsPersonaHired(persona.key)) continue;
8653
+ if (!catalogJobs.some((j) => j && j.requiredPersonaKey === persona.key)) continue;
8654
+ if (!anyMgrEmployee) {
8655
+ const head = document.createElement('div'); head.className = 'area-rail-head'; head.textContent = 'Your employees';
8656
+ rail.appendChild(head);
8657
+ anyMgrEmployee = true;
8658
+ }
8659
+ tfRenderManagerPersonaGroup(rail, persona);
8660
+ }
8661
+ if (anyMgrEmployee) {
8662
+ const pNote = document.createElement('div'); pNote.className = 'area-rail-note';
8663
+ 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.";
8664
+ rail.appendChild(pNote);
8665
+ }
8219
8666
  }
8220
8667
  const profile = document.getElementById('manager-profile');
8221
8668
  const learn = document.getElementById('manager-learnings');
@@ -8847,6 +9294,15 @@ const AREA_ONBOARD_CONFIG = {
8847
9294
  title: 'Manager agreements',
8848
9295
  desc: 'FRAIM will learn how you work and write manager_context.md and manager_rules.md. Add any specific direction below.',
8849
9296
  },
9297
+ // Issue #702: Ashley's manager-tab jobs.
9298
+ 'executive-assistant': {
9299
+ title: 'Ashley — what needs you today',
9300
+ desc: "Ashley reviews the projects you manage plus your calendar and briefs you on what needs your attention. Add any specific focus below.",
9301
+ },
9302
+ 'weekly-operating-review': {
9303
+ title: 'Ashley — weekly operating review',
9304
+ desc: 'Ashley produces your weekly portfolio operating brief. Add any specific focus areas below.',
9305
+ },
8850
9306
  };
8851
9307
 
8852
9308
  function tfOpenAreaOnboardModal(jobId, baseMessage, area) {
@@ -8892,7 +9348,7 @@ async function tfStartOnboardingJob(jobId, message, targetArea, userContext) {
8892
9348
  const finalMessage = (userContext && userContext.trim()) ? userContext.trim() : jobMessage;
8893
9349
  if (job && typeof startRun === 'function') {
8894
9350
  if (targetArea) tfShowArea(targetArea);
8895
- await startRun(job, finalMessage, employeeId);
9351
+ await startRun(job, finalMessage, employeeId, undefined, targetArea);
8896
9352
  // After run creation refresh the area panel so the conversation becomes visible.
8897
9353
  if (targetArea === 'company') tfRenderCompany();
8898
9354
  else if (targetArea === 'manager') tfRenderManager();