fraim 2.0.154 → 2.0.160

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 (45) hide show
  1. package/README.md +1 -1
  2. package/dist/src/ai-hub/cert-store.js +70 -0
  3. package/dist/src/ai-hub/desktop-main.js +225 -50
  4. package/dist/src/ai-hub/hosts.js +135 -8
  5. package/dist/src/ai-hub/manager-turns.js +38 -0
  6. package/dist/src/ai-hub/office-sideload.js +138 -0
  7. package/dist/src/ai-hub/openclaw-bridge.js +239 -0
  8. package/dist/src/ai-hub/server.js +479 -48
  9. package/dist/src/ai-hub/word-sideload.js +95 -0
  10. package/dist/src/cli/commands/add-ide.js +9 -0
  11. package/dist/src/cli/commands/init-project.js +46 -34
  12. package/dist/src/cli/commands/login.js +1 -2
  13. package/dist/src/cli/commands/setup.js +0 -2
  14. package/dist/src/cli/commands/sync.js +41 -11
  15. package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
  16. package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
  17. package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
  18. package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
  19. package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
  20. package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
  21. package/dist/src/cli/utils/fraim-gitignore.js +11 -0
  22. package/dist/src/cli/utils/github-workflow-sync.js +231 -0
  23. package/dist/src/cli/utils/managed-agent-paths.js +1 -1
  24. package/dist/src/cli/utils/project-bootstrap.js +6 -3
  25. package/dist/src/cli/utils/remote-sync.js +1 -1
  26. package/dist/src/core/ai-mentor.js +46 -37
  27. package/dist/src/core/config-loader.js +69 -2
  28. package/dist/src/core/fraim-config-schema.generated.js +267 -6
  29. package/dist/src/core/types.js +0 -1
  30. package/dist/src/core/utils/fraim-labels.js +182 -0
  31. package/dist/src/core/utils/git-utils.js +22 -1
  32. package/dist/src/core/utils/project-fraim-paths.js +58 -0
  33. package/dist/src/first-run/session-service.js +3 -3
  34. package/dist/src/first-run/types.js +1 -1
  35. package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
  36. package/dist/src/local-mcp-server/stdio-server.js +212 -13
  37. package/package.json +6 -2
  38. package/public/ai-hub/index.html +289 -229
  39. package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
  40. package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
  41. package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
  42. package/public/ai-hub/script.js +1155 -586
  43. package/public/ai-hub/styles.css +1226 -722
  44. package/public/first-run/index.html +35 -35
  45. package/public/first-run/script.js +667 -667
@@ -21,10 +21,17 @@ const state = {
21
21
  selectedJob: null, // chosen in modal step 1
22
22
  selectedEmployeeId: null,
23
23
  selectedPersonaKey: null, // R4: null = "All employees"
24
- modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
25
- storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
26
- panelState: {}, // { [convId]: { coach?: boolean } }
27
- };
24
+ modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
25
+ storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
26
+ panelState: {}, // { [convId]: { coach?: boolean } }
27
+ wordContext: null, // WordContext pushed from taskpane.html via postMessage
28
+ // Pending coaching job selected via a quick-coach button or template picker.
29
+ // Sent as coachingJobId in the next continueRun call and then cleared.
30
+ // The invocation prefix ($fraim / /fraim) is added server-side based on
31
+ // the active employee; the user never sees the raw invocation syntax.
32
+ pendingCoachingJobId: null,
33
+ pendingCoachingLabel: null,
34
+ };
28
35
 
29
36
  const els = {};
30
37
 
@@ -37,13 +44,14 @@ function gatherElements() {
37
44
  'project-button', 'project-name',
38
45
  'new-conv-btn', 'conv-list',
39
46
  // Issue #385: team roster
40
- 'team-roster',
41
- 'empty', 'active-conv', 'active-title', 'active-job', 'active-identity', 'run-state-pill',
42
- 'progress', 'stage', 'latest', 'artifact-slot', 'messages',
43
- 'coach-text', 'send', 'micro-manage', 'micro-log',
44
- 'status-line', 'coach-note',
45
- 'coach-panel', 'coach-summary',
46
- 'modal', 'step1', 'step2',
47
+ 'team-roster',
48
+ 'empty', 'active-conv', 'active-title', 'active-identity', 'run-state-pill',
49
+ 'messages',
50
+ 'coach-text', 'send', 'micro-manage', 'micro-log',
51
+ 'status-line', 'coach-note',
52
+ 'coach-panel', 'coach-summary',
53
+ 'thread-panel', 'quick-coach-btns', 'other-manager-jobs-btn',
54
+ 'modal', 'step1', 'step2',
47
55
  'cancel1', 'next1', 'back2', 'start',
48
56
  'job-search', 'job-catalog', 'job-pick-status',
49
57
  // Issue #385: hire-required notice, persona job filter
@@ -56,6 +64,12 @@ function gatherElements() {
56
64
  'tracker', 'tracker-rows', 'tracker-note',
57
65
  'template-picker-btn', 'template-popover',
58
66
  'totals',
67
+ // Issue #442: A/B mode elements.
68
+ 'ab-toggle-wrap', 'ab-direct-panel',
69
+ 'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
70
+ // Issue #489: Word context elements.
71
+ 'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
72
+ 'word-context-card', 'word-ctx-card-label', 'word-ctx-card-body', 'word-ctx-card-toggle',
59
73
  ];
60
74
  for (const id of ids) {
61
75
  els[id] = document.getElementById(id);
@@ -63,6 +77,14 @@ function gatherElements() {
63
77
  }
64
78
 
65
79
  async function requestJson(url, options) {
80
+ // On localhost inject x-api-key: local-dev so the dev server auth bypass fires.
81
+ // The server's isLocalDev check already gates this to NODE_ENV !== 'production',
82
+ // so this header is harmless if accidentally sent to a non-dev server.
83
+ const isLocalHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
84
+ if (isLocalHost) {
85
+ const headers = { 'x-api-key': 'local-dev', ...((options && options.headers) || {}) };
86
+ options = { ...(options || {}), headers };
87
+ }
66
88
  const response = await fetch(url, options);
67
89
  let payload = null;
68
90
  try { payload = await response.json(); } catch { /* may be empty */ }
@@ -74,10 +96,13 @@ async function requestJson(url, options) {
74
96
  return payload;
75
97
  }
76
98
 
77
- async function loadBootstrap(projectPath) {
78
- const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
99
+ async function loadBootstrap(projectPath, docUrl) {
100
+ const params = new URLSearchParams();
101
+ if (projectPath) params.set('projectPath', projectPath);
102
+ if (docUrl) params.set('docUrl', docUrl);
103
+ const query = params.toString() ? '?' + params.toString() : '';
79
104
  const fetchOptions = {};
80
- if (state.storedApiKey) fetchOptions.headers = { 'x-fraim-api-key': state.storedApiKey };
105
+ if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
81
106
  const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
82
107
  state.bootstrap = bootstrap;
83
108
  state.projectPath = bootstrap.project.path;
@@ -90,6 +115,20 @@ async function loadBootstrap(projectPath) {
90
115
  els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
91
116
  els['status-line'].textContent = bootstrap.project.message || '';
92
117
  els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
118
+ // Update the compact project label shown in task-pane surface rail.
119
+ const tpLabel = document.getElementById('tp-project-label');
120
+ if (tpLabel) {
121
+ const projectName = friendlyProjectName(bootstrap.project.path);
122
+ const resolvedDocUrl = docUrl || new URLSearchParams(window.location.search).get('docUrl') || '';
123
+ let docName = '';
124
+ if (resolvedDocUrl) {
125
+ try {
126
+ const rawName = resolvedDocUrl.replace(/\\/g, '/').split('/').pop() || '';
127
+ docName = decodeURIComponent(rawName).replace(/\.[^.]+$/, '');
128
+ } catch {}
129
+ }
130
+ tpLabel.textContent = docName ? `${projectName} · ${docName}` : projectName;
131
+ }
93
132
  // Populate the welcome-line popovers with REAL jobs from the bootstrap.
94
133
  populateConceptPopovers();
95
134
  // Render rail and active state for this project.
@@ -97,6 +136,121 @@ async function loadBootstrap(projectPath) {
97
136
  renderActive();
98
137
  }
99
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // Word context bridge (postMessage from taskpane.html parent frame)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ // Apply a full or partial Word context update and re-render dependent UI.
144
+ // Called by the postMessage bridge and usable directly in tests.
145
+ function applyWordContext(ctx, partial) {
146
+ if (partial) {
147
+ if (!state.wordContext) state.wordContext = {};
148
+ Object.assign(state.wordContext, ctx || {});
149
+ } else {
150
+ state.wordContext = ctx || null;
151
+ }
152
+ renderWordContextBar();
153
+ renderWordContextInModal();
154
+ }
155
+
156
+ function setupWordBridge() {
157
+ if (window.parent === window) return; // not inside an iframe — no bridge needed
158
+ window.addEventListener('message', function(event) {
159
+ if (event.source !== window.parent) return;
160
+ const msg = event.data || {};
161
+ if (msg.type === 'word-context') {
162
+ applyWordContext(msg.payload, false);
163
+ } else if (msg.type === 'word-context-update') {
164
+ applyWordContext(msg.payload, true);
165
+ }
166
+ // word-response messages are handled by per-request listeners in requestWordContext()
167
+ });
168
+ // Signal taskpane.html that Hub is ready to receive context.
169
+ window.parent.postMessage({ type: 'hub-ready' }, '*');
170
+ }
171
+
172
+ // Send a word-request to taskpane.html and await the word-response.
173
+ // Resolves null if not in iframe, no response, or timeout (3 s).
174
+ function requestWordContext(action, payload) {
175
+ return new Promise(function(resolve) {
176
+ if (window.parent === window) { resolve(null); return; }
177
+ const requestId = Math.random().toString(36).slice(2);
178
+ const timer = setTimeout(function() { cleanup(); resolve(null); }, 3000);
179
+ function onMsg(event) {
180
+ if (event.source !== window.parent) return;
181
+ const m = event.data || {};
182
+ if (m.type === 'word-response' && m.requestId === requestId) {
183
+ cleanup(); resolve(m.payload || null);
184
+ }
185
+ }
186
+ function cleanup() { clearTimeout(timer); window.removeEventListener('message', onMsg); }
187
+ window.addEventListener('message', onMsg);
188
+ window.parent.postMessage({ type: 'word-request', requestId, action, payload: payload || {} }, '*');
189
+ });
190
+ }
191
+
192
+ function buildWordContextBlock(wc) {
193
+ if (!wc) return '';
194
+ const parts = [];
195
+ if (wc.docTitle) parts.push(`Document: ${wc.docTitle}`);
196
+ if (wc.selection) {
197
+ const wordCount = wc.selection.split(/\s+/).filter(Boolean).length;
198
+ parts.push(`Selected text (${wordCount} word${wordCount !== 1 ? 's' : ''}):\n"""\n${wc.selection.slice(0, 500)}\n"""`);
199
+ } else if (wc.bodyPreview) {
200
+ parts.push(`Document preview:\n"""\n${wc.bodyPreview.slice(0, 800)}\n"""`);
201
+ }
202
+ if (wc.wordCount) parts.push(`Document length: ~${wc.wordCount} words`);
203
+ if (wc.comments && wc.comments.length > 0) {
204
+ const lines = wc.comments.slice(0, 5).map(c => `- ${c.author || 'Author'}: ${c.text}`).join('\n');
205
+ parts.push(`Document comments (${wc.comments.length}):\n${lines}`);
206
+ }
207
+ if (parts.length === 0) return '';
208
+ return `[Word Document Context]\n${parts.join('\n\n')}`;
209
+ }
210
+
211
+ function renderWordContextBar() {
212
+ const bar = els['word-context-bar'];
213
+ const txt = els['word-ctx-text'];
214
+ if (!bar || !txt) return;
215
+ const wc = state.wordContext;
216
+ if (!wc || (!wc.docTitle && !wc.selection && !wc.bodyPreview)) {
217
+ bar.hidden = true;
218
+ return;
219
+ }
220
+ bar.hidden = false;
221
+ if (wc.selection) {
222
+ const words = wc.selection.split(/\s+/).filter(Boolean).length;
223
+ txt.textContent = `${words} word${words !== 1 ? 's' : ''} selected — "${wc.selection.slice(0, 60)}${wc.selection.length > 60 ? '…' : ''}"`;
224
+ } else if (wc.bodyPreview) {
225
+ const words = wc.wordCount || 0;
226
+ txt.textContent = `${wc.docTitle || 'Document'} · ${words} words — full doc context active`;
227
+ } else {
228
+ txt.textContent = wc.docTitle || 'Document context active';
229
+ }
230
+ }
231
+
232
+ function renderWordContextInModal() {
233
+ const card = els['word-context-card'];
234
+ const label = els['word-ctx-card-label'];
235
+ const body = els['word-ctx-card-body'];
236
+ if (!card || !label || !body) return;
237
+ const wc = state.wordContext;
238
+ if (!wc || (!wc.selection && !wc.bodyPreview && !wc.comments?.length)) {
239
+ card.hidden = true;
240
+ return;
241
+ }
242
+ card.hidden = false;
243
+ // Label line: "3 words selected · MyDoc" or "MyDoc · 1,234 words"
244
+ if (wc.selection) {
245
+ const words = wc.selection.split(/\s+/).filter(Boolean).length;
246
+ label.textContent = `${words} word${words !== 1 ? 's' : ''} selected${wc.docTitle ? ' · ' + wc.docTitle : ''}`;
247
+ } else {
248
+ label.textContent = (wc.docTitle || 'Document') + (wc.wordCount ? ` · ${wc.wordCount} words` : '');
249
+ }
250
+ // Body: shows block that will be sent to the agent
251
+ body.textContent = buildWordContextBlock(wc);
252
+ }
253
+
100
254
  // ---------------------------------------------------------------------------
101
255
  // Concept popover population (drives the welcome-line "see ... jobs" lists)
102
256
  // ---------------------------------------------------------------------------
@@ -226,34 +380,129 @@ function findConversation(id) {
226
380
  return null;
227
381
  }
228
382
 
229
- function activeConversation() {
230
- if (!state.activeId) return null;
231
- return findConversation(state.activeId);
232
- }
233
-
234
- function panelStateFor(convId) {
235
- if (!convId) return {};
236
- if (!state.panelState[convId]) state.panelState[convId] = {};
237
- return state.panelState[convId];
238
- }
239
-
240
- function defaultCoachOpen(conv) {
241
- return true;
242
- }
243
-
244
- function syncConversationPanels(conv, switchedConv) {
245
- const coach = els['coach-panel'];
246
- if (!conv || !coach) return;
247
- const panelState = panelStateFor(conv.id);
248
- if (switchedConv) {
249
- coach.open = panelState.coach ?? defaultCoachOpen(conv);
250
- }
251
- if (els['coach-summary']) {
252
- els['coach-summary'].textContent = conv.status === 'running'
253
- ? 'Open to coach or redirect the work.'
254
- : 'Open when you want to steer the next step.';
255
- }
256
- }
383
+ function activeConversation() {
384
+ if (!state.activeId) return null;
385
+ return findConversation(state.activeId);
386
+ }
387
+
388
+ function panelStateFor(convId) {
389
+ if (!convId) return {};
390
+ if (!state.panelState[convId]) state.panelState[convId] = {};
391
+ return state.panelState[convId];
392
+ }
393
+
394
+ function defaultCoachOpen(conv) {
395
+ return true;
396
+ }
397
+
398
+ // R4: resolve employee display name for the coach label.
399
+ function coachEmployeeLabel(conv) {
400
+ if (!conv) return 'Maestro';
401
+ if (conv.personaKey) {
402
+ const persona = (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey);
403
+ if (persona) return persona.displayName;
404
+ }
405
+ if (conv.employeeId) {
406
+ const emp = getEmployeeStatus(conv.employeeId);
407
+ if (emp) return emp.label;
408
+ }
409
+ return 'Maestro';
410
+ }
411
+
412
+ // R4: update coach label with employee name + initial badge.
413
+ function syncCoachEmployeeLabel(conv) {
414
+ const label = document.querySelector('.active-employee-label');
415
+ if (!label) return;
416
+ const name = coachEmployeeLabel(conv);
417
+ label.textContent = '';
418
+ // Use persona avatar image when available (matches renderConversationIdentity).
419
+ const persona = conv && conv.personaKey
420
+ ? (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey)
421
+ : null;
422
+ let badge;
423
+ if (persona && persona.avatarUrl) {
424
+ badge = document.createElement('img');
425
+ badge.className = 'coach-employee-badge coach-employee-avatar';
426
+ badge.src = persona.avatarUrl;
427
+ badge.alt = name;
428
+ } else {
429
+ badge = document.createElement('span');
430
+ badge.className = 'coach-employee-badge';
431
+ badge.textContent = name.slice(0, 1).toUpperCase();
432
+ }
433
+ const nameSpan = document.createElement('span');
434
+ nameSpan.className = 'coach-label-name';
435
+ nameSpan.textContent = name;
436
+ label.appendChild(badge);
437
+ label.appendChild(nameSpan);
438
+ }
439
+
440
+ // R5: show/hide quick-access coaching buttons based on managerTemplates.
441
+ function syncQuickCoachButtons(conv) {
442
+ const row = els['quick-coach-btns'];
443
+ if (!row) return;
444
+ const hasTemplates = state.bootstrap && state.bootstrap.managerTemplates && state.bootstrap.managerTemplates.length > 0;
445
+ const hasRun = conv && (conv.status === 'running' || conv.status === 'completed');
446
+ row.hidden = !(hasTemplates && hasRun);
447
+ }
448
+
449
+ // R8: render markdown subset safely. HTML is escaped first.
450
+ function formatEmployeeText(text) {
451
+ if (!text) return '';
452
+ // 1. Escape HTML entities.
453
+ let s = text
454
+ .replace(/&/g, '&')
455
+ .replace(/</g, '&lt;')
456
+ .replace(/>/g, '&gt;')
457
+ .replace(/"/g, '&quot;');
458
+ // 2. Fenced code blocks (triple backtick).
459
+ s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
460
+ // 3. Inline code (single backtick, non-greedy, no newlines).
461
+ s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
462
+ // 4. Bold (**text**).
463
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
464
+ // 5–7. Process line by line for lists and paragraphs.
465
+ const lines = s.split('\n');
466
+ const out = [];
467
+ let ulOpen = false;
468
+ let olOpen = false;
469
+ for (const line of lines) {
470
+ const ulMatch = line.match(/^[-*] (.+)/);
471
+ const olMatch = line.match(/^\d+\. (.+)/);
472
+ if (ulMatch) {
473
+ if (olOpen) { out.push('</ol>'); olOpen = false; }
474
+ if (!ulOpen) { out.push('<ul>'); ulOpen = true; }
475
+ out.push(`<li>${ulMatch[1]}</li>`);
476
+ } else if (olMatch) {
477
+ if (ulOpen) { out.push('</ul>'); ulOpen = false; }
478
+ if (!olOpen) { out.push('<ol>'); olOpen = true; }
479
+ out.push(`<li>${olMatch[1]}</li>`);
480
+ } else {
481
+ if (ulOpen) { out.push('</ul>'); ulOpen = false; }
482
+ if (olOpen) { out.push('</ol>'); olOpen = false; }
483
+ if (line.trim()) out.push(`<p>${line}</p>`);
484
+ }
485
+ }
486
+ if (ulOpen) out.push('</ul>');
487
+ if (olOpen) out.push('</ol>');
488
+ return out.join('\n');
489
+ }
490
+
491
+ function syncConversationPanels(conv, switchedConv) {
492
+ const coach = els['coach-panel'];
493
+ if (!conv || !coach) return;
494
+ const panelState = panelStateFor(conv.id);
495
+ if (switchedConv) {
496
+ coach.open = panelState.coach ?? defaultCoachOpen(conv);
497
+ if (els['thread-panel']) {
498
+ els['thread-panel'].open = panelState.thread ?? true;
499
+ }
500
+ }
501
+ // R3.3: hide the summary hint when the coach panel is open.
502
+ if (els['coach-summary']) {
503
+ els['coach-summary'].hidden = coach.open;
504
+ }
505
+ }
257
506
 
258
507
  function upsertConversation(conv) {
259
508
  const list = projectConversations().slice();
@@ -327,54 +576,61 @@ function renderRail() {
327
576
  if (conv.status === 'running') statusSpan.classList.add('running');
328
577
  if (conv.status === 'failed') statusSpan.classList.add('failed');
329
578
  btn.appendChild(statusSpan);
579
+ // Issue #442: A/B badge on rail entry.
580
+ if (conv.compareMode === 'ab') {
581
+ const badge = document.createElement('span');
582
+ badge.className = 'ab-badge';
583
+ badge.textContent = 'A/B';
584
+ btn.appendChild(badge);
585
+ }
330
586
  btn.addEventListener('click', () => switchToConversation(conv.id));
331
587
  els['conv-list'].appendChild(btn);
332
588
  }
333
589
  }
334
590
 
335
- function statusLabel(s) {
336
- if (s === 'running') return 'Running';
337
- if (s === 'failed') return 'Needs you';
338
- return 'Done';
339
- }
340
-
341
- function personaMap() {
342
- const map = new Map();
343
- for (const persona of state.bootstrap?.personas || []) {
344
- map.set(persona.key, persona);
345
- }
346
- return map;
347
- }
348
-
349
- function getConversationPersona(conv) {
350
- if (!conv || !conv.personaKey) return null;
351
- return personaMap().get(conv.personaKey) || null;
352
- }
353
-
354
- function getEmployeeStatus(employeeId) {
355
- return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
356
- }
357
-
358
- function getEmployeeTitle(conv) {
359
- const persona = getConversationPersona(conv);
360
- if (persona) return persona.role;
361
- const employee = getEmployeeStatus(conv?.employeeId);
362
- return employee ? employee.label : 'AI Employee';
363
- }
364
-
365
- function roleLabel(role, conv) {
366
- if (role === 'manager') return 'Manager';
367
- if (role === 'employee') {
368
- const persona = getConversationPersona(conv);
369
- return persona ? persona.displayName : 'Employee';
370
- }
371
- return 'System';
372
- }
373
-
374
- function initialBadge(text) {
375
- const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
376
- return (cleaned.slice(0, 2) || 'FH');
377
- }
591
+ function statusLabel(s) {
592
+ if (s === 'running') return 'Running';
593
+ if (s === 'failed') return 'Needs you';
594
+ return 'Done';
595
+ }
596
+
597
+ function personaMap() {
598
+ const map = new Map();
599
+ for (const persona of state.bootstrap?.personas || []) {
600
+ map.set(persona.key, persona);
601
+ }
602
+ return map;
603
+ }
604
+
605
+ function getConversationPersona(conv) {
606
+ if (!conv || !conv.personaKey) return null;
607
+ return personaMap().get(conv.personaKey) || null;
608
+ }
609
+
610
+ function getEmployeeStatus(employeeId) {
611
+ return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
612
+ }
613
+
614
+ function getEmployeeTitle(conv) {
615
+ const persona = getConversationPersona(conv);
616
+ if (persona) return persona.role;
617
+ const employee = getEmployeeStatus(conv?.employeeId);
618
+ return employee ? employee.label : 'AI Employee';
619
+ }
620
+
621
+ function roleLabel(role, conv) {
622
+ if (role === 'manager') return 'Manager';
623
+ if (role === 'employee') {
624
+ const persona = getConversationPersona(conv);
625
+ return persona ? persona.displayName : 'Employee';
626
+ }
627
+ return 'System';
628
+ }
629
+
630
+ function initialBadge(text) {
631
+ const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
632
+ return (cleaned.slice(0, 2) || 'FH');
633
+ }
378
634
 
379
635
  // ---------------------------------------------------------------------------
380
636
  // Issue #385 — Persona UI (R3 + R4)
@@ -393,62 +649,62 @@ function conversationTitle(conv) {
393
649
 
394
650
  // R4.2 — team roster: one avatar chip per hired persona above the conv list.
395
651
  // Only rendered when at least one persona is hired (subscription active).
396
- function renderTeamRoster() {
397
- const roster = els['team-roster'];
398
- if (!roster) return;
399
- const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
400
- if (personas.length === 0) {
652
+ function renderTeamRoster() {
653
+ const roster = els['team-roster'];
654
+ if (!roster) return;
655
+ const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
656
+ if (personas.length === 0) {
401
657
  roster.hidden = true;
402
658
  // Reset persona selection when there's no active subscription.
403
659
  if (state.selectedPersonaKey) state.selectedPersonaKey = null;
404
660
  return;
405
- }
406
- roster.hidden = false;
407
- roster.innerHTML = '';
408
- const allChip = document.createElement('button');
409
- allChip.type = 'button';
410
- allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
411
- allChip.title = 'All employees';
412
- allChip.setAttribute('aria-label', 'All employees');
413
- allChip.innerHTML = `
414
- <span class="roster-avatar">All</span>
415
- <span class="roster-copy">
416
- <strong>All employees</strong>
417
- <small>Show every hired employee</small>
418
- </span>
419
- `;
420
- allChip.addEventListener('click', () => setSelectedPersona(null));
421
- roster.appendChild(allChip);
422
- for (const persona of personas) {
423
- const chip = document.createElement('button');
424
- chip.type = 'button';
425
- chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
426
- chip.title = persona.displayName;
427
- chip.setAttribute('aria-label', persona.displayName);
428
- const avatar = document.createElement('span');
429
- avatar.className = 'roster-avatar';
430
- if (persona.avatarUrl) {
431
- const img = document.createElement('img');
432
- img.src = persona.avatarUrl;
433
- img.alt = persona.displayName;
434
- avatar.appendChild(img);
435
- } else {
436
- avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
437
- }
438
- const copy = document.createElement('span');
439
- copy.className = 'roster-copy';
440
- const name = document.createElement('strong');
441
- name.textContent = persona.displayName;
442
- const role = document.createElement('small');
443
- role.textContent = persona.role || 'AI Employee';
444
- copy.appendChild(name);
445
- copy.appendChild(role);
446
- chip.appendChild(avatar);
447
- chip.appendChild(copy);
448
- chip.addEventListener('click', () => setSelectedPersona(persona.key));
449
- roster.appendChild(chip);
450
- }
451
- }
661
+ }
662
+ roster.hidden = false;
663
+ roster.innerHTML = '';
664
+ const allChip = document.createElement('button');
665
+ allChip.type = 'button';
666
+ allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
667
+ allChip.title = 'All employees';
668
+ allChip.setAttribute('aria-label', 'All employees');
669
+ allChip.innerHTML = `
670
+ <span class="roster-avatar">All</span>
671
+ <span class="roster-copy">
672
+ <strong>All employees</strong>
673
+ <small>Show every hired employee</small>
674
+ </span>
675
+ `;
676
+ allChip.addEventListener('click', () => setSelectedPersona(null));
677
+ roster.appendChild(allChip);
678
+ for (const persona of personas) {
679
+ const chip = document.createElement('button');
680
+ chip.type = 'button';
681
+ chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
682
+ chip.title = persona.displayName;
683
+ chip.setAttribute('aria-label', persona.displayName);
684
+ const avatar = document.createElement('span');
685
+ avatar.className = 'roster-avatar';
686
+ if (persona.avatarUrl) {
687
+ const img = document.createElement('img');
688
+ img.src = persona.avatarUrl;
689
+ img.alt = persona.displayName;
690
+ avatar.appendChild(img);
691
+ } else {
692
+ avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
693
+ }
694
+ const copy = document.createElement('span');
695
+ copy.className = 'roster-copy';
696
+ const name = document.createElement('strong');
697
+ name.textContent = persona.displayName;
698
+ const role = document.createElement('small');
699
+ role.textContent = persona.role || 'AI Employee';
700
+ copy.appendChild(name);
701
+ copy.appendChild(role);
702
+ chip.appendChild(avatar);
703
+ chip.appendChild(copy);
704
+ chip.addEventListener('click', () => setSelectedPersona(persona.key));
705
+ roster.appendChild(chip);
706
+ }
707
+ }
452
708
 
453
709
  // R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
454
710
  function buildAvatarChip(persona, size) {
@@ -566,101 +822,111 @@ function renderModalPersonaFilter() {
566
822
  // event rows are already in the DOM. Polling fires every second; without
567
823
  // this, every tick wiped the messages list and re-played the slidein
568
824
  // animation (= the screen flash the user reported as 'distracting').
569
- let renderedConvId = null;
570
- let renderedMessageCount = 0;
571
- let renderedEventCount = 0;
572
- let renderedArtifactKey = null;
573
- let renderedStatus = null;
574
-
575
- function renderActive() {
576
- const conv = activeConversation();
577
- if (!conv) {
578
- els['empty'].hidden = false;
579
- els['active-conv'].hidden = true;
580
- renderedConvId = null;
581
- renderedMessageCount = 0;
582
- renderedEventCount = 0;
583
- renderedArtifactKey = null;
584
- renderedStatus = null;
585
- return;
586
- }
587
- els['empty'].hidden = true;
588
- els['active-conv'].hidden = false;
589
- els['active-title'].textContent = conversationTitle(conv);
590
- els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)} · ${getEmployeeStatus(conv.employeeId)?.label || conv.employeeId}`;
591
- renderConversationIdentity(conv);
592
- els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)}`;
593
- renderRunStatePill(conv);
594
- els['coach-note'].textContent = conv.status === 'running'
595
- ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
596
- : 'The employee is waiting on you. Send the next instruction to continue this run.';
597
-
598
- // Progress section. Plain text updates are cheap and don't animate.
599
- const stage = derivedStage(conv);
600
- els['stage'].textContent = stage.text;
601
- els['progress'].classList.remove('done', 'attention', 'failed');
602
- if (stage.kind) els['progress'].classList.add(stage.kind);
603
- els['latest'].textContent = derivedLatest(conv);
825
+ let renderedConvId = null;
826
+ let renderedMessageCount = 0;
827
+ let renderedEventCount = 0;
828
+ let renderedStatus = null;
829
+ let renderedDirectEventCount = 0;
830
+
831
+ function renderActive() {
832
+ const conv = activeConversation();
833
+ if (!conv) {
834
+ els['empty'].hidden = false;
835
+ els['active-conv'].hidden = true;
836
+ const _c = document.getElementById('conversation');
837
+ if (_c) _c.classList.remove('ab-mode');
838
+ if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
839
+ renderedConvId = null;
840
+ renderedMessageCount = 0;
841
+ renderedEventCount = 0;
842
+ renderedStatus = null;
843
+ renderedDirectEventCount = 0;
844
+ return;
845
+ }
846
+ els['empty'].hidden = true;
847
+ els['active-conv'].hidden = false;
848
+ els['active-title'].textContent = conversationTitle(conv);
849
+ renderConversationIdentity(conv);
850
+ renderRunStatePill(conv);
851
+ syncCoachEmployeeLabel(conv);
852
+ els['coach-note'].textContent = conv.status === 'running'
853
+ ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
854
+ : 'The employee is waiting on you. Send the next instruction to continue this run.';
604
855
 
605
856
  // If we switched conversations (or this is the first render), wipe and
606
857
  // start fresh. Otherwise we're going to do an incremental update below.
607
- const switchedConv = renderedConvId !== conv.id;
608
- if (switchedConv) {
609
- els['artifact-slot'].innerHTML = '';
858
+ const switchedConv = renderedConvId !== conv.id;
859
+ if (switchedConv) {
610
860
  els['messages'].innerHTML = '';
861
+ els['messages'].className = 'messages';
611
862
  els['micro-log'].textContent = '';
612
863
  renderedConvId = conv.id;
613
864
  renderedMessageCount = 0;
614
- renderedEventCount = 0;
615
- renderedArtifactKey = null;
616
- }
617
- const statusChanged = renderedStatus !== conv.status;
618
- syncConversationPanels(conv, switchedConv);
619
-
620
- // Artifact callout — only re-render when the latest artifact actually
621
- // changed. Avoids the 'pulse' animation re-firing on every poll tick.
622
- const latestArtifact = conv.artifacts && conv.artifacts.length > 0
623
- ? conv.artifacts[conv.artifacts.length - 1]
624
- : null;
625
- const artifactKey = latestArtifact ? `${latestArtifact.where}${latestArtifact.name}` : null;
626
- if (artifactKey !== renderedArtifactKey) {
627
- els['artifact-slot'].innerHTML = '';
628
- if (latestArtifact) {
629
- const span = document.createElement('span');
630
- span.className = 'artifact';
631
- span.title = `${latestArtifact.where}${latestArtifact.name}`;
632
- span.innerHTML = `
633
- <span class="artifact-dot" aria-hidden="true"></span>
634
- <span class="artifact-label">file</span>
635
- <span class="artifact-name"></span>`;
636
- span.querySelector('.artifact-name').textContent = latestArtifact.name;
637
- els['artifact-slot'].appendChild(span);
638
- }
639
- renderedArtifactKey = artifactKey;
865
+ renderedEventCount = 0;
866
+ renderedDirectEventCount = 0;
867
+ // A pending coaching job is scoped to the active conversation — discard it
868
+ // whenever the user switches to a different conversation.
869
+ clearPendingCoachingJob();
640
870
  }
871
+ const statusChanged = renderedStatus !== conv.status;
872
+ syncConversationPanels(conv, switchedConv);
873
+ syncQuickCoachButtons(conv);
641
874
 
642
875
  // Messages — append only the rows that aren't already in the DOM, so
643
876
  // existing rows don't re-animate. If for some reason the data shrunk
644
877
  // (server revoked a message), fall back to a full re-render.
645
878
  const messages = conv.messages || [];
646
- if (messages.length < renderedMessageCount) {
647
- els['messages'].innerHTML = '';
648
- renderedMessageCount = 0;
649
- }
650
- for (let i = renderedMessageCount; i < messages.length; i += 1) {
651
- appendMessageDom(messages[i].role, messages[i].text, conv);
652
- }
653
- const appendedMessages = messages.length - renderedMessageCount;
654
- renderedMessageCount = messages.length;
655
- // Running threads stay pinned near the newest work. Once the employee
656
- // is done, snap to the review point instead of leaving the manager at
657
- // a stale scroll offset.
658
- const m = els['messages'];
659
- if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
660
- m.scrollTop = m.scrollHeight;
661
- } else if (switchedConv || statusChanged || appendedMessages > 0) {
662
- scrollThreadForReview(conv);
663
- }
879
+ const isABConv = conv.compareMode === 'ab';
880
+
881
+ // Issue #442: A/B split — full panel. #conversation becomes a flex row;
882
+ // FRAIM side is the unchanged #active-conv; Direct side is #ab-direct-panel.
883
+ const _container = document.getElementById('conversation');
884
+ if (isABConv) {
885
+ if (_container) _container.classList.add('ab-mode');
886
+ if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = false;
887
+ if (switchedConv) {
888
+ const dl = document.getElementById('ab-direct-log');
889
+ if (dl) dl.textContent = '';
890
+ renderedDirectEventCount = 0;
891
+ }
892
+ } else {
893
+ if (_container) _container.classList.remove('ab-mode');
894
+ if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
895
+ }
896
+
897
+ // FRAIM messages — render into #messages via appendMessageDom (same for AB and non-AB).
898
+ if (messages.length < renderedMessageCount) {
899
+ els['messages'].innerHTML = '';
900
+ renderedMessageCount = 0;
901
+ }
902
+ for (let i = renderedMessageCount; i < messages.length; i += 1) {
903
+ appendMessageDom(messages[i].role, messages[i].text, conv);
904
+ }
905
+ const appendedMessages = messages.length - renderedMessageCount;
906
+ renderedMessageCount = messages.length;
907
+ const m = els['messages'];
908
+ if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
909
+ m.scrollTop = m.scrollHeight;
910
+ } else if (switchedConv || statusChanged || appendedMessages > 0) {
911
+ scrollThreadForReview(conv);
912
+ }
913
+
914
+ // Direct raw event log — append-only like the FRAIM micro-log, shows all
915
+ // stdout/stderr/system events from the B run so the user sees everything.
916
+ if (isABConv) {
917
+ const directEvents = (conv.compareRun && conv.compareRun.events) || [];
918
+ const directLog = document.getElementById('ab-direct-log');
919
+ if (directLog) {
920
+ if (directEvents.length < renderedDirectEventCount) { directLog.textContent = ''; renderedDirectEventCount = 0; }
921
+ for (let i = renderedDirectEventCount; i < directEvents.length; i += 1) {
922
+ const line = `[${directEvents[i].channel || 'system'}] ${directEvents[i].text}\n`;
923
+ directLog.appendChild(document.createTextNode(line));
924
+ }
925
+ renderedDirectEventCount = directEvents.length;
926
+ const ds = (conv.compareRun && conv.compareRun.status) || 'running';
927
+ if (ds === 'running') directLog.scrollTop = directLog.scrollHeight;
928
+ }
929
+ }
664
930
 
665
931
  // Micro-manage — only append new events. textContent assignment on the
666
932
  // <pre> wipes the entire log every tick which is wasteful.
@@ -686,64 +952,104 @@ function renderActive() {
686
952
  // foldRunIntoConversation); for runs that have not yet polled we
687
953
  // simply hide the surfaces.
688
954
  renderTracker(conv);
689
- renderTotals(conv);
690
- syncTemplatePickerVisibility();
691
- // Browser-tab title mirrors the active conversation (R3).
692
- document.title = conv.title ? conv.title : 'AI Hub';
693
- renderedStatus = conv.status;
694
- }
695
-
696
- function renderConversationIdentity(conv) {
697
- const host = els['active-identity'];
698
- if (!host) return;
699
- const persona = getConversationPersona(conv);
700
- const employee = getEmployeeStatus(conv.employeeId);
701
- host.innerHTML = '';
702
-
703
- const avatar = document.createElement(persona && persona.avatarUrl ? 'img' : 'span');
704
- avatar.className = 'identity-avatar';
705
- if (persona && persona.avatarUrl) {
706
- avatar.src = persona.avatarUrl;
707
- avatar.alt = persona.displayName;
708
- } else {
709
- avatar.textContent = initialBadge(employee?.label || 'Hub');
710
- }
711
-
712
- const text = document.createElement('span');
713
- text.className = 'identity-copy';
714
-
715
- const name = document.createElement('strong');
716
- name.textContent = persona ? persona.displayName : (employee ? employee.label : 'AI Employee');
717
-
718
- const title = document.createElement('small');
719
- title.textContent = getEmployeeTitle(conv);
720
-
721
- text.appendChild(name);
722
- text.appendChild(title);
723
- host.appendChild(avatar);
724
- host.appendChild(text);
725
- }
726
-
727
- function renderRunStatePill(conv) {
728
- const pill = els['run-state-pill'];
729
- if (!pill) return;
730
- pill.textContent = statusLabel(conv.status).toUpperCase();
731
- pill.className = `run-state-pill ${conv.status}`;
732
- }
733
-
734
- function buildConversationSummary(conv) {
735
- const employeeReply = latestEmployeeSurfaceText(conv);
736
- if (employeeReply) return clampSummaryText(employeeReply);
737
- if (conv.status === 'running') return 'The employee is working through your request.';
738
- if (conv.status === 'failed') return 'This run needs your attention before it can continue.';
739
- return 'The latest work is ready for review.';
740
- }
741
-
742
- function clampSummaryText(text, maxChars = 260) {
743
- const raw = String(text || '').replace(/\s+/g, ' ').trim();
744
- if (raw.length <= maxChars) return raw;
745
- return raw.slice(0, maxChars - 1).trimEnd() + '…';
746
- }
955
+ renderTotals(conv);
956
+ syncTemplatePickerVisibility();
957
+ // Issue #442: Direct panel status pill, simple progress indicator, and totals.
958
+ if (isABConv) {
959
+ // Mirror the job title from A into the B topline.
960
+ const directTitleEl = document.querySelector('#ab-direct-panel .conv-job-title');
961
+ if (directTitleEl) directTitleEl.textContent = conv.title || '';
962
+
963
+ const ds = (conv.compareRun && conv.compareRun.status) || 'running';
964
+ const dpill = document.getElementById('ab-direct-pill');
965
+ if (dpill) {
966
+ dpill.textContent = statusLabel(ds).toUpperCase();
967
+ dpill.className = `run-state-pill ${ds}`;
968
+ }
969
+ // Simple Running→Done progress row (mirrors FRAIM pizza tracker position).
970
+ const dprogress = els['ab-direct-progress'];
971
+ if (dprogress) {
972
+ if (ds === 'running') { dprogress.textContent = '● Running'; dprogress.className = 'ab-direct-progress running'; }
973
+ else if (ds === 'completed') { dprogress.textContent = '✓ Done'; dprogress.className = 'ab-direct-progress done'; }
974
+ else if (ds === 'failed') { dprogress.textContent = '✗ Failed'; dprogress.className = 'ab-direct-progress failed'; }
975
+ else { dprogress.textContent = statusLabel(ds); dprogress.className = 'ab-direct-progress'; }
976
+ }
977
+ // Direct totals row — same format as FRAIM's totals row.
978
+ renderDirectTotals(conv);
979
+ // Direct send button — enabled only when idle and session is resumable.
980
+ const dsend = els['ab-direct-send'];
981
+ if (dsend) {
982
+ const dinput = document.getElementById('ab-direct-input');
983
+ const hasSession = !!(conv.compareRun && conv.compareRun.sessionId);
984
+ const notRunning = ds !== 'running';
985
+ dsend.disabled = !hasSession || !notRunning || !(dinput && dinput.value.trim());
986
+ }
987
+ // Align B's header rows to A's so the thread panels start at the same Y.
988
+ requestAnimationFrame(() => {
989
+ const aTopline = document.querySelector('#active-conv > .conv-topline');
990
+ const aStatus = document.querySelector('#active-conv > .conversation-status');
991
+ const bTopline = document.querySelector('#ab-direct-panel > .conv-topline');
992
+ const bStatus = document.querySelector('#ab-direct-panel > .conversation-status');
993
+ if (aTopline && bTopline) bTopline.style.minHeight = aTopline.offsetHeight + 'px';
994
+ if (aStatus && bStatus) bStatus.style.minHeight = aStatus.offsetHeight + 'px';
995
+ });
996
+ }
997
+ // Browser-tab title mirrors the active conversation (R3).
998
+ document.title = conv.title ? conv.title : 'AI Hub';
999
+ renderedStatus = conv.status;
1000
+ }
1001
+
1002
+ function renderConversationIdentity(conv) {
1003
+ const host = els['active-identity'];
1004
+ if (!host) return;
1005
+ const persona = getConversationPersona(conv);
1006
+ const employee = getEmployeeStatus(conv.employeeId);
1007
+ host.innerHTML = '';
1008
+
1009
+ const avatar = document.createElement(persona && persona.avatarUrl ? 'img' : 'span');
1010
+ avatar.className = 'identity-avatar';
1011
+ if (persona && persona.avatarUrl) {
1012
+ avatar.src = persona.avatarUrl;
1013
+ avatar.alt = persona.displayName;
1014
+ } else {
1015
+ avatar.textContent = initialBadge(employee?.label || 'Hub');
1016
+ }
1017
+
1018
+ const text = document.createElement('span');
1019
+ text.className = 'identity-copy';
1020
+
1021
+ const name = document.createElement('strong');
1022
+ name.textContent = persona ? persona.displayName : (employee ? employee.label : 'AI Employee');
1023
+
1024
+ const title = document.createElement('small');
1025
+ title.textContent = getEmployeeTitle(conv);
1026
+
1027
+ text.appendChild(name);
1028
+ text.appendChild(title);
1029
+ host.appendChild(avatar);
1030
+ host.appendChild(text);
1031
+ }
1032
+
1033
+ function renderRunStatePill(conv) {
1034
+ const pill = els['run-state-pill'];
1035
+ if (!pill) return;
1036
+ pill.textContent = statusLabel(conv.status).toUpperCase();
1037
+ pill.className = `run-state-pill ${conv.status}`;
1038
+ }
1039
+
1040
+ function buildConversationSummary(conv) {
1041
+ const employeeReply = latestEmployeeSurfaceText(conv);
1042
+ if (employeeReply) return clampSummaryText(employeeReply);
1043
+ if (conv.status === 'running') return 'The employee is working through your request.';
1044
+ if (conv.status === 'failed') return 'This run needs your attention before it can continue.';
1045
+ return 'The latest work is ready for review.';
1046
+ }
1047
+
1048
+ function clampSummaryText(text, maxChars = 260) {
1049
+ const raw = String(text || '').replace(/\s+/g, ' ').trim();
1050
+ if (raw.length <= maxChars) return raw;
1051
+ return raw.slice(0, maxChars - 1).trimEnd() + '…';
1052
+ }
747
1053
 
748
1054
  // Render the inline employee selector shown in the coach section of an
749
1055
  // active conversation. Allows switching agents without reopening the modal.
@@ -905,7 +1211,7 @@ function syncTrackerNote(conv) {
905
1211
  }
906
1212
  }
907
1213
 
908
- // Issue #347 R4 — render the totals line below the textarea.
1214
+ // Issue #347 R4 — render the totals line inside the tracker section.
909
1215
  function renderTotals(conv) {
910
1216
  const totals = els['totals'];
911
1217
  if (!totals) return;
@@ -914,18 +1220,44 @@ function renderTotals(conv) {
914
1220
  totals.hidden = true;
915
1221
  return;
916
1222
  }
1223
+ // Totals now live inside the tracker section; only render when visible.
1224
+ const tracker = els['tracker'];
1225
+ if (tracker && tracker.hidden) {
1226
+ totals.hidden = true;
1227
+ return;
1228
+ }
917
1229
  totals.hidden = false;
918
1230
  const tokens = data.tokenTotals || {};
919
1231
  const tokenLabel = formatTokens(tokens);
920
1232
  const costLabel = formatCost(tokens);
921
1233
  totals.innerHTML = '';
922
- pushTotalsSpan(totals, formatDuration(data.totalDurationMs), 'total', 'total: from start to now');
923
- pushTotalsSpan(totals, formatDuration(data.workingDurationMs), 'working', 'working: while the employee was running');
924
- pushTotalsSpan(totals, formatDuration(data.waitingDurationMs), 'waiting', 'waiting: while waiting for you');
1234
+ const fmtDur = (ms) => ms >= 60000 ? formatDuration(ms) : ms > 0 ? `${Math.round(ms / 1000)}s` : '—';
1235
+ pushTotalsSpan(totals, fmtDur(data.totalDurationMs), 'total', 'total: from start to now');
1236
+ pushTotalsSpan(totals, fmtDur(data.workingDurationMs), 'working', 'working: while the employee was running');
1237
+ pushTotalsSpan(totals, fmtDur(data.waitingDurationMs), 'waiting', 'waiting: while waiting for you');
925
1238
  pushTotalsSpan(totals, tokenLabel, 'tokens', 'tokens: from each phase report; some agents do not yet emit usage data');
926
1239
  pushTotalsSpan(totals, costLabel, '', "cost: derived from token totals and the agent's published per-million rate");
927
1240
  }
928
1241
 
1242
+ // Issue #442: render Direct totals row using the same pushTotalsSpan helper as FRAIM.
1243
+ function renderDirectTotals(conv) {
1244
+ const dtotals = els['ab-direct-totals'];
1245
+ if (!dtotals) return;
1246
+ const data = conv.compareRun && conv.compareRun.totals;
1247
+ if (!data) { dtotals.hidden = true; return; }
1248
+ dtotals.hidden = false;
1249
+ dtotals.innerHTML = '';
1250
+ const tokens = data.tokenTotals || {};
1251
+ const durMs = data.totalDurationMs || 0;
1252
+ const durLabel = durMs >= 60000 ? formatDuration(durMs) : durMs > 0 ? `${Math.round(durMs / 1000)}s` : '—';
1253
+ pushTotalsSpan(dtotals, durLabel, 'total', 'total: from start to now');
1254
+ pushTotalsSpan(dtotals, formatTokens(tokens), 'tokens', 'tokens from Direct run');
1255
+ pushTotalsSpan(dtotals, formatCost(tokens), '', 'cost of Direct run');
1256
+ }
1257
+
1258
+
1259
+
1260
+
929
1261
  function pushTotalsSpan(host, value, suffix, title) {
930
1262
  if (host.children.length > 0) {
931
1263
  const sep = document.createElement('span');
@@ -1036,42 +1368,44 @@ function renderTemplatePopover() {
1036
1368
 
1037
1369
  function openTemplatePopover() {
1038
1370
  const popover = els['template-popover'];
1039
- const btn = els['template-picker-btn'];
1040
- if (!popover || !btn) return;
1371
+ if (!popover) return;
1041
1372
  renderTemplatePopover();
1373
+ // Anchor fixed popover above the trigger button, right-aligned to its right edge.
1374
+ const trigger = els['other-manager-jobs-btn'];
1375
+ if (trigger) {
1376
+ const rect = trigger.getBoundingClientRect();
1377
+ popover.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
1378
+ popover.style.bottom = Math.max(8, window.innerHeight - rect.top + 8) + 'px';
1379
+ popover.style.left = 'auto';
1380
+ popover.style.top = 'auto';
1381
+ }
1042
1382
  popover.hidden = false;
1043
- btn.setAttribute('aria-expanded', 'true');
1383
+ els['template-picker-btn']?.setAttribute('aria-expanded', 'true');
1044
1384
  }
1045
1385
 
1046
1386
  function closeTemplatePopover() {
1047
1387
  const popover = els['template-popover'];
1048
- const btn = els['template-picker-btn'];
1049
- if (!popover || !btn) return;
1388
+ if (!popover) return;
1050
1389
  popover.hidden = true;
1051
- btn.setAttribute('aria-expanded', 'false');
1052
- }
1053
-
1054
- function applyTemplateInvocation(managerJobId) {
1055
- const conv = activeConversation();
1056
- // Use the conversation's own employee for the invocation symbol, NOT
1057
- // the manager's last selection in another conversation (R2.5).
1058
- const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
1059
- const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1060
- const invocation = `${symbol} ${managerJobId}`;
1061
- const textarea = els['coach-text'];
1062
- const prior = textarea.value;
1063
- let combined;
1064
- if (prior.trim().length === 0) {
1065
- combined = invocation;
1066
- } else {
1067
- const strippedPrior = prior.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ').replace(/\s+/g, ' ').trim();
1068
- combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
1069
- }
1070
- textarea.value = combined;
1071
- // Caret at the end.
1072
- textarea.setSelectionRange(combined.length, combined.length);
1390
+ els['template-picker-btn']?.setAttribute('aria-expanded', 'false');
1391
+ }
1392
+
1393
+ function applyTemplateInvocation(managerJobId) {
1394
+ // The invocation prefix ($fraim / /fraim) is an implementation detail that
1395
+ // the server adds based on the active employee the user should never see it.
1396
+ // Store the job ID as a pending coaching job. The #coach-note shows a
1397
+ // human-readable label; the textarea stays focused for optional extra context.
1398
+ // When the user clicks Send, continueRun sends { coachingJobId, instructions }.
1399
+ const label = managerJobId.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
1400
+ // Strip any prior /fraim invocation text already in the textarea (backward-compat
1401
+ // for sessions that stored raw invocation text before this change).
1402
+ const textarea = els['coach-text'];
1403
+ textarea.value = textarea.value
1404
+ .replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
1405
+ .replace(/\s+/g, ' ')
1406
+ .trim();
1073
1407
  textarea.focus();
1074
- syncSendButton();
1408
+ setPendingCoachingJob(managerJobId, label);
1075
1409
  closeTemplatePopover();
1076
1410
  }
1077
1411
 
@@ -1081,174 +1415,167 @@ function derivedStage(conv) {
1081
1415
  return { text: 'Done — please review', kind: 'done' };
1082
1416
  }
1083
1417
 
1084
- function derivedLatest(conv) {
1085
- if (conv.status !== 'running') return '';
1086
- const employeeReply = latestEmployeeSurfaceText(conv);
1087
- if (employeeReply) return employeeReply;
1088
- if (conv.status === 'running') return 'Working on it…';
1089
- return '';
1090
- }
1091
-
1092
- function humanizeSlug(slug) {
1093
- return String(slug || '')
1094
- .split('-')
1095
- .filter(Boolean)
1096
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1097
- .join(' ');
1098
- }
1099
-
1100
- function stripStubReference(text) {
1101
- return String(text || '')
1102
- .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
1103
- .replace(/\s+/g, ' ')
1104
- .trim();
1105
- }
1106
-
1107
- function surfaceText(role, text, conv) {
1108
- const raw = stripStubReference(text);
1109
- if (!raw) return '';
1110
-
1111
- if (role === 'manager') {
1112
- const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
1113
- if (invocationOnly) return `Run ${humanizeSlug(invocationOnly[1])}.`;
1114
- const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
1115
- const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
1116
- return raw
1117
- .replace(invocationWithSlug, '')
1118
- .replace(/^(?:[$/]fraim)\s*/i, '')
1119
- .trim();
1120
- }
1121
-
1122
- if (role === 'employee') {
1123
- const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
1124
- if (startedMatch) {
1125
- const cleaned = surfaceText('manager', startedMatch[1], conv);
1126
- if (conv.status === 'completed') return 'Done — please review.';
1127
- return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
1128
- }
1129
- }
1130
-
1131
- if (role === 'employee') {
1132
- const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
1133
- if (resumedMatch) {
1134
- const cleaned = surfaceText('manager', resumedMatch[1], conv);
1135
- if (conv.status === 'completed') return 'Done - please review.';
1136
- return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
1137
- }
1138
- }
1139
-
1140
- return raw;
1141
- }
1142
-
1143
- function extractExplicitFraimInvocation(text) {
1144
- const raw = String(text || '');
1145
- const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
1146
- if (!match || match.index == null) return null;
1147
- const before = raw.slice(0, match.index).trim();
1148
- const after = raw.slice(match.index + match[0].length).trim();
1149
- const remainder = [before, after].filter(Boolean).join('\n\n').trim();
1150
- return {
1151
- symbol: match[1],
1152
- jobId: match[2],
1153
- remainder,
1154
- };
1155
- }
1156
-
1157
- function latestEmployeeSurfaceText(conv) {
1158
- const messages = conv.messages || [];
1159
- for (let i = messages.length - 1; i >= 0; i -= 1) {
1160
- if (messages[i].role !== 'employee') continue;
1161
- const cleaned = surfaceText('employee', messages[i].text, conv);
1162
- if (cleaned) return cleaned;
1163
- }
1164
- return '';
1165
- }
1166
-
1167
- function appendMessageDom(role, text, conv) {
1168
- const article = document.createElement('article');
1169
- article.className = 'message ' + role;
1170
-
1171
- const meta = document.createElement('div');
1172
- meta.className = 'message-meta';
1173
-
1174
- const avatar = document.createElement('span');
1175
- avatar.className = `message-avatar ${role}`;
1176
-
1177
- if (role === 'employee') {
1178
- const persona = getConversationPersona(conv);
1179
- if (persona && persona.avatarUrl) {
1180
- const img = document.createElement('img');
1181
- img.src = persona.avatarUrl;
1182
- img.alt = persona.displayName;
1183
- avatar.appendChild(img);
1184
- } else {
1185
- avatar.textContent = initialBadge(roleLabel(role, conv));
1186
- }
1187
- } else if (role === 'manager') {
1188
- avatar.textContent = 'M';
1189
- } else {
1190
- avatar.textContent = '•';
1191
- }
1192
-
1193
- const who = document.createElement('span');
1194
- who.className = 'who';
1195
- who.textContent = roleLabel(role, conv);
1196
-
1197
- const lane = document.createElement('span');
1198
- lane.className = 'lane-label';
1199
- lane.textContent = role === 'manager'
1200
- ? 'Manager direction'
1201
- : role === 'employee'
1202
- ? 'Employee response'
1203
- : 'System update';
1204
-
1205
- meta.appendChild(avatar);
1206
- meta.appendChild(who);
1207
- meta.appendChild(lane);
1208
- article.appendChild(meta);
1209
-
1210
- const bubble = document.createElement('div');
1211
- bubble.className = 'bubble';
1212
- bubble.textContent = surfaceText(role, text, conv) || (
1213
- role === 'employee'
1214
- ? (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…')
1215
- : text
1216
- );
1217
- if (role === 'manager') {
1218
- const raw = document.createElement('span');
1219
- raw.className = 'transport-raw';
1220
- raw.textContent = text;
1221
- article.appendChild(raw);
1222
- }
1223
- article.appendChild(bubble);
1224
- els['messages'].appendChild(article);
1225
- }
1226
-
1227
- function scrollThreadForReview(conv) {
1228
- const host = els['messages'];
1229
- if (!host) return;
1230
- const nodes = [...host.querySelectorAll('.message')];
1231
- if (nodes.length === 0) return;
1232
-
1233
- if (conv.status === 'running') {
1234
- host.scrollTop = host.scrollHeight;
1235
- return;
1236
- }
1237
-
1238
- const target = [...nodes].reverse().find((node) =>
1239
- node.classList.contains('employee') || node.classList.contains('system')
1240
- ) || nodes[nodes.length - 1];
1241
-
1242
- const hostRect = host.getBoundingClientRect();
1243
- const targetRect = target.getBoundingClientRect();
1244
- const currentTop = host.scrollTop;
1245
- const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
1246
- const reviewOffset = Math.max(24, host.clientHeight * 0.16);
1247
- const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
1248
- host.scrollTo({ top: desiredTop, behavior: 'smooth' });
1249
- }
1250
-
1251
- function syncSendButton() {
1418
+ function derivedLatest(conv) {
1419
+ if (conv.status !== 'running') return '';
1420
+ const employeeReply = latestEmployeeSurfaceText(conv);
1421
+ if (employeeReply) return employeeReply;
1422
+ if (conv.status === 'running') return 'Working on it…';
1423
+ return '';
1424
+ }
1425
+
1426
+ function humanizeSlug(slug) {
1427
+ return String(slug || '')
1428
+ .split('-')
1429
+ .filter(Boolean)
1430
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1431
+ .join(' ');
1432
+ }
1433
+
1434
+ function stripStubReference(text) {
1435
+ return String(text || '')
1436
+ .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
1437
+ .replace(/\s+/g, ' ')
1438
+ .trim();
1439
+ }
1440
+
1441
+ function surfaceText(role, text, conv) {
1442
+ const raw = stripStubReference(text);
1443
+ if (!raw) return '';
1444
+
1445
+ if (role === 'manager') {
1446
+ const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
1447
+ if (invocationOnly) return raw;
1448
+ const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
1449
+ const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
1450
+ return raw
1451
+ .replace(invocationWithSlug, '')
1452
+ .replace(/^(?:[$/]fraim)\s*/i, '')
1453
+ .trim();
1454
+ }
1455
+
1456
+ if (role === 'employee') {
1457
+ const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
1458
+ if (startedMatch) {
1459
+ const cleaned = surfaceText('manager', startedMatch[1], conv);
1460
+ if (conv.status === 'completed') return 'Done — please review.';
1461
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
1462
+ }
1463
+ }
1464
+
1465
+ if (role === 'employee') {
1466
+ const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
1467
+ if (resumedMatch) {
1468
+ const cleaned = surfaceText('manager', resumedMatch[1], conv);
1469
+ if (conv.status === 'completed') return 'Done - please review.';
1470
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
1471
+ }
1472
+ }
1473
+
1474
+ return raw;
1475
+ }
1476
+
1477
+ function latestEmployeeSurfaceText(conv) {
1478
+ const messages = conv.messages || [];
1479
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1480
+ if (messages[i].role !== 'employee') continue;
1481
+ const cleaned = surfaceText('employee', messages[i].text, conv);
1482
+ if (cleaned) return cleaned;
1483
+ }
1484
+ return '';
1485
+ }
1486
+
1487
+ function appendMessageDom(role, text, conv) {
1488
+ const article = document.createElement('article');
1489
+ article.className = 'message ' + role;
1490
+
1491
+ const meta = document.createElement('div');
1492
+ meta.className = 'message-meta';
1493
+
1494
+ const avatar = document.createElement('span');
1495
+ avatar.className = `message-avatar ${role}`;
1496
+
1497
+ if (role === 'employee') {
1498
+ const persona = getConversationPersona(conv);
1499
+ if (persona && persona.avatarUrl) {
1500
+ const img = document.createElement('img');
1501
+ img.src = persona.avatarUrl;
1502
+ img.alt = persona.displayName;
1503
+ avatar.appendChild(img);
1504
+ } else {
1505
+ avatar.textContent = initialBadge(roleLabel(role, conv));
1506
+ }
1507
+ } else if (role === 'manager') {
1508
+ avatar.textContent = 'M';
1509
+ } else {
1510
+ avatar.textContent = '•';
1511
+ }
1512
+
1513
+ const who = document.createElement('span');
1514
+ who.className = 'who';
1515
+ who.textContent = roleLabel(role, conv);
1516
+
1517
+ const lane = document.createElement('span');
1518
+ lane.className = 'lane-label';
1519
+ lane.textContent = role === 'manager'
1520
+ ? 'Manager direction'
1521
+ : role === 'employee'
1522
+ ? 'Employee response'
1523
+ : 'System update';
1524
+
1525
+ meta.appendChild(avatar);
1526
+ meta.appendChild(who);
1527
+ meta.appendChild(lane);
1528
+ article.appendChild(meta);
1529
+
1530
+ const bubble = document.createElement('div');
1531
+ bubble.className = 'bubble';
1532
+ if (role === 'employee') {
1533
+ const surfaced = surfaceText(role, text, conv);
1534
+ const raw = String(text || '');
1535
+ // surfaceText collapses whitespace which destroys markdown. Only use it
1536
+ // when it returned a transformed value (Started/Resumed prefix overrides).
1537
+ const useSurfaced = surfaced && surfaced !== raw.replace(/\s+/g, ' ').trim();
1538
+ const content = useSurfaced ? surfaced : raw;
1539
+ bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…'));
1540
+ } else {
1541
+ const surfaced = surfaceText(role, text, conv);
1542
+ bubble.textContent = surfaced || text;
1543
+ }
1544
+ if (role === 'manager') {
1545
+ const raw = document.createElement('span');
1546
+ raw.className = 'transport-raw';
1547
+ raw.textContent = text;
1548
+ article.appendChild(raw);
1549
+ }
1550
+ article.appendChild(bubble);
1551
+ els['messages'].appendChild(article);
1552
+ }
1553
+
1554
+ function scrollThreadForReview(conv) {
1555
+ const host = els['messages'];
1556
+ if (!host) return;
1557
+ const nodes = [...host.querySelectorAll('.message')];
1558
+ if (nodes.length === 0) return;
1559
+
1560
+ if (conv.status === 'running') {
1561
+ host.scrollTop = host.scrollHeight;
1562
+ return;
1563
+ }
1564
+
1565
+ const target = [...nodes].reverse().find((node) =>
1566
+ node.classList.contains('employee') || node.classList.contains('system')
1567
+ ) || nodes[nodes.length - 1];
1568
+
1569
+ const hostRect = host.getBoundingClientRect();
1570
+ const targetRect = target.getBoundingClientRect();
1571
+ const currentTop = host.scrollTop;
1572
+ const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
1573
+ const reviewOffset = Math.max(24, host.clientHeight * 0.16);
1574
+ const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
1575
+ host.scrollTo({ top: desiredTop, behavior: 'smooth' });
1576
+ }
1577
+
1578
+ function syncSendButton() {
1252
1579
  const conv = activeConversation();
1253
1580
  const hasText = els['coach-text'].value.trim().length > 0;
1254
1581
  // Send is enabled as soon as the host session exists. We deliberately
@@ -1256,7 +1583,39 @@ function syncSendButton() {
1256
1583
  // coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
1257
1584
  // is for; the server only requires sessionId).
1258
1585
  const resumable = !!(conv && conv.sessionId);
1259
- els['send'].disabled = !(hasText && resumable);
1586
+ // A pending coaching job (set by quick-coach buttons or template picker)
1587
+ // is enough to enable Send even with an empty textarea — the user just
1588
+ // wants to fire that coaching directive, optionally with added context.
1589
+ const hasPendingJob = !!state.pendingCoachingJobId;
1590
+ els['send'].disabled = !((hasText || hasPendingJob) && resumable);
1591
+ }
1592
+
1593
+ // ---------------------------------------------------------------------------
1594
+ // Pending coaching job helpers
1595
+ // ---------------------------------------------------------------------------
1596
+
1597
+ // Set a pending coaching job selected via a quick-coach button or template
1598
+ // picker. The invocation is NOT put into the textarea — the server adds it
1599
+ // based on the active employee. The coach-note shows a human-readable label
1600
+ // so the user knows what will fire when they hit Send.
1601
+ function setPendingCoachingJob(jobId, label) {
1602
+ state.pendingCoachingJobId = jobId;
1603
+ state.pendingCoachingLabel = label;
1604
+ if (els['coach-note']) {
1605
+ els['coach-note'].textContent = `▶ ${label} — add context below (optional) then send`;
1606
+ els['coach-note'].classList.add('pending-job');
1607
+ }
1608
+ syncSendButton();
1609
+ }
1610
+
1611
+ function clearPendingCoachingJob() {
1612
+ state.pendingCoachingJobId = null;
1613
+ state.pendingCoachingLabel = null;
1614
+ if (els['coach-note']) {
1615
+ els['coach-note'].classList.remove('pending-job');
1616
+ // Note text is restored to normal status by the next renderActive call.
1617
+ }
1618
+ syncSendButton();
1260
1619
  }
1261
1620
 
1262
1621
  // ---------------------------------------------------------------------------
@@ -1502,6 +1861,16 @@ function renderEmployeeSelect() {
1502
1861
  els['employee-select'].appendChild(opt);
1503
1862
  }
1504
1863
  renderAgentInstallPanel();
1864
+ updateAbToggleVisibility();
1865
+ }
1866
+
1867
+ function updateAbToggleVisibility() {
1868
+ const wrap = els['ab-toggle-wrap'];
1869
+ if (!wrap) return;
1870
+ const sel = document.getElementById('employee-select');
1871
+ const empId = sel ? sel.value : (state.selectedEmployeeId || 'claude');
1872
+ const emp = (state.bootstrap && state.bootstrap.employees || []).find(e => e.id === empId);
1873
+ wrap.hidden = !(emp && emp.supportsRaw);
1505
1874
  }
1506
1875
 
1507
1876
  // ---------------------------------------------------------------------------
@@ -1694,50 +2063,26 @@ function deriveTitle(jobTitle, instructions) {
1694
2063
  // (per developers.openai.com/codex/skills: "type $ to mention a skill")
1695
2064
  // - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
1696
2065
  //
1697
- // Every command typed by the manager is prefixed with the agent's FRAIM
1698
- // symbol so the host always sees that this is a FRAIM job, not a freeform
1699
- // prompt. Follow-up coaching keeps the jobId too; otherwise headless hosts
1700
- // lose which FRAIM workflow the manager meant to run.
1701
- const FRAIM_INVOCATION_SYMBOL = {
1702
- codex: '$fraim',
1703
- claude: '/fraim',
1704
- gemini: '/fraim',
1705
- };
1706
-
1707
- function fraimInvocationFor(employeeId, jobId, kind) {
1708
- if (jobId === '__freeform__') return null;
1709
- const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1710
- return `${symbol} ${jobId}`;
1711
- }
1712
-
1713
- // Wrap the manager's typed instructions with the host-appropriate FRAIM
1714
- // invocation. The wrapped text is what we ACTUALLY send to the host CLI
1715
- // AND what we show in the timeline so the manager sees what the agent
1716
- // received. For freeform jobs (no FRAIM job assigned), the instructions
1717
- // are sent verbatim with no invocation prefix.
1718
- function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
1719
- const trimmed = (instructions || '').trim();
1720
- const explicit = extractExplicitFraimInvocation(trimmed);
1721
- const effectiveJobId = explicit?.jobId || jobId;
1722
- const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
1723
- // Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
1724
- if (!invocation) return explicit?.remainder || trimmed;
1725
- const remainder = explicit ? explicit.remainder : trimmed;
1726
- const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
1727
- if (!remainder) return `${invocation}${stub}`;
1728
- return `${invocation}${stub}\n\n${remainder}`;
1729
- }
1730
-
2066
+ // The browser sends raw manager instructions. AI Hub normalizes assigned
2067
+ // FRAIM jobs into host-facing invocations so the Hub UI and channel callers
2068
+ // share one start/continue contract.
1731
2069
  async function startRun(job, instructions, employeeId) {
1732
- // Prefix the manager's typed instructions with the FRAIM invocation so
1733
- // the underlying host actually launches the right job. The prefixed
1734
- // text is what the host receives AND what we show in the timeline.
1735
- // Freeform jobs carry no job stub and no FRAIM invocation prefix.
1736
2070
  const isFreeform = job.id === '__freeform__';
1737
- const absoluteStubPath = (!isFreeform && job.stubPath && state.projectPath)
1738
- ? [state.projectPath, job.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
1739
- : undefined;
1740
- const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions, absoluteStubPath);
2071
+ // In task-pane/extension mode: get a fresh selection snapshot and prepend
2072
+ // document context to the instructions so the agent knows what the user is
2073
+ // looking at. stubPath resolution and FRAIM invocation are now server-side.
2074
+ let effectiveInstructions = instructions || '';
2075
+ if (document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension') {
2076
+ const fresh = await requestWordContext('get-selection').catch(() => null);
2077
+ const wc = (fresh && (fresh.selection || fresh.hasSelection)) ? { ...state.wordContext, ...fresh } : state.wordContext;
2078
+ const ctxBlock = buildWordContextBlock(wc);
2079
+ if (ctxBlock) effectiveInstructions = ctxBlock + '\n\n---\n\n' + effectiveInstructions;
2080
+ }
2081
+
2082
+ // Issue #442: read the A/B toggle state from the modal before it closes.
2083
+ const abToggle = document.getElementById('ab-toggle');
2084
+ const isAB = !isFreeform && abToggle && abToggle.checked;
2085
+
1741
2086
  const conv = {
1742
2087
  id: newConversationId(),
1743
2088
  projectPath: state.projectPath,
@@ -1750,10 +2095,16 @@ async function startRun(job, instructions, employeeId) {
1750
2095
  runId: null,
1751
2096
  sessionId: null,
1752
2097
  status: 'running',
1753
- messages: [{ role: 'manager', text: agentMessage, at: Date.now() }],
2098
+ messages: [],
1754
2099
  events: [],
1755
2100
  artifacts: [],
1756
2101
  lastUpdatedAt: Date.now(),
2102
+ // Issue #442: A/B mode fields (only set when toggle was on).
2103
+ compareMode: isAB ? 'ab' : undefined,
2104
+ compareRunId: null,
2105
+ compareRun: null,
2106
+ // Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
2107
+ wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
1757
2108
  };
1758
2109
  upsertConversation(conv);
1759
2110
  state.activeId = conv.id;
@@ -1769,11 +2120,17 @@ async function startRun(job, instructions, employeeId) {
1769
2120
  projectPath: state.projectPath,
1770
2121
  hostId: employeeId,
1771
2122
  jobId: job.id,
1772
- message: agentMessage,
2123
+ instructions: effectiveInstructions,
2124
+ ...(isAB ? { compareMode: 'ab', directInstructions: effectiveInstructions } : {}),
1773
2125
  }),
1774
2126
  });
1775
2127
  conv.runId = run.id;
1776
2128
  foldRunIntoConversation(conv, run);
2129
+ // Issue #442: when the server created a paired Direct run, capture it.
2130
+ if (run.compareRunId) {
2131
+ conv.compareRunId = run.compareRunId;
2132
+ if (run.compareRun) foldCompareRunIntoConversation(conv, run.compareRun);
2133
+ }
1777
2134
  upsertConversation(conv);
1778
2135
  renderRail();
1779
2136
  renderActive();
@@ -1791,10 +2148,14 @@ async function startRun(job, instructions, employeeId) {
1791
2148
  // Start a NEW run on an existing conversation with a different agent.
1792
2149
  // The conversation's message history stays intact; only the run restarts.
1793
2150
  async function restartConvWithAgent(conv, newAgentId, text) {
1794
- const agentMessage = buildAgentMessage(newAgentId, conv.jobId, 'start', text);
2151
+ // Snapshot both the message history and the event log so they survive the
2152
+ // restart. foldRunIntoConversation replaces these arrays on every call
2153
+ // (including poll ticks), so we must store them on the conv object itself —
2154
+ // not as local variables that only live until the first poll fires.
2155
+ conv._priorMessages = Array.isArray(conv.messages) ? [...conv.messages] : [];
2156
+ conv._priorEvents = Array.isArray(conv.events) ? [...conv.events] : [];
1795
2157
  conv.status = 'running';
1796
2158
  conv.sessionId = null;
1797
- conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
1798
2159
  upsertConversation(conv);
1799
2160
  renderRail();
1800
2161
  renderActive();
@@ -1806,11 +2167,13 @@ async function restartConvWithAgent(conv, newAgentId, text) {
1806
2167
  projectPath: state.projectPath,
1807
2168
  hostId: newAgentId,
1808
2169
  jobId: conv.jobId,
1809
- message: agentMessage,
2170
+ instructions: text,
1810
2171
  }),
1811
2172
  });
1812
2173
  conv.runId = run.id;
1813
2174
  foldRunIntoConversation(conv, run);
2175
+ // Note: foldRunIntoConversation now prepends _priorMessages/_priorEvents
2176
+ // on every call, so the history is preserved through poll ticks too.
1814
2177
  upsertConversation(conv);
1815
2178
  renderRail();
1816
2179
  renderActive();
@@ -1828,11 +2191,12 @@ async function restartConvWithAgent(conv, newAgentId, text) {
1828
2191
  async function continueRun(text) {
1829
2192
  const conv = activeConversation();
1830
2193
  if (!conv || !conv.runId) return;
1831
- // Prefix follow-up coaching with @fraim so the host knows this is a
1832
- // continuation of the active FRAIM session rather than a fresh prompt.
1833
- const agentMessage = buildAgentMessage(conv.employeeId, conv.jobId, 'continue', text);
2194
+ // Consume any pending coaching job selected via quick-coach or template picker.
2195
+ // The server adds the correct invocation prefix ($fraim / /fraim) based on
2196
+ // the active employee the user never sees raw invocation syntax.
2197
+ const coachingJobId = state.pendingCoachingJobId || undefined;
2198
+ clearPendingCoachingJob();
1834
2199
  conv.status = 'running';
1835
- conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
1836
2200
  upsertConversation(conv);
1837
2201
  renderRail();
1838
2202
  renderActive();
@@ -1840,7 +2204,7 @@ async function continueRun(text) {
1840
2204
  const run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
1841
2205
  method: 'POST',
1842
2206
  headers: { 'Content-Type': 'application/json' },
1843
- body: JSON.stringify({ message: agentMessage }),
2207
+ body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
1844
2208
  });
1845
2209
  foldRunIntoConversation(conv, run);
1846
2210
  upsertConversation(conv);
@@ -1869,15 +2233,19 @@ function foldRunIntoConversation(conv, run) {
1869
2233
  totals: run.totals || null,
1870
2234
  };
1871
2235
  // Replace the conversation's events with the run's events (single source of truth on the server).
1872
- conv.events = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
1873
- // Append any employee messages we don't already have.
1874
- const existingEmployeeTexts = new Set(conv.messages.filter((m) => m.role === 'employee').map((m) => m.text));
1875
- for (const m of run.messages || []) {
1876
- if (m.role === 'employee' && !existingEmployeeTexts.has(m.text)) {
1877
- conv.messages.push({ role: 'employee', text: m.text, at: Date.now() });
1878
- existingEmployeeTexts.add(m.text);
1879
- }
1880
- }
2236
+ // If this conversation was restarted on a different agent, prepend the prior run's events so
2237
+ // the micro-log shows the full diagnostic history across all run attempts (Issue #495).
2238
+ const newEvents = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
2239
+ const priorEvents = Array.isArray(conv._priorEvents) ? conv._priorEvents : [];
2240
+ conv.events = priorEvents.length > 0 ? [...priorEvents, ...newEvents] : newEvents;
2241
+ // Same treatment for messages: prepend any cross-run history captured at restart time.
2242
+ const newMessages = (run.messages || []).map((m) => ({
2243
+ role: m.role,
2244
+ text: m.text,
2245
+ at: Date.parse(m.createdAt) || Date.now(),
2246
+ }));
2247
+ const priorMessages = Array.isArray(conv._priorMessages) ? conv._priorMessages : [];
2248
+ conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
1881
2249
  // Track session for resumption.
1882
2250
  if (run.sessionId) conv.sessionId = run.sessionId;
1883
2251
  // R4: persist the persona key from the server-side run record.
@@ -1898,6 +2266,21 @@ function foldRunIntoConversation(conv, run) {
1898
2266
  conv.lastUpdatedAt = Date.now();
1899
2267
  }
1900
2268
 
2269
+ // Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
2270
+ function foldCompareRunIntoConversation(conv, compareRun) {
2271
+ if (!compareRun) return;
2272
+ conv.compareRun = {
2273
+ id: compareRun.id,
2274
+ sessionId: compareRun.sessionId || null,
2275
+ status: compareRun.status || 'running',
2276
+ stages: compareRun.stages || [],
2277
+ currentPhase: compareRun.currentPhase || null,
2278
+ totals: compareRun.totals || null,
2279
+ messages: compareRun.messages || [],
2280
+ events: compareRun.events || [],
2281
+ };
2282
+ }
2283
+
1901
2284
  const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-\.\/]*?(?:docs|public|src|tests)\/[A-Za-z0-9_\-\.\/]+\.[A-Za-z0-9]+)/;
1902
2285
  // Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
1903
2286
  // raw learnings, evidence dumps, mock files), not deliverables the
@@ -1921,20 +2304,33 @@ function startPolling() {
1921
2304
  state.pollHandle = window.setInterval(async () => {
1922
2305
  const conv = activeConversation();
1923
2306
  if (!conv || !conv.runId) return;
1924
- if (conv.status !== 'running') {
2307
+ // Issue #442: for A/B conversations keep polling until BOTH sides are done.
2308
+ const fraimDone = conv.status !== 'running';
2309
+ const directDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
2310
+ if (fraimDone && directDone) {
1925
2311
  window.clearInterval(state.pollHandle);
1926
2312
  state.pollHandle = null;
1927
2313
  return;
1928
2314
  }
1929
2315
  try {
1930
- const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
1931
- foldRunIntoConversation(conv, run);
2316
+ if (!fraimDone) {
2317
+ const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
2318
+ foldRunIntoConversation(conv, run);
2319
+ }
2320
+ // Issue #442: also poll the compare run when present and not yet terminal.
2321
+ if (conv.compareRunId && !directDone) {
2322
+ const compareRun = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}`);
2323
+ foldCompareRunIntoConversation(conv, compareRun);
2324
+ }
1932
2325
  upsertConversation(conv);
1933
2326
  renderRail();
1934
2327
  renderActive();
1935
- if (conv.status !== 'running') {
2328
+ const nowFraimDone = conv.status !== 'running';
2329
+ const nowDirectDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
2330
+ if (nowFraimDone && nowDirectDone) {
1936
2331
  window.clearInterval(state.pollHandle);
1937
2332
  state.pollHandle = null;
2333
+ tryWordWriteBack(conv);
1938
2334
  }
1939
2335
  } catch (error) {
1940
2336
  console.warn('Polling failed:', error);
@@ -1942,13 +2338,49 @@ function startPolling() {
1942
2338
  }, 1000);
1943
2339
  }
1944
2340
 
2341
+ // Issue #489: When a job completes in Word task-pane surface, write the final
2342
+ // agent output back into the document with tracked changes so the user can
2343
+ // accept/reject via Word's normal review flow.
2344
+ async function tryWordWriteBack(conv) {
2345
+ if (conv.wordWrittenBack) return;
2346
+ if (document.body.dataset.surface !== 'task-pane') return;
2347
+ if (!state.wordContext || !state.wordContext.docTitle) return;
2348
+ if (conv.status !== 'completed') return;
2349
+
2350
+ const employeeMessages = (conv.messages || []).filter(m => m.role === 'employee');
2351
+ if (employeeMessages.length === 0) return;
2352
+ const outputText = employeeMessages[employeeMessages.length - 1].text;
2353
+ if (!outputText || !outputText.trim()) return;
2354
+
2355
+ // Mark before async calls to guard against concurrent poll ticks.
2356
+ conv.wordWrittenBack = true;
2357
+ upsertConversation(conv);
2358
+
2359
+ try {
2360
+ await requestWordContext('track-changes-on');
2361
+ const action = conv.wordStartedWithSelection ? 'insert-after' : 'append-to-doc';
2362
+ await requestWordContext(action, { text: outputText });
2363
+ await requestWordContext('track-changes-off');
2364
+ showStatus('Result written to document — review tracked changes in Word.');
2365
+ } catch (e) {
2366
+ console.warn('Word write-back failed:', e);
2367
+ }
2368
+ }
2369
+
2370
+ function convNeedsPolling(conv) {
2371
+ if (!conv || !conv.runId) return false;
2372
+ const fraimRunning = conv.status === 'running';
2373
+ const directRunning = !!conv.compareRunId && (!conv.compareRun || conv.compareRun.status === 'running');
2374
+ return fraimRunning || directRunning;
2375
+ }
2376
+
1945
2377
  function switchToConversation(id) {
1946
2378
  state.activeId = id;
1947
2379
  persistConversations();
1948
2380
  renderRail();
1949
2381
  renderActive();
1950
2382
  const conv = activeConversation();
1951
- if (conv && conv.status === 'running' && conv.runId) {
2383
+ if (convNeedsPolling(conv)) {
1952
2384
  startPolling();
1953
2385
  } else if (state.pollHandle) {
1954
2386
  window.clearInterval(state.pollHandle);
@@ -1967,11 +2399,9 @@ function showStatus(text, isError) {
1967
2399
 
1968
2400
  async function pickProject() {
1969
2401
  try {
1970
- const response = await fetch('/api/ai-hub/project-path/pick', { method: 'POST' });
1971
- if (response.status === 204) return;
1972
- const payload = await response.json();
1973
- if (!response.ok) throw new Error(payload.error || 'Could not choose a folder.');
1974
- if (payload.path) {
2402
+ // 204 = user cancelled the OS folder picker; requestJson returns null for empty bodies.
2403
+ const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
2404
+ if (payload && payload.path) {
1975
2405
  await loadBootstrap(payload.path);
1976
2406
  }
1977
2407
  } catch (error) {
@@ -2001,6 +2431,7 @@ function showStep2(job) {
2001
2431
  els['start'].disabled = true;
2002
2432
  els['step1'].hidden = true;
2003
2433
  els['step2'].hidden = false;
2434
+ renderWordContextInModal();
2004
2435
  setTimeout(() => els['instructions'].focus(), 50);
2005
2436
  }
2006
2437
 
@@ -2041,6 +2472,11 @@ function wireEvents() {
2041
2472
  const employeeId = els['employee-select'].value || state.selectedEmployeeId;
2042
2473
  state.selectedEmployeeId = employeeId;
2043
2474
  closeModal();
2475
+ // R2: if no project path is set, show the inline project picker instead.
2476
+ if (!state.projectPath) {
2477
+ renderFirstRunLanding('hub');
2478
+ return;
2479
+ }
2044
2480
  await startRun(job, text, employeeId);
2045
2481
  });
2046
2482
  els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
@@ -2051,7 +2487,9 @@ function wireEvents() {
2051
2487
  els['coach-text'].addEventListener('input', syncSendButton);
2052
2488
  els['send'].addEventListener('click', async () => {
2053
2489
  const text = els['coach-text'].value.trim();
2054
- if (!text) return;
2490
+ // Allow send when textarea is empty IFF a coaching job is pending — the
2491
+ // user clicked a quick-coach button and just wants to fire that job.
2492
+ if (!text && !state.pendingCoachingJobId) return;
2055
2493
  els['coach-text'].value = '';
2056
2494
  syncSendButton();
2057
2495
  // If the user changed the agent via the inline selector, restart with the
@@ -2068,25 +2506,109 @@ function wireEvents() {
2068
2506
  }
2069
2507
  });
2070
2508
 
2071
- if (els['active-employee-select']) {
2072
- els['active-employee-select'].addEventListener('change', () => {
2509
+ if (els['active-employee-select']) {
2510
+ els['active-employee-select'].addEventListener('change', () => {
2073
2511
  // Only update the global preference here. Do NOT update conv.employeeId —
2074
2512
  // the send handler compares sel.value vs conv.employeeId to detect a
2075
2513
  // switch; updating conv here would make them equal and the restart would
2076
2514
  // never fire.
2077
- state.selectedEmployeeId = els['active-employee-select'].value;
2078
- });
2079
- }
2080
-
2081
- if (els['coach-panel']) {
2082
- els['coach-panel'].addEventListener('toggle', () => {
2083
- const conv = activeConversation();
2084
- if (!conv) return;
2085
- panelStateFor(conv.id).coach = els['coach-panel'].open;
2086
- });
2087
- }
2088
-
2089
- // Issue #347 R2 — template picker.
2515
+ state.selectedEmployeeId = els['active-employee-select'].value;
2516
+ });
2517
+ }
2518
+
2519
+ // Issue #442: keep A/B toggle visible only when the selected employee supports
2520
+ // direct-path invocation (supportsRaw). Also wire the explanation paragraph.
2521
+ if (els['employee-select']) {
2522
+ els['employee-select'].addEventListener('change', updateAbToggleVisibility);
2523
+ }
2524
+ const abToggleCheckbox = document.getElementById('ab-toggle');
2525
+ if (abToggleCheckbox) {
2526
+ abToggleCheckbox.addEventListener('change', () => {
2527
+ const exp = document.getElementById('ab-toggle-explanation');
2528
+ if (exp) exp.hidden = !abToggleCheckbox.checked;
2529
+ // R1.3: Start button label changes to "Start A/B test" when toggle is on.
2530
+ if (els['start']) els['start'].textContent = abToggleCheckbox.checked ? 'Start A/B test' : 'Start';
2531
+ });
2532
+ }
2533
+
2534
+ // Issue #442: Direct panel send button.
2535
+ const abDirectInput = document.getElementById('ab-direct-input');
2536
+ if (abDirectInput && els['ab-direct-send']) {
2537
+ abDirectInput.addEventListener('input', () => {
2538
+ const conv = activeConversation();
2539
+ const hasSession = !!(conv && conv.compareRun && conv.compareRun.sessionId);
2540
+ const notRunning = !conv || (conv.compareRun && conv.compareRun.status) !== 'running';
2541
+ els['ab-direct-send'].disabled = !hasSession || !notRunning || !abDirectInput.value.trim();
2542
+ });
2543
+ abDirectInput.addEventListener('keydown', (e) => {
2544
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!els['ab-direct-send'].disabled) els['ab-direct-send'].click(); }
2545
+ });
2546
+ els['ab-direct-send'].addEventListener('click', async () => {
2547
+ const conv = activeConversation();
2548
+ if (!conv || !conv.compareRunId) return;
2549
+ const text = abDirectInput.value.trim();
2550
+ if (!text) return;
2551
+ abDirectInput.value = '';
2552
+ els['ab-direct-send'].disabled = true;
2553
+ try {
2554
+ const updated = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}/direct-messages`, {
2555
+ method: 'POST',
2556
+ headers: { 'Content-Type': 'application/json' },
2557
+ body: JSON.stringify({ message: text }),
2558
+ });
2559
+ foldCompareRunIntoConversation(conv, updated);
2560
+ renderActive();
2561
+ } catch (err) {
2562
+ console.error('Direct send failed', err);
2563
+ }
2564
+ });
2565
+ }
2566
+
2567
+ if (els['coach-panel']) {
2568
+ els['coach-panel'].addEventListener('toggle', () => {
2569
+ const conv = activeConversation();
2570
+ if (!conv) return;
2571
+ panelStateFor(conv.id).coach = els['coach-panel'].open;
2572
+ // R3.3: keep coach-summary visibility in sync when toggled directly.
2573
+ if (els['coach-summary']) els['coach-summary'].hidden = els['coach-panel'].open;
2574
+ });
2575
+ }
2576
+
2577
+ // R6: thread panel toggle — persist open/close state per conversation.
2578
+ if (els['thread-panel']) {
2579
+ els['thread-panel'].addEventListener('toggle', () => {
2580
+ const conv = activeConversation();
2581
+ if (!conv) return;
2582
+ panelStateFor(conv.id).thread = els['thread-panel'].open;
2583
+ });
2584
+ }
2585
+
2586
+ // R5: quick-access coaching buttons.
2587
+ // The invocation syntax ($fraim / /fraim) stays out of the UI entirely —
2588
+ // the server adds it based on the active employee. Clicking a button sets a
2589
+ // pending coaching job (shown in #coach-note); the user can add optional
2590
+ // context in the textarea and then click Send.
2591
+ if (els['quick-coach-btns']) {
2592
+ els['quick-coach-btns'].addEventListener('click', (e) => {
2593
+ const btn = e.target.closest('.quick-coach-btn');
2594
+ if (!btn) return;
2595
+ const jobId = btn.dataset.job;
2596
+ if (!jobId) return;
2597
+ const label = btn.textContent.trim();
2598
+ setPendingCoachingJob(jobId, label);
2599
+ els['coach-text'].focus();
2600
+ });
2601
+ }
2602
+ if (els['other-manager-jobs-btn']) {
2603
+ els['other-manager-jobs-btn'].addEventListener('click', (e) => {
2604
+ e.stopPropagation();
2605
+ const popover = els['template-popover'];
2606
+ if (popover && popover.hidden === false) closeTemplatePopover();
2607
+ else openTemplatePopover();
2608
+ });
2609
+ }
2610
+
2611
+ // Issue #347 R2 — template picker.
2090
2612
  if (els['template-picker-btn']) {
2091
2613
  els['template-picker-btn'].addEventListener('click', (e) => {
2092
2614
  e.stopPropagation();
@@ -2097,7 +2619,7 @@ function wireEvents() {
2097
2619
  document.addEventListener('click', (e) => {
2098
2620
  const popover = els['template-popover'];
2099
2621
  if (!popover || popover.hidden) return;
2100
- if (!e.target.closest('#template-popover') && !e.target.closest('#template-picker-btn')) {
2622
+ if (!e.target.closest('#template-popover') && !e.target.closest('#other-manager-jobs-btn')) {
2101
2623
  closeTemplatePopover();
2102
2624
  }
2103
2625
  });
@@ -2116,6 +2638,24 @@ function wireEvents() {
2116
2638
  }
2117
2639
  bindEnterToButton(els['coach-text'], els['send']);
2118
2640
  bindEnterToButton(els['instructions'], els['start']);
2641
+
2642
+ // Word context bar refresh button.
2643
+ if (els['word-ctx-refresh']) {
2644
+ els['word-ctx-refresh'].addEventListener('click', async () => {
2645
+ const fresh = await requestWordContext('get-context');
2646
+ if (fresh) { state.wordContext = fresh; renderWordContextBar(); renderWordContextInModal(); }
2647
+ });
2648
+ }
2649
+ // Word context card expand/collapse toggle.
2650
+ if (els['word-ctx-card-toggle']) {
2651
+ els['word-ctx-card-toggle'].addEventListener('click', () => {
2652
+ const body = els['word-ctx-card-body'];
2653
+ if (!body) return;
2654
+ const expanded = !body.hidden;
2655
+ body.hidden = expanded;
2656
+ els['word-ctx-card-toggle'].textContent = expanded ? '▸' : '▾';
2657
+ });
2658
+ }
2119
2659
  }
2120
2660
 
2121
2661
  // ---------------------------------------------------------------------------
@@ -2123,6 +2663,9 @@ function wireEvents() {
2123
2663
  // ---------------------------------------------------------------------------
2124
2664
 
2125
2665
  (async function init() {
2666
+ // html.electron is already set by the inline script in <head> for zero-flash.
2667
+ // This is a belt-and-suspenders fallback in case the inline script was skipped.
2668
+
2126
2669
  gatherElements();
2127
2670
  loadConversationsFromStorage();
2128
2671
  wirePopovers();
@@ -2130,14 +2673,34 @@ function wireEvents() {
2130
2673
 
2131
2674
  const urlParams = new URLSearchParams(window.location.search);
2132
2675
  const isFirstRun = urlParams.get('firstRun') === 'true';
2676
+ const surface = urlParams.get('surface') || 'hub';
2677
+ const docUrl = urlParams.get('docUrl') || '';
2678
+
2679
+ // Apply surface class before bootstrap so CSS takes effect immediately.
2680
+ if (surface !== 'hub') document.body.dataset.surface = surface;
2681
+
2682
+ // Register the Word context bridge (no-op outside iframe context).
2683
+ setupWordBridge();
2133
2684
 
2134
2685
  try {
2135
- await loadBootstrap();
2686
+ await loadBootstrap(null, docUrl);
2136
2687
  } catch (error) {
2137
2688
  showStatus(error.message, true);
2138
2689
  return;
2139
2690
  }
2140
2691
 
2692
+ // Task-pane and extension surfaces: skip welcome/onboarding, show active job
2693
+ // or open the new-job picker. Layout is compact (rail hidden via CSS).
2694
+ if (surface === 'task-pane' || surface === 'extension') {
2695
+ const conv = activeConversation();
2696
+ if (conv && conv.projectPath === state.projectPath) {
2697
+ if (convNeedsPolling(conv)) startPolling();
2698
+ } else {
2699
+ openModal();
2700
+ }
2701
+ return;
2702
+ }
2703
+
2141
2704
  if (isFirstRun) {
2142
2705
  renderFirstRunLanding();
2143
2706
  return;
@@ -2156,7 +2719,7 @@ function wireEvents() {
2156
2719
  // If an active conversation belongs to the loaded project and is still running, resume polling.
2157
2720
  const conv = activeConversation();
2158
2721
  if (conv && conv.projectPath === state.projectPath) {
2159
- if (conv.status === 'running' && conv.runId) startPolling();
2722
+ if (convNeedsPolling(conv)) startPolling();
2160
2723
  } else {
2161
2724
  state.activeId = null;
2162
2725
  persistConversations();
@@ -2211,10 +2774,11 @@ async function autoOnboardProject() {
2211
2774
  }
2212
2775
  }
2213
2776
 
2214
- function renderFirstRunLanding() {
2215
- // Show first-run onboarding screen over the normal Hub UI.
2216
- // The user picks a project folder here; clicking Start fires the
2217
- // Project Onboarding job automatically (R4.3/R4.4).
2777
+ function renderFirstRunLanding(mode) {
2778
+ // mode === 'hub' → R2: shown when a job is started without a project path.
2779
+ // After picking, reload bootstrap and return to the hub.
2780
+ // mode === 'firstrun' legacy path: shown on ?firstRun=true; auto-starts onboarding.
2781
+ // Default: 'firstrun' for backward compatibility.
2218
2782
  const existing = document.getElementById('fraim-first-run-landing');
2219
2783
  if (existing) existing.remove();
2220
2784
 
@@ -2234,14 +2798,14 @@ function renderFirstRunLanding() {
2234
2798
  'box-shadow:0 1px 2px rgba(20,40,30,.04);',
2235
2799
  ].join('');
2236
2800
 
2237
- const title = document.createElement('h1');
2238
- title.textContent = "Let's get started";
2801
+ const title = document.createElement('h2');
2802
+ title.textContent = 'Pick a project folder';
2239
2803
  title.style.cssText = 'font-size:22px;font-weight:600;margin:0;';
2240
2804
  card.appendChild(title);
2241
2805
 
2242
2806
  const desc = document.createElement('p');
2243
- desc.textContent = "Let's introduce your AI employees to their first project.";
2244
- desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;';
2807
+ desc.textContent = "Your AI employees need a project to work in. Pick the folder where your code lives. Think of it like onboarding a new hire — you're giving them a home and asking them to learn everything they can about it.";
2808
+ desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;line-height:1.55;';
2245
2809
  card.appendChild(desc);
2246
2810
 
2247
2811
  const folderLabel = document.createElement('label');
@@ -2277,11 +2841,8 @@ function renderFirstRunLanding() {
2277
2841
  ].join('');
2278
2842
  browseBtn.addEventListener('click', async () => {
2279
2843
  try {
2280
- const response = await fetch('/api/ai-hub/project-path/pick', { method: 'POST' });
2281
- if (response.status === 204) return;
2282
- const payload = await response.json();
2283
- if (!response.ok) throw new Error(payload.error || 'Could not pick folder.');
2284
- if (payload.path) {
2844
+ const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
2845
+ if (payload && payload.path) {
2285
2846
  await loadBootstrap(payload.path);
2286
2847
  folderInput.value = state.projectPath;
2287
2848
  folderInput.style.color = 'var(--text,#1f2a24)';
@@ -2300,10 +2861,10 @@ function renderFirstRunLanding() {
2300
2861
 
2301
2862
  const startBtn = document.createElement('button');
2302
2863
  startBtn.type = 'button';
2303
- startBtn.textContent = 'Start onboarding →';
2864
+ startBtn.textContent = 'Start in this project →';
2304
2865
  startBtn.disabled = !state.projectPath;
2305
2866
  startBtn.style.cssText = [
2306
- 'background:var(--accent,#3d8a6e);color:#fff;border:none;border-radius:8px;',
2867
+ 'background:var(--accent,#1f437d);color:#fff;border:none;border-radius:8px;',
2307
2868
  'padding:11px 20px;font-size:14px;font-weight:600;cursor:pointer;',
2308
2869
  'opacity:' + (state.projectPath ? '1' : '0.5') + ';',
2309
2870
  'transition:opacity .15s;',
@@ -2311,16 +2872,24 @@ function renderFirstRunLanding() {
2311
2872
  startBtn.addEventListener('click', async () => {
2312
2873
  if (!state.projectPath) return;
2313
2874
  startBtn.disabled = true;
2314
- startBtn.textContent = 'Starting…';
2315
2875
  overlay.remove();
2876
+ if (mode === 'hub') {
2877
+ // R2: reload bootstrap for the chosen project and return to hub.
2878
+ try {
2879
+ await loadBootstrap(state.projectPath);
2880
+ } catch (err) {
2881
+ showStatus(err.message, true);
2882
+ }
2883
+ return;
2884
+ }
2885
+ // Legacy first-run path: auto-start project-onboarding.
2886
+ startBtn.textContent = 'Starting…';
2316
2887
  renderActive();
2317
- // Find the project-onboarding job from bootstrap, fall back to direct POST.
2318
2888
  const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
2319
2889
  const employeeId = state.selectedEmployeeId || 'claude';
2320
2890
  if (job) {
2321
2891
  await startRun(job, 'Onboard this project', employeeId);
2322
2892
  } else {
2323
- // Job not in catalog yet (project not initialized) — POST directly.
2324
2893
  try {
2325
2894
  const run = await requestJson('/api/ai-hub/runs', {
2326
2895
  method: 'POST',