fraim-framework 2.0.153 → 2.0.159
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 +135 -8
- package/dist/src/ai-hub/server.js +201 -1
- package/dist/src/cli/commands/init-project.js +50 -36
- package/dist/src/cli/commands/sync.js +22 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
- package/dist/src/cli/utils/github-workflow-sync.js +231 -0
- package/dist/src/cli/utils/managed-agent-paths.js +1 -1
- package/dist/src/cli/utils/project-bootstrap.js +6 -3
- package/dist/src/core/ai-mentor.js +46 -37
- package/dist/src/core/config-loader.js +68 -0
- package/dist/src/core/fraim-config-schema.generated.js +267 -1
- package/dist/src/core/utils/fraim-labels.js +182 -0
- package/dist/src/core/utils/git-utils.js +22 -1
- package/dist/src/core/utils/project-fraim-paths.js +58 -0
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
- package/dist/src/local-mcp-server/stdio-server.js +212 -13
- package/package.json +8 -3
- package/public/ai-hub/index.html +271 -229
- package/public/ai-hub/script.js +879 -527
- package/public/ai-hub/styles.css +877 -694
- package/public/first-run/index.html +35 -35
- package/public/first-run/script.js +667 -667
package/public/ai-hub/script.js
CHANGED
|
@@ -21,10 +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
|
-
panelState: {}, // { [convId]: { coach?: boolean } }
|
|
27
|
-
};
|
|
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
|
+
};
|
|
28
28
|
|
|
29
29
|
const els = {};
|
|
30
30
|
|
|
@@ -37,13 +37,14 @@ function gatherElements() {
|
|
|
37
37
|
'project-button', 'project-name',
|
|
38
38
|
'new-conv-btn', 'conv-list',
|
|
39
39
|
// Issue #385: team roster
|
|
40
|
-
'team-roster',
|
|
41
|
-
'empty', 'active-conv', 'active-title', 'active-
|
|
42
|
-
'
|
|
43
|
-
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
44
|
-
'status-line', 'coach-note',
|
|
45
|
-
'coach-panel', 'coach-summary',
|
|
46
|
-
'
|
|
40
|
+
'team-roster',
|
|
41
|
+
'empty', 'active-conv', 'active-title', 'active-identity', 'run-state-pill',
|
|
42
|
+
'messages',
|
|
43
|
+
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
44
|
+
'status-line', 'coach-note',
|
|
45
|
+
'coach-panel', 'coach-summary',
|
|
46
|
+
'thread-panel', 'quick-coach-btns', 'other-manager-jobs-btn',
|
|
47
|
+
'modal', 'step1', 'step2',
|
|
47
48
|
'cancel1', 'next1', 'back2', 'start',
|
|
48
49
|
'job-search', 'job-catalog', 'job-pick-status',
|
|
49
50
|
// Issue #385: hire-required notice, persona job filter
|
|
@@ -56,6 +57,9 @@ function gatherElements() {
|
|
|
56
57
|
'tracker', 'tracker-rows', 'tracker-note',
|
|
57
58
|
'template-picker-btn', 'template-popover',
|
|
58
59
|
'totals',
|
|
60
|
+
// Issue #442: A/B mode elements.
|
|
61
|
+
'ab-toggle-wrap', 'ab-direct-panel',
|
|
62
|
+
'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
|
|
59
63
|
];
|
|
60
64
|
for (const id of ids) {
|
|
61
65
|
els[id] = document.getElementById(id);
|
|
@@ -226,34 +230,129 @@ function findConversation(id) {
|
|
|
226
230
|
return null;
|
|
227
231
|
}
|
|
228
232
|
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
if (!conv
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
233
|
+
function activeConversation() {
|
|
234
|
+
if (!state.activeId) return null;
|
|
235
|
+
return findConversation(state.activeId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function panelStateFor(convId) {
|
|
239
|
+
if (!convId) return {};
|
|
240
|
+
if (!state.panelState[convId]) state.panelState[convId] = {};
|
|
241
|
+
return state.panelState[convId];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function defaultCoachOpen(conv) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// R4: resolve employee display name for the coach label.
|
|
249
|
+
function coachEmployeeLabel(conv) {
|
|
250
|
+
if (!conv) return 'Maestro';
|
|
251
|
+
if (conv.personaKey) {
|
|
252
|
+
const persona = (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey);
|
|
253
|
+
if (persona) return persona.displayName;
|
|
254
|
+
}
|
|
255
|
+
if (conv.employeeId) {
|
|
256
|
+
const emp = getEmployeeStatus(conv.employeeId);
|
|
257
|
+
if (emp) return emp.label;
|
|
258
|
+
}
|
|
259
|
+
return 'Maestro';
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// R4: update coach label with employee name + initial badge.
|
|
263
|
+
function syncCoachEmployeeLabel(conv) {
|
|
264
|
+
const label = document.querySelector('.active-employee-label');
|
|
265
|
+
if (!label) return;
|
|
266
|
+
const name = coachEmployeeLabel(conv);
|
|
267
|
+
label.textContent = '';
|
|
268
|
+
// Use persona avatar image when available (matches renderConversationIdentity).
|
|
269
|
+
const persona = conv && conv.personaKey
|
|
270
|
+
? (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey)
|
|
271
|
+
: null;
|
|
272
|
+
let badge;
|
|
273
|
+
if (persona && persona.avatarUrl) {
|
|
274
|
+
badge = document.createElement('img');
|
|
275
|
+
badge.className = 'coach-employee-badge coach-employee-avatar';
|
|
276
|
+
badge.src = persona.avatarUrl;
|
|
277
|
+
badge.alt = name;
|
|
278
|
+
} else {
|
|
279
|
+
badge = document.createElement('span');
|
|
280
|
+
badge.className = 'coach-employee-badge';
|
|
281
|
+
badge.textContent = name.slice(0, 1).toUpperCase();
|
|
282
|
+
}
|
|
283
|
+
const nameSpan = document.createElement('span');
|
|
284
|
+
nameSpan.className = 'coach-label-name';
|
|
285
|
+
nameSpan.textContent = name;
|
|
286
|
+
label.appendChild(badge);
|
|
287
|
+
label.appendChild(nameSpan);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// R5: show/hide quick-access coaching buttons based on managerTemplates.
|
|
291
|
+
function syncQuickCoachButtons(conv) {
|
|
292
|
+
const row = els['quick-coach-btns'];
|
|
293
|
+
if (!row) return;
|
|
294
|
+
const hasTemplates = state.bootstrap && state.bootstrap.managerTemplates && state.bootstrap.managerTemplates.length > 0;
|
|
295
|
+
const hasRun = conv && (conv.status === 'running' || conv.status === 'completed');
|
|
296
|
+
row.hidden = !(hasTemplates && hasRun);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// R8: render markdown subset safely. HTML is escaped first.
|
|
300
|
+
function formatEmployeeText(text) {
|
|
301
|
+
if (!text) return '';
|
|
302
|
+
// 1. Escape HTML entities.
|
|
303
|
+
let s = text
|
|
304
|
+
.replace(/&/g, '&')
|
|
305
|
+
.replace(/</g, '<')
|
|
306
|
+
.replace(/>/g, '>')
|
|
307
|
+
.replace(/"/g, '"');
|
|
308
|
+
// 2. Fenced code blocks (triple backtick).
|
|
309
|
+
s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
|
|
310
|
+
// 3. Inline code (single backtick, non-greedy, no newlines).
|
|
311
|
+
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
312
|
+
// 4. Bold (**text**).
|
|
313
|
+
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
314
|
+
// 5–7. Process line by line for lists and paragraphs.
|
|
315
|
+
const lines = s.split('\n');
|
|
316
|
+
const out = [];
|
|
317
|
+
let ulOpen = false;
|
|
318
|
+
let olOpen = false;
|
|
319
|
+
for (const line of lines) {
|
|
320
|
+
const ulMatch = line.match(/^[-*] (.+)/);
|
|
321
|
+
const olMatch = line.match(/^\d+\. (.+)/);
|
|
322
|
+
if (ulMatch) {
|
|
323
|
+
if (olOpen) { out.push('</ol>'); olOpen = false; }
|
|
324
|
+
if (!ulOpen) { out.push('<ul>'); ulOpen = true; }
|
|
325
|
+
out.push(`<li>${ulMatch[1]}</li>`);
|
|
326
|
+
} else if (olMatch) {
|
|
327
|
+
if (ulOpen) { out.push('</ul>'); ulOpen = false; }
|
|
328
|
+
if (!olOpen) { out.push('<ol>'); olOpen = true; }
|
|
329
|
+
out.push(`<li>${olMatch[1]}</li>`);
|
|
330
|
+
} else {
|
|
331
|
+
if (ulOpen) { out.push('</ul>'); ulOpen = false; }
|
|
332
|
+
if (olOpen) { out.push('</ol>'); olOpen = false; }
|
|
333
|
+
if (line.trim()) out.push(`<p>${line}</p>`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (ulOpen) out.push('</ul>');
|
|
337
|
+
if (olOpen) out.push('</ol>');
|
|
338
|
+
return out.join('\n');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function syncConversationPanels(conv, switchedConv) {
|
|
342
|
+
const coach = els['coach-panel'];
|
|
343
|
+
if (!conv || !coach) return;
|
|
344
|
+
const panelState = panelStateFor(conv.id);
|
|
345
|
+
if (switchedConv) {
|
|
346
|
+
coach.open = panelState.coach ?? defaultCoachOpen(conv);
|
|
347
|
+
if (els['thread-panel']) {
|
|
348
|
+
els['thread-panel'].open = panelState.thread ?? true;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// R3.3: hide the summary hint when the coach panel is open.
|
|
352
|
+
if (els['coach-summary']) {
|
|
353
|
+
els['coach-summary'].hidden = coach.open;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
257
356
|
|
|
258
357
|
function upsertConversation(conv) {
|
|
259
358
|
const list = projectConversations().slice();
|
|
@@ -327,54 +426,61 @@ function renderRail() {
|
|
|
327
426
|
if (conv.status === 'running') statusSpan.classList.add('running');
|
|
328
427
|
if (conv.status === 'failed') statusSpan.classList.add('failed');
|
|
329
428
|
btn.appendChild(statusSpan);
|
|
429
|
+
// Issue #442: A/B badge on rail entry.
|
|
430
|
+
if (conv.compareMode === 'ab') {
|
|
431
|
+
const badge = document.createElement('span');
|
|
432
|
+
badge.className = 'ab-badge';
|
|
433
|
+
badge.textContent = 'A/B';
|
|
434
|
+
btn.appendChild(badge);
|
|
435
|
+
}
|
|
330
436
|
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
331
437
|
els['conv-list'].appendChild(btn);
|
|
332
438
|
}
|
|
333
439
|
}
|
|
334
440
|
|
|
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
|
-
}
|
|
441
|
+
function statusLabel(s) {
|
|
442
|
+
if (s === 'running') return 'Running';
|
|
443
|
+
if (s === 'failed') return 'Needs you';
|
|
444
|
+
return 'Done';
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function personaMap() {
|
|
448
|
+
const map = new Map();
|
|
449
|
+
for (const persona of state.bootstrap?.personas || []) {
|
|
450
|
+
map.set(persona.key, persona);
|
|
451
|
+
}
|
|
452
|
+
return map;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function getConversationPersona(conv) {
|
|
456
|
+
if (!conv || !conv.personaKey) return null;
|
|
457
|
+
return personaMap().get(conv.personaKey) || null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function getEmployeeStatus(employeeId) {
|
|
461
|
+
return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function getEmployeeTitle(conv) {
|
|
465
|
+
const persona = getConversationPersona(conv);
|
|
466
|
+
if (persona) return persona.role;
|
|
467
|
+
const employee = getEmployeeStatus(conv?.employeeId);
|
|
468
|
+
return employee ? employee.label : 'AI Employee';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function roleLabel(role, conv) {
|
|
472
|
+
if (role === 'manager') return 'Manager';
|
|
473
|
+
if (role === 'employee') {
|
|
474
|
+
const persona = getConversationPersona(conv);
|
|
475
|
+
return persona ? persona.displayName : 'Employee';
|
|
476
|
+
}
|
|
477
|
+
return 'System';
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function initialBadge(text) {
|
|
481
|
+
const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
|
|
482
|
+
return (cleaned.slice(0, 2) || 'FH');
|
|
483
|
+
}
|
|
378
484
|
|
|
379
485
|
// ---------------------------------------------------------------------------
|
|
380
486
|
// Issue #385 — Persona UI (R3 + R4)
|
|
@@ -393,62 +499,62 @@ function conversationTitle(conv) {
|
|
|
393
499
|
|
|
394
500
|
// R4.2 — team roster: one avatar chip per hired persona above the conv list.
|
|
395
501
|
// Only rendered when at least one persona is hired (subscription active).
|
|
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) {
|
|
502
|
+
function renderTeamRoster() {
|
|
503
|
+
const roster = els['team-roster'];
|
|
504
|
+
if (!roster) return;
|
|
505
|
+
const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
|
|
506
|
+
if (personas.length === 0) {
|
|
401
507
|
roster.hidden = true;
|
|
402
508
|
// Reset persona selection when there's no active subscription.
|
|
403
509
|
if (state.selectedPersonaKey) state.selectedPersonaKey = null;
|
|
404
510
|
return;
|
|
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
|
-
}
|
|
511
|
+
}
|
|
512
|
+
roster.hidden = false;
|
|
513
|
+
roster.innerHTML = '';
|
|
514
|
+
const allChip = document.createElement('button');
|
|
515
|
+
allChip.type = 'button';
|
|
516
|
+
allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
|
|
517
|
+
allChip.title = 'All employees';
|
|
518
|
+
allChip.setAttribute('aria-label', 'All employees');
|
|
519
|
+
allChip.innerHTML = `
|
|
520
|
+
<span class="roster-avatar">All</span>
|
|
521
|
+
<span class="roster-copy">
|
|
522
|
+
<strong>All employees</strong>
|
|
523
|
+
<small>Show every hired employee</small>
|
|
524
|
+
</span>
|
|
525
|
+
`;
|
|
526
|
+
allChip.addEventListener('click', () => setSelectedPersona(null));
|
|
527
|
+
roster.appendChild(allChip);
|
|
528
|
+
for (const persona of personas) {
|
|
529
|
+
const chip = document.createElement('button');
|
|
530
|
+
chip.type = 'button';
|
|
531
|
+
chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
|
|
532
|
+
chip.title = persona.displayName;
|
|
533
|
+
chip.setAttribute('aria-label', persona.displayName);
|
|
534
|
+
const avatar = document.createElement('span');
|
|
535
|
+
avatar.className = 'roster-avatar';
|
|
536
|
+
if (persona.avatarUrl) {
|
|
537
|
+
const img = document.createElement('img');
|
|
538
|
+
img.src = persona.avatarUrl;
|
|
539
|
+
img.alt = persona.displayName;
|
|
540
|
+
avatar.appendChild(img);
|
|
541
|
+
} else {
|
|
542
|
+
avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
|
|
543
|
+
}
|
|
544
|
+
const copy = document.createElement('span');
|
|
545
|
+
copy.className = 'roster-copy';
|
|
546
|
+
const name = document.createElement('strong');
|
|
547
|
+
name.textContent = persona.displayName;
|
|
548
|
+
const role = document.createElement('small');
|
|
549
|
+
role.textContent = persona.role || 'AI Employee';
|
|
550
|
+
copy.appendChild(name);
|
|
551
|
+
copy.appendChild(role);
|
|
552
|
+
chip.appendChild(avatar);
|
|
553
|
+
chip.appendChild(copy);
|
|
554
|
+
chip.addEventListener('click', () => setSelectedPersona(persona.key));
|
|
555
|
+
roster.appendChild(chip);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
452
558
|
|
|
453
559
|
// R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
|
|
454
560
|
function buildAvatarChip(persona, size) {
|
|
@@ -566,101 +672,108 @@ function renderModalPersonaFilter() {
|
|
|
566
672
|
// event rows are already in the DOM. Polling fires every second; without
|
|
567
673
|
// this, every tick wiped the messages list and re-played the slidein
|
|
568
674
|
// animation (= the screen flash the user reported as 'distracting').
|
|
569
|
-
let renderedConvId = null;
|
|
570
|
-
let renderedMessageCount = 0;
|
|
571
|
-
let renderedEventCount = 0;
|
|
572
|
-
let
|
|
573
|
-
let
|
|
574
|
-
|
|
575
|
-
function renderActive() {
|
|
576
|
-
const conv = activeConversation();
|
|
577
|
-
if (!conv) {
|
|
578
|
-
els['empty'].hidden = false;
|
|
579
|
-
els['active-conv'].hidden = true;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
els['
|
|
591
|
-
|
|
592
|
-
els['active-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const stage = derivedStage(conv);
|
|
600
|
-
els['stage'].textContent = stage.text;
|
|
601
|
-
els['progress'].classList.remove('done', 'attention', 'failed');
|
|
602
|
-
if (stage.kind) els['progress'].classList.add(stage.kind);
|
|
603
|
-
els['latest'].textContent = derivedLatest(conv);
|
|
675
|
+
let renderedConvId = null;
|
|
676
|
+
let renderedMessageCount = 0;
|
|
677
|
+
let renderedEventCount = 0;
|
|
678
|
+
let renderedStatus = null;
|
|
679
|
+
let renderedDirectEventCount = 0;
|
|
680
|
+
|
|
681
|
+
function renderActive() {
|
|
682
|
+
const conv = activeConversation();
|
|
683
|
+
if (!conv) {
|
|
684
|
+
els['empty'].hidden = false;
|
|
685
|
+
els['active-conv'].hidden = true;
|
|
686
|
+
const _c = document.getElementById('conversation');
|
|
687
|
+
if (_c) _c.classList.remove('ab-mode');
|
|
688
|
+
if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
|
|
689
|
+
renderedConvId = null;
|
|
690
|
+
renderedMessageCount = 0;
|
|
691
|
+
renderedEventCount = 0;
|
|
692
|
+
renderedStatus = null;
|
|
693
|
+
renderedDirectEventCount = 0;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
els['empty'].hidden = true;
|
|
697
|
+
els['active-conv'].hidden = false;
|
|
698
|
+
els['active-title'].textContent = conversationTitle(conv);
|
|
699
|
+
renderConversationIdentity(conv);
|
|
700
|
+
renderRunStatePill(conv);
|
|
701
|
+
syncCoachEmployeeLabel(conv);
|
|
702
|
+
els['coach-note'].textContent = conv.status === 'running'
|
|
703
|
+
? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
|
|
704
|
+
: 'The employee is waiting on you. Send the next instruction to continue this run.';
|
|
604
705
|
|
|
605
706
|
// If we switched conversations (or this is the first render), wipe and
|
|
606
707
|
// start fresh. Otherwise we're going to do an incremental update below.
|
|
607
|
-
const switchedConv = renderedConvId !== conv.id;
|
|
608
|
-
if (switchedConv) {
|
|
609
|
-
els['artifact-slot'].innerHTML = '';
|
|
708
|
+
const switchedConv = renderedConvId !== conv.id;
|
|
709
|
+
if (switchedConv) {
|
|
610
710
|
els['messages'].innerHTML = '';
|
|
711
|
+
els['messages'].className = 'messages';
|
|
611
712
|
els['micro-log'].textContent = '';
|
|
612
713
|
renderedConvId = conv.id;
|
|
613
714
|
renderedMessageCount = 0;
|
|
614
|
-
renderedEventCount = 0;
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
const statusChanged = renderedStatus !== conv.status;
|
|
618
|
-
syncConversationPanels(conv, switchedConv);
|
|
619
|
-
|
|
620
|
-
// Artifact callout — only re-render when the latest artifact actually
|
|
621
|
-
// changed. Avoids the 'pulse' animation re-firing on every poll tick.
|
|
622
|
-
const latestArtifact = conv.artifacts && conv.artifacts.length > 0
|
|
623
|
-
? conv.artifacts[conv.artifacts.length - 1]
|
|
624
|
-
: null;
|
|
625
|
-
const artifactKey = latestArtifact ? `${latestArtifact.where}${latestArtifact.name}` : null;
|
|
626
|
-
if (artifactKey !== renderedArtifactKey) {
|
|
627
|
-
els['artifact-slot'].innerHTML = '';
|
|
628
|
-
if (latestArtifact) {
|
|
629
|
-
const span = document.createElement('span');
|
|
630
|
-
span.className = 'artifact';
|
|
631
|
-
span.title = `${latestArtifact.where}${latestArtifact.name}`;
|
|
632
|
-
span.innerHTML = `
|
|
633
|
-
<span class="artifact-dot" aria-hidden="true"></span>
|
|
634
|
-
<span class="artifact-label">file</span>
|
|
635
|
-
<span class="artifact-name"></span>`;
|
|
636
|
-
span.querySelector('.artifact-name').textContent = latestArtifact.name;
|
|
637
|
-
els['artifact-slot'].appendChild(span);
|
|
638
|
-
}
|
|
639
|
-
renderedArtifactKey = artifactKey;
|
|
715
|
+
renderedEventCount = 0;
|
|
716
|
+
renderedDirectEventCount = 0;
|
|
640
717
|
}
|
|
718
|
+
const statusChanged = renderedStatus !== conv.status;
|
|
719
|
+
syncConversationPanels(conv, switchedConv);
|
|
720
|
+
syncQuickCoachButtons(conv);
|
|
641
721
|
|
|
642
722
|
// Messages — append only the rows that aren't already in the DOM, so
|
|
643
723
|
// existing rows don't re-animate. If for some reason the data shrunk
|
|
644
724
|
// (server revoked a message), fall back to a full re-render.
|
|
645
725
|
const messages = conv.messages || [];
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
726
|
+
const isABConv = conv.compareMode === 'ab';
|
|
727
|
+
|
|
728
|
+
// Issue #442: A/B split — full panel. #conversation becomes a flex row;
|
|
729
|
+
// FRAIM side is the unchanged #active-conv; Direct side is #ab-direct-panel.
|
|
730
|
+
const _container = document.getElementById('conversation');
|
|
731
|
+
if (isABConv) {
|
|
732
|
+
if (_container) _container.classList.add('ab-mode');
|
|
733
|
+
if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = false;
|
|
734
|
+
if (switchedConv) {
|
|
735
|
+
const dl = document.getElementById('ab-direct-log');
|
|
736
|
+
if (dl) dl.textContent = '';
|
|
737
|
+
renderedDirectEventCount = 0;
|
|
738
|
+
}
|
|
739
|
+
} else {
|
|
740
|
+
if (_container) _container.classList.remove('ab-mode');
|
|
741
|
+
if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// FRAIM messages — render into #messages via appendMessageDom (same for AB and non-AB).
|
|
745
|
+
if (messages.length < renderedMessageCount) {
|
|
746
|
+
els['messages'].innerHTML = '';
|
|
747
|
+
renderedMessageCount = 0;
|
|
748
|
+
}
|
|
749
|
+
for (let i = renderedMessageCount; i < messages.length; i += 1) {
|
|
750
|
+
appendMessageDom(messages[i].role, messages[i].text, conv);
|
|
751
|
+
}
|
|
752
|
+
const appendedMessages = messages.length - renderedMessageCount;
|
|
753
|
+
renderedMessageCount = messages.length;
|
|
754
|
+
const m = els['messages'];
|
|
755
|
+
if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
|
|
756
|
+
m.scrollTop = m.scrollHeight;
|
|
757
|
+
} else if (switchedConv || statusChanged || appendedMessages > 0) {
|
|
758
|
+
scrollThreadForReview(conv);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// Direct raw event log — append-only like the FRAIM micro-log, shows all
|
|
762
|
+
// stdout/stderr/system events from the B run so the user sees everything.
|
|
763
|
+
if (isABConv) {
|
|
764
|
+
const directEvents = (conv.compareRun && conv.compareRun.events) || [];
|
|
765
|
+
const directLog = document.getElementById('ab-direct-log');
|
|
766
|
+
if (directLog) {
|
|
767
|
+
if (directEvents.length < renderedDirectEventCount) { directLog.textContent = ''; renderedDirectEventCount = 0; }
|
|
768
|
+
for (let i = renderedDirectEventCount; i < directEvents.length; i += 1) {
|
|
769
|
+
const line = `[${directEvents[i].channel || 'system'}] ${directEvents[i].text}\n`;
|
|
770
|
+
directLog.appendChild(document.createTextNode(line));
|
|
771
|
+
}
|
|
772
|
+
renderedDirectEventCount = directEvents.length;
|
|
773
|
+
const ds = (conv.compareRun && conv.compareRun.status) || 'running';
|
|
774
|
+
if (ds === 'running') directLog.scrollTop = directLog.scrollHeight;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
664
777
|
|
|
665
778
|
// Micro-manage — only append new events. textContent assignment on the
|
|
666
779
|
// <pre> wipes the entire log every tick which is wasteful.
|
|
@@ -686,64 +799,104 @@ function renderActive() {
|
|
|
686
799
|
// foldRunIntoConversation); for runs that have not yet polled we
|
|
687
800
|
// simply hide the surfaces.
|
|
688
801
|
renderTracker(conv);
|
|
689
|
-
renderTotals(conv);
|
|
690
|
-
syncTemplatePickerVisibility();
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
802
|
+
renderTotals(conv);
|
|
803
|
+
syncTemplatePickerVisibility();
|
|
804
|
+
// Issue #442: Direct panel status pill, simple progress indicator, and totals.
|
|
805
|
+
if (isABConv) {
|
|
806
|
+
// Mirror the job title from A into the B topline.
|
|
807
|
+
const directTitleEl = document.querySelector('#ab-direct-panel .conv-job-title');
|
|
808
|
+
if (directTitleEl) directTitleEl.textContent = conv.title || '';
|
|
809
|
+
|
|
810
|
+
const ds = (conv.compareRun && conv.compareRun.status) || 'running';
|
|
811
|
+
const dpill = document.getElementById('ab-direct-pill');
|
|
812
|
+
if (dpill) {
|
|
813
|
+
dpill.textContent = statusLabel(ds).toUpperCase();
|
|
814
|
+
dpill.className = `run-state-pill ${ds}`;
|
|
815
|
+
}
|
|
816
|
+
// Simple Running→Done progress row (mirrors FRAIM pizza tracker position).
|
|
817
|
+
const dprogress = els['ab-direct-progress'];
|
|
818
|
+
if (dprogress) {
|
|
819
|
+
if (ds === 'running') { dprogress.textContent = '● Running'; dprogress.className = 'ab-direct-progress running'; }
|
|
820
|
+
else if (ds === 'completed') { dprogress.textContent = '✓ Done'; dprogress.className = 'ab-direct-progress done'; }
|
|
821
|
+
else if (ds === 'failed') { dprogress.textContent = '✗ Failed'; dprogress.className = 'ab-direct-progress failed'; }
|
|
822
|
+
else { dprogress.textContent = statusLabel(ds); dprogress.className = 'ab-direct-progress'; }
|
|
823
|
+
}
|
|
824
|
+
// Direct totals row — same format as FRAIM's totals row.
|
|
825
|
+
renderDirectTotals(conv);
|
|
826
|
+
// Direct send button — enabled only when idle and session is resumable.
|
|
827
|
+
const dsend = els['ab-direct-send'];
|
|
828
|
+
if (dsend) {
|
|
829
|
+
const dinput = document.getElementById('ab-direct-input');
|
|
830
|
+
const hasSession = !!(conv.compareRun && conv.compareRun.sessionId);
|
|
831
|
+
const notRunning = ds !== 'running';
|
|
832
|
+
dsend.disabled = !hasSession || !notRunning || !(dinput && dinput.value.trim());
|
|
833
|
+
}
|
|
834
|
+
// Align B's header rows to A's so the thread panels start at the same Y.
|
|
835
|
+
requestAnimationFrame(() => {
|
|
836
|
+
const aTopline = document.querySelector('#active-conv > .conv-topline');
|
|
837
|
+
const aStatus = document.querySelector('#active-conv > .conversation-status');
|
|
838
|
+
const bTopline = document.querySelector('#ab-direct-panel > .conv-topline');
|
|
839
|
+
const bStatus = document.querySelector('#ab-direct-panel > .conversation-status');
|
|
840
|
+
if (aTopline && bTopline) bTopline.style.minHeight = aTopline.offsetHeight + 'px';
|
|
841
|
+
if (aStatus && bStatus) bStatus.style.minHeight = aStatus.offsetHeight + 'px';
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
// Browser-tab title mirrors the active conversation (R3).
|
|
845
|
+
document.title = conv.title ? conv.title : 'AI Hub';
|
|
846
|
+
renderedStatus = conv.status;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function renderConversationIdentity(conv) {
|
|
850
|
+
const host = els['active-identity'];
|
|
851
|
+
if (!host) return;
|
|
852
|
+
const persona = getConversationPersona(conv);
|
|
853
|
+
const employee = getEmployeeStatus(conv.employeeId);
|
|
854
|
+
host.innerHTML = '';
|
|
855
|
+
|
|
856
|
+
const avatar = document.createElement(persona && persona.avatarUrl ? 'img' : 'span');
|
|
857
|
+
avatar.className = 'identity-avatar';
|
|
858
|
+
if (persona && persona.avatarUrl) {
|
|
859
|
+
avatar.src = persona.avatarUrl;
|
|
860
|
+
avatar.alt = persona.displayName;
|
|
861
|
+
} else {
|
|
862
|
+
avatar.textContent = initialBadge(employee?.label || 'Hub');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
const text = document.createElement('span');
|
|
866
|
+
text.className = 'identity-copy';
|
|
867
|
+
|
|
868
|
+
const name = document.createElement('strong');
|
|
869
|
+
name.textContent = persona ? persona.displayName : (employee ? employee.label : 'AI Employee');
|
|
870
|
+
|
|
871
|
+
const title = document.createElement('small');
|
|
872
|
+
title.textContent = getEmployeeTitle(conv);
|
|
873
|
+
|
|
874
|
+
text.appendChild(name);
|
|
875
|
+
text.appendChild(title);
|
|
876
|
+
host.appendChild(avatar);
|
|
877
|
+
host.appendChild(text);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function renderRunStatePill(conv) {
|
|
881
|
+
const pill = els['run-state-pill'];
|
|
882
|
+
if (!pill) return;
|
|
883
|
+
pill.textContent = statusLabel(conv.status).toUpperCase();
|
|
884
|
+
pill.className = `run-state-pill ${conv.status}`;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function buildConversationSummary(conv) {
|
|
888
|
+
const employeeReply = latestEmployeeSurfaceText(conv);
|
|
889
|
+
if (employeeReply) return clampSummaryText(employeeReply);
|
|
890
|
+
if (conv.status === 'running') return 'The employee is working through your request.';
|
|
891
|
+
if (conv.status === 'failed') return 'This run needs your attention before it can continue.';
|
|
892
|
+
return 'The latest work is ready for review.';
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function clampSummaryText(text, maxChars = 260) {
|
|
896
|
+
const raw = String(text || '').replace(/\s+/g, ' ').trim();
|
|
897
|
+
if (raw.length <= maxChars) return raw;
|
|
898
|
+
return raw.slice(0, maxChars - 1).trimEnd() + '…';
|
|
899
|
+
}
|
|
747
900
|
|
|
748
901
|
// Render the inline employee selector shown in the coach section of an
|
|
749
902
|
// active conversation. Allows switching agents without reopening the modal.
|
|
@@ -905,7 +1058,7 @@ function syncTrackerNote(conv) {
|
|
|
905
1058
|
}
|
|
906
1059
|
}
|
|
907
1060
|
|
|
908
|
-
// Issue #347 R4 — render the totals line
|
|
1061
|
+
// Issue #347 R4 — render the totals line inside the tracker section.
|
|
909
1062
|
function renderTotals(conv) {
|
|
910
1063
|
const totals = els['totals'];
|
|
911
1064
|
if (!totals) return;
|
|
@@ -914,18 +1067,44 @@ function renderTotals(conv) {
|
|
|
914
1067
|
totals.hidden = true;
|
|
915
1068
|
return;
|
|
916
1069
|
}
|
|
1070
|
+
// Totals now live inside the tracker section; only render when visible.
|
|
1071
|
+
const tracker = els['tracker'];
|
|
1072
|
+
if (tracker && tracker.hidden) {
|
|
1073
|
+
totals.hidden = true;
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
917
1076
|
totals.hidden = false;
|
|
918
1077
|
const tokens = data.tokenTotals || {};
|
|
919
1078
|
const tokenLabel = formatTokens(tokens);
|
|
920
1079
|
const costLabel = formatCost(tokens);
|
|
921
1080
|
totals.innerHTML = '';
|
|
922
|
-
|
|
923
|
-
pushTotalsSpan(totals,
|
|
924
|
-
pushTotalsSpan(totals,
|
|
1081
|
+
const fmtDur = (ms) => ms >= 60000 ? formatDuration(ms) : ms > 0 ? `${Math.round(ms / 1000)}s` : '—';
|
|
1082
|
+
pushTotalsSpan(totals, fmtDur(data.totalDurationMs), 'total', 'total: from start to now');
|
|
1083
|
+
pushTotalsSpan(totals, fmtDur(data.workingDurationMs), 'working', 'working: while the employee was running');
|
|
1084
|
+
pushTotalsSpan(totals, fmtDur(data.waitingDurationMs), 'waiting', 'waiting: while waiting for you');
|
|
925
1085
|
pushTotalsSpan(totals, tokenLabel, 'tokens', 'tokens: from each phase report; some agents do not yet emit usage data');
|
|
926
1086
|
pushTotalsSpan(totals, costLabel, '', "cost: derived from token totals and the agent's published per-million rate");
|
|
927
1087
|
}
|
|
928
1088
|
|
|
1089
|
+
// Issue #442: render Direct totals row using the same pushTotalsSpan helper as FRAIM.
|
|
1090
|
+
function renderDirectTotals(conv) {
|
|
1091
|
+
const dtotals = els['ab-direct-totals'];
|
|
1092
|
+
if (!dtotals) return;
|
|
1093
|
+
const data = conv.compareRun && conv.compareRun.totals;
|
|
1094
|
+
if (!data) { dtotals.hidden = true; return; }
|
|
1095
|
+
dtotals.hidden = false;
|
|
1096
|
+
dtotals.innerHTML = '';
|
|
1097
|
+
const tokens = data.tokenTotals || {};
|
|
1098
|
+
const durMs = data.totalDurationMs || 0;
|
|
1099
|
+
const durLabel = durMs >= 60000 ? formatDuration(durMs) : durMs > 0 ? `${Math.round(durMs / 1000)}s` : '—';
|
|
1100
|
+
pushTotalsSpan(dtotals, durLabel, 'total', 'total: from start to now');
|
|
1101
|
+
pushTotalsSpan(dtotals, formatTokens(tokens), 'tokens', 'tokens from Direct run');
|
|
1102
|
+
pushTotalsSpan(dtotals, formatCost(tokens), '', 'cost of Direct run');
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
|
|
929
1108
|
function pushTotalsSpan(host, value, suffix, title) {
|
|
930
1109
|
if (host.children.length > 0) {
|
|
931
1110
|
const sep = document.createElement('span');
|
|
@@ -1036,38 +1215,45 @@ function renderTemplatePopover() {
|
|
|
1036
1215
|
|
|
1037
1216
|
function openTemplatePopover() {
|
|
1038
1217
|
const popover = els['template-popover'];
|
|
1039
|
-
|
|
1040
|
-
if (!popover || !btn) return;
|
|
1218
|
+
if (!popover) return;
|
|
1041
1219
|
renderTemplatePopover();
|
|
1220
|
+
// Anchor fixed popover above the trigger button, right-aligned to its right edge.
|
|
1221
|
+
const trigger = els['other-manager-jobs-btn'];
|
|
1222
|
+
if (trigger) {
|
|
1223
|
+
const rect = trigger.getBoundingClientRect();
|
|
1224
|
+
popover.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
|
|
1225
|
+
popover.style.bottom = Math.max(8, window.innerHeight - rect.top + 8) + 'px';
|
|
1226
|
+
popover.style.left = 'auto';
|
|
1227
|
+
popover.style.top = 'auto';
|
|
1228
|
+
}
|
|
1042
1229
|
popover.hidden = false;
|
|
1043
|
-
btn
|
|
1230
|
+
els['template-picker-btn']?.setAttribute('aria-expanded', 'true');
|
|
1044
1231
|
}
|
|
1045
1232
|
|
|
1046
1233
|
function closeTemplatePopover() {
|
|
1047
1234
|
const popover = els['template-popover'];
|
|
1048
|
-
|
|
1049
|
-
if (!popover || !btn) return;
|
|
1235
|
+
if (!popover) return;
|
|
1050
1236
|
popover.hidden = true;
|
|
1051
|
-
btn
|
|
1237
|
+
els['template-picker-btn']?.setAttribute('aria-expanded', 'false');
|
|
1052
1238
|
}
|
|
1053
1239
|
|
|
1054
|
-
function applyTemplateInvocation(managerJobId) {
|
|
1055
|
-
const conv = activeConversation();
|
|
1240
|
+
function applyTemplateInvocation(managerJobId) {
|
|
1241
|
+
const conv = activeConversation();
|
|
1056
1242
|
// Use the conversation's own employee for the invocation symbol, NOT
|
|
1057
1243
|
// the manager's last selection in another conversation (R2.5).
|
|
1058
1244
|
const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
|
|
1059
1245
|
const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
|
|
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;
|
|
1246
|
+
const invocation = `${symbol} ${managerJobId}`;
|
|
1247
|
+
const textarea = els['coach-text'];
|
|
1248
|
+
const prior = textarea.value;
|
|
1249
|
+
let combined;
|
|
1250
|
+
if (prior.trim().length === 0) {
|
|
1251
|
+
combined = invocation;
|
|
1252
|
+
} else {
|
|
1253
|
+
const strippedPrior = prior.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ').replace(/\s+/g, ' ').trim();
|
|
1254
|
+
combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
|
|
1255
|
+
}
|
|
1256
|
+
textarea.value = combined;
|
|
1071
1257
|
// Caret at the end.
|
|
1072
1258
|
textarea.setSelectionRange(combined.length, combined.length);
|
|
1073
1259
|
textarea.focus();
|
|
@@ -1081,174 +1267,181 @@ function derivedStage(conv) {
|
|
|
1081
1267
|
return { text: 'Done — please review', kind: 'done' };
|
|
1082
1268
|
}
|
|
1083
1269
|
|
|
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
|
|
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
|
-
|
|
1213
|
-
role
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
const
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1270
|
+
function derivedLatest(conv) {
|
|
1271
|
+
if (conv.status !== 'running') return '';
|
|
1272
|
+
const employeeReply = latestEmployeeSurfaceText(conv);
|
|
1273
|
+
if (employeeReply) return employeeReply;
|
|
1274
|
+
if (conv.status === 'running') return 'Working on it…';
|
|
1275
|
+
return '';
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function humanizeSlug(slug) {
|
|
1279
|
+
return String(slug || '')
|
|
1280
|
+
.split('-')
|
|
1281
|
+
.filter(Boolean)
|
|
1282
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1283
|
+
.join(' ');
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function stripStubReference(text) {
|
|
1287
|
+
return String(text || '')
|
|
1288
|
+
.replace(/\n?\[Job stub:[^\]]+\]/gi, '')
|
|
1289
|
+
.replace(/\s+/g, ' ')
|
|
1290
|
+
.trim();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function surfaceText(role, text, conv) {
|
|
1294
|
+
const raw = stripStubReference(text);
|
|
1295
|
+
if (!raw) return '';
|
|
1296
|
+
|
|
1297
|
+
if (role === 'manager') {
|
|
1298
|
+
const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
|
|
1299
|
+
if (invocationOnly) return raw;
|
|
1300
|
+
const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
|
|
1301
|
+
const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
1302
|
+
return raw
|
|
1303
|
+
.replace(invocationWithSlug, '')
|
|
1304
|
+
.replace(/^(?:[$/]fraim)\s*/i, '')
|
|
1305
|
+
.trim();
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
if (role === 'employee') {
|
|
1309
|
+
const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
|
|
1310
|
+
if (startedMatch) {
|
|
1311
|
+
const cleaned = surfaceText('manager', startedMatch[1], conv);
|
|
1312
|
+
if (conv.status === 'completed') return 'Done — please review.';
|
|
1313
|
+
return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (role === 'employee') {
|
|
1318
|
+
const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
|
|
1319
|
+
if (resumedMatch) {
|
|
1320
|
+
const cleaned = surfaceText('manager', resumedMatch[1], conv);
|
|
1321
|
+
if (conv.status === 'completed') return 'Done - please review.';
|
|
1322
|
+
return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
return raw;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function extractExplicitFraimInvocation(text) {
|
|
1330
|
+
const raw = String(text || '');
|
|
1331
|
+
const match = raw.match(/(?:^|\n|\s)([$/]fraim)\s+([a-z0-9][a-z0-9-]*)(?=\s|$)/i);
|
|
1332
|
+
if (!match || match.index == null) return null;
|
|
1333
|
+
const before = raw.slice(0, match.index).trim();
|
|
1334
|
+
const after = raw.slice(match.index + match[0].length).trim();
|
|
1335
|
+
const remainder = [before, after].filter(Boolean).join('\n\n').trim();
|
|
1336
|
+
return {
|
|
1337
|
+
symbol: match[1],
|
|
1338
|
+
jobId: match[2],
|
|
1339
|
+
remainder,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function latestEmployeeSurfaceText(conv) {
|
|
1344
|
+
const messages = conv.messages || [];
|
|
1345
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1346
|
+
if (messages[i].role !== 'employee') continue;
|
|
1347
|
+
const cleaned = surfaceText('employee', messages[i].text, conv);
|
|
1348
|
+
if (cleaned) return cleaned;
|
|
1349
|
+
}
|
|
1350
|
+
return '';
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function appendMessageDom(role, text, conv) {
|
|
1354
|
+
const article = document.createElement('article');
|
|
1355
|
+
article.className = 'message ' + role;
|
|
1356
|
+
|
|
1357
|
+
const meta = document.createElement('div');
|
|
1358
|
+
meta.className = 'message-meta';
|
|
1359
|
+
|
|
1360
|
+
const avatar = document.createElement('span');
|
|
1361
|
+
avatar.className = `message-avatar ${role}`;
|
|
1362
|
+
|
|
1363
|
+
if (role === 'employee') {
|
|
1364
|
+
const persona = getConversationPersona(conv);
|
|
1365
|
+
if (persona && persona.avatarUrl) {
|
|
1366
|
+
const img = document.createElement('img');
|
|
1367
|
+
img.src = persona.avatarUrl;
|
|
1368
|
+
img.alt = persona.displayName;
|
|
1369
|
+
avatar.appendChild(img);
|
|
1370
|
+
} else {
|
|
1371
|
+
avatar.textContent = initialBadge(roleLabel(role, conv));
|
|
1372
|
+
}
|
|
1373
|
+
} else if (role === 'manager') {
|
|
1374
|
+
avatar.textContent = 'M';
|
|
1375
|
+
} else {
|
|
1376
|
+
avatar.textContent = '•';
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const who = document.createElement('span');
|
|
1380
|
+
who.className = 'who';
|
|
1381
|
+
who.textContent = roleLabel(role, conv);
|
|
1382
|
+
|
|
1383
|
+
const lane = document.createElement('span');
|
|
1384
|
+
lane.className = 'lane-label';
|
|
1385
|
+
lane.textContent = role === 'manager'
|
|
1386
|
+
? 'Manager direction'
|
|
1387
|
+
: role === 'employee'
|
|
1388
|
+
? 'Employee response'
|
|
1389
|
+
: 'System update';
|
|
1390
|
+
|
|
1391
|
+
meta.appendChild(avatar);
|
|
1392
|
+
meta.appendChild(who);
|
|
1393
|
+
meta.appendChild(lane);
|
|
1394
|
+
article.appendChild(meta);
|
|
1395
|
+
|
|
1396
|
+
const bubble = document.createElement('div');
|
|
1397
|
+
bubble.className = 'bubble';
|
|
1398
|
+
if (role === 'employee') {
|
|
1399
|
+
const surfaced = surfaceText(role, text, conv);
|
|
1400
|
+
const raw = String(text || '');
|
|
1401
|
+
// surfaceText collapses whitespace which destroys markdown. Only use it
|
|
1402
|
+
// when it returned a transformed value (Started/Resumed prefix overrides).
|
|
1403
|
+
const useSurfaced = surfaced && surfaced !== raw.replace(/\s+/g, ' ').trim();
|
|
1404
|
+
const content = useSurfaced ? surfaced : raw;
|
|
1405
|
+
bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…'));
|
|
1406
|
+
} else {
|
|
1407
|
+
const surfaced = surfaceText(role, text, conv);
|
|
1408
|
+
bubble.textContent = surfaced || text;
|
|
1409
|
+
}
|
|
1410
|
+
if (role === 'manager') {
|
|
1411
|
+
const raw = document.createElement('span');
|
|
1412
|
+
raw.className = 'transport-raw';
|
|
1413
|
+
raw.textContent = text;
|
|
1414
|
+
article.appendChild(raw);
|
|
1415
|
+
}
|
|
1416
|
+
article.appendChild(bubble);
|
|
1417
|
+
els['messages'].appendChild(article);
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
function scrollThreadForReview(conv) {
|
|
1421
|
+
const host = els['messages'];
|
|
1422
|
+
if (!host) return;
|
|
1423
|
+
const nodes = [...host.querySelectorAll('.message')];
|
|
1424
|
+
if (nodes.length === 0) return;
|
|
1425
|
+
|
|
1426
|
+
if (conv.status === 'running') {
|
|
1427
|
+
host.scrollTop = host.scrollHeight;
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
const target = [...nodes].reverse().find((node) =>
|
|
1432
|
+
node.classList.contains('employee') || node.classList.contains('system')
|
|
1433
|
+
) || nodes[nodes.length - 1];
|
|
1434
|
+
|
|
1435
|
+
const hostRect = host.getBoundingClientRect();
|
|
1436
|
+
const targetRect = target.getBoundingClientRect();
|
|
1437
|
+
const currentTop = host.scrollTop;
|
|
1438
|
+
const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
|
|
1439
|
+
const reviewOffset = Math.max(24, host.clientHeight * 0.16);
|
|
1440
|
+
const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
|
|
1441
|
+
host.scrollTo({ top: desiredTop, behavior: 'smooth' });
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function syncSendButton() {
|
|
1252
1445
|
const conv = activeConversation();
|
|
1253
1446
|
const hasText = els['coach-text'].value.trim().length > 0;
|
|
1254
1447
|
// Send is enabled as soon as the host session exists. We deliberately
|
|
@@ -1502,6 +1695,16 @@ function renderEmployeeSelect() {
|
|
|
1502
1695
|
els['employee-select'].appendChild(opt);
|
|
1503
1696
|
}
|
|
1504
1697
|
renderAgentInstallPanel();
|
|
1698
|
+
updateAbToggleVisibility();
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function updateAbToggleVisibility() {
|
|
1702
|
+
const wrap = els['ab-toggle-wrap'];
|
|
1703
|
+
if (!wrap) return;
|
|
1704
|
+
const sel = document.getElementById('employee-select');
|
|
1705
|
+
const empId = sel ? sel.value : (state.selectedEmployeeId || 'claude');
|
|
1706
|
+
const emp = (state.bootstrap && state.bootstrap.employees || []).find(e => e.id === empId);
|
|
1707
|
+
wrap.hidden = !(emp && emp.supportsRaw);
|
|
1505
1708
|
}
|
|
1506
1709
|
|
|
1507
1710
|
// ---------------------------------------------------------------------------
|
|
@@ -1694,39 +1897,39 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
1694
1897
|
// (per developers.openai.com/codex/skills: "type $ to mention a skill")
|
|
1695
1898
|
// - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
|
|
1696
1899
|
//
|
|
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
|
-
}
|
|
1900
|
+
// Every command typed by the manager is prefixed with the agent's FRAIM
|
|
1901
|
+
// symbol so the host always sees that this is a FRAIM job, not a freeform
|
|
1902
|
+
// prompt. Follow-up coaching keeps the jobId too; otherwise headless hosts
|
|
1903
|
+
// lose which FRAIM workflow the manager meant to run.
|
|
1904
|
+
const FRAIM_INVOCATION_SYMBOL = {
|
|
1905
|
+
codex: '$fraim',
|
|
1906
|
+
claude: '/fraim',
|
|
1907
|
+
gemini: '/fraim',
|
|
1908
|
+
};
|
|
1909
|
+
|
|
1910
|
+
function fraimInvocationFor(employeeId, jobId, kind) {
|
|
1911
|
+
if (jobId === '__freeform__') return null;
|
|
1912
|
+
const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
|
|
1913
|
+
return `${symbol} ${jobId}`;
|
|
1914
|
+
}
|
|
1712
1915
|
|
|
1713
1916
|
// Wrap the manager's typed instructions with the host-appropriate FRAIM
|
|
1714
1917
|
// invocation. The wrapped text is what we ACTUALLY send to the host CLI
|
|
1715
1918
|
// AND what we show in the timeline so the manager sees what the agent
|
|
1716
1919
|
// received. For freeform jobs (no FRAIM job assigned), the instructions
|
|
1717
1920
|
// are sent verbatim with no invocation prefix.
|
|
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
|
-
}
|
|
1921
|
+
function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
|
|
1922
|
+
const trimmed = (instructions || '').trim();
|
|
1923
|
+
const explicit = extractExplicitFraimInvocation(trimmed);
|
|
1924
|
+
const effectiveJobId = explicit?.jobId || jobId;
|
|
1925
|
+
const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
|
|
1926
|
+
// Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
|
|
1927
|
+
if (!invocation) return explicit?.remainder || trimmed;
|
|
1928
|
+
const remainder = explicit ? explicit.remainder : trimmed;
|
|
1929
|
+
const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
|
|
1930
|
+
if (!remainder) return `${invocation}${stub}`;
|
|
1931
|
+
return `${invocation}${stub}\n\n${remainder}`;
|
|
1932
|
+
}
|
|
1730
1933
|
|
|
1731
1934
|
async function startRun(job, instructions, employeeId) {
|
|
1732
1935
|
// Prefix the manager's typed instructions with the FRAIM invocation so
|
|
@@ -1738,6 +1941,10 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1738
1941
|
? [state.projectPath, job.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
1739
1942
|
: undefined;
|
|
1740
1943
|
const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions, absoluteStubPath);
|
|
1944
|
+
// Issue #442: read the A/B toggle state from the modal before it closes.
|
|
1945
|
+
const abToggle = document.getElementById('ab-toggle');
|
|
1946
|
+
const isAB = !isFreeform && abToggle && abToggle.checked;
|
|
1947
|
+
|
|
1741
1948
|
const conv = {
|
|
1742
1949
|
id: newConversationId(),
|
|
1743
1950
|
projectPath: state.projectPath,
|
|
@@ -1754,6 +1961,10 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1754
1961
|
events: [],
|
|
1755
1962
|
artifacts: [],
|
|
1756
1963
|
lastUpdatedAt: Date.now(),
|
|
1964
|
+
// Issue #442: A/B mode fields (only set when toggle was on).
|
|
1965
|
+
compareMode: isAB ? 'ab' : undefined,
|
|
1966
|
+
compareRunId: null,
|
|
1967
|
+
compareRun: null,
|
|
1757
1968
|
};
|
|
1758
1969
|
upsertConversation(conv);
|
|
1759
1970
|
state.activeId = conv.id;
|
|
@@ -1770,10 +1981,16 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1770
1981
|
hostId: employeeId,
|
|
1771
1982
|
jobId: job.id,
|
|
1772
1983
|
message: agentMessage,
|
|
1984
|
+
...(isAB ? { compareMode: 'ab', directInstructions: instructions } : {}),
|
|
1773
1985
|
}),
|
|
1774
1986
|
});
|
|
1775
1987
|
conv.runId = run.id;
|
|
1776
1988
|
foldRunIntoConversation(conv, run);
|
|
1989
|
+
// Issue #442: when the server created a paired Direct run, capture it.
|
|
1990
|
+
if (run.compareRunId) {
|
|
1991
|
+
conv.compareRunId = run.compareRunId;
|
|
1992
|
+
if (run.compareRun) foldCompareRunIntoConversation(conv, run.compareRun);
|
|
1993
|
+
}
|
|
1777
1994
|
upsertConversation(conv);
|
|
1778
1995
|
renderRail();
|
|
1779
1996
|
renderActive();
|
|
@@ -1898,6 +2115,21 @@ function foldRunIntoConversation(conv, run) {
|
|
|
1898
2115
|
conv.lastUpdatedAt = Date.now();
|
|
1899
2116
|
}
|
|
1900
2117
|
|
|
2118
|
+
// Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
|
|
2119
|
+
function foldCompareRunIntoConversation(conv, compareRun) {
|
|
2120
|
+
if (!compareRun) return;
|
|
2121
|
+
conv.compareRun = {
|
|
2122
|
+
id: compareRun.id,
|
|
2123
|
+
sessionId: compareRun.sessionId || null,
|
|
2124
|
+
status: compareRun.status || 'running',
|
|
2125
|
+
stages: compareRun.stages || [],
|
|
2126
|
+
currentPhase: compareRun.currentPhase || null,
|
|
2127
|
+
totals: compareRun.totals || null,
|
|
2128
|
+
messages: compareRun.messages || [],
|
|
2129
|
+
events: compareRun.events || [],
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
|
|
1901
2133
|
const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-\.\/]*?(?:docs|public|src|tests)\/[A-Za-z0-9_\-\.\/]+\.[A-Za-z0-9]+)/;
|
|
1902
2134
|
// Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
|
|
1903
2135
|
// raw learnings, evidence dumps, mock files), not deliverables the
|
|
@@ -1921,18 +2153,30 @@ function startPolling() {
|
|
|
1921
2153
|
state.pollHandle = window.setInterval(async () => {
|
|
1922
2154
|
const conv = activeConversation();
|
|
1923
2155
|
if (!conv || !conv.runId) return;
|
|
1924
|
-
|
|
2156
|
+
// Issue #442: for A/B conversations keep polling until BOTH sides are done.
|
|
2157
|
+
const fraimDone = conv.status !== 'running';
|
|
2158
|
+
const directDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
|
|
2159
|
+
if (fraimDone && directDone) {
|
|
1925
2160
|
window.clearInterval(state.pollHandle);
|
|
1926
2161
|
state.pollHandle = null;
|
|
1927
2162
|
return;
|
|
1928
2163
|
}
|
|
1929
2164
|
try {
|
|
1930
|
-
|
|
1931
|
-
|
|
2165
|
+
if (!fraimDone) {
|
|
2166
|
+
const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
|
|
2167
|
+
foldRunIntoConversation(conv, run);
|
|
2168
|
+
}
|
|
2169
|
+
// Issue #442: also poll the compare run when present and not yet terminal.
|
|
2170
|
+
if (conv.compareRunId && !directDone) {
|
|
2171
|
+
const compareRun = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}`);
|
|
2172
|
+
foldCompareRunIntoConversation(conv, compareRun);
|
|
2173
|
+
}
|
|
1932
2174
|
upsertConversation(conv);
|
|
1933
2175
|
renderRail();
|
|
1934
2176
|
renderActive();
|
|
1935
|
-
|
|
2177
|
+
const nowFraimDone = conv.status !== 'running';
|
|
2178
|
+
const nowDirectDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
|
|
2179
|
+
if (nowFraimDone && nowDirectDone) {
|
|
1936
2180
|
window.clearInterval(state.pollHandle);
|
|
1937
2181
|
state.pollHandle = null;
|
|
1938
2182
|
}
|
|
@@ -1942,13 +2186,20 @@ function startPolling() {
|
|
|
1942
2186
|
}, 1000);
|
|
1943
2187
|
}
|
|
1944
2188
|
|
|
2189
|
+
function convNeedsPolling(conv) {
|
|
2190
|
+
if (!conv || !conv.runId) return false;
|
|
2191
|
+
const fraimRunning = conv.status === 'running';
|
|
2192
|
+
const directRunning = !!conv.compareRunId && (!conv.compareRun || conv.compareRun.status === 'running');
|
|
2193
|
+
return fraimRunning || directRunning;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
1945
2196
|
function switchToConversation(id) {
|
|
1946
2197
|
state.activeId = id;
|
|
1947
2198
|
persistConversations();
|
|
1948
2199
|
renderRail();
|
|
1949
2200
|
renderActive();
|
|
1950
2201
|
const conv = activeConversation();
|
|
1951
|
-
if (conv
|
|
2202
|
+
if (convNeedsPolling(conv)) {
|
|
1952
2203
|
startPolling();
|
|
1953
2204
|
} else if (state.pollHandle) {
|
|
1954
2205
|
window.clearInterval(state.pollHandle);
|
|
@@ -2041,6 +2292,11 @@ function wireEvents() {
|
|
|
2041
2292
|
const employeeId = els['employee-select'].value || state.selectedEmployeeId;
|
|
2042
2293
|
state.selectedEmployeeId = employeeId;
|
|
2043
2294
|
closeModal();
|
|
2295
|
+
// R2: if no project path is set, show the inline project picker instead.
|
|
2296
|
+
if (!state.projectPath) {
|
|
2297
|
+
renderFirstRunLanding('hub');
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2044
2300
|
await startRun(job, text, employeeId);
|
|
2045
2301
|
});
|
|
2046
2302
|
els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
|
|
@@ -2068,25 +2324,112 @@ function wireEvents() {
|
|
|
2068
2324
|
}
|
|
2069
2325
|
});
|
|
2070
2326
|
|
|
2071
|
-
if (els['active-employee-select']) {
|
|
2072
|
-
els['active-employee-select'].addEventListener('change', () => {
|
|
2327
|
+
if (els['active-employee-select']) {
|
|
2328
|
+
els['active-employee-select'].addEventListener('change', () => {
|
|
2073
2329
|
// Only update the global preference here. Do NOT update conv.employeeId —
|
|
2074
2330
|
// the send handler compares sel.value vs conv.employeeId to detect a
|
|
2075
2331
|
// switch; updating conv here would make them equal and the restart would
|
|
2076
2332
|
// never fire.
|
|
2077
|
-
state.selectedEmployeeId = els['active-employee-select'].value;
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2333
|
+
state.selectedEmployeeId = els['active-employee-select'].value;
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// Issue #442: keep A/B toggle visible only when the selected employee supports
|
|
2338
|
+
// direct-path invocation (supportsRaw). Also wire the explanation paragraph.
|
|
2339
|
+
if (els['employee-select']) {
|
|
2340
|
+
els['employee-select'].addEventListener('change', updateAbToggleVisibility);
|
|
2341
|
+
}
|
|
2342
|
+
const abToggleCheckbox = document.getElementById('ab-toggle');
|
|
2343
|
+
if (abToggleCheckbox) {
|
|
2344
|
+
abToggleCheckbox.addEventListener('change', () => {
|
|
2345
|
+
const exp = document.getElementById('ab-toggle-explanation');
|
|
2346
|
+
if (exp) exp.hidden = !abToggleCheckbox.checked;
|
|
2347
|
+
// R1.3: Start button label changes to "Start A/B test" when toggle is on.
|
|
2348
|
+
if (els['start']) els['start'].textContent = abToggleCheckbox.checked ? 'Start A/B test' : 'Start';
|
|
2349
|
+
});
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
// Issue #442: Direct panel send button.
|
|
2353
|
+
const abDirectInput = document.getElementById('ab-direct-input');
|
|
2354
|
+
if (abDirectInput && els['ab-direct-send']) {
|
|
2355
|
+
abDirectInput.addEventListener('input', () => {
|
|
2356
|
+
const conv = activeConversation();
|
|
2357
|
+
const hasSession = !!(conv && conv.compareRun && conv.compareRun.sessionId);
|
|
2358
|
+
const notRunning = !conv || (conv.compareRun && conv.compareRun.status) !== 'running';
|
|
2359
|
+
els['ab-direct-send'].disabled = !hasSession || !notRunning || !abDirectInput.value.trim();
|
|
2360
|
+
});
|
|
2361
|
+
abDirectInput.addEventListener('keydown', (e) => {
|
|
2362
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!els['ab-direct-send'].disabled) els['ab-direct-send'].click(); }
|
|
2363
|
+
});
|
|
2364
|
+
els['ab-direct-send'].addEventListener('click', async () => {
|
|
2365
|
+
const conv = activeConversation();
|
|
2366
|
+
if (!conv || !conv.compareRunId) return;
|
|
2367
|
+
const text = abDirectInput.value.trim();
|
|
2368
|
+
if (!text) return;
|
|
2369
|
+
abDirectInput.value = '';
|
|
2370
|
+
els['ab-direct-send'].disabled = true;
|
|
2371
|
+
try {
|
|
2372
|
+
const updated = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}/direct-messages`, {
|
|
2373
|
+
method: 'POST',
|
|
2374
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2375
|
+
body: JSON.stringify({ message: text }),
|
|
2376
|
+
});
|
|
2377
|
+
foldCompareRunIntoConversation(conv, updated);
|
|
2378
|
+
renderActive();
|
|
2379
|
+
} catch (err) {
|
|
2380
|
+
console.error('Direct send failed', err);
|
|
2381
|
+
}
|
|
2382
|
+
});
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
if (els['coach-panel']) {
|
|
2386
|
+
els['coach-panel'].addEventListener('toggle', () => {
|
|
2387
|
+
const conv = activeConversation();
|
|
2388
|
+
if (!conv) return;
|
|
2389
|
+
panelStateFor(conv.id).coach = els['coach-panel'].open;
|
|
2390
|
+
// R3.3: keep coach-summary visibility in sync when toggled directly.
|
|
2391
|
+
if (els['coach-summary']) els['coach-summary'].hidden = els['coach-panel'].open;
|
|
2392
|
+
});
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// R6: thread panel toggle — persist open/close state per conversation.
|
|
2396
|
+
if (els['thread-panel']) {
|
|
2397
|
+
els['thread-panel'].addEventListener('toggle', () => {
|
|
2398
|
+
const conv = activeConversation();
|
|
2399
|
+
if (!conv) return;
|
|
2400
|
+
panelStateFor(conv.id).thread = els['thread-panel'].open;
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// R5: quick-access coaching buttons.
|
|
2405
|
+
if (els['quick-coach-btns']) {
|
|
2406
|
+
els['quick-coach-btns'].addEventListener('click', (e) => {
|
|
2407
|
+
const btn = e.target.closest('.quick-coach-btn');
|
|
2408
|
+
if (!btn) return;
|
|
2409
|
+
const jobId = btn.dataset.job;
|
|
2410
|
+
if (!jobId) return;
|
|
2411
|
+
const conv = activeConversation();
|
|
2412
|
+
const prefix = conv && conv.employeeId && conv.employeeId.toLowerCase().includes('codex') ? '$fraim' : '/fraim';
|
|
2413
|
+
const invocation = `${prefix} ${jobId}`;
|
|
2414
|
+
const textarea = els['coach-text'];
|
|
2415
|
+
const existing = textarea.value;
|
|
2416
|
+
const sep = existing && !existing.endsWith(' ') ? ' ' : '';
|
|
2417
|
+
textarea.value = existing + sep + invocation;
|
|
2418
|
+
textarea.focus();
|
|
2419
|
+
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
|
2420
|
+
syncSendButton();
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
if (els['other-manager-jobs-btn']) {
|
|
2424
|
+
els['other-manager-jobs-btn'].addEventListener('click', (e) => {
|
|
2425
|
+
e.stopPropagation();
|
|
2426
|
+
const popover = els['template-popover'];
|
|
2427
|
+
if (popover && popover.hidden === false) closeTemplatePopover();
|
|
2428
|
+
else openTemplatePopover();
|
|
2429
|
+
});
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// Issue #347 R2 — template picker.
|
|
2090
2433
|
if (els['template-picker-btn']) {
|
|
2091
2434
|
els['template-picker-btn'].addEventListener('click', (e) => {
|
|
2092
2435
|
e.stopPropagation();
|
|
@@ -2097,7 +2440,7 @@ function wireEvents() {
|
|
|
2097
2440
|
document.addEventListener('click', (e) => {
|
|
2098
2441
|
const popover = els['template-popover'];
|
|
2099
2442
|
if (!popover || popover.hidden) return;
|
|
2100
|
-
if (!e.target.closest('#template-popover') && !e.target.closest('#
|
|
2443
|
+
if (!e.target.closest('#template-popover') && !e.target.closest('#other-manager-jobs-btn')) {
|
|
2101
2444
|
closeTemplatePopover();
|
|
2102
2445
|
}
|
|
2103
2446
|
});
|
|
@@ -2156,7 +2499,7 @@ function wireEvents() {
|
|
|
2156
2499
|
// If an active conversation belongs to the loaded project and is still running, resume polling.
|
|
2157
2500
|
const conv = activeConversation();
|
|
2158
2501
|
if (conv && conv.projectPath === state.projectPath) {
|
|
2159
|
-
if (conv
|
|
2502
|
+
if (convNeedsPolling(conv)) startPolling();
|
|
2160
2503
|
} else {
|
|
2161
2504
|
state.activeId = null;
|
|
2162
2505
|
persistConversations();
|
|
@@ -2211,10 +2554,11 @@ async function autoOnboardProject() {
|
|
|
2211
2554
|
}
|
|
2212
2555
|
}
|
|
2213
2556
|
|
|
2214
|
-
function renderFirstRunLanding() {
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2557
|
+
function renderFirstRunLanding(mode) {
|
|
2558
|
+
// mode === 'hub' → R2: shown when a job is started without a project path.
|
|
2559
|
+
// After picking, reload bootstrap and return to the hub.
|
|
2560
|
+
// mode === 'firstrun' → legacy path: shown on ?firstRun=true; auto-starts onboarding.
|
|
2561
|
+
// Default: 'firstrun' for backward compatibility.
|
|
2218
2562
|
const existing = document.getElementById('fraim-first-run-landing');
|
|
2219
2563
|
if (existing) existing.remove();
|
|
2220
2564
|
|
|
@@ -2234,14 +2578,14 @@ function renderFirstRunLanding() {
|
|
|
2234
2578
|
'box-shadow:0 1px 2px rgba(20,40,30,.04);',
|
|
2235
2579
|
].join('');
|
|
2236
2580
|
|
|
2237
|
-
const title = document.createElement('
|
|
2238
|
-
title.textContent =
|
|
2581
|
+
const title = document.createElement('h2');
|
|
2582
|
+
title.textContent = 'Pick a project folder';
|
|
2239
2583
|
title.style.cssText = 'font-size:22px;font-weight:600;margin:0;';
|
|
2240
2584
|
card.appendChild(title);
|
|
2241
2585
|
|
|
2242
2586
|
const desc = document.createElement('p');
|
|
2243
|
-
desc.textContent = "
|
|
2244
|
-
desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;';
|
|
2587
|
+
desc.textContent = "Your AI employees need a project to work in. Pick the folder where your code lives. Think of it like onboarding a new hire — you're giving them a home and asking them to learn everything they can about it.";
|
|
2588
|
+
desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;line-height:1.55;';
|
|
2245
2589
|
card.appendChild(desc);
|
|
2246
2590
|
|
|
2247
2591
|
const folderLabel = document.createElement('label');
|
|
@@ -2300,10 +2644,10 @@ function renderFirstRunLanding() {
|
|
|
2300
2644
|
|
|
2301
2645
|
const startBtn = document.createElement('button');
|
|
2302
2646
|
startBtn.type = 'button';
|
|
2303
|
-
startBtn.textContent = 'Start
|
|
2647
|
+
startBtn.textContent = 'Start in this project →';
|
|
2304
2648
|
startBtn.disabled = !state.projectPath;
|
|
2305
2649
|
startBtn.style.cssText = [
|
|
2306
|
-
'background:var(--accent,#
|
|
2650
|
+
'background:var(--accent,#1f437d);color:#fff;border:none;border-radius:8px;',
|
|
2307
2651
|
'padding:11px 20px;font-size:14px;font-weight:600;cursor:pointer;',
|
|
2308
2652
|
'opacity:' + (state.projectPath ? '1' : '0.5') + ';',
|
|
2309
2653
|
'transition:opacity .15s;',
|
|
@@ -2311,16 +2655,24 @@ function renderFirstRunLanding() {
|
|
|
2311
2655
|
startBtn.addEventListener('click', async () => {
|
|
2312
2656
|
if (!state.projectPath) return;
|
|
2313
2657
|
startBtn.disabled = true;
|
|
2314
|
-
startBtn.textContent = 'Starting…';
|
|
2315
2658
|
overlay.remove();
|
|
2659
|
+
if (mode === 'hub') {
|
|
2660
|
+
// R2: reload bootstrap for the chosen project and return to hub.
|
|
2661
|
+
try {
|
|
2662
|
+
await loadBootstrap(state.projectPath);
|
|
2663
|
+
} catch (err) {
|
|
2664
|
+
showStatus(err.message, true);
|
|
2665
|
+
}
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2668
|
+
// Legacy first-run path: auto-start project-onboarding.
|
|
2669
|
+
startBtn.textContent = 'Starting…';
|
|
2316
2670
|
renderActive();
|
|
2317
|
-
// Find the project-onboarding job from bootstrap, fall back to direct POST.
|
|
2318
2671
|
const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
|
|
2319
2672
|
const employeeId = state.selectedEmployeeId || 'claude';
|
|
2320
2673
|
if (job) {
|
|
2321
2674
|
await startRun(job, 'Onboard this project', employeeId);
|
|
2322
2675
|
} else {
|
|
2323
|
-
// Job not in catalog yet (project not initialized) — POST directly.
|
|
2324
2676
|
try {
|
|
2325
2677
|
const run = await requestJson('/api/ai-hub/runs', {
|
|
2326
2678
|
method: 'POST',
|