fraim-framework 2.0.146 → 2.0.148

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.
@@ -37,10 +37,10 @@ function gatherElements() {
37
37
  'new-conv-btn', 'conv-list',
38
38
  // Issue #385: team roster
39
39
  'team-roster',
40
- 'empty', 'active-conv', 'active-title', 'active-job',
40
+ 'empty', 'active-conv', 'active-title', 'active-job', 'active-identity', 'run-state-pill', 'summary-strip',
41
41
  'progress', 'stage', 'latest', 'artifact-slot', 'messages',
42
42
  'coach-text', 'send', 'micro-manage', 'micro-log',
43
- 'status-line',
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',
@@ -312,6 +312,44 @@ function statusLabel(s) {
312
312
  return 'Done';
313
313
  }
314
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
+
315
353
  // ---------------------------------------------------------------------------
316
354
  // Issue #385 — Persona UI (R3 + R4)
317
355
  // ---------------------------------------------------------------------------
@@ -343,9 +381,16 @@ function renderTeamRoster() {
343
381
  roster.innerHTML = '';
344
382
  const allChip = document.createElement('button');
345
383
  allChip.type = 'button';
346
- allChip.className = 'roster-chip' + (!state.selectedPersonaKey ? ' active' : '');
384
+ allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
347
385
  allChip.title = 'All employees';
348
- allChip.textContent = 'All';
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
+ `;
349
394
  allChip.addEventListener('click', () => setSelectedPersona(null));
350
395
  roster.appendChild(allChip);
351
396
  for (const persona of personas) {
@@ -354,14 +399,26 @@ function renderTeamRoster() {
354
399
  chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
355
400
  chip.title = persona.displayName;
356
401
  chip.setAttribute('aria-label', persona.displayName);
402
+ const avatar = document.createElement('span');
403
+ avatar.className = 'roster-avatar';
357
404
  if (persona.avatarUrl) {
358
405
  const img = document.createElement('img');
359
406
  img.src = persona.avatarUrl;
360
407
  img.alt = persona.displayName;
361
- chip.appendChild(img);
408
+ avatar.appendChild(img);
362
409
  } else {
363
- chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
410
+ avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
364
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);
365
422
  chip.addEventListener('click', () => setSelectedPersona(persona.key));
366
423
  roster.appendChild(chip);
367
424
  }
@@ -487,6 +544,7 @@ let renderedConvId = null;
487
544
  let renderedMessageCount = 0;
488
545
  let renderedEventCount = 0;
489
546
  let renderedArtifactKey = null;
547
+ let renderedStatus = null;
490
548
 
491
549
  function renderActive() {
492
550
  const conv = activeConversation();
@@ -497,12 +555,20 @@ function renderActive() {
497
555
  renderedMessageCount = 0;
498
556
  renderedEventCount = 0;
499
557
  renderedArtifactKey = null;
558
+ renderedStatus = null;
500
559
  return;
501
560
  }
502
561
  els['empty'].hidden = true;
503
562
  els['active-conv'].hidden = false;
504
563
  els['active-title'].textContent = conversationTitle(conv);
505
- els['active-job'].textContent = `Job: ${conv.jobTitle}`;
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.';
506
572
 
507
573
  // Progress section. Plain text updates are cheap and don't animate.
508
574
  const stage = derivedStage(conv);
@@ -523,6 +589,7 @@ function renderActive() {
523
589
  renderedEventCount = 0;
524
590
  renderedArtifactKey = null;
525
591
  }
592
+ const statusChanged = renderedStatus !== conv.status;
526
593
 
527
594
  // Artifact callout — only re-render when the latest artifact actually
528
595
  // changed. Avoids the 'pulse' animation re-firing on every poll tick.
@@ -555,13 +622,18 @@ function renderActive() {
555
622
  renderedMessageCount = 0;
556
623
  }
557
624
  for (let i = renderedMessageCount; i < messages.length; i += 1) {
558
- appendMessageDom(messages[i].role, messages[i].text);
625
+ appendMessageDom(messages[i].role, messages[i].text, conv);
559
626
  }
627
+ const appendedMessages = messages.length - renderedMessageCount;
560
628
  renderedMessageCount = messages.length;
561
- // Keep the scroll pinned to the latest unless the user scrolled up.
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.
562
632
  const m = els['messages'];
563
- if (m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
633
+ if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
564
634
  m.scrollTop = m.scrollHeight;
635
+ } else if (switchedConv || statusChanged || appendedMessages > 0) {
636
+ scrollThreadForReview(conv);
565
637
  }
566
638
 
567
639
  // Micro-manage — only append new events. textContent assignment on the
@@ -592,6 +664,53 @@ function renderActive() {
592
664
  syncTemplatePickerVisibility();
593
665
  // Browser-tab title mirrors the active conversation (R3).
594
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.';
595
714
  }
596
715
 
597
716
  // Render the inline employee selector shown in the coach section of an
@@ -929,23 +1048,141 @@ function derivedStage(conv) {
929
1048
  }
930
1049
 
931
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) {
932
1101
  const messages = conv.messages || [];
933
1102
  for (let i = messages.length - 1; i >= 0; i -= 1) {
934
- if (messages[i].role === 'employee') return messages[i].text;
1103
+ if (messages[i].role !== 'employee') continue;
1104
+ const cleaned = surfaceText('employee', messages[i].text, conv);
1105
+ if (cleaned) return cleaned;
935
1106
  }
936
- if (conv.status === 'running') return 'Loading the work and getting started.';
937
1107
  return '';
938
1108
  }
939
1109
 
940
- function appendMessageDom(role, text) {
941
- const div = document.createElement('div');
942
- div.className = 'message ' + role;
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
+
943
1136
  const who = document.createElement('span');
944
1137
  who.className = 'who';
945
- who.textContent = role === 'manager' ? 'You' : (role === 'employee' ? 'Employee' : 'System');
946
- div.appendChild(who);
947
- div.appendChild(document.createTextNode(text));
948
- els['messages'].appendChild(div);
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' });
949
1186
  }
950
1187
 
951
1188
  function syncSendButton() {