fraim-framework 2.0.153 → 2.0.159

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.
@@ -21,10 +21,10 @@ 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
+ };
28
28
 
29
29
  const els = {};
30
30
 
@@ -37,13 +37,14 @@ function gatherElements() {
37
37
  'project-button', 'project-name',
38
38
  'new-conv-btn', 'conv-list',
39
39
  // 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',
40
+ 'team-roster',
41
+ 'empty', 'active-conv', 'active-title', 'active-identity', 'run-state-pill',
42
+ 'messages',
43
+ 'coach-text', 'send', 'micro-manage', 'micro-log',
44
+ 'status-line', 'coach-note',
45
+ 'coach-panel', 'coach-summary',
46
+ 'thread-panel', 'quick-coach-btns', 'other-manager-jobs-btn',
47
+ 'modal', 'step1', 'step2',
47
48
  'cancel1', 'next1', 'back2', 'start',
48
49
  'job-search', 'job-catalog', 'job-pick-status',
49
50
  // Issue #385: hire-required notice, persona job filter
@@ -56,6 +57,9 @@ function gatherElements() {
56
57
  'tracker', 'tracker-rows', 'tracker-note',
57
58
  'template-picker-btn', 'template-popover',
58
59
  'totals',
60
+ // Issue #442: A/B mode elements.
61
+ 'ab-toggle-wrap', 'ab-direct-panel',
62
+ 'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
59
63
  ];
60
64
  for (const id of ids) {
61
65
  els[id] = document.getElementById(id);
@@ -226,34 +230,129 @@ function findConversation(id) {
226
230
  return null;
227
231
  }
228
232
 
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
- }
233
+ function activeConversation() {
234
+ if (!state.activeId) return null;
235
+ return findConversation(state.activeId);
236
+ }
237
+
238
+ function panelStateFor(convId) {
239
+ if (!convId) return {};
240
+ if (!state.panelState[convId]) state.panelState[convId] = {};
241
+ return state.panelState[convId];
242
+ }
243
+
244
+ function defaultCoachOpen(conv) {
245
+ return true;
246
+ }
247
+
248
+ // R4: resolve employee display name for the coach label.
249
+ function coachEmployeeLabel(conv) {
250
+ if (!conv) return 'Maestro';
251
+ if (conv.personaKey) {
252
+ const persona = (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey);
253
+ if (persona) return persona.displayName;
254
+ }
255
+ if (conv.employeeId) {
256
+ const emp = getEmployeeStatus(conv.employeeId);
257
+ if (emp) return emp.label;
258
+ }
259
+ return 'Maestro';
260
+ }
261
+
262
+ // R4: update coach label with employee name + initial badge.
263
+ function syncCoachEmployeeLabel(conv) {
264
+ const label = document.querySelector('.active-employee-label');
265
+ if (!label) return;
266
+ const name = coachEmployeeLabel(conv);
267
+ label.textContent = '';
268
+ // Use persona avatar image when available (matches renderConversationIdentity).
269
+ const persona = conv && conv.personaKey
270
+ ? (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey)
271
+ : null;
272
+ let badge;
273
+ if (persona && persona.avatarUrl) {
274
+ badge = document.createElement('img');
275
+ badge.className = 'coach-employee-badge coach-employee-avatar';
276
+ badge.src = persona.avatarUrl;
277
+ badge.alt = name;
278
+ } else {
279
+ badge = document.createElement('span');
280
+ badge.className = 'coach-employee-badge';
281
+ badge.textContent = name.slice(0, 1).toUpperCase();
282
+ }
283
+ const nameSpan = document.createElement('span');
284
+ nameSpan.className = 'coach-label-name';
285
+ nameSpan.textContent = name;
286
+ label.appendChild(badge);
287
+ label.appendChild(nameSpan);
288
+ }
289
+
290
+ // R5: show/hide quick-access coaching buttons based on managerTemplates.
291
+ function syncQuickCoachButtons(conv) {
292
+ const row = els['quick-coach-btns'];
293
+ if (!row) return;
294
+ const hasTemplates = state.bootstrap && state.bootstrap.managerTemplates && state.bootstrap.managerTemplates.length > 0;
295
+ const hasRun = conv && (conv.status === 'running' || conv.status === 'completed');
296
+ row.hidden = !(hasTemplates && hasRun);
297
+ }
298
+
299
+ // R8: render markdown subset safely. HTML is escaped first.
300
+ function formatEmployeeText(text) {
301
+ if (!text) return '';
302
+ // 1. Escape HTML entities.
303
+ let s = text
304
+ .replace(/&/g, '&')
305
+ .replace(/</g, '&lt;')
306
+ .replace(/>/g, '&gt;')
307
+ .replace(/"/g, '&quot;');
308
+ // 2. Fenced code blocks (triple backtick).
309
+ s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
310
+ // 3. Inline code (single backtick, non-greedy, no newlines).
311
+ s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
312
+ // 4. Bold (**text**).
313
+ s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
314
+ // 5–7. Process line by line for lists and paragraphs.
315
+ const lines = s.split('\n');
316
+ const out = [];
317
+ let ulOpen = false;
318
+ let olOpen = false;
319
+ for (const line of lines) {
320
+ const ulMatch = line.match(/^[-*] (.+)/);
321
+ const olMatch = line.match(/^\d+\. (.+)/);
322
+ if (ulMatch) {
323
+ if (olOpen) { out.push('</ol>'); olOpen = false; }
324
+ if (!ulOpen) { out.push('<ul>'); ulOpen = true; }
325
+ out.push(`<li>${ulMatch[1]}</li>`);
326
+ } else if (olMatch) {
327
+ if (ulOpen) { out.push('</ul>'); ulOpen = false; }
328
+ if (!olOpen) { out.push('<ol>'); olOpen = true; }
329
+ out.push(`<li>${olMatch[1]}</li>`);
330
+ } else {
331
+ if (ulOpen) { out.push('</ul>'); ulOpen = false; }
332
+ if (olOpen) { out.push('</ol>'); olOpen = false; }
333
+ if (line.trim()) out.push(`<p>${line}</p>`);
334
+ }
335
+ }
336
+ if (ulOpen) out.push('</ul>');
337
+ if (olOpen) out.push('</ol>');
338
+ return out.join('\n');
339
+ }
340
+
341
+ function syncConversationPanels(conv, switchedConv) {
342
+ const coach = els['coach-panel'];
343
+ if (!conv || !coach) return;
344
+ const panelState = panelStateFor(conv.id);
345
+ if (switchedConv) {
346
+ coach.open = panelState.coach ?? defaultCoachOpen(conv);
347
+ if (els['thread-panel']) {
348
+ els['thread-panel'].open = panelState.thread ?? true;
349
+ }
350
+ }
351
+ // R3.3: hide the summary hint when the coach panel is open.
352
+ if (els['coach-summary']) {
353
+ els['coach-summary'].hidden = coach.open;
354
+ }
355
+ }
257
356
 
258
357
  function upsertConversation(conv) {
259
358
  const list = projectConversations().slice();
@@ -327,54 +426,61 @@ function renderRail() {
327
426
  if (conv.status === 'running') statusSpan.classList.add('running');
328
427
  if (conv.status === 'failed') statusSpan.classList.add('failed');
329
428
  btn.appendChild(statusSpan);
429
+ // Issue #442: A/B badge on rail entry.
430
+ if (conv.compareMode === 'ab') {
431
+ const badge = document.createElement('span');
432
+ badge.className = 'ab-badge';
433
+ badge.textContent = 'A/B';
434
+ btn.appendChild(badge);
435
+ }
330
436
  btn.addEventListener('click', () => switchToConversation(conv.id));
331
437
  els['conv-list'].appendChild(btn);
332
438
  }
333
439
  }
334
440
 
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
- }
441
+ function statusLabel(s) {
442
+ if (s === 'running') return 'Running';
443
+ if (s === 'failed') return 'Needs you';
444
+ return 'Done';
445
+ }
446
+
447
+ function personaMap() {
448
+ const map = new Map();
449
+ for (const persona of state.bootstrap?.personas || []) {
450
+ map.set(persona.key, persona);
451
+ }
452
+ return map;
453
+ }
454
+
455
+ function getConversationPersona(conv) {
456
+ if (!conv || !conv.personaKey) return null;
457
+ return personaMap().get(conv.personaKey) || null;
458
+ }
459
+
460
+ function getEmployeeStatus(employeeId) {
461
+ return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
462
+ }
463
+
464
+ function getEmployeeTitle(conv) {
465
+ const persona = getConversationPersona(conv);
466
+ if (persona) return persona.role;
467
+ const employee = getEmployeeStatus(conv?.employeeId);
468
+ return employee ? employee.label : 'AI Employee';
469
+ }
470
+
471
+ function roleLabel(role, conv) {
472
+ if (role === 'manager') return 'Manager';
473
+ if (role === 'employee') {
474
+ const persona = getConversationPersona(conv);
475
+ return persona ? persona.displayName : 'Employee';
476
+ }
477
+ return 'System';
478
+ }
479
+
480
+ function initialBadge(text) {
481
+ const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
482
+ return (cleaned.slice(0, 2) || 'FH');
483
+ }
378
484
 
379
485
  // ---------------------------------------------------------------------------
380
486
  // Issue #385 — Persona UI (R3 + R4)
@@ -393,62 +499,62 @@ function conversationTitle(conv) {
393
499
 
394
500
  // R4.2 — team roster: one avatar chip per hired persona above the conv list.
395
501
  // 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) {
502
+ function renderTeamRoster() {
503
+ const roster = els['team-roster'];
504
+ if (!roster) return;
505
+ const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
506
+ if (personas.length === 0) {
401
507
  roster.hidden = true;
402
508
  // Reset persona selection when there's no active subscription.
403
509
  if (state.selectedPersonaKey) state.selectedPersonaKey = null;
404
510
  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
- }
511
+ }
512
+ roster.hidden = false;
513
+ roster.innerHTML = '';
514
+ const allChip = document.createElement('button');
515
+ allChip.type = 'button';
516
+ allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
517
+ allChip.title = 'All employees';
518
+ allChip.setAttribute('aria-label', 'All employees');
519
+ allChip.innerHTML = `
520
+ <span class="roster-avatar">All</span>
521
+ <span class="roster-copy">
522
+ <strong>All employees</strong>
523
+ <small>Show every hired employee</small>
524
+ </span>
525
+ `;
526
+ allChip.addEventListener('click', () => setSelectedPersona(null));
527
+ roster.appendChild(allChip);
528
+ for (const persona of personas) {
529
+ const chip = document.createElement('button');
530
+ chip.type = 'button';
531
+ chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
532
+ chip.title = persona.displayName;
533
+ chip.setAttribute('aria-label', persona.displayName);
534
+ const avatar = document.createElement('span');
535
+ avatar.className = 'roster-avatar';
536
+ if (persona.avatarUrl) {
537
+ const img = document.createElement('img');
538
+ img.src = persona.avatarUrl;
539
+ img.alt = persona.displayName;
540
+ avatar.appendChild(img);
541
+ } else {
542
+ avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
543
+ }
544
+ const copy = document.createElement('span');
545
+ copy.className = 'roster-copy';
546
+ const name = document.createElement('strong');
547
+ name.textContent = persona.displayName;
548
+ const role = document.createElement('small');
549
+ role.textContent = persona.role || 'AI Employee';
550
+ copy.appendChild(name);
551
+ copy.appendChild(role);
552
+ chip.appendChild(avatar);
553
+ chip.appendChild(copy);
554
+ chip.addEventListener('click', () => setSelectedPersona(persona.key));
555
+ roster.appendChild(chip);
556
+ }
557
+ }
452
558
 
453
559
  // R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
454
560
  function buildAvatarChip(persona, size) {
@@ -566,101 +672,108 @@ function renderModalPersonaFilter() {
566
672
  // event rows are already in the DOM. Polling fires every second; without
567
673
  // this, every tick wiped the messages list and re-played the slidein
568
674
  // 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);
675
+ let renderedConvId = null;
676
+ let renderedMessageCount = 0;
677
+ let renderedEventCount = 0;
678
+ let renderedStatus = null;
679
+ let renderedDirectEventCount = 0;
680
+
681
+ function renderActive() {
682
+ const conv = activeConversation();
683
+ if (!conv) {
684
+ els['empty'].hidden = false;
685
+ els['active-conv'].hidden = true;
686
+ const _c = document.getElementById('conversation');
687
+ if (_c) _c.classList.remove('ab-mode');
688
+ if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
689
+ renderedConvId = null;
690
+ renderedMessageCount = 0;
691
+ renderedEventCount = 0;
692
+ renderedStatus = null;
693
+ renderedDirectEventCount = 0;
694
+ return;
695
+ }
696
+ els['empty'].hidden = true;
697
+ els['active-conv'].hidden = false;
698
+ els['active-title'].textContent = conversationTitle(conv);
699
+ renderConversationIdentity(conv);
700
+ renderRunStatePill(conv);
701
+ syncCoachEmployeeLabel(conv);
702
+ els['coach-note'].textContent = conv.status === 'running'
703
+ ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
704
+ : 'The employee is waiting on you. Send the next instruction to continue this run.';
604
705
 
605
706
  // If we switched conversations (or this is the first render), wipe and
606
707
  // 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 = '';
708
+ const switchedConv = renderedConvId !== conv.id;
709
+ if (switchedConv) {
610
710
  els['messages'].innerHTML = '';
711
+ els['messages'].className = 'messages';
611
712
  els['micro-log'].textContent = '';
612
713
  renderedConvId = conv.id;
613
714
  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;
715
+ renderedEventCount = 0;
716
+ renderedDirectEventCount = 0;
640
717
  }
718
+ const statusChanged = renderedStatus !== conv.status;
719
+ syncConversationPanels(conv, switchedConv);
720
+ syncQuickCoachButtons(conv);
641
721
 
642
722
  // Messages — append only the rows that aren't already in the DOM, so
643
723
  // existing rows don't re-animate. If for some reason the data shrunk
644
724
  // (server revoked a message), fall back to a full re-render.
645
725
  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
- }
726
+ const isABConv = conv.compareMode === 'ab';
727
+
728
+ // Issue #442: A/B split — full panel. #conversation becomes a flex row;
729
+ // FRAIM side is the unchanged #active-conv; Direct side is #ab-direct-panel.
730
+ const _container = document.getElementById('conversation');
731
+ if (isABConv) {
732
+ if (_container) _container.classList.add('ab-mode');
733
+ if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = false;
734
+ if (switchedConv) {
735
+ const dl = document.getElementById('ab-direct-log');
736
+ if (dl) dl.textContent = '';
737
+ renderedDirectEventCount = 0;
738
+ }
739
+ } else {
740
+ if (_container) _container.classList.remove('ab-mode');
741
+ if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
742
+ }
743
+
744
+ // FRAIM messages — render into #messages via appendMessageDom (same for AB and non-AB).
745
+ if (messages.length < renderedMessageCount) {
746
+ els['messages'].innerHTML = '';
747
+ renderedMessageCount = 0;
748
+ }
749
+ for (let i = renderedMessageCount; i < messages.length; i += 1) {
750
+ appendMessageDom(messages[i].role, messages[i].text, conv);
751
+ }
752
+ const appendedMessages = messages.length - renderedMessageCount;
753
+ renderedMessageCount = messages.length;
754
+ const m = els['messages'];
755
+ if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
756
+ m.scrollTop = m.scrollHeight;
757
+ } else if (switchedConv || statusChanged || appendedMessages > 0) {
758
+ scrollThreadForReview(conv);
759
+ }
760
+
761
+ // Direct raw event log — append-only like the FRAIM micro-log, shows all
762
+ // stdout/stderr/system events from the B run so the user sees everything.
763
+ if (isABConv) {
764
+ const directEvents = (conv.compareRun && conv.compareRun.events) || [];
765
+ const directLog = document.getElementById('ab-direct-log');
766
+ if (directLog) {
767
+ if (directEvents.length < renderedDirectEventCount) { directLog.textContent = ''; renderedDirectEventCount = 0; }
768
+ for (let i = renderedDirectEventCount; i < directEvents.length; i += 1) {
769
+ const line = `[${directEvents[i].channel || 'system'}] ${directEvents[i].text}\n`;
770
+ directLog.appendChild(document.createTextNode(line));
771
+ }
772
+ renderedDirectEventCount = directEvents.length;
773
+ const ds = (conv.compareRun && conv.compareRun.status) || 'running';
774
+ if (ds === 'running') directLog.scrollTop = directLog.scrollHeight;
775
+ }
776
+ }
664
777
 
665
778
  // Micro-manage — only append new events. textContent assignment on the
666
779
  // <pre> wipes the entire log every tick which is wasteful.
@@ -686,64 +799,104 @@ function renderActive() {
686
799
  // foldRunIntoConversation); for runs that have not yet polled we
687
800
  // simply hide the surfaces.
688
801
  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
- }
802
+ renderTotals(conv);
803
+ syncTemplatePickerVisibility();
804
+ // Issue #442: Direct panel status pill, simple progress indicator, and totals.
805
+ if (isABConv) {
806
+ // Mirror the job title from A into the B topline.
807
+ const directTitleEl = document.querySelector('#ab-direct-panel .conv-job-title');
808
+ if (directTitleEl) directTitleEl.textContent = conv.title || '';
809
+
810
+ const ds = (conv.compareRun && conv.compareRun.status) || 'running';
811
+ const dpill = document.getElementById('ab-direct-pill');
812
+ if (dpill) {
813
+ dpill.textContent = statusLabel(ds).toUpperCase();
814
+ dpill.className = `run-state-pill ${ds}`;
815
+ }
816
+ // Simple Running→Done progress row (mirrors FRAIM pizza tracker position).
817
+ const dprogress = els['ab-direct-progress'];
818
+ if (dprogress) {
819
+ if (ds === 'running') { dprogress.textContent = '● Running'; dprogress.className = 'ab-direct-progress running'; }
820
+ else if (ds === 'completed') { dprogress.textContent = '✓ Done'; dprogress.className = 'ab-direct-progress done'; }
821
+ else if (ds === 'failed') { dprogress.textContent = '✗ Failed'; dprogress.className = 'ab-direct-progress failed'; }
822
+ else { dprogress.textContent = statusLabel(ds); dprogress.className = 'ab-direct-progress'; }
823
+ }
824
+ // Direct totals row — same format as FRAIM's totals row.
825
+ renderDirectTotals(conv);
826
+ // Direct send button — enabled only when idle and session is resumable.
827
+ const dsend = els['ab-direct-send'];
828
+ if (dsend) {
829
+ const dinput = document.getElementById('ab-direct-input');
830
+ const hasSession = !!(conv.compareRun && conv.compareRun.sessionId);
831
+ const notRunning = ds !== 'running';
832
+ dsend.disabled = !hasSession || !notRunning || !(dinput && dinput.value.trim());
833
+ }
834
+ // Align B's header rows to A's so the thread panels start at the same Y.
835
+ requestAnimationFrame(() => {
836
+ const aTopline = document.querySelector('#active-conv > .conv-topline');
837
+ const aStatus = document.querySelector('#active-conv > .conversation-status');
838
+ const bTopline = document.querySelector('#ab-direct-panel > .conv-topline');
839
+ const bStatus = document.querySelector('#ab-direct-panel > .conversation-status');
840
+ if (aTopline && bTopline) bTopline.style.minHeight = aTopline.offsetHeight + 'px';
841
+ if (aStatus && bStatus) bStatus.style.minHeight = aStatus.offsetHeight + 'px';
842
+ });
843
+ }
844
+ // Browser-tab title mirrors the active conversation (R3).
845
+ document.title = conv.title ? conv.title : 'AI Hub';
846
+ renderedStatus = conv.status;
847
+ }
848
+
849
+ function renderConversationIdentity(conv) {
850
+ const host = els['active-identity'];
851
+ if (!host) return;
852
+ const persona = getConversationPersona(conv);
853
+ const employee = getEmployeeStatus(conv.employeeId);
854
+ host.innerHTML = '';
855
+
856
+ const avatar = document.createElement(persona && persona.avatarUrl ? 'img' : 'span');
857
+ avatar.className = 'identity-avatar';
858
+ if (persona && persona.avatarUrl) {
859
+ avatar.src = persona.avatarUrl;
860
+ avatar.alt = persona.displayName;
861
+ } else {
862
+ avatar.textContent = initialBadge(employee?.label || 'Hub');
863
+ }
864
+
865
+ const text = document.createElement('span');
866
+ text.className = 'identity-copy';
867
+
868
+ const name = document.createElement('strong');
869
+ name.textContent = persona ? persona.displayName : (employee ? employee.label : 'AI Employee');
870
+
871
+ const title = document.createElement('small');
872
+ title.textContent = getEmployeeTitle(conv);
873
+
874
+ text.appendChild(name);
875
+ text.appendChild(title);
876
+ host.appendChild(avatar);
877
+ host.appendChild(text);
878
+ }
879
+
880
+ function renderRunStatePill(conv) {
881
+ const pill = els['run-state-pill'];
882
+ if (!pill) return;
883
+ pill.textContent = statusLabel(conv.status).toUpperCase();
884
+ pill.className = `run-state-pill ${conv.status}`;
885
+ }
886
+
887
+ function buildConversationSummary(conv) {
888
+ const employeeReply = latestEmployeeSurfaceText(conv);
889
+ if (employeeReply) return clampSummaryText(employeeReply);
890
+ if (conv.status === 'running') return 'The employee is working through your request.';
891
+ if (conv.status === 'failed') return 'This run needs your attention before it can continue.';
892
+ return 'The latest work is ready for review.';
893
+ }
894
+
895
+ function clampSummaryText(text, maxChars = 260) {
896
+ const raw = String(text || '').replace(/\s+/g, ' ').trim();
897
+ if (raw.length <= maxChars) return raw;
898
+ return raw.slice(0, maxChars - 1).trimEnd() + '…';
899
+ }
747
900
 
748
901
  // Render the inline employee selector shown in the coach section of an
749
902
  // active conversation. Allows switching agents without reopening the modal.
@@ -905,7 +1058,7 @@ function syncTrackerNote(conv) {
905
1058
  }
906
1059
  }
907
1060
 
908
- // Issue #347 R4 — render the totals line below the textarea.
1061
+ // Issue #347 R4 — render the totals line inside the tracker section.
909
1062
  function renderTotals(conv) {
910
1063
  const totals = els['totals'];
911
1064
  if (!totals) return;
@@ -914,18 +1067,44 @@ function renderTotals(conv) {
914
1067
  totals.hidden = true;
915
1068
  return;
916
1069
  }
1070
+ // Totals now live inside the tracker section; only render when visible.
1071
+ const tracker = els['tracker'];
1072
+ if (tracker && tracker.hidden) {
1073
+ totals.hidden = true;
1074
+ return;
1075
+ }
917
1076
  totals.hidden = false;
918
1077
  const tokens = data.tokenTotals || {};
919
1078
  const tokenLabel = formatTokens(tokens);
920
1079
  const costLabel = formatCost(tokens);
921
1080
  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');
1081
+ const fmtDur = (ms) => ms >= 60000 ? formatDuration(ms) : ms > 0 ? `${Math.round(ms / 1000)}s` : '—';
1082
+ pushTotalsSpan(totals, fmtDur(data.totalDurationMs), 'total', 'total: from start to now');
1083
+ pushTotalsSpan(totals, fmtDur(data.workingDurationMs), 'working', 'working: while the employee was running');
1084
+ pushTotalsSpan(totals, fmtDur(data.waitingDurationMs), 'waiting', 'waiting: while waiting for you');
925
1085
  pushTotalsSpan(totals, tokenLabel, 'tokens', 'tokens: from each phase report; some agents do not yet emit usage data');
926
1086
  pushTotalsSpan(totals, costLabel, '', "cost: derived from token totals and the agent's published per-million rate");
927
1087
  }
928
1088
 
1089
+ // Issue #442: render Direct totals row using the same pushTotalsSpan helper as FRAIM.
1090
+ function renderDirectTotals(conv) {
1091
+ const dtotals = els['ab-direct-totals'];
1092
+ if (!dtotals) return;
1093
+ const data = conv.compareRun && conv.compareRun.totals;
1094
+ if (!data) { dtotals.hidden = true; return; }
1095
+ dtotals.hidden = false;
1096
+ dtotals.innerHTML = '';
1097
+ const tokens = data.tokenTotals || {};
1098
+ const durMs = data.totalDurationMs || 0;
1099
+ const durLabel = durMs >= 60000 ? formatDuration(durMs) : durMs > 0 ? `${Math.round(durMs / 1000)}s` : '—';
1100
+ pushTotalsSpan(dtotals, durLabel, 'total', 'total: from start to now');
1101
+ pushTotalsSpan(dtotals, formatTokens(tokens), 'tokens', 'tokens from Direct run');
1102
+ pushTotalsSpan(dtotals, formatCost(tokens), '', 'cost of Direct run');
1103
+ }
1104
+
1105
+
1106
+
1107
+
929
1108
  function pushTotalsSpan(host, value, suffix, title) {
930
1109
  if (host.children.length > 0) {
931
1110
  const sep = document.createElement('span');
@@ -1036,38 +1215,45 @@ function renderTemplatePopover() {
1036
1215
 
1037
1216
  function openTemplatePopover() {
1038
1217
  const popover = els['template-popover'];
1039
- const btn = els['template-picker-btn'];
1040
- if (!popover || !btn) return;
1218
+ if (!popover) return;
1041
1219
  renderTemplatePopover();
1220
+ // Anchor fixed popover above the trigger button, right-aligned to its right edge.
1221
+ const trigger = els['other-manager-jobs-btn'];
1222
+ if (trigger) {
1223
+ const rect = trigger.getBoundingClientRect();
1224
+ popover.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
1225
+ popover.style.bottom = Math.max(8, window.innerHeight - rect.top + 8) + 'px';
1226
+ popover.style.left = 'auto';
1227
+ popover.style.top = 'auto';
1228
+ }
1042
1229
  popover.hidden = false;
1043
- btn.setAttribute('aria-expanded', 'true');
1230
+ els['template-picker-btn']?.setAttribute('aria-expanded', 'true');
1044
1231
  }
1045
1232
 
1046
1233
  function closeTemplatePopover() {
1047
1234
  const popover = els['template-popover'];
1048
- const btn = els['template-picker-btn'];
1049
- if (!popover || !btn) return;
1235
+ if (!popover) return;
1050
1236
  popover.hidden = true;
1051
- btn.setAttribute('aria-expanded', 'false');
1237
+ els['template-picker-btn']?.setAttribute('aria-expanded', 'false');
1052
1238
  }
1053
1239
 
1054
- function applyTemplateInvocation(managerJobId) {
1055
- const conv = activeConversation();
1240
+ function applyTemplateInvocation(managerJobId) {
1241
+ const conv = activeConversation();
1056
1242
  // Use the conversation's own employee for the invocation symbol, NOT
1057
1243
  // the manager's last selection in another conversation (R2.5).
1058
1244
  const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
1059
1245
  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;
1246
+ const invocation = `${symbol} ${managerJobId}`;
1247
+ const textarea = els['coach-text'];
1248
+ const prior = textarea.value;
1249
+ let combined;
1250
+ if (prior.trim().length === 0) {
1251
+ combined = invocation;
1252
+ } else {
1253
+ const strippedPrior = prior.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ').replace(/\s+/g, ' ').trim();
1254
+ combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
1255
+ }
1256
+ textarea.value = combined;
1071
1257
  // Caret at the end.
1072
1258
  textarea.setSelectionRange(combined.length, combined.length);
1073
1259
  textarea.focus();
@@ -1081,174 +1267,181 @@ function derivedStage(conv) {
1081
1267
  return { text: 'Done — please review', kind: 'done' };
1082
1268
  }
1083
1269
 
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() {
1270
+ function derivedLatest(conv) {
1271
+ if (conv.status !== 'running') return '';
1272
+ const employeeReply = latestEmployeeSurfaceText(conv);
1273
+ if (employeeReply) return employeeReply;
1274
+ if (conv.status === 'running') return 'Working on it…';
1275
+ return '';
1276
+ }
1277
+
1278
+ function humanizeSlug(slug) {
1279
+ return String(slug || '')
1280
+ .split('-')
1281
+ .filter(Boolean)
1282
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1283
+ .join(' ');
1284
+ }
1285
+
1286
+ function stripStubReference(text) {
1287
+ return String(text || '')
1288
+ .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
1289
+ .replace(/\s+/g, ' ')
1290
+ .trim();
1291
+ }
1292
+
1293
+ function surfaceText(role, text, conv) {
1294
+ const raw = stripStubReference(text);
1295
+ if (!raw) return '';
1296
+
1297
+ if (role === 'manager') {
1298
+ const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
1299
+ if (invocationOnly) return raw;
1300
+ const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
1301
+ const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
1302
+ return raw
1303
+ .replace(invocationWithSlug, '')
1304
+ .replace(/^(?:[$/]fraim)\s*/i, '')
1305
+ .trim();
1306
+ }
1307
+
1308
+ if (role === 'employee') {
1309
+ const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
1310
+ if (startedMatch) {
1311
+ const cleaned = surfaceText('manager', startedMatch[1], conv);
1312
+ if (conv.status === 'completed') return 'Done — please review.';
1313
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
1314
+ }
1315
+ }
1316
+
1317
+ if (role === 'employee') {
1318
+ const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
1319
+ if (resumedMatch) {
1320
+ const cleaned = surfaceText('manager', resumedMatch[1], conv);
1321
+ if (conv.status === 'completed') return 'Done - please review.';
1322
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
1323
+ }
1324
+ }
1325
+
1326
+ return raw;
1327
+ }
1328
+
1329
+ function extractExplicitFraimInvocation(text) {
1330
+ const raw = String(text || '');
1331
+ const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
1332
+ if (!match || match.index == null) return null;
1333
+ const before = raw.slice(0, match.index).trim();
1334
+ const after = raw.slice(match.index + match[0].length).trim();
1335
+ const remainder = [before, after].filter(Boolean).join('\n\n').trim();
1336
+ return {
1337
+ symbol: match[1],
1338
+ jobId: match[2],
1339
+ remainder,
1340
+ };
1341
+ }
1342
+
1343
+ function latestEmployeeSurfaceText(conv) {
1344
+ const messages = conv.messages || [];
1345
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1346
+ if (messages[i].role !== 'employee') continue;
1347
+ const cleaned = surfaceText('employee', messages[i].text, conv);
1348
+ if (cleaned) return cleaned;
1349
+ }
1350
+ return '';
1351
+ }
1352
+
1353
+ function appendMessageDom(role, text, conv) {
1354
+ const article = document.createElement('article');
1355
+ article.className = 'message ' + role;
1356
+
1357
+ const meta = document.createElement('div');
1358
+ meta.className = 'message-meta';
1359
+
1360
+ const avatar = document.createElement('span');
1361
+ avatar.className = `message-avatar ${role}`;
1362
+
1363
+ if (role === 'employee') {
1364
+ const persona = getConversationPersona(conv);
1365
+ if (persona && persona.avatarUrl) {
1366
+ const img = document.createElement('img');
1367
+ img.src = persona.avatarUrl;
1368
+ img.alt = persona.displayName;
1369
+ avatar.appendChild(img);
1370
+ } else {
1371
+ avatar.textContent = initialBadge(roleLabel(role, conv));
1372
+ }
1373
+ } else if (role === 'manager') {
1374
+ avatar.textContent = 'M';
1375
+ } else {
1376
+ avatar.textContent = '•';
1377
+ }
1378
+
1379
+ const who = document.createElement('span');
1380
+ who.className = 'who';
1381
+ who.textContent = roleLabel(role, conv);
1382
+
1383
+ const lane = document.createElement('span');
1384
+ lane.className = 'lane-label';
1385
+ lane.textContent = role === 'manager'
1386
+ ? 'Manager direction'
1387
+ : role === 'employee'
1388
+ ? 'Employee response'
1389
+ : 'System update';
1390
+
1391
+ meta.appendChild(avatar);
1392
+ meta.appendChild(who);
1393
+ meta.appendChild(lane);
1394
+ article.appendChild(meta);
1395
+
1396
+ const bubble = document.createElement('div');
1397
+ bubble.className = 'bubble';
1398
+ if (role === 'employee') {
1399
+ const surfaced = surfaceText(role, text, conv);
1400
+ const raw = String(text || '');
1401
+ // surfaceText collapses whitespace which destroys markdown. Only use it
1402
+ // when it returned a transformed value (Started/Resumed prefix overrides).
1403
+ const useSurfaced = surfaced && surfaced !== raw.replace(/\s+/g, ' ').trim();
1404
+ const content = useSurfaced ? surfaced : raw;
1405
+ bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…'));
1406
+ } else {
1407
+ const surfaced = surfaceText(role, text, conv);
1408
+ bubble.textContent = surfaced || text;
1409
+ }
1410
+ if (role === 'manager') {
1411
+ const raw = document.createElement('span');
1412
+ raw.className = 'transport-raw';
1413
+ raw.textContent = text;
1414
+ article.appendChild(raw);
1415
+ }
1416
+ article.appendChild(bubble);
1417
+ els['messages'].appendChild(article);
1418
+ }
1419
+
1420
+ function scrollThreadForReview(conv) {
1421
+ const host = els['messages'];
1422
+ if (!host) return;
1423
+ const nodes = [...host.querySelectorAll('.message')];
1424
+ if (nodes.length === 0) return;
1425
+
1426
+ if (conv.status === 'running') {
1427
+ host.scrollTop = host.scrollHeight;
1428
+ return;
1429
+ }
1430
+
1431
+ const target = [...nodes].reverse().find((node) =>
1432
+ node.classList.contains('employee') || node.classList.contains('system')
1433
+ ) || nodes[nodes.length - 1];
1434
+
1435
+ const hostRect = host.getBoundingClientRect();
1436
+ const targetRect = target.getBoundingClientRect();
1437
+ const currentTop = host.scrollTop;
1438
+ const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
1439
+ const reviewOffset = Math.max(24, host.clientHeight * 0.16);
1440
+ const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
1441
+ host.scrollTo({ top: desiredTop, behavior: 'smooth' });
1442
+ }
1443
+
1444
+ function syncSendButton() {
1252
1445
  const conv = activeConversation();
1253
1446
  const hasText = els['coach-text'].value.trim().length > 0;
1254
1447
  // Send is enabled as soon as the host session exists. We deliberately
@@ -1502,6 +1695,16 @@ function renderEmployeeSelect() {
1502
1695
  els['employee-select'].appendChild(opt);
1503
1696
  }
1504
1697
  renderAgentInstallPanel();
1698
+ updateAbToggleVisibility();
1699
+ }
1700
+
1701
+ function updateAbToggleVisibility() {
1702
+ const wrap = els['ab-toggle-wrap'];
1703
+ if (!wrap) return;
1704
+ const sel = document.getElementById('employee-select');
1705
+ const empId = sel ? sel.value : (state.selectedEmployeeId || 'claude');
1706
+ const emp = (state.bootstrap && state.bootstrap.employees || []).find(e => e.id === empId);
1707
+ wrap.hidden = !(emp && emp.supportsRaw);
1505
1708
  }
1506
1709
 
1507
1710
  // ---------------------------------------------------------------------------
@@ -1694,39 +1897,39 @@ function deriveTitle(jobTitle, instructions) {
1694
1897
  // (per developers.openai.com/codex/skills: "type $ to mention a skill")
1695
1898
  // - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
1696
1899
  //
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
- }
1900
+ // Every command typed by the manager is prefixed with the agent's FRAIM
1901
+ // symbol so the host always sees that this is a FRAIM job, not a freeform
1902
+ // prompt. Follow-up coaching keeps the jobId too; otherwise headless hosts
1903
+ // lose which FRAIM workflow the manager meant to run.
1904
+ const FRAIM_INVOCATION_SYMBOL = {
1905
+ codex: '$fraim',
1906
+ claude: '/fraim',
1907
+ gemini: '/fraim',
1908
+ };
1909
+
1910
+ function fraimInvocationFor(employeeId, jobId, kind) {
1911
+ if (jobId === '__freeform__') return null;
1912
+ const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1913
+ return `${symbol} ${jobId}`;
1914
+ }
1712
1915
 
1713
1916
  // Wrap the manager's typed instructions with the host-appropriate FRAIM
1714
1917
  // invocation. The wrapped text is what we ACTUALLY send to the host CLI
1715
1918
  // AND what we show in the timeline so the manager sees what the agent
1716
1919
  // received. For freeform jobs (no FRAIM job assigned), the instructions
1717
1920
  // 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
- }
1921
+ function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
1922
+ const trimmed = (instructions || '').trim();
1923
+ const explicit = extractExplicitFraimInvocation(trimmed);
1924
+ const effectiveJobId = explicit?.jobId || jobId;
1925
+ const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
1926
+ // Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
1927
+ if (!invocation) return explicit?.remainder || trimmed;
1928
+ const remainder = explicit ? explicit.remainder : trimmed;
1929
+ const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
1930
+ if (!remainder) return `${invocation}${stub}`;
1931
+ return `${invocation}${stub}\n\n${remainder}`;
1932
+ }
1730
1933
 
1731
1934
  async function startRun(job, instructions, employeeId) {
1732
1935
  // Prefix the manager's typed instructions with the FRAIM invocation so
@@ -1738,6 +1941,10 @@ async function startRun(job, instructions, employeeId) {
1738
1941
  ? [state.projectPath, job.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
1739
1942
  : undefined;
1740
1943
  const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions, absoluteStubPath);
1944
+ // Issue #442: read the A/B toggle state from the modal before it closes.
1945
+ const abToggle = document.getElementById('ab-toggle');
1946
+ const isAB = !isFreeform && abToggle && abToggle.checked;
1947
+
1741
1948
  const conv = {
1742
1949
  id: newConversationId(),
1743
1950
  projectPath: state.projectPath,
@@ -1754,6 +1961,10 @@ async function startRun(job, instructions, employeeId) {
1754
1961
  events: [],
1755
1962
  artifacts: [],
1756
1963
  lastUpdatedAt: Date.now(),
1964
+ // Issue #442: A/B mode fields (only set when toggle was on).
1965
+ compareMode: isAB ? 'ab' : undefined,
1966
+ compareRunId: null,
1967
+ compareRun: null,
1757
1968
  };
1758
1969
  upsertConversation(conv);
1759
1970
  state.activeId = conv.id;
@@ -1770,10 +1981,16 @@ async function startRun(job, instructions, employeeId) {
1770
1981
  hostId: employeeId,
1771
1982
  jobId: job.id,
1772
1983
  message: agentMessage,
1984
+ ...(isAB ? { compareMode: 'ab', directInstructions: instructions } : {}),
1773
1985
  }),
1774
1986
  });
1775
1987
  conv.runId = run.id;
1776
1988
  foldRunIntoConversation(conv, run);
1989
+ // Issue #442: when the server created a paired Direct run, capture it.
1990
+ if (run.compareRunId) {
1991
+ conv.compareRunId = run.compareRunId;
1992
+ if (run.compareRun) foldCompareRunIntoConversation(conv, run.compareRun);
1993
+ }
1777
1994
  upsertConversation(conv);
1778
1995
  renderRail();
1779
1996
  renderActive();
@@ -1898,6 +2115,21 @@ function foldRunIntoConversation(conv, run) {
1898
2115
  conv.lastUpdatedAt = Date.now();
1899
2116
  }
1900
2117
 
2118
+ // Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
2119
+ function foldCompareRunIntoConversation(conv, compareRun) {
2120
+ if (!compareRun) return;
2121
+ conv.compareRun = {
2122
+ id: compareRun.id,
2123
+ sessionId: compareRun.sessionId || null,
2124
+ status: compareRun.status || 'running',
2125
+ stages: compareRun.stages || [],
2126
+ currentPhase: compareRun.currentPhase || null,
2127
+ totals: compareRun.totals || null,
2128
+ messages: compareRun.messages || [],
2129
+ events: compareRun.events || [],
2130
+ };
2131
+ }
2132
+
1901
2133
  const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-\.\/]*?(?:docs|public|src|tests)\/[A-Za-z0-9_\-\.\/]+\.[A-Za-z0-9]+)/;
1902
2134
  // Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
1903
2135
  // raw learnings, evidence dumps, mock files), not deliverables the
@@ -1921,18 +2153,30 @@ function startPolling() {
1921
2153
  state.pollHandle = window.setInterval(async () => {
1922
2154
  const conv = activeConversation();
1923
2155
  if (!conv || !conv.runId) return;
1924
- if (conv.status !== 'running') {
2156
+ // Issue #442: for A/B conversations keep polling until BOTH sides are done.
2157
+ const fraimDone = conv.status !== 'running';
2158
+ const directDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
2159
+ if (fraimDone && directDone) {
1925
2160
  window.clearInterval(state.pollHandle);
1926
2161
  state.pollHandle = null;
1927
2162
  return;
1928
2163
  }
1929
2164
  try {
1930
- const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
1931
- foldRunIntoConversation(conv, run);
2165
+ if (!fraimDone) {
2166
+ const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
2167
+ foldRunIntoConversation(conv, run);
2168
+ }
2169
+ // Issue #442: also poll the compare run when present and not yet terminal.
2170
+ if (conv.compareRunId && !directDone) {
2171
+ const compareRun = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}`);
2172
+ foldCompareRunIntoConversation(conv, compareRun);
2173
+ }
1932
2174
  upsertConversation(conv);
1933
2175
  renderRail();
1934
2176
  renderActive();
1935
- if (conv.status !== 'running') {
2177
+ const nowFraimDone = conv.status !== 'running';
2178
+ const nowDirectDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
2179
+ if (nowFraimDone && nowDirectDone) {
1936
2180
  window.clearInterval(state.pollHandle);
1937
2181
  state.pollHandle = null;
1938
2182
  }
@@ -1942,13 +2186,20 @@ function startPolling() {
1942
2186
  }, 1000);
1943
2187
  }
1944
2188
 
2189
+ function convNeedsPolling(conv) {
2190
+ if (!conv || !conv.runId) return false;
2191
+ const fraimRunning = conv.status === 'running';
2192
+ const directRunning = !!conv.compareRunId && (!conv.compareRun || conv.compareRun.status === 'running');
2193
+ return fraimRunning || directRunning;
2194
+ }
2195
+
1945
2196
  function switchToConversation(id) {
1946
2197
  state.activeId = id;
1947
2198
  persistConversations();
1948
2199
  renderRail();
1949
2200
  renderActive();
1950
2201
  const conv = activeConversation();
1951
- if (conv && conv.status === 'running' && conv.runId) {
2202
+ if (convNeedsPolling(conv)) {
1952
2203
  startPolling();
1953
2204
  } else if (state.pollHandle) {
1954
2205
  window.clearInterval(state.pollHandle);
@@ -2041,6 +2292,11 @@ function wireEvents() {
2041
2292
  const employeeId = els['employee-select'].value || state.selectedEmployeeId;
2042
2293
  state.selectedEmployeeId = employeeId;
2043
2294
  closeModal();
2295
+ // R2: if no project path is set, show the inline project picker instead.
2296
+ if (!state.projectPath) {
2297
+ renderFirstRunLanding('hub');
2298
+ return;
2299
+ }
2044
2300
  await startRun(job, text, employeeId);
2045
2301
  });
2046
2302
  els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
@@ -2068,25 +2324,112 @@ function wireEvents() {
2068
2324
  }
2069
2325
  });
2070
2326
 
2071
- if (els['active-employee-select']) {
2072
- els['active-employee-select'].addEventListener('change', () => {
2327
+ if (els['active-employee-select']) {
2328
+ els['active-employee-select'].addEventListener('change', () => {
2073
2329
  // Only update the global preference here. Do NOT update conv.employeeId —
2074
2330
  // the send handler compares sel.value vs conv.employeeId to detect a
2075
2331
  // switch; updating conv here would make them equal and the restart would
2076
2332
  // 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.
2333
+ state.selectedEmployeeId = els['active-employee-select'].value;
2334
+ });
2335
+ }
2336
+
2337
+ // Issue #442: keep A/B toggle visible only when the selected employee supports
2338
+ // direct-path invocation (supportsRaw). Also wire the explanation paragraph.
2339
+ if (els['employee-select']) {
2340
+ els['employee-select'].addEventListener('change', updateAbToggleVisibility);
2341
+ }
2342
+ const abToggleCheckbox = document.getElementById('ab-toggle');
2343
+ if (abToggleCheckbox) {
2344
+ abToggleCheckbox.addEventListener('change', () => {
2345
+ const exp = document.getElementById('ab-toggle-explanation');
2346
+ if (exp) exp.hidden = !abToggleCheckbox.checked;
2347
+ // R1.3: Start button label changes to "Start A/B test" when toggle is on.
2348
+ if (els['start']) els['start'].textContent = abToggleCheckbox.checked ? 'Start A/B test' : 'Start';
2349
+ });
2350
+ }
2351
+
2352
+ // Issue #442: Direct panel send button.
2353
+ const abDirectInput = document.getElementById('ab-direct-input');
2354
+ if (abDirectInput && els['ab-direct-send']) {
2355
+ abDirectInput.addEventListener('input', () => {
2356
+ const conv = activeConversation();
2357
+ const hasSession = !!(conv && conv.compareRun && conv.compareRun.sessionId);
2358
+ const notRunning = !conv || (conv.compareRun && conv.compareRun.status) !== 'running';
2359
+ els['ab-direct-send'].disabled = !hasSession || !notRunning || !abDirectInput.value.trim();
2360
+ });
2361
+ abDirectInput.addEventListener('keydown', (e) => {
2362
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!els['ab-direct-send'].disabled) els['ab-direct-send'].click(); }
2363
+ });
2364
+ els['ab-direct-send'].addEventListener('click', async () => {
2365
+ const conv = activeConversation();
2366
+ if (!conv || !conv.compareRunId) return;
2367
+ const text = abDirectInput.value.trim();
2368
+ if (!text) return;
2369
+ abDirectInput.value = '';
2370
+ els['ab-direct-send'].disabled = true;
2371
+ try {
2372
+ const updated = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}/direct-messages`, {
2373
+ method: 'POST',
2374
+ headers: { 'Content-Type': 'application/json' },
2375
+ body: JSON.stringify({ message: text }),
2376
+ });
2377
+ foldCompareRunIntoConversation(conv, updated);
2378
+ renderActive();
2379
+ } catch (err) {
2380
+ console.error('Direct send failed', err);
2381
+ }
2382
+ });
2383
+ }
2384
+
2385
+ if (els['coach-panel']) {
2386
+ els['coach-panel'].addEventListener('toggle', () => {
2387
+ const conv = activeConversation();
2388
+ if (!conv) return;
2389
+ panelStateFor(conv.id).coach = els['coach-panel'].open;
2390
+ // R3.3: keep coach-summary visibility in sync when toggled directly.
2391
+ if (els['coach-summary']) els['coach-summary'].hidden = els['coach-panel'].open;
2392
+ });
2393
+ }
2394
+
2395
+ // R6: thread panel toggle — persist open/close state per conversation.
2396
+ if (els['thread-panel']) {
2397
+ els['thread-panel'].addEventListener('toggle', () => {
2398
+ const conv = activeConversation();
2399
+ if (!conv) return;
2400
+ panelStateFor(conv.id).thread = els['thread-panel'].open;
2401
+ });
2402
+ }
2403
+
2404
+ // R5: quick-access coaching buttons.
2405
+ if (els['quick-coach-btns']) {
2406
+ els['quick-coach-btns'].addEventListener('click', (e) => {
2407
+ const btn = e.target.closest('.quick-coach-btn');
2408
+ if (!btn) return;
2409
+ const jobId = btn.dataset.job;
2410
+ if (!jobId) return;
2411
+ const conv = activeConversation();
2412
+ const prefix = conv && conv.employeeId && conv.employeeId.toLowerCase().includes('codex') ? '$fraim' : '/fraim';
2413
+ const invocation = `${prefix} ${jobId}`;
2414
+ const textarea = els['coach-text'];
2415
+ const existing = textarea.value;
2416
+ const sep = existing && !existing.endsWith(' ') ? ' ' : '';
2417
+ textarea.value = existing + sep + invocation;
2418
+ textarea.focus();
2419
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
2420
+ syncSendButton();
2421
+ });
2422
+ }
2423
+ if (els['other-manager-jobs-btn']) {
2424
+ els['other-manager-jobs-btn'].addEventListener('click', (e) => {
2425
+ e.stopPropagation();
2426
+ const popover = els['template-popover'];
2427
+ if (popover && popover.hidden === false) closeTemplatePopover();
2428
+ else openTemplatePopover();
2429
+ });
2430
+ }
2431
+
2432
+ // Issue #347 R2 — template picker.
2090
2433
  if (els['template-picker-btn']) {
2091
2434
  els['template-picker-btn'].addEventListener('click', (e) => {
2092
2435
  e.stopPropagation();
@@ -2097,7 +2440,7 @@ function wireEvents() {
2097
2440
  document.addEventListener('click', (e) => {
2098
2441
  const popover = els['template-popover'];
2099
2442
  if (!popover || popover.hidden) return;
2100
- if (!e.target.closest('#template-popover') && !e.target.closest('#template-picker-btn')) {
2443
+ if (!e.target.closest('#template-popover') && !e.target.closest('#other-manager-jobs-btn')) {
2101
2444
  closeTemplatePopover();
2102
2445
  }
2103
2446
  });
@@ -2156,7 +2499,7 @@ function wireEvents() {
2156
2499
  // If an active conversation belongs to the loaded project and is still running, resume polling.
2157
2500
  const conv = activeConversation();
2158
2501
  if (conv && conv.projectPath === state.projectPath) {
2159
- if (conv.status === 'running' && conv.runId) startPolling();
2502
+ if (convNeedsPolling(conv)) startPolling();
2160
2503
  } else {
2161
2504
  state.activeId = null;
2162
2505
  persistConversations();
@@ -2211,10 +2554,11 @@ async function autoOnboardProject() {
2211
2554
  }
2212
2555
  }
2213
2556
 
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).
2557
+ function renderFirstRunLanding(mode) {
2558
+ // mode === 'hub' → R2: shown when a job is started without a project path.
2559
+ // After picking, reload bootstrap and return to the hub.
2560
+ // mode === 'firstrun' legacy path: shown on ?firstRun=true; auto-starts onboarding.
2561
+ // Default: 'firstrun' for backward compatibility.
2218
2562
  const existing = document.getElementById('fraim-first-run-landing');
2219
2563
  if (existing) existing.remove();
2220
2564
 
@@ -2234,14 +2578,14 @@ function renderFirstRunLanding() {
2234
2578
  'box-shadow:0 1px 2px rgba(20,40,30,.04);',
2235
2579
  ].join('');
2236
2580
 
2237
- const title = document.createElement('h1');
2238
- title.textContent = "Let's get started";
2581
+ const title = document.createElement('h2');
2582
+ title.textContent = 'Pick a project folder';
2239
2583
  title.style.cssText = 'font-size:22px;font-weight:600;margin:0;';
2240
2584
  card.appendChild(title);
2241
2585
 
2242
2586
  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;';
2587
+ 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.";
2588
+ desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;line-height:1.55;';
2245
2589
  card.appendChild(desc);
2246
2590
 
2247
2591
  const folderLabel = document.createElement('label');
@@ -2300,10 +2644,10 @@ function renderFirstRunLanding() {
2300
2644
 
2301
2645
  const startBtn = document.createElement('button');
2302
2646
  startBtn.type = 'button';
2303
- startBtn.textContent = 'Start onboarding →';
2647
+ startBtn.textContent = 'Start in this project →';
2304
2648
  startBtn.disabled = !state.projectPath;
2305
2649
  startBtn.style.cssText = [
2306
- 'background:var(--accent,#3d8a6e);color:#fff;border:none;border-radius:8px;',
2650
+ 'background:var(--accent,#1f437d);color:#fff;border:none;border-radius:8px;',
2307
2651
  'padding:11px 20px;font-size:14px;font-weight:600;cursor:pointer;',
2308
2652
  'opacity:' + (state.projectPath ? '1' : '0.5') + ';',
2309
2653
  'transition:opacity .15s;',
@@ -2311,16 +2655,24 @@ function renderFirstRunLanding() {
2311
2655
  startBtn.addEventListener('click', async () => {
2312
2656
  if (!state.projectPath) return;
2313
2657
  startBtn.disabled = true;
2314
- startBtn.textContent = 'Starting…';
2315
2658
  overlay.remove();
2659
+ if (mode === 'hub') {
2660
+ // R2: reload bootstrap for the chosen project and return to hub.
2661
+ try {
2662
+ await loadBootstrap(state.projectPath);
2663
+ } catch (err) {
2664
+ showStatus(err.message, true);
2665
+ }
2666
+ return;
2667
+ }
2668
+ // Legacy first-run path: auto-start project-onboarding.
2669
+ startBtn.textContent = 'Starting…';
2316
2670
  renderActive();
2317
- // Find the project-onboarding job from bootstrap, fall back to direct POST.
2318
2671
  const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
2319
2672
  const employeeId = state.selectedEmployeeId || 'claude';
2320
2673
  if (job) {
2321
2674
  await startRun(job, 'Onboard this project', employeeId);
2322
2675
  } else {
2323
- // Job not in catalog yet (project not initialized) — POST directly.
2324
2676
  try {
2325
2677
  const run = await requestJson('/api/ai-hub/runs', {
2326
2678
  method: 'POST',