fraim 2.0.162 → 2.0.164

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.
Files changed (33) hide show
  1. package/dist/src/ai-hub/desktop-main.js +4 -1
  2. package/dist/src/ai-hub/hosts.js +97 -12
  3. package/dist/src/ai-hub/preferences.js +1 -1
  4. package/dist/src/ai-hub/server.js +49 -123
  5. package/dist/src/cli/commands/init-project.js +15 -14
  6. package/dist/src/cli/commands/sync.js +38 -0
  7. package/dist/src/cli/doctor/check-runner.js +3 -1
  8. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +261 -2
  9. package/dist/src/cli/utils/git-org-sync.js +56 -0
  10. package/dist/src/cli/utils/org-migration.js +50 -0
  11. package/dist/src/cli/utils/org-pack-sync.js +208 -0
  12. package/dist/src/cli/utils/project-bootstrap.js +20 -7
  13. package/dist/src/cli/utils/user-config.js +68 -0
  14. package/dist/src/core/fraim-config-schema.generated.js +10 -0
  15. package/dist/src/first-run/types.js +8 -0
  16. package/dist/src/local-mcp-server/agent-token-prices.js +30 -0
  17. package/dist/src/local-mcp-server/learning-context-builder.js +78 -29
  18. package/dist/src/local-mcp-server/stdio-server.js +30 -0
  19. package/index.js +1 -1
  20. package/package.json +2 -3
  21. package/public/ai-hub/index.html +5 -5
  22. package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
  23. package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
  24. package/public/ai-hub/review.css +15 -15
  25. package/public/ai-hub/script.js +254 -195
  26. package/public/ai-hub/styles.css +206 -16
  27. package/public/first-run/styles.css +73 -73
  28. package/dist/src/ai-hub/word-sideload.js +0 -95
  29. package/dist/src/cli/commands/test-mcp.js +0 -171
  30. package/dist/src/cli/setup/first-run.js +0 -242
  31. package/dist/src/core/config-writer.js +0 -75
  32. package/dist/src/core/utils/job-aliases.js +0 -47
  33. package/dist/src/core/utils/workflow-parser.js +0 -174
@@ -9,8 +9,6 @@
9
9
  // conversation maps to one job and (when active) one backend run id. The
10
10
  // existing single-active-run backend stays untouched per spec R15.
11
11
 
12
- const STORAGE_KEY_CONVERSATIONS = 'fraim.aiHub.conversations.v1';
13
- const STORAGE_KEY_ACTIVE = 'fraim.aiHub.activeConversation.v1';
14
12
  const STORAGE_KEY_TREE_WIDTH = 'fraim.aiHub.treeSidebarWidth.v1';
15
13
  const STORAGE_KEY_TREE_COLLAPSED = 'fraim.aiHub.treeSidebarCollapsed.v1';
16
14
  const TREE_WIDTH_MIN = 176;
@@ -386,35 +384,6 @@ function normalizeGeminiConversationMessages(conv) {
386
384
  return changed;
387
385
  }
388
386
 
389
- function loadConversationsFromStorage() {
390
- try {
391
- const raw = window.localStorage.getItem(STORAGE_KEY_CONVERSATIONS);
392
- state.conversations = raw ? JSON.parse(raw) : {};
393
- } catch {
394
- state.conversations = {};
395
- }
396
- try {
397
- state.activeId = window.localStorage.getItem(STORAGE_KEY_ACTIVE) || null;
398
- } catch {
399
- state.activeId = null;
400
- }
401
- // Retroactively fix titles that were saved as the generic "New job" fallback
402
- // by re-deriving from the first manager message.
403
- let normalizedLegacyConversations = false;
404
- for (const convList of Object.values(state.conversations)) {
405
- for (const conv of convList) {
406
- normalizedLegacyConversations = normalizeGeminiConversationMessages(conv) || normalizedLegacyConversations;
407
- if (conv.title === 'New job') {
408
- const firstMsg = (conv.messages || []).find((m) => m.role === 'manager');
409
- if (!firstMsg) continue;
410
- const rederived = deriveTitle(conv.jobTitle || '', firstMsg.text || '');
411
- if (rederived !== 'New job') conv.title = rederived;
412
- }
413
- }
414
- }
415
- if (normalizedLegacyConversations) persistConversations();
416
- }
417
-
418
387
  function projectConversationPayload() {
419
388
  return {
420
389
  projectPath: state.projectPath || '',
@@ -423,19 +392,6 @@ function projectConversationPayload() {
423
392
  };
424
393
  }
425
394
 
426
- function persistConversationsToCache() {
427
- try {
428
- window.localStorage.setItem(STORAGE_KEY_CONVERSATIONS, JSON.stringify(state.conversations));
429
- if (state.activeId) {
430
- window.localStorage.setItem(STORAGE_KEY_ACTIVE, state.activeId);
431
- } else {
432
- window.localStorage.removeItem(STORAGE_KEY_ACTIVE);
433
- }
434
- } catch (error) {
435
- console.warn('Could not persist conversations:', error);
436
- }
437
- }
438
-
439
395
  function scheduleConversationDiskPersist() {
440
396
  if (!state.projectPath) return;
441
397
  if (state.conversationPersistTimer) window.clearTimeout(state.conversationPersistTimer);
@@ -457,7 +413,6 @@ function scheduleConversationDiskPersist() {
457
413
 
458
414
  function persistConversations(options) {
459
415
  const opts = options || {};
460
- persistConversationsToCache();
461
416
  if (opts.disk !== false) scheduleConversationDiskPersist();
462
417
  }
463
418
 
@@ -465,33 +420,17 @@ async function hydrateConversationsFromServer() {
465
420
  if (!state.projectPath) return;
466
421
  try {
467
422
  const payload = await requestJson(`/api/ai-hub/conversations?projectPath=${encodeURIComponent(state.projectPath)}`);
468
- const serverConversations = Array.isArray(payload.conversations) ? payload.conversations : [];
469
- const localConversations = projectConversations();
470
- const mergedById = new Map();
471
- for (const conv of serverConversations) mergedById.set(conv.id, conv);
472
- for (const conv of localConversations) {
473
- const existing = mergedById.get(conv.id);
474
- if (!existing || conversationTimestamp(conv) > conversationTimestamp(existing)) {
475
- mergedById.set(conv.id, conv);
476
- }
477
- }
478
- const conversations = Array.from(mergedById.values())
479
- .sort((a, b) => conversationTimestamp(b) - conversationTimestamp(a));
480
- // #534: apply the same Gemini stdout/stderr normalization as the localStorage
481
- // path so server-hydrated conversations render identically (the localStorage
482
- // path normalizes in loadConversationsFromStorage; without this, a hydrate-vs-
483
- // load race can surface uncoalesced/stale rows).
423
+ const conversations = Array.isArray(payload.conversations) ? payload.conversations : [];
484
424
  for (const conv of conversations) normalizeGeminiConversationMessages(conv);
485
425
  state.conversations[state.projectPath] = conversations;
486
426
  const activeCandidates = [state.activeId, payload.activeId].filter(Boolean);
487
427
  state.activeId = activeCandidates.find((id) => conversations.some((conv) => conv.id === id)) || (conversations[0] ? conversations[0].id : null);
488
428
  state.conversationDiskAvailable = true;
489
- persistConversations({ disk: false });
490
429
  renderRail();
491
430
  renderActive();
492
431
  } catch (error) {
493
432
  state.conversationDiskAvailable = false;
494
- console.warn('Could not hydrate conversations from local disk:', error);
433
+ console.warn('Could not hydrate conversations from server:', error);
495
434
  }
496
435
  }
497
436
 
@@ -600,10 +539,18 @@ function stripReviewHandoffBlocks(text) {
600
539
  .trim();
601
540
  }
602
541
 
542
+ function stripHubInjectedPromptBlocks(text) {
543
+ return String(text || '')
544
+ .replace(/(?:^|\n)\s*\[How to talk to me\][\s\S]*?(?=\n\s*\[[^\]\n]+\]|\n\s*#{1,6}\s|\n\s*[-*]\s|\n{2,}|$)/gi, '\n')
545
+ .replace(/(?:^|\n)\s*(?:[$/]fraim)\s+[a-z0-9-]+(?:\s*\[[^\]\n]*\])?[^\n]*(?=\n|$)/gi, '\n')
546
+ .replace(/\n{3,}/g, '\n\n')
547
+ .trim();
548
+ }
549
+
603
550
  // R8: render markdown subset safely. HTML is escaped first.
604
551
  function formatEmployeeText(text) {
605
552
  if (!text) return '';
606
- const visibleText = stripReviewHandoffBlocks(text);
553
+ const visibleText = stripHubInjectedPromptBlocks(stripReviewHandoffBlocks(text));
607
554
  if (!visibleText) return '';
608
555
  // 1. Escape HTML entities.
609
556
  let s = visibleText
@@ -767,14 +714,72 @@ function renderRail() {
767
714
  !(inWorkspace && projectUpdateJobs.has(conv.jobId))
768
715
  );
769
716
 
717
+ // Issue #550: Two-path routing for ad-hoc (freeform) conversations.
718
+ // Path A: jobId === '__freeform__' AND conv.personaKey resolves to a FRAIM
719
+ // employee the user has access to -> groups with that employee's
720
+ // accordion, identical to a structured catalog run.
721
+ // Path B: jobId === '__freeform__' AND no personaKey (no FRAIM employee
722
+ // identity was resolved) -> deferred to a single "Watercooler
723
+ // Conversations" group rendered after all employee groups (R3).
724
+ // The agentName (host CLI) is NOT a FRAIM employee and does not
725
+ // qualify an ad-hoc run for Path A — only personaKey does.
726
+ const watercoolerConvs = [];
770
727
  const groups = new Map();
771
728
  for (const conv of list) {
729
+ const isFreeform = conv.jobId === '__freeform__';
730
+ if (isFreeform && !conv.personaKey) {
731
+ // Path B: unmatched ad-hoc — collect for the Watercooler group.
732
+ watercoolerConvs.push(conv);
733
+ continue;
734
+ }
735
+ // Path A (matched ad-hoc with personaKey) or structured run:
736
+ // group by employee key as before.
772
737
  const key = conv.personaKey || conversationAgentName(conv) || 'free';
773
738
  const label = getConversationEmployeeLabel(conv);
774
739
  if (!groups.has(key)) groups.set(key, { key, label, detail: getConversationEmployeeDetail(conv), sample: conv, conversations: [] });
775
740
  groups.get(key).conversations.push(conv);
776
741
  }
777
742
 
743
+ // Helper: build the run-item buttons shared by employee groups and Watercooler.
744
+ function buildGroupList(conversations) {
745
+ const groupList = document.createElement('div');
746
+ groupList.className = 'conv-employee-list';
747
+ for (const conv of conversations) {
748
+ const btn = document.createElement('button');
749
+ btn.type = 'button';
750
+ btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
751
+ btn.dataset.conv = conv.id;
752
+ const bodyDiv = document.createElement('span');
753
+ bodyDiv.className = 'conv-body';
754
+ const titleSpan = document.createElement('span');
755
+ titleSpan.className = 'conv-title';
756
+ titleSpan.textContent = conv.title || '';
757
+ bodyDiv.appendChild(titleSpan);
758
+ btn.appendChild(bodyDiv);
759
+ const dotClass = conversationStateDotClass(conv);
760
+ const statusDot = document.createElement('span');
761
+ statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
762
+ statusDot.title = tfDotTitle(dotClass);
763
+ btn.appendChild(statusDot);
764
+ const statusSpan = document.createElement('span');
765
+ statusSpan.className = 'conv-status';
766
+ statusSpan.textContent = conversationStateLabel(conv);
767
+ statusSpan.classList.add(conversationUiState(conv));
768
+ btn.appendChild(statusSpan);
769
+ // Issue #442: A/B badge on rail entry.
770
+ if (conv.compareMode === 'ab') {
771
+ const badge = document.createElement('span');
772
+ badge.className = 'ab-badge';
773
+ badge.textContent = 'A/B';
774
+ btn.appendChild(badge);
775
+ }
776
+ tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
777
+ btn.addEventListener('click', () => switchToConversation(conv.id));
778
+ groupList.appendChild(btn);
779
+ }
780
+ return groupList;
781
+ }
782
+
778
783
  for (const group of groups.values()) {
779
784
  const details = document.createElement('details');
780
785
  details.className = 'conv-employee-group';
@@ -812,48 +817,46 @@ function renderRail() {
812
817
  summary.appendChild(addBtn);
813
818
  summary.appendChild(count);
814
819
  details.appendChild(summary);
815
-
816
- const groupList = document.createElement('div');
817
- groupList.className = 'conv-employee-list';
818
-
819
- for (const conv of group.conversations) {
820
- const btn = document.createElement('button');
821
- btn.type = 'button';
822
- btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
823
- btn.dataset.conv = conv.id;
824
- const bodyDiv = document.createElement('span');
825
- bodyDiv.className = 'conv-body';
826
- const titleSpan = document.createElement('span');
827
- titleSpan.className = 'conv-title';
828
- titleSpan.textContent = conv.title || '';
829
- bodyDiv.appendChild(titleSpan);
830
- btn.appendChild(bodyDiv);
831
- const dotClass = conversationStateDotClass(conv);
832
- const statusDot = document.createElement('span');
833
- statusDot.className = 'state-dot conv-state-dot dot-' + dotClass;
834
- statusDot.title = tfDotTitle(dotClass);
835
- btn.appendChild(statusDot);
836
- const statusSpan = document.createElement('span');
837
- statusSpan.className = 'conv-status';
838
- statusSpan.textContent = conversationStateLabel(conv);
839
- statusSpan.classList.add(conversationUiState(conv));
840
- btn.appendChild(statusSpan);
841
- // Issue #442: A/B badge on rail entry.
842
- if (conv.compareMode === 'ab') {
843
- const badge = document.createElement('span');
844
- badge.className = 'ab-badge';
845
- badge.textContent = 'A/B';
846
- btn.appendChild(badge);
847
- }
848
- tfAttachRunDelete(btn, conv, { tree: false }); // #533 R6: delete a run
849
- btn.addEventListener('click', () => switchToConversation(conv.id));
850
- groupList.appendChild(btn);
851
- }
852
-
853
- details.appendChild(groupList);
820
+ details.appendChild(buildGroupList(group.conversations));
854
821
  els['conv-list'].appendChild(details);
855
822
  }
856
823
 
824
+ // Issue #550 R2/R3: Render the Watercooler Conversations group after all
825
+ // employee groups, only when at least one unmatched ad-hoc conv exists.
826
+ // R9: the persona filter excludes unmatched ad-hoc convs entirely (they carry
827
+ // no personaKey so they never satisfy the filter), meaning watercoolerConvs is
828
+ // already empty when a persona filter is active.
829
+ if (watercoolerConvs.length > 0) {
830
+ const wcDetails = document.createElement('details');
831
+ wcDetails.className = 'conv-employee-group conv-employee-group--adhoc';
832
+ wcDetails.open = true;
833
+ const wcSummary = document.createElement('summary');
834
+ wcSummary.className = 'conv-employee-tab';
835
+ // R4: dashed-border avatar placeholder — no image or initials.
836
+ const wcAvatar = document.createElement('span');
837
+ wcAvatar.className = 'conv-employee-avatar conv-employee-avatar--adhoc';
838
+ const wcCopy = document.createElement('span');
839
+ wcCopy.className = 'conv-employee-tab-copy';
840
+ const wcLabel = document.createElement('strong');
841
+ wcLabel.className = 'conv-employee-tab-label';
842
+ wcLabel.textContent = 'Watercooler Conversations'; // R5
843
+ const wcDetail = document.createElement('small');
844
+ wcDetail.className = 'conv-employee-tab-detail';
845
+ wcDetail.textContent = 'Unmatched ad-hoc tasks'; // R5
846
+ wcCopy.appendChild(wcLabel);
847
+ wcCopy.appendChild(wcDetail);
848
+ const wcCount = document.createElement('span');
849
+ wcCount.className = 'conv-employee-tab-count';
850
+ wcCount.textContent = String(watercoolerConvs.length);
851
+ // R6: NO "+ assign" button in Watercooler summary.
852
+ wcSummary.appendChild(wcAvatar);
853
+ wcSummary.appendChild(wcCopy);
854
+ wcSummary.appendChild(wcCount);
855
+ wcDetails.appendChild(wcSummary);
856
+ wcDetails.appendChild(buildGroupList(watercoolerConvs));
857
+ els['conv-list'].appendChild(wcDetails);
858
+ }
859
+
857
860
  // #521: hide the "Runs" section header when there's nothing in it — in the
858
861
  // workspace, project-update jobs are deduped out, so a project that has only
859
862
  // run onboarding would otherwise show a dangling empty "Runs" label.
@@ -870,6 +873,11 @@ function statusLabel(s) {
870
873
  function conversationUiState(conv) {
871
874
  if (!conv) return 'idle';
872
875
  if (conv.status === 'running') return 'working';
876
+ // #549: conv.stopped is set by tfStopRun after a manager-initiated stop.
877
+ // A stopped run is an intentional pause — visually distinct from an error-failed run.
878
+ // Guard on status==='failed' only: a completed run should resolve to complete/waiting,
879
+ // not stay stuck as stopped even if the stopped flag was not cleared.
880
+ if (conv.stopped && conv.status === 'failed') return 'stopped';
873
881
  if (conv.status === 'failed') return 'waiting';
874
882
  if (conv.status === 'completed' && conv.reviewApproved) return 'complete';
875
883
  if (conv.status === 'completed') return 'waiting';
@@ -881,6 +889,8 @@ function conversationStateDotClass(conv) {
881
889
  if (uiState === 'working') return 'amber';
882
890
  if (uiState === 'waiting') return 'red';
883
891
  if (uiState === 'complete') return 'green';
892
+ // #549: stopped runs use a non-pulsing amber dot (intentional pause, not error).
893
+ if (uiState === 'stopped') return 'amber-static';
884
894
  return 'grey';
885
895
  }
886
896
 
@@ -889,6 +899,8 @@ function conversationStateLabel(conv) {
889
899
  if (uiState === 'working') return 'Working';
890
900
  if (uiState === 'waiting') return 'Waiting on you';
891
901
  if (uiState === 'complete') return 'Done';
902
+ // #549: manager-stopped run gets a distinct "Stopped" label in the rail.
903
+ if (uiState === 'stopped') return 'Stopped';
892
904
  return 'Idle';
893
905
  }
894
906
 
@@ -1301,6 +1313,7 @@ function renderActive() {
1301
1313
  }
1302
1314
  els['empty'].hidden = true;
1303
1315
  els['active-conv'].hidden = false;
1316
+ els['active-conv'].dataset.runId = conv.runId || '';
1304
1317
  els['active-title'].textContent = conversationTitle(conv);
1305
1318
  renderConversationIdentity(conv);
1306
1319
  renderRunStatePill(conv);
@@ -1516,6 +1529,8 @@ function renderRunStatePill(conv) {
1516
1529
  pill.textContent = 'DONE';
1517
1530
  pill.className = 'run-state-pill complete';
1518
1531
  } else {
1532
+ // #549 R3: conversationUiState now returns 'stopped' for manager-stopped runs.
1533
+ // conversationStateLabel returns 'Stopped' for that state; toUpperCase() => 'STOPPED'.
1519
1534
  pill.textContent = conversationStateLabel(conv).toUpperCase();
1520
1535
  pill.className = `run-state-pill ${conversationUiState(conv)}`;
1521
1536
  }
@@ -1523,16 +1538,33 @@ function renderRunStatePill(conv) {
1523
1538
  const stopBtn = els['run-stop-btn'];
1524
1539
  if (stopBtn) {
1525
1540
  const canStop = conv.status === 'running' && !!conv.runId && !conv._stopping;
1526
- stopBtn.hidden = !canStop;
1541
+ // #549 R1/AC1.3: While in-flight (_stopping===true), keep the button VISIBLE but disabled
1542
+ // so the manager retains the affordance showing "Stopping…". Hide only when the run is
1543
+ // not active at all (canStop===false AND not in-flight).
1544
+ stopBtn.hidden = !canStop && !conv._stopping;
1527
1545
  stopBtn.disabled = !!conv._stopping;
1546
+ if (conv._stopping) {
1547
+ stopBtn.textContent = '⏹ Stopping…';
1548
+ stopBtn.setAttribute('aria-label', 'Stopping the employee, please wait');
1549
+ } else {
1550
+ stopBtn.textContent = '⏹ Stop';
1551
+ stopBtn.setAttribute('aria-label', 'Ask the employee to stop and wait for you');
1552
+ }
1528
1553
  }
1529
1554
  }
1530
1555
 
1531
1556
  // #521: ask the agent to stop and park the run in "waiting on you" mode.
1532
1557
  async function tfStopRun(conv) {
1533
- if (!conv || !conv.runId || conv.status !== 'running') return;
1558
+ // #549 B2: Guard against _stopping so a rapid double-click (or force-click
1559
+ // that bypasses the disabled button) cannot fire a second stop request while
1560
+ // the first is still in-flight. The button is set disabled in renderRunStatePill
1561
+ // but DOM state is not a reliable guard for programmatic re-entry.
1562
+ if (!conv || !conv.runId || conv.status !== 'running' || conv._stopping) return;
1534
1563
  conv._stopping = true;
1535
1564
  if (typeof renderRunStatePill === 'function') renderRunStatePill(conv);
1565
+ // #549 R1: Update the thread indicator immediately to show the in-flight dash
1566
+ // so the manager sees "stopping in progress" rather than "still working".
1567
+ if (typeof syncWorkingIndicator === 'function') syncWorkingIndicator(conv);
1536
1568
  try {
1537
1569
  const run = await requestJson('/api/ai-hub/runs/' + encodeURIComponent(conv.runId) + '/stop', {
1538
1570
  method: 'POST',
@@ -1553,6 +1585,11 @@ async function tfStopRun(conv) {
1553
1585
  } catch (e) {
1554
1586
  conv._stopping = false;
1555
1587
  if (typeof renderRunStatePill === 'function') renderRunStatePill(conv);
1588
+ // #549 B1: Restore the three typing-dots when the stop POST fails.
1589
+ // Without this call, syncWorkingIndicator was never called after the catch
1590
+ // cleared _stopping, leaving the stopping-dash in #employee-working-indicator
1591
+ // permanently even though the employee is still running.
1592
+ if (typeof syncWorkingIndicator === 'function') syncWorkingIndicator(conv);
1556
1593
  if (typeof showStatus === 'function') showStatus((e && e.message) || 'Could not stop the run.', true);
1557
1594
  }
1558
1595
  }
@@ -1571,23 +1608,58 @@ function syncThreadUiState(conv) {
1571
1608
  function syncWorkingIndicator(conv) {
1572
1609
  const host = els['messages'];
1573
1610
  if (!host) return;
1574
- const shouldShow = conversationUiState(conv) === 'working';
1611
+ const uiState = conversationUiState(conv);
1612
+ const isStopping = conv && conv._stopping;
1613
+ const shouldShowWorking = uiState === 'working';
1614
+ const shouldShowStopped = uiState === 'stopped';
1615
+
1616
+ // ── #549 R2: Remove stopped indicator when state is no longer stopped. ──
1617
+ let stoppedIndicator = host.querySelector('#employee-stopped-indicator');
1618
+ if (!shouldShowStopped && stoppedIndicator) {
1619
+ stoppedIndicator.remove();
1620
+ stoppedIndicator = null;
1621
+ }
1622
+
1623
+ // ── Working indicator (three-dot bounce or in-flight dash). ──
1575
1624
  let indicator = host.querySelector('#employee-working-indicator');
1576
- if (!shouldShow) {
1625
+ if (!shouldShowWorking && !isStopping) {
1577
1626
  if (indicator) indicator.remove();
1578
- return;
1627
+ } else {
1628
+ // Create the bubble if it doesn't exist yet.
1629
+ if (!indicator) {
1630
+ indicator = document.createElement('article');
1631
+ indicator.id = 'employee-working-indicator';
1632
+ indicator.className = 'message typing-indicator';
1633
+ indicator.setAttribute('aria-label', 'Employee is working');
1634
+ const bubble = document.createElement('div');
1635
+ bubble.className = 'bubble';
1636
+ bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
1637
+ indicator.appendChild(bubble);
1638
+ }
1639
+ // #549 R1: While stop is in-flight, replace the three-dot animation with
1640
+ // a single static dash so the manager sees "stopping" rather than "still working".
1641
+ const bubble = indicator.querySelector('.bubble');
1642
+ if (isStopping) {
1643
+ bubble.innerHTML = '<span class="stopping-dash">–</span>';
1644
+ } else if (!bubble.querySelector('.typing-dot')) {
1645
+ // Restore three dots if _stopping cleared (e.g. stop failed).
1646
+ bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
1647
+ }
1648
+ host.appendChild(indicator);
1579
1649
  }
1580
- if (!indicator) {
1581
- indicator = document.createElement('article');
1582
- indicator.id = 'employee-working-indicator';
1583
- indicator.className = 'message typing-indicator';
1584
- indicator.setAttribute('aria-label', 'Employee is working');
1650
+
1651
+ // ── #549 R2: Show stopped-state system message after stop completes. ──
1652
+ if (shouldShowStopped && !stoppedIndicator) {
1653
+ const article = document.createElement('article');
1654
+ article.id = 'employee-stopped-indicator';
1655
+ article.className = 'message system';
1656
+ article.setAttribute('aria-label', 'Employee stopped');
1585
1657
  const bubble = document.createElement('div');
1586
1658
  bubble.className = 'bubble';
1587
- bubble.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
1588
- indicator.appendChild(bubble);
1659
+ bubble.textContent = ' Employee stopped. Send your next instruction to continue.';
1660
+ article.appendChild(bubble);
1661
+ host.appendChild(article);
1589
1662
  }
1590
- host.appendChild(indicator);
1591
1663
  }
1592
1664
 
1593
1665
  function buildConversationSummary(conv) {
@@ -2020,7 +2092,7 @@ function stripStubReference(text) {
2020
2092
  }
2021
2093
 
2022
2094
  function surfaceText(role, text, conv) {
2023
- const raw = stripStubReference(text);
2095
+ const raw = stripHubInjectedPromptBlocks(stripStubReference(text));
2024
2096
  if (!raw) return '';
2025
2097
 
2026
2098
  if (role === 'manager') {
@@ -2143,20 +2215,13 @@ function ensureThreadMessageViewportObserver() {
2143
2215
  }
2144
2216
 
2145
2217
  function syncThreadMessageViewport() {
2146
- const panel = els['thread-panel'];
2147
2218
  const messages = els['messages'];
2148
- if (!panel || !messages || !panel.open) return;
2149
- const summary = panel.querySelector('summary');
2150
- const panelHeight = panel.clientHeight || panel.getBoundingClientRect().height || 0;
2151
- const summaryHeight = summary ? (summary.offsetHeight || summary.getBoundingClientRect().height || 0) : 0;
2152
- const available = Math.floor(panelHeight - summaryHeight - 2);
2153
- if (available > 0) {
2154
- messages.style.height = `${Math.max(120, available)}px`;
2155
- messages.style.maxHeight = `${Math.max(120, available)}px`;
2156
- } else {
2157
- messages.style.height = '';
2158
- messages.style.maxHeight = '';
2159
- }
2219
+ if (!messages) return;
2220
+ // Flex layout now owns the thread height: #thread-panel[open] grows to fill the
2221
+ // available real estate and #messages (flex child, overflow-y:auto) scrolls
2222
+ // internally. Clear any legacy inline sizing so CSS stays authoritative.
2223
+ messages.style.height = '';
2224
+ messages.style.maxHeight = '';
2160
2225
  }
2161
2226
 
2162
2227
  function scrollThreadAfterViewportSync(conv, runningShouldStickToBottom, shouldScrollForUpdate) {
@@ -2185,7 +2250,8 @@ function scrollThreadForReview(conv) {
2185
2250
  return;
2186
2251
  }
2187
2252
 
2188
- const target = [...nodes].reverse().find((node) =>
2253
+ const completion = host.querySelector('#review-completion');
2254
+ const target = completion || [...nodes].reverse().find((node) =>
2189
2255
  node.classList.contains('employee') || node.classList.contains('system')
2190
2256
  ) || nodes[nodes.length - 1];
2191
2257
 
@@ -2193,7 +2259,7 @@ function scrollThreadForReview(conv) {
2193
2259
  const targetRect = target.getBoundingClientRect();
2194
2260
  const currentTop = host.scrollTop;
2195
2261
  const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
2196
- const reviewOffset = Math.max(24, host.clientHeight * 0.16);
2262
+ const reviewOffset = completion ? 8 : Math.max(24, host.clientHeight * 0.16);
2197
2263
  const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
2198
2264
  host.scrollTo({ top: desiredTop, behavior: 'smooth' });
2199
2265
  }
@@ -2449,6 +2515,25 @@ function artifactLabel(artifact) {
2449
2515
  return artifact.label || artifact.name || artifact.path || artifact.url || 'artifact';
2450
2516
  }
2451
2517
 
2518
+ function artifactBasename(artifact) {
2519
+ const raw = artifact && (artifact.path || artifact.url || artifact.name || '');
2520
+ const cleaned = String(raw || '').split(/[?#]/)[0];
2521
+ return cleaned.split(/[\\/]/).filter(Boolean).pop() || '';
2522
+ }
2523
+
2524
+ function artifactDisplayLabel(artifact) {
2525
+ const label = artifactLabel(artifact);
2526
+ const base = artifactBasename(artifact);
2527
+ if (!base || label === base || String(label).includes(base)) return label;
2528
+ return `${label} (${base})`;
2529
+ }
2530
+
2531
+ function reviewArtifactSummary(handoff) {
2532
+ const artifacts = handoff && Array.isArray(handoff.artifacts) ? handoff.artifacts : [];
2533
+ if (!artifacts.length) return '';
2534
+ return artifacts.map(artifactDisplayLabel).join('; ');
2535
+ }
2536
+
2452
2537
  function artifactLocalPath(artifact) {
2453
2538
  if (!artifact) return '';
2454
2539
  const raw = artifact.path
@@ -2500,7 +2585,7 @@ function deriveFormatFromReviewHandoff(handoff) {
2500
2585
  const actions = artifacts.length > 0
2501
2586
  ? artifacts.map((artifact, index) => ({
2502
2587
  id: `review-artifact-${index}`,
2503
- label: artifacts.length === 1 ? `Review ${artifact.label}` : artifact.label,
2588
+ label: artifactDisplayLabel(artifact),
2504
2589
  primary: index === 0,
2505
2590
  kind: artifactActionKind(artifact),
2506
2591
  artifact,
@@ -2564,7 +2649,7 @@ function deriveDeliverableFormat(conv) {
2564
2649
  key: 'markdown',
2565
2650
  label: artifactName || artifactLabel(artifact),
2566
2651
  actions: [
2567
- { id: 'download-docx', label: 'Download as .docx', primary: true, kind: 'export', artifact },
2652
+ { id: 'open-artifact', label: artifactDisplayLabel(artifact), primary: true, kind: 'doc', artifact },
2568
2653
  { id: 'inline-feedback', label: 'Type feedback inline', primary: false, kind: 'feedback', artifact },
2569
2654
  ],
2570
2655
  };
@@ -2585,7 +2670,7 @@ function deriveDeliverableFormat(conv) {
2585
2670
  // read-side already gives us and degrade gracefully when a field is unknown.
2586
2671
  function stripMarkdownForDisplay(text) {
2587
2672
  if (!text) return text;
2588
- return stripReviewHandoffBlocks(text)
2673
+ return stripHubInjectedPromptBlocks(stripReviewHandoffBlocks(text))
2589
2674
  .replace(/\*\*(.+?)\*\*/g, '$1')
2590
2675
  .replace(/\*(.+?)\*/g, '$1')
2591
2676
  .replace(/^---+$/gm, '')
@@ -2614,8 +2699,10 @@ function buildReviewCardRows(conv, fmt) {
2614
2699
  : `Completed "${conv.title || conv.jobTitle || 'the assigned work'}".`;
2615
2700
  return [
2616
2701
  { k: 'What changed', v: whatChanged },
2617
- reliableLabel
2618
- ? { k: 'Files updated', v: `${fmt.label}` }
2702
+ handoff && handoff.reviewTarget && handoff.reviewTarget.type === 'artifact_set'
2703
+ ? { k: 'Files updated', v: reviewArtifactSummary(handoff) || `${fmt.label}` }
2704
+ : reliableLabel
2705
+ ? { k: 'Files updated', v: `${fmt.label}` }
2619
2706
  : { k: 'Where to look', v: 'Open the updated files in your project (the Brief section shows the project context & rules) to review.' },
2620
2707
  { k: 'What to do next', v: 'Approve, or type what to change in the Coach box below.' },
2621
2708
  ];
@@ -2623,15 +2710,27 @@ function buildReviewCardRows(conv, fmt) {
2623
2710
  const whatIDid = lastEmployee
2624
2711
  ? clampSummaryText(stripMarkdownForDisplay(lastEmployee), 220)
2625
2712
  : `Completed “${conv.title || conv.jobTitle || 'the assigned work'}”.`;
2713
+ if (handoff && handoff.reviewTarget && handoff.reviewTarget.type === 'artifact_set') {
2714
+ return [
2715
+ { k: 'Files to review', v: reviewArtifactSummary(handoff) || `${fmt.label}` },
2716
+ { k: 'What I need', v: 'Your review — approve, or tell me what to sharpen.' },
2717
+ ];
2718
+ }
2626
2719
  const rows = [{ k: 'What I did', v: whatIDid }];
2627
- if (reliableLabel) rows.push({ k: 'Deliverable', v: `${fmt.label}` });
2720
+ if (reliableLabel) {
2721
+ rows.push({ k: 'Deliverable', v: `${fmt.label}` });
2722
+ }
2628
2723
  rows.push({ k: 'What I need', v: 'Your review — approve, or tell me what to sharpen.' });
2629
2724
  return rows;
2630
2725
  }
2631
2726
 
2632
- // The completion card lives as the LAST child of #messages so the thread
2633
- // panel's flex/scroll geometry is unchanged (it scrolls with the thread, like
2634
- // any employee turn). Find-or-create keeps it idempotent across poll ticks.
2727
+ // The completion card is a decision surface, not chat history. Keep it outside
2728
+ // the collapsible thread so the exact deliverables remain visible even when the
2729
+ // manager minimizes the conversation transcript.
2730
+ // The "Ready for your review" card is the employee's review turn, so it lives as
2731
+ // the LAST child of #messages — inside the manager/employee thread. Keeping it in
2732
+ // the thread (rather than as a separate panel below it) means it scrolls with the
2733
+ // conversation and the thread does not need to collapse to surface it.
2635
2734
  function reviewCompletionHost() {
2636
2735
  const messages = els['messages'];
2637
2736
  if (!messages) return null;
@@ -2649,6 +2748,12 @@ function clearReviewCompletion() {
2649
2748
  const messages = els['messages'];
2650
2749
  const existing = messages && messages.querySelector('#review-completion');
2651
2750
  if (existing) existing.remove();
2751
+ if (messages) {
2752
+ messages.classList.remove('review-focused');
2753
+ messages.querySelectorAll('.review-source-message').forEach((node) => {
2754
+ node.classList.remove('review-source-message');
2755
+ });
2756
+ }
2652
2757
  }
2653
2758
 
2654
2759
  function renderReviewExperience(conv) {
@@ -2728,9 +2833,9 @@ function renderReviewExperience(conv) {
2728
2833
  }
2729
2834
  }
2730
2835
 
2731
- // Comments follow the artifact (R7.7): a PR opens GitHub; markdown previews /
2732
- // exports; report views inline. Nothing new is invented these surface what the
2733
- // read-side already knows and otherwise leave a status hint.
2836
+ // Comments follow the artifact (R7.7): a PR opens GitHub; local artifacts open
2837
+ // from disk; reports view inline. Nothing new is invented - these surface what
2838
+ // the read-side already knows and otherwise leave a status hint.
2734
2839
  async function handleArtifactAction(conv, action) {
2735
2840
  const artifact = action.artifact || ((conv && conv.artifacts && conv.artifacts[0]) || null);
2736
2841
  if (action.kind === 'github') {
@@ -2774,55 +2879,6 @@ async function handleArtifactAction(conv, action) {
2774
2879
  }
2775
2880
  return;
2776
2881
  }
2777
- if (action.kind === 'export') {
2778
- // Download the deliverable as .docx for Word annotation. Try the on-disk file
2779
- // first; if it doesn't actually exist (404 — e.g. the employee named a file it
2780
- // never created) or there is no path, fall back to exporting the employee's
2781
- // written deliverable text. Either way we download a real .docx, never a JSON
2782
- // error. Feedback comes back through the Coach box (no "Done reviewing").
2783
- const artifactPath = artifact && (artifact.path || artifact.where || artifact.url);
2784
- const hasLocalFile = artifactPath && !String(artifactPath).startsWith('http');
2785
- const baseName = ((artifact && artifact.name) || conversationTitle(conv) || 'deliverable');
2786
- const triggerDownload = (blob) => {
2787
- const url = URL.createObjectURL(blob);
2788
- const a = document.createElement('a');
2789
- a.href = url;
2790
- a.download = String(baseName).replace(/\.[^.]+$/, '') + '.docx';
2791
- a.style.display = 'none';
2792
- document.body.appendChild(a);
2793
- a.click();
2794
- document.body.removeChild(a);
2795
- URL.revokeObjectURL(url);
2796
- };
2797
- const isDocx = (resp) => (resp.headers.get('content-type') || '').includes('word');
2798
- try {
2799
- let blob = null;
2800
- if (hasLocalFile) {
2801
- const resp = await fetch('/api/ai-hub/artifact/export-docx?path=' + encodeURIComponent(artifactPath));
2802
- if (resp.ok && isDocx(resp)) blob = await resp.blob();
2803
- // else: file missing on disk → fall through to the inline export below.
2804
- }
2805
- if (!blob) {
2806
- const inline = (typeof latestEmployeeSurfaceText === 'function' && latestEmployeeSurfaceText(conv)) || '';
2807
- if (!inline.trim()) {
2808
- showStatus('Nothing to download yet — the employee has not produced a file or a written deliverable.', true);
2809
- return;
2810
- }
2811
- const resp = await fetch('/api/ai-hub/artifact/export-docx', {
2812
- method: 'POST',
2813
- headers: { 'Content-Type': 'application/json' },
2814
- body: JSON.stringify({ content: inline, filename: baseName }),
2815
- });
2816
- if (!resp.ok) { const e = await resp.json().catch(() => ({})); throw new Error(e.error || 'Export failed.'); }
2817
- blob = await resp.blob();
2818
- }
2819
- triggerDownload(blob);
2820
- showStatus('Downloaded as .docx — mark it up in Word, then type your feedback in the Coach box below to send changes back.', false);
2821
- } catch (err) {
2822
- showStatus(err instanceof Error ? err.message : 'Could not export the deliverable.', true);
2823
- }
2824
- return;
2825
- }
2826
2882
  // Any other action: feedback goes through the Coach box.
2827
2883
  showStatus('Type your feedback in the Coach box below — it becomes a coaching message for the employee.', false);
2828
2884
  }
@@ -3837,6 +3893,9 @@ async function continueRun(text) {
3837
3893
  const coachingJobId = state.pendingCoachingJobId || undefined;
3838
3894
  clearPendingCoachingJob();
3839
3895
  conv.reviewApproved = false;
3896
+ // #549 R6/AC6.1: Clear the stopped flag before starting the new run so
3897
+ // stopped-state indicators are removed on the next render tick.
3898
+ conv.stopped = false;
3840
3899
  conv.status = 'running';
3841
3900
  upsertConversation(conv);
3842
3901
  refreshStatusSurfaces(); // #533 R5: also recolor the tree/area dots back to working
@@ -4651,7 +4710,8 @@ function wireEvents() {
4651
4710
  // This is a belt-and-suspenders fallback in case the inline script was skipped.
4652
4711
 
4653
4712
  gatherElements();
4654
- loadConversationsFromStorage();
4713
+ state.conversations = {};
4714
+ state.activeId = null;
4655
4715
  wirePopovers();
4656
4716
  wireEvents();
4657
4717
 
@@ -4707,7 +4767,6 @@ function wireEvents() {
4707
4767
  if (convNeedsPolling(conv)) startPolling();
4708
4768
  } else {
4709
4769
  state.activeId = null;
4710
- persistConversations();
4711
4770
  renderActive();
4712
4771
  }
4713
4772
  })();