fraim-framework 2.0.150 → 2.0.152

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.
@@ -36,11 +36,11 @@ function gatherElements() {
36
36
  'project-button', 'project-name',
37
37
  'new-conv-btn', 'conv-list',
38
38
  // 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',
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
44
  'modal', 'step1', 'step2',
45
45
  'cancel1', 'next1', 'back2', 'start',
46
46
  'job-search', 'job-catalog', 'job-pick-status',
@@ -306,49 +306,49 @@ function renderRail() {
306
306
  }
307
307
  }
308
308
 
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
- }
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
+ }
352
352
 
353
353
  // ---------------------------------------------------------------------------
354
354
  // Issue #385 — Persona UI (R3 + R4)
@@ -367,62 +367,62 @@ function conversationTitle(conv) {
367
367
 
368
368
  // R4.2 — team roster: one avatar chip per hired persona above the conv list.
369
369
  // 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) {
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) {
375
375
  roster.hidden = true;
376
376
  // Reset persona selection when there's no active subscription.
377
377
  if (state.selectedPersonaKey) state.selectedPersonaKey = null;
378
378
  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
- }
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
+ }
426
426
 
427
427
  // R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
428
428
  function buildAvatarChip(persona, size) {
@@ -540,39 +540,39 @@ function renderModalPersonaFilter() {
540
540
  // event rows are already in the DOM. Polling fires every second; without
541
541
  // this, every tick wiped the messages list and re-played the slidein
542
542
  // 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;
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;
576
576
  els['progress'].classList.remove('done', 'attention', 'failed');
577
577
  if (stage.kind) els['progress'].classList.add(stage.kind);
578
578
  els['latest'].textContent = derivedLatest(conv);
@@ -580,16 +580,16 @@ function renderActive() {
580
580
  // If we switched conversations (or this is the first render), wipe and
581
581
  // start fresh. Otherwise we're going to do an incremental update below.
582
582
  const switchedConv = renderedConvId !== conv.id;
583
- if (switchedConv) {
583
+ if (switchedConv) {
584
584
  els['artifact-slot'].innerHTML = '';
585
585
  els['messages'].innerHTML = '';
586
586
  els['micro-log'].textContent = '';
587
587
  renderedConvId = conv.id;
588
588
  renderedMessageCount = 0;
589
- renderedEventCount = 0;
590
- renderedArtifactKey = null;
591
- }
592
- const statusChanged = renderedStatus !== conv.status;
589
+ renderedEventCount = 0;
590
+ renderedArtifactKey = null;
591
+ }
592
+ const statusChanged = renderedStatus !== conv.status;
593
593
 
594
594
  // Artifact callout — only re-render when the latest artifact actually
595
595
  // changed. Avoids the 'pulse' animation re-firing on every poll tick.
@@ -617,24 +617,24 @@ function renderActive() {
617
617
  // existing rows don't re-animate. If for some reason the data shrunk
618
618
  // (server revoked a message), fall back to a full re-render.
619
619
  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
- }
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
+ }
638
638
 
639
639
  // Micro-manage — only append new events. textContent assignment on the
640
640
  // <pre> wipes the entire log every tick which is wasteful.
@@ -660,58 +660,64 @@ function renderActive() {
660
660
  // foldRunIntoConversation); for runs that have not yet polled we
661
661
  // simply hide the surfaces.
662
662
  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
- }
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 clampSummaryText(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
+ }
715
+
716
+ function clampSummaryText(text, maxChars = 260) {
717
+ const raw = String(text || '').replace(/\s+/g, ' ').trim();
718
+ if (raw.length <= maxChars) return raw;
719
+ return raw.slice(0, maxChars - 1).trimEnd() + '…';
720
+ }
715
721
 
716
722
  // Render the inline employee selector shown in the coach section of an
717
723
  // active conversation. Allows switching agents without reopening the modal.
@@ -1047,145 +1053,151 @@ function derivedStage(conv) {
1047
1053
  return { text: 'Done — please review', kind: 'done' };
1048
1054
  }
1049
1055
 
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() {
1056
+ function derivedLatest(conv) {
1057
+ if (conv.status !== 'running') return '';
1058
+ const employeeReply = latestEmployeeSurfaceText(conv);
1059
+ if (employeeReply) return employeeReply;
1060
+ if (conv.status === 'running') return 'Working on it…';
1061
+ return '';
1062
+ }
1063
+
1064
+ function humanizeSlug(slug) {
1065
+ return String(slug || '')
1066
+ .split('-')
1067
+ .filter(Boolean)
1068
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1069
+ .join(' ');
1070
+ }
1071
+
1072
+ function stripStubReference(text) {
1073
+ return String(text || '')
1074
+ .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
1075
+ .replace(/\s+/g, ' ')
1076
+ .trim();
1077
+ }
1078
+
1079
+ function surfaceText(role, text, conv) {
1080
+ const raw = stripStubReference(text);
1081
+ if (!raw) return '';
1082
+
1083
+ if (role === 'manager') {
1084
+ const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
1085
+ if (invocationOnly) return `Run ${humanizeSlug(invocationOnly[1])}.`;
1086
+ const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
1087
+ const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
1088
+ return raw
1089
+ .replace(invocationWithSlug, '')
1090
+ .replace(/^(?:[$/]fraim)\s*/i, '')
1091
+ .trim();
1092
+ }
1093
+
1094
+ if (role === 'employee') {
1095
+ const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
1096
+ if (startedMatch) {
1097
+ const cleaned = surfaceText('manager', startedMatch[1], conv);
1098
+ if (conv.status === 'completed') return 'Done — please review.';
1099
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
1100
+ }
1101
+ }
1102
+
1103
+ return raw;
1104
+ }
1105
+
1106
+ function latestEmployeeSurfaceText(conv) {
1107
+ const messages = conv.messages || [];
1108
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1109
+ if (messages[i].role !== 'employee') continue;
1110
+ const cleaned = surfaceText('employee', messages[i].text, conv);
1111
+ if (cleaned) return cleaned;
1112
+ }
1113
+ return '';
1114
+ }
1115
+
1116
+ function appendMessageDom(role, text, conv) {
1117
+ const article = document.createElement('article');
1118
+ article.className = 'message ' + role;
1119
+
1120
+ const meta = document.createElement('div');
1121
+ meta.className = 'message-meta';
1122
+
1123
+ const avatar = document.createElement('span');
1124
+ avatar.className = `message-avatar ${role}`;
1125
+
1126
+ if (role === 'employee') {
1127
+ const persona = getConversationPersona(conv);
1128
+ if (persona && persona.avatarUrl) {
1129
+ const img = document.createElement('img');
1130
+ img.src = persona.avatarUrl;
1131
+ img.alt = persona.displayName;
1132
+ avatar.appendChild(img);
1133
+ } else {
1134
+ avatar.textContent = initialBadge(roleLabel(role, conv));
1135
+ }
1136
+ } else if (role === 'manager') {
1137
+ avatar.textContent = 'M';
1138
+ } else {
1139
+ avatar.textContent = '•';
1140
+ }
1141
+
1142
+ const who = document.createElement('span');
1143
+ who.className = 'who';
1144
+ who.textContent = roleLabel(role, conv);
1145
+
1146
+ const lane = document.createElement('span');
1147
+ lane.className = 'lane-label';
1148
+ lane.textContent = role === 'manager'
1149
+ ? 'Manager direction'
1150
+ : role === 'employee'
1151
+ ? 'Employee response'
1152
+ : 'System update';
1153
+
1154
+ meta.appendChild(avatar);
1155
+ meta.appendChild(who);
1156
+ meta.appendChild(lane);
1157
+ article.appendChild(meta);
1158
+
1159
+ const bubble = document.createElement('div');
1160
+ bubble.className = 'bubble';
1161
+ bubble.textContent = surfaceText(role, text, conv) || (
1162
+ role === 'employee'
1163
+ ? (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…')
1164
+ : text
1165
+ );
1166
+ if (role === 'manager') {
1167
+ const raw = document.createElement('span');
1168
+ raw.className = 'transport-raw';
1169
+ raw.textContent = text;
1170
+ article.appendChild(raw);
1171
+ }
1172
+ article.appendChild(bubble);
1173
+ els['messages'].appendChild(article);
1174
+ }
1175
+
1176
+ function scrollThreadForReview(conv) {
1177
+ const host = els['messages'];
1178
+ if (!host) return;
1179
+ const nodes = [...host.querySelectorAll('.message')];
1180
+ if (nodes.length === 0) return;
1181
+
1182
+ if (conv.status === 'running') {
1183
+ host.scrollTop = host.scrollHeight;
1184
+ return;
1185
+ }
1186
+
1187
+ const target = [...nodes].reverse().find((node) =>
1188
+ node.classList.contains('employee') || node.classList.contains('system')
1189
+ ) || nodes[nodes.length - 1];
1190
+
1191
+ const hostRect = host.getBoundingClientRect();
1192
+ const targetRect = target.getBoundingClientRect();
1193
+ const currentTop = host.scrollTop;
1194
+ const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
1195
+ const reviewOffset = Math.max(24, host.clientHeight * 0.16);
1196
+ const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
1197
+ host.scrollTo({ top: desiredTop, behavior: 'smooth' });
1198
+ }
1199
+
1200
+ function syncSendButton() {
1189
1201
  const conv = activeConversation();
1190
1202
  const hasText = els['coach-text'].value.trim().length > 0;
1191
1203
  // Send is enabled as soon as the host session exists. We deliberately
@@ -1631,24 +1643,21 @@ function deriveTitle(jobTitle, instructions) {
1631
1643
  // (per developers.openai.com/codex/skills: "type $ to mention a skill")
1632
1644
  // - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
1633
1645
  //
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
- }
1646
+ // Every command typed by the manager is prefixed with the agent's FRAIM
1647
+ // symbol so the host always sees that this is a FRAIM job, not a freeform
1648
+ // prompt. Follow-up coaching keeps the jobId too; otherwise headless hosts
1649
+ // lose which FRAIM workflow the manager meant to run.
1650
+ const FRAIM_INVOCATION_SYMBOL = {
1651
+ codex: '$fraim',
1652
+ claude: '/fraim',
1653
+ gemini: '/fraim',
1654
+ };
1655
+
1656
+ function fraimInvocationFor(employeeId, jobId, kind) {
1657
+ if (jobId === '__freeform__') return null;
1658
+ const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
1659
+ return `${symbol} ${jobId}`;
1660
+ }
1652
1661
 
1653
1662
  // Wrap the manager's typed instructions with the host-appropriate FRAIM
1654
1663
  // invocation. The wrapped text is what we ACTUALLY send to the host CLI