fraim-framework 2.0.146 → 2.0.147

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',
41
- 'progress', 'stage', 'latest', 'artifact-slot', 'messages',
42
- 'coach-text', 'send', 'micro-manage', 'micro-log',
43
- 'status-line',
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,11 +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
- }
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
+ }
314
352
 
315
353
  // ---------------------------------------------------------------------------
316
354
  // Issue #385 — Persona UI (R3 + R4)
@@ -329,43 +367,62 @@ function conversationTitle(conv) {
329
367
 
330
368
  // R4.2 — team roster: one avatar chip per hired persona above the conv list.
331
369
  // Only rendered when at least one persona is hired (subscription active).
332
- function renderTeamRoster() {
333
- const roster = els['team-roster'];
334
- if (!roster) return;
335
- const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
336
- 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) {
337
375
  roster.hidden = true;
338
376
  // Reset persona selection when there's no active subscription.
339
377
  if (state.selectedPersonaKey) state.selectedPersonaKey = null;
340
378
  return;
341
- }
342
- roster.hidden = false;
343
- roster.innerHTML = '';
344
- const allChip = document.createElement('button');
345
- allChip.type = 'button';
346
- allChip.className = 'roster-chip' + (!state.selectedPersonaKey ? ' active' : '');
347
- allChip.title = 'All employees';
348
- allChip.textContent = 'All';
349
- allChip.addEventListener('click', () => setSelectedPersona(null));
350
- roster.appendChild(allChip);
351
- for (const persona of personas) {
352
- const chip = document.createElement('button');
353
- chip.type = 'button';
354
- chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
355
- chip.title = persona.displayName;
356
- chip.setAttribute('aria-label', persona.displayName);
357
- if (persona.avatarUrl) {
358
- const img = document.createElement('img');
359
- img.src = persona.avatarUrl;
360
- img.alt = persona.displayName;
361
- chip.appendChild(img);
362
- } else {
363
- chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
364
- }
365
- chip.addEventListener('click', () => setSelectedPersona(persona.key));
366
- roster.appendChild(chip);
367
- }
368
- }
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
+ }
369
426
 
370
427
  // R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
371
428
  function buildAvatarChip(persona, size) {
@@ -483,30 +540,39 @@ function renderModalPersonaFilter() {
483
540
  // event rows are already in the DOM. Polling fires every second; without
484
541
  // this, every tick wiped the messages list and re-played the slidein
485
542
  // animation (= the screen flash the user reported as 'distracting').
486
- let renderedConvId = null;
487
- let renderedMessageCount = 0;
488
- let renderedEventCount = 0;
489
- let renderedArtifactKey = null;
490
-
491
- function renderActive() {
492
- const conv = activeConversation();
493
- if (!conv) {
494
- els['empty'].hidden = false;
495
- els['active-conv'].hidden = true;
496
- renderedConvId = null;
497
- renderedMessageCount = 0;
498
- renderedEventCount = 0;
499
- renderedArtifactKey = null;
500
- return;
501
- }
502
- els['empty'].hidden = true;
503
- els['active-conv'].hidden = false;
504
- els['active-title'].textContent = conversationTitle(conv);
505
- els['active-job'].textContent = `Job: ${conv.jobTitle}`;
506
-
507
- // Progress section. Plain text updates are cheap and don't animate.
508
- const stage = derivedStage(conv);
509
- 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;
510
576
  els['progress'].classList.remove('done', 'attention', 'failed');
511
577
  if (stage.kind) els['progress'].classList.add(stage.kind);
512
578
  els['latest'].textContent = derivedLatest(conv);
@@ -514,15 +580,16 @@ function renderActive() {
514
580
  // If we switched conversations (or this is the first render), wipe and
515
581
  // start fresh. Otherwise we're going to do an incremental update below.
516
582
  const switchedConv = renderedConvId !== conv.id;
517
- if (switchedConv) {
583
+ if (switchedConv) {
518
584
  els['artifact-slot'].innerHTML = '';
519
585
  els['messages'].innerHTML = '';
520
586
  els['micro-log'].textContent = '';
521
587
  renderedConvId = conv.id;
522
588
  renderedMessageCount = 0;
523
- renderedEventCount = 0;
524
- renderedArtifactKey = null;
525
- }
589
+ renderedEventCount = 0;
590
+ renderedArtifactKey = null;
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.
@@ -550,19 +617,24 @@ function renderActive() {
550
617
  // existing rows don't re-animate. If for some reason the data shrunk
551
618
  // (server revoked a message), fall back to a full re-render.
552
619
  const messages = conv.messages || [];
553
- if (messages.length < renderedMessageCount) {
554
- els['messages'].innerHTML = '';
555
- renderedMessageCount = 0;
556
- }
557
- for (let i = renderedMessageCount; i < messages.length; i += 1) {
558
- appendMessageDom(messages[i].role, messages[i].text);
559
- }
560
- renderedMessageCount = messages.length;
561
- // Keep the scroll pinned to the latest unless the user scrolled up.
562
- const m = els['messages'];
563
- if (m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
564
- m.scrollTop = m.scrollHeight;
565
- }
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
+ }
566
638
 
567
639
  // Micro-manage — only append new events. textContent assignment on the
568
640
  // <pre> wipes the entire log every tick which is wasteful.
@@ -588,11 +660,58 @@ function renderActive() {
588
660
  // foldRunIntoConversation); for runs that have not yet polled we
589
661
  // simply hide the surfaces.
590
662
  renderTracker(conv);
591
- renderTotals(conv);
592
- syncTemplatePickerVisibility();
593
- // Browser-tab title mirrors the active conversation (R3).
594
- document.title = conv.title ? conv.title : 'AI Hub';
595
- }
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
+ }
596
715
 
597
716
  // Render the inline employee selector shown in the coach section of an
598
717
  // active conversation. Allows switching agents without reopening the modal.
@@ -928,27 +1047,145 @@ function derivedStage(conv) {
928
1047
  return { text: 'Done — please review', kind: 'done' };
929
1048
  }
930
1049
 
931
- function derivedLatest(conv) {
932
- const messages = conv.messages || [];
933
- for (let i = messages.length - 1; i >= 0; i -= 1) {
934
- if (messages[i].role === 'employee') return messages[i].text;
935
- }
936
- if (conv.status === 'running') return 'Loading the work and getting started.';
937
- return '';
938
- }
939
-
940
- function appendMessageDom(role, text) {
941
- const div = document.createElement('div');
942
- div.className = 'message ' + role;
943
- const who = document.createElement('span');
944
- 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);
949
- }
950
-
951
- function syncSendButton() {
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() {
952
1189
  const conv = activeConversation();
953
1190
  const hasText = els['coach-text'].value.trim().length > 0;
954
1191
  // Send is enabled as soon as the host session exists. We deliberately