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.
- package/dist/src/ai-hub/hosts.js +36 -18
- package/package.json +1 -1
- package/public/ai-hub/index.html +67 -44
- package/public/ai-hub/script.js +347 -110
- package/public/ai-hub/styles.css +561 -264
- package/public/first-run/index.html +1 -0
- package/public/first-run/script.js +30 -18
- package/public/first-run/styles.css +73 -49
package/public/ai-hub/script.js
CHANGED
|
@@ -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.
|
|
349
|
-
allChip.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
els['
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
els['
|
|
505
|
-
els['active-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
els['
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
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
|