fraim-framework 2.0.151 → 2.0.153

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,9 +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
- };
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
+ };
27
28
 
28
29
  const els = {};
29
30
 
@@ -36,12 +37,13 @@ function gatherElements() {
36
37
  'project-button', 'project-name',
37
38
  'new-conv-btn', 'conv-list',
38
39
  // Issue #385: team roster
39
- 'team-roster',
40
- 'empty', 'active-conv', 'active-title', 'active-job', 'active-identity', 'run-state-pill', 'summary-strip',
41
- 'progress', 'stage', 'latest', 'artifact-slot', 'messages',
42
- 'coach-text', 'send', 'micro-manage', 'micro-log',
43
- 'status-line', 'coach-note',
44
- 'modal', 'step1', 'step2',
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',
45
47
  'cancel1', 'next1', 'back2', 'start',
46
48
  'job-search', 'job-catalog', 'job-pick-status',
47
49
  // Issue #385: hire-required notice, persona job filter
@@ -224,10 +226,34 @@ function findConversation(id) {
224
226
  return null;
225
227
  }
226
228
 
227
- function activeConversation() {
228
- if (!state.activeId) return null;
229
- return findConversation(state.activeId);
230
- }
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
+ }
231
257
 
232
258
  function upsertConversation(conv) {
233
259
  const list = projectConversations().slice();
@@ -306,49 +332,49 @@ function renderRail() {
306
332
  }
307
333
  }
308
334
 
309
- function statusLabel(s) {
310
- if (s === 'running') return 'Running';
311
- if (s === 'failed') return 'Needs you';
312
- return 'Done';
313
- }
314
-
315
- function personaMap() {
316
- const map = new Map();
317
- for (const persona of state.bootstrap?.personas || []) {
318
- map.set(persona.key, persona);
319
- }
320
- return map;
321
- }
322
-
323
- function getConversationPersona(conv) {
324
- if (!conv || !conv.personaKey) return null;
325
- return personaMap().get(conv.personaKey) || null;
326
- }
327
-
328
- function getEmployeeStatus(employeeId) {
329
- return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
330
- }
331
-
332
- function getEmployeeTitle(conv) {
333
- const persona = getConversationPersona(conv);
334
- if (persona) return persona.role;
335
- const employee = getEmployeeStatus(conv?.employeeId);
336
- return employee ? employee.label : 'AI Employee';
337
- }
338
-
339
- function roleLabel(role, conv) {
340
- if (role === 'manager') return 'Manager';
341
- if (role === 'employee') {
342
- const persona = getConversationPersona(conv);
343
- return persona ? persona.displayName : 'Employee';
344
- }
345
- return 'System';
346
- }
347
-
348
- function initialBadge(text) {
349
- const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
350
- return (cleaned.slice(0, 2) || 'FH');
351
- }
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
+ }
352
378
 
353
379
  // ---------------------------------------------------------------------------
354
380
  // Issue #385 — Persona UI (R3 + R4)
@@ -367,62 +393,62 @@ function conversationTitle(conv) {
367
393
 
368
394
  // R4.2 — team roster: one avatar chip per hired persona above the conv list.
369
395
  // Only rendered when at least one persona is hired (subscription active).
370
- function renderTeamRoster() {
371
- const roster = els['team-roster'];
372
- if (!roster) return;
373
- const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
374
- if (personas.length === 0) {
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) {
375
401
  roster.hidden = true;
376
402
  // Reset persona selection when there's no active subscription.
377
403
  if (state.selectedPersonaKey) state.selectedPersonaKey = null;
378
404
  return;
379
- }
380
- roster.hidden = false;
381
- roster.innerHTML = '';
382
- const allChip = document.createElement('button');
383
- allChip.type = 'button';
384
- allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
385
- allChip.title = 'All employees';
386
- allChip.setAttribute('aria-label', 'All employees');
387
- allChip.innerHTML = `
388
- <span class="roster-avatar">All</span>
389
- <span class="roster-copy">
390
- <strong>All employees</strong>
391
- <small>Show every hired employee</small>
392
- </span>
393
- `;
394
- allChip.addEventListener('click', () => setSelectedPersona(null));
395
- roster.appendChild(allChip);
396
- for (const persona of personas) {
397
- const chip = document.createElement('button');
398
- chip.type = 'button';
399
- chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
400
- chip.title = persona.displayName;
401
- chip.setAttribute('aria-label', persona.displayName);
402
- const avatar = document.createElement('span');
403
- avatar.className = 'roster-avatar';
404
- if (persona.avatarUrl) {
405
- const img = document.createElement('img');
406
- img.src = persona.avatarUrl;
407
- img.alt = persona.displayName;
408
- avatar.appendChild(img);
409
- } else {
410
- avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
411
- }
412
- const copy = document.createElement('span');
413
- copy.className = 'roster-copy';
414
- const name = document.createElement('strong');
415
- name.textContent = persona.displayName;
416
- const role = document.createElement('small');
417
- role.textContent = persona.role || 'AI Employee';
418
- copy.appendChild(name);
419
- copy.appendChild(role);
420
- chip.appendChild(avatar);
421
- chip.appendChild(copy);
422
- chip.addEventListener('click', () => setSelectedPersona(persona.key));
423
- roster.appendChild(chip);
424
- }
425
- }
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
+ }
426
452
 
427
453
  // R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
428
454
  function buildAvatarChip(persona, size) {
@@ -540,56 +566,56 @@ function renderModalPersonaFilter() {
540
566
  // event rows are already in the DOM. Polling fires every second; without
541
567
  // this, every tick wiped the messages list and re-played the slidein
542
568
  // animation (= the screen flash the user reported as 'distracting').
543
- let renderedConvId = null;
544
- let renderedMessageCount = 0;
545
- let renderedEventCount = 0;
546
- let renderedArtifactKey = null;
547
- let renderedStatus = null;
548
-
549
- function renderActive() {
550
- const conv = activeConversation();
551
- if (!conv) {
552
- els['empty'].hidden = false;
553
- els['active-conv'].hidden = true;
554
- renderedConvId = null;
555
- renderedMessageCount = 0;
556
- renderedEventCount = 0;
557
- renderedArtifactKey = null;
558
- renderedStatus = null;
559
- return;
560
- }
561
- els['empty'].hidden = true;
562
- els['active-conv'].hidden = false;
563
- els['active-title'].textContent = conversationTitle(conv);
564
- els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)} · ${getEmployeeStatus(conv.employeeId)?.label || conv.employeeId}`;
565
- renderConversationIdentity(conv);
566
- els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)}`;
567
- renderRunStatePill(conv);
568
- els['summary-strip'].textContent = buildConversationSummary(conv);
569
- els['coach-note'].textContent = conv.status === 'running'
570
- ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
571
- : 'The employee is waiting on you. Send the next instruction to continue this run.';
572
-
573
- // Progress section. Plain text updates are cheap and don't animate.
574
- const stage = derivedStage(conv);
575
- els['stage'].textContent = stage.text;
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;
576
601
  els['progress'].classList.remove('done', 'attention', 'failed');
577
602
  if (stage.kind) els['progress'].classList.add(stage.kind);
578
603
  els['latest'].textContent = derivedLatest(conv);
579
604
 
580
605
  // If we switched conversations (or this is the first render), wipe and
581
606
  // start fresh. Otherwise we're going to do an incremental update below.
582
- const switchedConv = renderedConvId !== conv.id;
583
- if (switchedConv) {
607
+ const switchedConv = renderedConvId !== conv.id;
608
+ if (switchedConv) {
584
609
  els['artifact-slot'].innerHTML = '';
585
610
  els['messages'].innerHTML = '';
586
611
  els['micro-log'].textContent = '';
587
612
  renderedConvId = conv.id;
588
613
  renderedMessageCount = 0;
589
- renderedEventCount = 0;
590
- renderedArtifactKey = null;
591
- }
592
- const statusChanged = renderedStatus !== conv.status;
614
+ renderedEventCount = 0;
615
+ renderedArtifactKey = null;
616
+ }
617
+ const statusChanged = renderedStatus !== conv.status;
618
+ syncConversationPanels(conv, switchedConv);
593
619
 
594
620
  // Artifact callout — only re-render when the latest artifact actually
595
621
  // changed. Avoids the 'pulse' animation re-firing on every poll tick.
@@ -617,24 +643,24 @@ function renderActive() {
617
643
  // existing rows don't re-animate. If for some reason the data shrunk
618
644
  // (server revoked a message), fall back to a full re-render.
619
645
  const messages = conv.messages || [];
620
- if (messages.length < renderedMessageCount) {
621
- els['messages'].innerHTML = '';
622
- renderedMessageCount = 0;
623
- }
624
- for (let i = renderedMessageCount; i < messages.length; i += 1) {
625
- appendMessageDom(messages[i].role, messages[i].text, conv);
626
- }
627
- const appendedMessages = messages.length - renderedMessageCount;
628
- renderedMessageCount = messages.length;
629
- // Running threads stay pinned near the newest work. Once the employee
630
- // is done, snap to the review point instead of leaving the manager at
631
- // a stale scroll offset.
632
- const m = els['messages'];
633
- if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
634
- m.scrollTop = m.scrollHeight;
635
- } else if (switchedConv || statusChanged || appendedMessages > 0) {
636
- scrollThreadForReview(conv);
637
- }
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
+ }
638
664
 
639
665
  // Micro-manage — only append new events. textContent assignment on the
640
666
  // <pre> wipes the entire log every tick which is wasteful.
@@ -660,58 +686,64 @@ function renderActive() {
660
686
  // foldRunIntoConversation); for runs that have not yet polled we
661
687
  // simply hide the surfaces.
662
688
  renderTracker(conv);
663
- renderTotals(conv);
664
- syncTemplatePickerVisibility();
665
- // Browser-tab title mirrors the active conversation (R3).
666
- document.title = conv.title ? conv.title : 'AI Hub';
667
- renderedStatus = conv.status;
668
- }
669
-
670
- function renderConversationIdentity(conv) {
671
- const host = els['active-identity'];
672
- if (!host) return;
673
- const persona = getConversationPersona(conv);
674
- const employee = getEmployeeStatus(conv.employeeId);
675
- host.innerHTML = '';
676
-
677
- const avatar = document.createElement(persona && persona.avatarUrl ? 'img' : 'span');
678
- avatar.className = 'identity-avatar';
679
- if (persona && persona.avatarUrl) {
680
- avatar.src = persona.avatarUrl;
681
- avatar.alt = persona.displayName;
682
- } else {
683
- avatar.textContent = initialBadge(employee?.label || 'Hub');
684
- }
685
-
686
- const text = document.createElement('span');
687
- text.className = 'identity-copy';
688
-
689
- const name = document.createElement('strong');
690
- name.textContent = persona ? persona.displayName : (employee ? employee.label : 'AI Employee');
691
-
692
- const title = document.createElement('small');
693
- title.textContent = getEmployeeTitle(conv);
694
-
695
- text.appendChild(name);
696
- text.appendChild(title);
697
- host.appendChild(avatar);
698
- host.appendChild(text);
699
- }
700
-
701
- function renderRunStatePill(conv) {
702
- const pill = els['run-state-pill'];
703
- if (!pill) return;
704
- pill.textContent = statusLabel(conv.status).toUpperCase();
705
- pill.className = `run-state-pill ${conv.status}`;
706
- }
707
-
708
- function buildConversationSummary(conv) {
709
- const employeeReply = latestEmployeeSurfaceText(conv);
710
- if (employeeReply) return employeeReply;
711
- if (conv.status === 'running') return 'The employee is working through your request.';
712
- if (conv.status === 'failed') return 'This run needs your attention before it can continue.';
713
- return 'The latest work is ready for review.';
714
- }
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
+ }
715
747
 
716
748
  // Render the inline employee selector shown in the coach section of an
717
749
  // active conversation. Allows switching agents without reopening the modal.
@@ -1019,21 +1051,23 @@ function closeTemplatePopover() {
1019
1051
  btn.setAttribute('aria-expanded', 'false');
1020
1052
  }
1021
1053
 
1022
- function applyTemplateInvocation(managerJobId) {
1023
- const conv = activeConversation();
1054
+ function applyTemplateInvocation(managerJobId) {
1055
+ const conv = activeConversation();
1024
1056
  // Use the conversation's own employee for the invocation symbol, NOT
1025
1057
  // the manager's last selection in another conversation (R2.5).
1026
1058
  const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
1027
1059
  const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1028
- const invocation = `${symbol} ${managerJobId}`;
1029
- const textarea = els['coach-text'];
1030
- const prior = textarea.value;
1031
- // Append (with a single space separator if needed) — never replace.
1032
- let combined;
1033
- if (prior.length === 0) combined = invocation;
1034
- else if (/\s$/.test(prior)) combined = prior + invocation;
1035
- else combined = prior + ' ' + invocation;
1036
- textarea.value = combined;
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;
1037
1071
  // Caret at the end.
1038
1072
  textarea.setSelectionRange(combined.length, combined.length);
1039
1073
  textarea.focus();
@@ -1047,145 +1081,174 @@ function derivedStage(conv) {
1047
1081
  return { text: 'Done — please review', kind: 'done' };
1048
1082
  }
1049
1083
 
1050
- function derivedLatest(conv) {
1051
- if (conv.status !== 'running') return '';
1052
- const employeeReply = latestEmployeeSurfaceText(conv);
1053
- if (employeeReply) return employeeReply;
1054
- if (conv.status === 'running') return 'Working on it…';
1055
- return '';
1056
- }
1057
-
1058
- function humanizeSlug(slug) {
1059
- return String(slug || '')
1060
- .split('-')
1061
- .filter(Boolean)
1062
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1063
- .join(' ');
1064
- }
1065
-
1066
- function stripStubReference(text) {
1067
- return String(text || '')
1068
- .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
1069
- .replace(/\s+/g, ' ')
1070
- .trim();
1071
- }
1072
-
1073
- function surfaceText(role, text, conv) {
1074
- const raw = stripStubReference(text);
1075
- if (!raw) return '';
1076
-
1077
- if (role === 'manager') {
1078
- const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
1079
- if (invocationOnly) return `Run ${humanizeSlug(invocationOnly[1])}.`;
1080
- const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
1081
- const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
1082
- return raw
1083
- .replace(invocationWithSlug, '')
1084
- .replace(/^(?:[$/]fraim)\s*/i, '')
1085
- .trim();
1086
- }
1087
-
1088
- if (role === 'employee') {
1089
- const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
1090
- if (startedMatch) {
1091
- const cleaned = surfaceText('manager', startedMatch[1], conv);
1092
- if (conv.status === 'completed') return 'Done — please review.';
1093
- return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
1094
- }
1095
- }
1096
-
1097
- return raw;
1098
- }
1099
-
1100
- function latestEmployeeSurfaceText(conv) {
1101
- const messages = conv.messages || [];
1102
- for (let i = messages.length - 1; i >= 0; i -= 1) {
1103
- if (messages[i].role !== 'employee') continue;
1104
- const cleaned = surfaceText('employee', messages[i].text, conv);
1105
- if (cleaned) return cleaned;
1106
- }
1107
- return '';
1108
- }
1109
-
1110
- function appendMessageDom(role, text, conv) {
1111
- const article = document.createElement('article');
1112
- article.className = 'message ' + role;
1113
-
1114
- const meta = document.createElement('div');
1115
- meta.className = 'message-meta';
1116
-
1117
- const avatar = document.createElement('span');
1118
- avatar.className = `message-avatar ${role}`;
1119
-
1120
- if (role === 'employee') {
1121
- const persona = getConversationPersona(conv);
1122
- if (persona && persona.avatarUrl) {
1123
- const img = document.createElement('img');
1124
- img.src = persona.avatarUrl;
1125
- img.alt = persona.displayName;
1126
- avatar.appendChild(img);
1127
- } else {
1128
- avatar.textContent = initialBadge(roleLabel(role, conv));
1129
- }
1130
- } else if (role === 'manager') {
1131
- avatar.textContent = 'M';
1132
- } else {
1133
- avatar.textContent = '•';
1134
- }
1135
-
1136
- const who = document.createElement('span');
1137
- who.className = 'who';
1138
- who.textContent = roleLabel(role, conv);
1139
-
1140
- const lane = document.createElement('span');
1141
- lane.className = 'lane-label';
1142
- lane.textContent = role === 'manager'
1143
- ? 'Manager direction'
1144
- : role === 'employee'
1145
- ? 'Employee response'
1146
- : 'System update';
1147
-
1148
- meta.appendChild(avatar);
1149
- meta.appendChild(who);
1150
- meta.appendChild(lane);
1151
- article.appendChild(meta);
1152
-
1153
- const bubble = document.createElement('div');
1154
- bubble.className = 'bubble';
1155
- bubble.textContent = surfaceText(role, text, conv) || (
1156
- role === 'employee'
1157
- ? (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…')
1158
- : text
1159
- );
1160
- if (role === 'manager') {
1161
- const raw = document.createElement('span');
1162
- raw.className = 'transport-raw';
1163
- raw.textContent = text;
1164
- article.appendChild(raw);
1165
- }
1166
- article.appendChild(bubble);
1167
- els['messages'].appendChild(article);
1168
- }
1169
-
1170
- function scrollThreadForReview(conv) {
1171
- const host = els['messages'];
1172
- if (!host) return;
1173
- const nodes = [...host.querySelectorAll('.message')];
1174
- if (nodes.length === 0) return;
1175
-
1176
- if (conv.status === 'running') {
1177
- host.scrollTop = host.scrollHeight;
1178
- return;
1179
- }
1180
-
1181
- const target = [...nodes].reverse().find((node) =>
1182
- node.classList.contains('employee') || node.classList.contains('system')
1183
- ) || nodes[nodes.length - 1];
1184
-
1185
- target.scrollIntoView({ block: 'center', behavior: 'smooth' });
1186
- }
1187
-
1188
- function syncSendButton() {
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() {
1189
1252
  const conv = activeConversation();
1190
1253
  const hasText = els['coach-text'].value.trim().length > 0;
1191
1254
  // Send is enabled as soon as the host session exists. We deliberately
@@ -1631,46 +1694,39 @@ function deriveTitle(jobTitle, instructions) {
1631
1694
  // (per developers.openai.com/codex/skills: "type $ to mention a skill")
1632
1695
  // - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
1633
1696
  //
1634
- // Every command typed by the manager is prefixed with the agent's FRAIM
1635
- // symbol so the host always sees that this is a FRAIM job, not a freeform
1636
- // prompt. The first turn includes the jobId; follow-up coaching sends just
1637
- // the bare symbol (the host is already inside the FRAIM session).
1638
- const FRAIM_INVOCATION_SYMBOL = {
1639
- codex: '$fraim',
1640
- claude: '/fraim',
1641
- gemini: '/fraim',
1642
- };
1643
-
1644
- function fraimInvocationFor(employeeId, jobId, kind) {
1645
- if (jobId === '__freeform__') return null;
1646
- const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1647
- if (kind === 'start') {
1648
- return `${symbol} ${jobId}`;
1649
- }
1650
- return symbol;
1651
- }
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
+ }
1652
1712
 
1653
1713
  // Wrap the manager's typed instructions with the host-appropriate FRAIM
1654
1714
  // invocation. The wrapped text is what we ACTUALLY send to the host CLI
1655
1715
  // AND what we show in the timeline so the manager sees what the agent
1656
1716
  // received. For freeform jobs (no FRAIM job assigned), the instructions
1657
1717
  // are sent verbatim with no invocation prefix.
1658
- function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
1659
- const trimmed = (instructions || '').trim();
1660
- const invocation = fraimInvocationFor(employeeId, jobId, kind);
1661
- // Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
1662
- if (!invocation) return trimmed;
1663
- const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
1664
- if (!trimmed) return `${invocation}${stub}`;
1665
- // For continue turns, applyTemplateInvocation already writes the full
1666
- // FRAIM invocation (e.g. "/fraim follow-your-mentor") into the textarea.
1667
- // If the text already starts with a known FRAIM symbol, don't prepend again.
1668
- if (kind === 'continue') {
1669
- const knownSymbols = Object.values(FRAIM_INVOCATION_SYMBOL);
1670
- if (knownSymbols.some((s) => trimmed.startsWith(s))) return trimmed;
1671
- }
1672
- return `${invocation}${stub}\n\n${trimmed}`;
1673
- }
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
+ }
1674
1730
 
1675
1731
  async function startRun(job, instructions, employeeId) {
1676
1732
  // Prefix the manager's typed instructions with the FRAIM invocation so
@@ -2012,17 +2068,25 @@ function wireEvents() {
2012
2068
  }
2013
2069
  });
2014
2070
 
2015
- if (els['active-employee-select']) {
2016
- els['active-employee-select'].addEventListener('change', () => {
2071
+ if (els['active-employee-select']) {
2072
+ els['active-employee-select'].addEventListener('change', () => {
2017
2073
  // Only update the global preference here. Do NOT update conv.employeeId —
2018
2074
  // the send handler compares sel.value vs conv.employeeId to detect a
2019
2075
  // switch; updating conv here would make them equal and the restart would
2020
2076
  // never fire.
2021
- state.selectedEmployeeId = els['active-employee-select'].value;
2022
- });
2023
- }
2024
-
2025
- // Issue #347 R2 — template picker.
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.
2026
2090
  if (els['template-picker-btn']) {
2027
2091
  els['template-picker-btn'].addEventListener('click', (e) => {
2028
2092
  e.stopPropagation();