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.
- package/dist/src/ai-hub/hosts.js +36 -11
- package/dist/src/ai-hub/server.js +47 -17
- package/dist/src/cli/commands/test-mcp.js +171 -0
- package/dist/src/cli/setup/first-run.js +242 -0
- package/dist/src/cli/setup/ide-detector.js +46 -18
- package/dist/src/cli/utils/managed-agent-paths.js +48 -0
- package/dist/src/core/config-writer.js +75 -0
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/workflow-parser.js +174 -0
- package/dist/src/first-run/session-service.js +35 -48
- package/index.js +1 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +229 -221
- package/public/ai-hub/script.js +472 -408
- package/public/ai-hub/styles.css +660 -582
- package/public/first-run/index.html +35 -35
- package/public/first-run/script.js +667 -667
- package/public/first-run/styles.css +73 -73
package/public/ai-hub/script.js
CHANGED
|
@@ -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',
|
|
41
|
-
'progress', 'stage', 'latest', 'artifact-slot', 'messages',
|
|
42
|
-
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
43
|
-
'status-line', 'coach-note',
|
|
44
|
-
'
|
|
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['
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
else
|
|
1035
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
const
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
const
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
);
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
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.
|
|
1637
|
-
//
|
|
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
|
-
|
|
1648
|
-
|
|
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
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
if (!
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
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
|
-
|
|
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();
|