fraim-framework 2.0.145 → 2.0.147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/ai-hub/desktop-main.js +10 -1
- package/dist/src/ai-hub/hosts.js +36 -18
- package/dist/src/ai-hub/preferences.js +3 -0
- package/dist/src/ai-hub/server.js +110 -4
- package/dist/src/cli/commands/add-ide.js +22 -21
- package/dist/src/cli/setup/ide-detector.js +13 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +77 -41
- package/public/ai-hub/script.js +611 -89
- package/public/ai-hub/styles.css +712 -220
- package/public/first-run/index.html +1 -0
- package/public/first-run/script.js +30 -18
- package/public/first-run/styles.css +73 -49
package/public/ai-hub/script.js
CHANGED
|
@@ -20,6 +20,9 @@ const state = {
|
|
|
20
20
|
pollHandle: null,
|
|
21
21
|
selectedJob: null, // chosen in modal step 1
|
|
22
22
|
selectedEmployeeId: null,
|
|
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
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
const els = {};
|
|
@@ -32,13 +35,18 @@ function gatherElements() {
|
|
|
32
35
|
const ids = [
|
|
33
36
|
'project-button', 'project-name',
|
|
34
37
|
'new-conv-btn', 'conv-list',
|
|
35
|
-
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
38
|
+
// Issue #385: team roster
|
|
39
|
+
'team-roster',
|
|
40
|
+
'empty', 'active-conv', 'active-title', 'active-job', 'active-identity', 'run-state-pill', 'summary-strip',
|
|
41
|
+
'progress', 'stage', 'latest', 'artifact-slot', 'messages',
|
|
42
|
+
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
43
|
+
'status-line', 'coach-note',
|
|
39
44
|
'modal', 'step1', 'step2',
|
|
40
45
|
'cancel1', 'next1', 'back2', 'start',
|
|
41
46
|
'job-search', 'job-catalog', 'job-pick-status',
|
|
47
|
+
// Issue #385: hire-required notice, persona job filter
|
|
48
|
+
'hire-notice', 'hire-notice-text', 'hire-notice-link', 'hire-notice-back',
|
|
49
|
+
'job-persona-filter',
|
|
42
50
|
'picked-name', 'picked-desc', 'instructions',
|
|
43
51
|
'employee-select', 'agent-install-panel', 'active-employee-select',
|
|
44
52
|
'freeform-btn',
|
|
@@ -57,7 +65,8 @@ async function requestJson(url, options) {
|
|
|
57
65
|
let payload = null;
|
|
58
66
|
try { payload = await response.json(); } catch { /* may be empty */ }
|
|
59
67
|
if (!response.ok) {
|
|
60
|
-
const
|
|
68
|
+
const errVal = payload && payload.error;
|
|
69
|
+
const message = (errVal && typeof errVal === 'object' ? errVal.message : errVal) || `Request failed (${response.status}).`;
|
|
61
70
|
throw new Error(message);
|
|
62
71
|
}
|
|
63
72
|
return payload;
|
|
@@ -65,10 +74,16 @@ async function requestJson(url, options) {
|
|
|
65
74
|
|
|
66
75
|
async function loadBootstrap(projectPath) {
|
|
67
76
|
const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
|
|
68
|
-
const
|
|
77
|
+
const fetchOptions = {};
|
|
78
|
+
if (state.storedApiKey) fetchOptions.headers = { 'x-fraim-api-key': state.storedApiKey };
|
|
79
|
+
const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
|
|
69
80
|
state.bootstrap = bootstrap;
|
|
70
81
|
state.projectPath = bootstrap.project.path;
|
|
71
82
|
state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
|
|
83
|
+
// Restore persona selection persisted on the server side.
|
|
84
|
+
if (bootstrap.preferences && bootstrap.preferences.personaKey !== undefined) {
|
|
85
|
+
state.selectedPersonaKey = bootstrap.preferences.personaKey;
|
|
86
|
+
}
|
|
72
87
|
// Render header project name + status line.
|
|
73
88
|
els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
|
|
74
89
|
els['status-line'].textContent = bootstrap.project.message || '';
|
|
@@ -165,6 +180,17 @@ function loadConversationsFromStorage() {
|
|
|
165
180
|
} catch {
|
|
166
181
|
state.activeId = null;
|
|
167
182
|
}
|
|
183
|
+
// Retroactively fix titles that were saved as the generic "New job" fallback
|
|
184
|
+
// by re-deriving from the first manager message.
|
|
185
|
+
for (const convList of Object.values(state.conversations)) {
|
|
186
|
+
for (const conv of convList) {
|
|
187
|
+
if (conv.title !== 'New job') continue;
|
|
188
|
+
const firstMsg = (conv.messages || []).find((m) => m.role === 'manager');
|
|
189
|
+
if (!firstMsg) continue;
|
|
190
|
+
const rederived = deriveTitle(conv.jobTitle || '', firstMsg.text || '');
|
|
191
|
+
if (rederived !== 'New job') conv.title = rederived;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
168
194
|
}
|
|
169
195
|
|
|
170
196
|
function persistConversations() {
|
|
@@ -221,28 +247,289 @@ function newConversationId() {
|
|
|
221
247
|
// ---------------------------------------------------------------------------
|
|
222
248
|
|
|
223
249
|
function renderRail() {
|
|
250
|
+
renderTeamRoster();
|
|
224
251
|
els['conv-list'].innerHTML = '';
|
|
225
|
-
|
|
252
|
+
// R4: filter by selected persona when one is active.
|
|
253
|
+
const list = projectConversations().filter((conv) =>
|
|
254
|
+
!state.selectedPersonaKey || conv.personaKey === state.selectedPersonaKey
|
|
255
|
+
);
|
|
226
256
|
for (const conv of list) {
|
|
227
257
|
const btn = document.createElement('button');
|
|
228
258
|
btn.type = 'button';
|
|
229
259
|
btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
|
|
230
260
|
btn.dataset.conv = conv.id;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (conv.
|
|
236
|
-
|
|
261
|
+
// Avatar chip — 34px visual anchor on the left for every item.
|
|
262
|
+
const personas = state.bootstrap?.personas || [];
|
|
263
|
+
let persona = null;
|
|
264
|
+
const chip = document.createElement('span');
|
|
265
|
+
if (conv.personaKey) {
|
|
266
|
+
persona = personas.find((p) => p.key === conv.personaKey) || null;
|
|
267
|
+
chip.className = 'conv-persona-chip';
|
|
268
|
+
chip.title = persona ? persona.displayName : conv.personaKey;
|
|
269
|
+
if (persona && persona.avatarUrl) {
|
|
270
|
+
const img = document.createElement('img');
|
|
271
|
+
img.src = persona.avatarUrl;
|
|
272
|
+
img.alt = persona.displayName;
|
|
273
|
+
chip.appendChild(img);
|
|
274
|
+
} else {
|
|
275
|
+
const label = persona ? persona.displayName : conv.personaKey;
|
|
276
|
+
chip.textContent = label.slice(0, 2).toUpperCase();
|
|
277
|
+
}
|
|
278
|
+
} else {
|
|
279
|
+
chip.className = 'conv-persona-chip conv-persona-chip--free';
|
|
280
|
+
chip.title = 'Free job';
|
|
281
|
+
chip.innerHTML = '<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" width="16" height="16"><circle cx="10" cy="7" r="3.5" stroke="currentColor" stroke-width="1.5"/><path d="M3 17c0-3.314 3.134-6 7-6s7 2.686 7 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>';
|
|
282
|
+
}
|
|
283
|
+
btn.appendChild(chip);
|
|
284
|
+
// Text body: persona name label (if persona) stacked above job title.
|
|
285
|
+
const bodyDiv = document.createElement('span');
|
|
286
|
+
bodyDiv.className = 'conv-body';
|
|
287
|
+
if (persona) {
|
|
288
|
+
const nameSpan = document.createElement('span');
|
|
289
|
+
nameSpan.className = 'conv-persona-name';
|
|
290
|
+
nameSpan.textContent = persona.displayName;
|
|
291
|
+
bodyDiv.appendChild(nameSpan);
|
|
292
|
+
}
|
|
293
|
+
const titleSpan = document.createElement('span');
|
|
294
|
+
titleSpan.className = 'conv-title';
|
|
295
|
+
titleSpan.textContent = conv.title || '';
|
|
296
|
+
bodyDiv.appendChild(titleSpan);
|
|
297
|
+
btn.appendChild(bodyDiv);
|
|
298
|
+
const statusSpan = document.createElement('span');
|
|
299
|
+
statusSpan.className = 'conv-status';
|
|
300
|
+
statusSpan.textContent = statusLabel(conv.status);
|
|
301
|
+
if (conv.status === 'running') statusSpan.classList.add('running');
|
|
302
|
+
if (conv.status === 'failed') statusSpan.classList.add('failed');
|
|
303
|
+
btn.appendChild(statusSpan);
|
|
237
304
|
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
238
305
|
els['conv-list'].appendChild(btn);
|
|
239
306
|
}
|
|
240
307
|
}
|
|
241
308
|
|
|
242
|
-
function statusLabel(s) {
|
|
243
|
-
if (s === 'running') return 'Running';
|
|
244
|
-
if (s === 'failed') return 'Needs you';
|
|
245
|
-
return 'Done';
|
|
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
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Issue #385 — Persona UI (R3 + R4)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
// Prefix a conversation title with its persona display name when a persona
|
|
358
|
+
// is assigned, so the rail and header always show who is responsible.
|
|
359
|
+
function conversationTitle(conv) {
|
|
360
|
+
if (!conv) return '';
|
|
361
|
+
if (!conv.personaKey) return conv.title || '';
|
|
362
|
+
const personas = state.bootstrap?.personas || [];
|
|
363
|
+
const persona = personas.find((p) => p.key === conv.personaKey);
|
|
364
|
+
const prefix = persona ? persona.displayName : conv.personaKey;
|
|
365
|
+
return `${prefix}: ${conv.title || ''}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// R4.2 — team roster: one avatar chip per hired persona above the conv list.
|
|
369
|
+
// 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) {
|
|
375
|
+
roster.hidden = true;
|
|
376
|
+
// Reset persona selection when there's no active subscription.
|
|
377
|
+
if (state.selectedPersonaKey) state.selectedPersonaKey = null;
|
|
378
|
+
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
|
+
}
|
|
426
|
+
|
|
427
|
+
// R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
|
|
428
|
+
function buildAvatarChip(persona, size) {
|
|
429
|
+
const chip = document.createElement('span');
|
|
430
|
+
chip.className = 'emp-avatar-sm';
|
|
431
|
+
chip.style.width = size + 'px';
|
|
432
|
+
chip.style.height = size + 'px';
|
|
433
|
+
chip.style.fontSize = Math.max(8, Math.floor(size * 0.42)) + 'px';
|
|
434
|
+
if (persona && persona.avatarUrl) {
|
|
435
|
+
const img = document.createElement('img');
|
|
436
|
+
img.src = persona.avatarUrl;
|
|
437
|
+
img.alt = persona.displayName;
|
|
438
|
+
chip.appendChild(img);
|
|
439
|
+
} else if (persona) {
|
|
440
|
+
chip.textContent = persona.displayName.slice(0, 2).toUpperCase();
|
|
441
|
+
}
|
|
442
|
+
return chip;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function setSelectedPersona(key) {
|
|
446
|
+
state.selectedPersonaKey = key || null;
|
|
447
|
+
renderRail();
|
|
448
|
+
savePersonaPreference(key || null);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function savePersonaPreference(key) {
|
|
452
|
+
try {
|
|
453
|
+
await requestJson('/api/ai-hub/preferences', {
|
|
454
|
+
method: 'POST',
|
|
455
|
+
headers: { 'Content-Type': 'application/json' },
|
|
456
|
+
body: JSON.stringify({ personaKey: key }),
|
|
457
|
+
});
|
|
458
|
+
} catch { /* best-effort */ }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// R3.2/R3.3 — show hire-required notice when a locked job is clicked.
|
|
462
|
+
function showHireNotice(job) {
|
|
463
|
+
const personas = state.bootstrap?.personas || [];
|
|
464
|
+
const persona = personas.find((p) => p.key === job.requiredPersonaKey);
|
|
465
|
+
els['job-catalog'].hidden = true;
|
|
466
|
+
els['hire-notice'].hidden = false;
|
|
467
|
+
if (els['hire-notice-text']) {
|
|
468
|
+
els['hire-notice-text'].textContent = persona
|
|
469
|
+
? `"${job.title}" requires ${persona.displayName} (${persona.pricingLabel}). Hire them to unlock this job.`
|
|
470
|
+
: `"${job.title}" requires a persona that is not yet hired. Go to Pricing to hire them.`;
|
|
471
|
+
}
|
|
472
|
+
if (els['hire-notice-link'] && persona && persona.hireUrl) {
|
|
473
|
+
// R3.3: append returnTo so the pricing page can redirect back after hire.
|
|
474
|
+
const returnTo = encodeURIComponent(window.location.href);
|
|
475
|
+
els['hire-notice-link'].href = `${persona.hireUrl}&returnTo=${returnTo}`;
|
|
476
|
+
}
|
|
477
|
+
state.selectedJob = null;
|
|
478
|
+
els['next1'].disabled = true;
|
|
479
|
+
els['job-pick-status'].textContent = 'Choose a job to continue';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function hideHireNotice() {
|
|
483
|
+
if (els['hire-notice']) els['hire-notice'].hidden = true;
|
|
484
|
+
if (els['job-catalog']) els['job-catalog'].hidden = false;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// R3+ — persona filter bar inside the New Job modal.
|
|
488
|
+
// Shows "All jobs" + one chip per hired persona + "Free only".
|
|
489
|
+
// Only rendered when at least one persona is hired (subscription active).
|
|
490
|
+
function renderModalPersonaFilter() {
|
|
491
|
+
const container = els['job-persona-filter'];
|
|
492
|
+
if (!container) return;
|
|
493
|
+
const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
|
|
494
|
+
if (personas.length === 0) {
|
|
495
|
+
container.hidden = true;
|
|
496
|
+
state.modalPersonaFilter = null;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
container.hidden = false;
|
|
500
|
+
container.innerHTML = '';
|
|
501
|
+
|
|
502
|
+
function makeFilterChip(key, label, avatarUrl) {
|
|
503
|
+
const btn = document.createElement('button');
|
|
504
|
+
btn.type = 'button';
|
|
505
|
+
btn.className = 'jf-chip' + (state.modalPersonaFilter === key ? ' active' : '');
|
|
506
|
+
if (avatarUrl) {
|
|
507
|
+
const img = document.createElement('img');
|
|
508
|
+
img.src = avatarUrl;
|
|
509
|
+
img.alt = label;
|
|
510
|
+
btn.appendChild(img);
|
|
511
|
+
}
|
|
512
|
+
const span = document.createElement('span');
|
|
513
|
+
span.textContent = label;
|
|
514
|
+
btn.appendChild(span);
|
|
515
|
+
btn.addEventListener('click', () => {
|
|
516
|
+
state.modalPersonaFilter = key;
|
|
517
|
+
// Reset job selection when filter changes.
|
|
518
|
+
state.selectedJob = null;
|
|
519
|
+
els['next1'].disabled = true;
|
|
520
|
+
els['job-pick-status'].textContent = 'Choose a job to continue';
|
|
521
|
+
hideHireNotice();
|
|
522
|
+
renderModalPersonaFilter();
|
|
523
|
+
renderJobCatalog(els['job-search'].value);
|
|
524
|
+
});
|
|
525
|
+
return btn;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
container.appendChild(makeFilterChip(null, 'All jobs'));
|
|
529
|
+
for (const persona of personas) {
|
|
530
|
+
container.appendChild(makeFilterChip(persona.key, persona.displayName, persona.avatarUrl));
|
|
531
|
+
}
|
|
532
|
+
container.appendChild(makeFilterChip('__free__', 'Free only'));
|
|
246
533
|
}
|
|
247
534
|
|
|
248
535
|
// ---------------------------------------------------------------------------
|
|
@@ -253,30 +540,39 @@ function statusLabel(s) {
|
|
|
253
540
|
// event rows are already in the DOM. Polling fires every second; without
|
|
254
541
|
// this, every tick wiped the messages list and re-played the slidein
|
|
255
542
|
// animation (= the screen flash the user reported as 'distracting').
|
|
256
|
-
let renderedConvId = null;
|
|
257
|
-
let renderedMessageCount = 0;
|
|
258
|
-
let renderedEventCount = 0;
|
|
259
|
-
let renderedArtifactKey = null;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
els['
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
els['
|
|
275
|
-
els['active-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
els['
|
|
543
|
+
let renderedConvId = null;
|
|
544
|
+
let renderedMessageCount = 0;
|
|
545
|
+
let renderedEventCount = 0;
|
|
546
|
+
let renderedArtifactKey = null;
|
|
547
|
+
let renderedStatus = null;
|
|
548
|
+
|
|
549
|
+
function renderActive() {
|
|
550
|
+
const conv = activeConversation();
|
|
551
|
+
if (!conv) {
|
|
552
|
+
els['empty'].hidden = false;
|
|
553
|
+
els['active-conv'].hidden = true;
|
|
554
|
+
renderedConvId = null;
|
|
555
|
+
renderedMessageCount = 0;
|
|
556
|
+
renderedEventCount = 0;
|
|
557
|
+
renderedArtifactKey = null;
|
|
558
|
+
renderedStatus = null;
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
els['empty'].hidden = true;
|
|
562
|
+
els['active-conv'].hidden = false;
|
|
563
|
+
els['active-title'].textContent = conversationTitle(conv);
|
|
564
|
+
els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)} · ${getEmployeeStatus(conv.employeeId)?.label || conv.employeeId}`;
|
|
565
|
+
renderConversationIdentity(conv);
|
|
566
|
+
els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)}`;
|
|
567
|
+
renderRunStatePill(conv);
|
|
568
|
+
els['summary-strip'].textContent = buildConversationSummary(conv);
|
|
569
|
+
els['coach-note'].textContent = conv.status === 'running'
|
|
570
|
+
? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
|
|
571
|
+
: 'The employee is waiting on you. Send the next instruction to continue this run.';
|
|
572
|
+
|
|
573
|
+
// Progress section. Plain text updates are cheap and don't animate.
|
|
574
|
+
const stage = derivedStage(conv);
|
|
575
|
+
els['stage'].textContent = stage.text;
|
|
280
576
|
els['progress'].classList.remove('done', 'attention', 'failed');
|
|
281
577
|
if (stage.kind) els['progress'].classList.add(stage.kind);
|
|
282
578
|
els['latest'].textContent = derivedLatest(conv);
|
|
@@ -284,15 +580,16 @@ function renderActive() {
|
|
|
284
580
|
// If we switched conversations (or this is the first render), wipe and
|
|
285
581
|
// start fresh. Otherwise we're going to do an incremental update below.
|
|
286
582
|
const switchedConv = renderedConvId !== conv.id;
|
|
287
|
-
if (switchedConv) {
|
|
583
|
+
if (switchedConv) {
|
|
288
584
|
els['artifact-slot'].innerHTML = '';
|
|
289
585
|
els['messages'].innerHTML = '';
|
|
290
586
|
els['micro-log'].textContent = '';
|
|
291
587
|
renderedConvId = conv.id;
|
|
292
588
|
renderedMessageCount = 0;
|
|
293
|
-
renderedEventCount = 0;
|
|
294
|
-
renderedArtifactKey = null;
|
|
295
|
-
}
|
|
589
|
+
renderedEventCount = 0;
|
|
590
|
+
renderedArtifactKey = null;
|
|
591
|
+
}
|
|
592
|
+
const statusChanged = renderedStatus !== conv.status;
|
|
296
593
|
|
|
297
594
|
// Artifact callout — only re-render when the latest artifact actually
|
|
298
595
|
// changed. Avoids the 'pulse' animation re-firing on every poll tick.
|
|
@@ -320,19 +617,24 @@ function renderActive() {
|
|
|
320
617
|
// existing rows don't re-animate. If for some reason the data shrunk
|
|
321
618
|
// (server revoked a message), fall back to a full re-render.
|
|
322
619
|
const messages = conv.messages || [];
|
|
323
|
-
if (messages.length < renderedMessageCount) {
|
|
324
|
-
els['messages'].innerHTML = '';
|
|
325
|
-
renderedMessageCount = 0;
|
|
326
|
-
}
|
|
327
|
-
for (let i = renderedMessageCount; i < messages.length; i += 1) {
|
|
328
|
-
appendMessageDom(messages[i].role, messages[i].text);
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
+
}
|
|
336
638
|
|
|
337
639
|
// Micro-manage — only append new events. textContent assignment on the
|
|
338
640
|
// <pre> wipes the entire log every tick which is wasteful.
|
|
@@ -358,11 +660,58 @@ function renderActive() {
|
|
|
358
660
|
// foldRunIntoConversation); for runs that have not yet polled we
|
|
359
661
|
// simply hide the surfaces.
|
|
360
662
|
renderTracker(conv);
|
|
361
|
-
renderTotals(conv);
|
|
362
|
-
syncTemplatePickerVisibility();
|
|
363
|
-
// Browser-tab title mirrors the active conversation (R3).
|
|
364
|
-
document.title = conv.title ? conv.title : 'AI Hub';
|
|
365
|
-
|
|
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
|
+
}
|
|
366
715
|
|
|
367
716
|
// Render the inline employee selector shown in the coach section of an
|
|
368
717
|
// active conversation. Allows switching agents without reopening the modal.
|
|
@@ -698,27 +1047,145 @@ function derivedStage(conv) {
|
|
|
698
1047
|
return { text: 'Done — please review', kind: 'done' };
|
|
699
1048
|
}
|
|
700
1049
|
|
|
701
|
-
function derivedLatest(conv) {
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
1050
|
+
function derivedLatest(conv) {
|
|
1051
|
+
if (conv.status !== 'running') return '';
|
|
1052
|
+
const employeeReply = latestEmployeeSurfaceText(conv);
|
|
1053
|
+
if (employeeReply) return employeeReply;
|
|
1054
|
+
if (conv.status === 'running') return 'Working on it…';
|
|
1055
|
+
return '';
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function humanizeSlug(slug) {
|
|
1059
|
+
return String(slug || '')
|
|
1060
|
+
.split('-')
|
|
1061
|
+
.filter(Boolean)
|
|
1062
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1063
|
+
.join(' ');
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function stripStubReference(text) {
|
|
1067
|
+
return String(text || '')
|
|
1068
|
+
.replace(/\n?\[Job stub:[^\]]+\]/gi, '')
|
|
1069
|
+
.replace(/\s+/g, ' ')
|
|
1070
|
+
.trim();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function surfaceText(role, text, conv) {
|
|
1074
|
+
const raw = stripStubReference(text);
|
|
1075
|
+
if (!raw) return '';
|
|
1076
|
+
|
|
1077
|
+
if (role === 'manager') {
|
|
1078
|
+
const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
|
|
1079
|
+
if (invocationOnly) return `Run ${humanizeSlug(invocationOnly[1])}.`;
|
|
1080
|
+
const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
|
|
1081
|
+
const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
1082
|
+
return raw
|
|
1083
|
+
.replace(invocationWithSlug, '')
|
|
1084
|
+
.replace(/^(?:[$/]fraim)\s*/i, '')
|
|
1085
|
+
.trim();
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
if (role === 'employee') {
|
|
1089
|
+
const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
|
|
1090
|
+
if (startedMatch) {
|
|
1091
|
+
const cleaned = surfaceText('manager', startedMatch[1], conv);
|
|
1092
|
+
if (conv.status === 'completed') return 'Done — please review.';
|
|
1093
|
+
return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
return raw;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function latestEmployeeSurfaceText(conv) {
|
|
1101
|
+
const messages = conv.messages || [];
|
|
1102
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1103
|
+
if (messages[i].role !== 'employee') continue;
|
|
1104
|
+
const cleaned = surfaceText('employee', messages[i].text, conv);
|
|
1105
|
+
if (cleaned) return cleaned;
|
|
1106
|
+
}
|
|
1107
|
+
return '';
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function appendMessageDom(role, text, conv) {
|
|
1111
|
+
const article = document.createElement('article');
|
|
1112
|
+
article.className = 'message ' + role;
|
|
1113
|
+
|
|
1114
|
+
const meta = document.createElement('div');
|
|
1115
|
+
meta.className = 'message-meta';
|
|
1116
|
+
|
|
1117
|
+
const avatar = document.createElement('span');
|
|
1118
|
+
avatar.className = `message-avatar ${role}`;
|
|
1119
|
+
|
|
1120
|
+
if (role === 'employee') {
|
|
1121
|
+
const persona = getConversationPersona(conv);
|
|
1122
|
+
if (persona && persona.avatarUrl) {
|
|
1123
|
+
const img = document.createElement('img');
|
|
1124
|
+
img.src = persona.avatarUrl;
|
|
1125
|
+
img.alt = persona.displayName;
|
|
1126
|
+
avatar.appendChild(img);
|
|
1127
|
+
} else {
|
|
1128
|
+
avatar.textContent = initialBadge(roleLabel(role, conv));
|
|
1129
|
+
}
|
|
1130
|
+
} else if (role === 'manager') {
|
|
1131
|
+
avatar.textContent = 'M';
|
|
1132
|
+
} else {
|
|
1133
|
+
avatar.textContent = '•';
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const who = document.createElement('span');
|
|
1137
|
+
who.className = 'who';
|
|
1138
|
+
who.textContent = roleLabel(role, conv);
|
|
1139
|
+
|
|
1140
|
+
const lane = document.createElement('span');
|
|
1141
|
+
lane.className = 'lane-label';
|
|
1142
|
+
lane.textContent = role === 'manager'
|
|
1143
|
+
? 'Manager direction'
|
|
1144
|
+
: role === 'employee'
|
|
1145
|
+
? 'Employee response'
|
|
1146
|
+
: 'System update';
|
|
1147
|
+
|
|
1148
|
+
meta.appendChild(avatar);
|
|
1149
|
+
meta.appendChild(who);
|
|
1150
|
+
meta.appendChild(lane);
|
|
1151
|
+
article.appendChild(meta);
|
|
1152
|
+
|
|
1153
|
+
const bubble = document.createElement('div');
|
|
1154
|
+
bubble.className = 'bubble';
|
|
1155
|
+
bubble.textContent = surfaceText(role, text, conv) || (
|
|
1156
|
+
role === 'employee'
|
|
1157
|
+
? (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…')
|
|
1158
|
+
: text
|
|
1159
|
+
);
|
|
1160
|
+
if (role === 'manager') {
|
|
1161
|
+
const raw = document.createElement('span');
|
|
1162
|
+
raw.className = 'transport-raw';
|
|
1163
|
+
raw.textContent = text;
|
|
1164
|
+
article.appendChild(raw);
|
|
1165
|
+
}
|
|
1166
|
+
article.appendChild(bubble);
|
|
1167
|
+
els['messages'].appendChild(article);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
function scrollThreadForReview(conv) {
|
|
1171
|
+
const host = els['messages'];
|
|
1172
|
+
if (!host) return;
|
|
1173
|
+
const nodes = [...host.querySelectorAll('.message')];
|
|
1174
|
+
if (nodes.length === 0) return;
|
|
1175
|
+
|
|
1176
|
+
if (conv.status === 'running') {
|
|
1177
|
+
host.scrollTop = host.scrollHeight;
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const target = [...nodes].reverse().find((node) =>
|
|
1182
|
+
node.classList.contains('employee') || node.classList.contains('system')
|
|
1183
|
+
) || nodes[nodes.length - 1];
|
|
1184
|
+
|
|
1185
|
+
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
function syncSendButton() {
|
|
722
1189
|
const conv = activeConversation();
|
|
723
1190
|
const hasText = els['coach-text'].value.trim().length > 0;
|
|
724
1191
|
// Send is enabled as soon as the host session exists. We deliberately
|
|
@@ -806,6 +1273,8 @@ function openModal(opts) {
|
|
|
806
1273
|
const filter = concept ? CONCEPT_PICKER_FILTERS[concept] : null;
|
|
807
1274
|
state.selectedJob = null;
|
|
808
1275
|
state.activeFilter = filter || null;
|
|
1276
|
+
// Inherit the rail persona selection as the starting filter for the modal.
|
|
1277
|
+
state.modalPersonaFilter = state.selectedPersonaKey || null;
|
|
809
1278
|
els['next1'].disabled = true;
|
|
810
1279
|
els['job-pick-status'].textContent = 'Choose a job to continue';
|
|
811
1280
|
els['job-search'].value = '';
|
|
@@ -813,6 +1282,9 @@ function openModal(opts) {
|
|
|
813
1282
|
els['start'].disabled = true;
|
|
814
1283
|
els['step1'].hidden = false;
|
|
815
1284
|
els['step2'].hidden = true;
|
|
1285
|
+
// R3: always start with the catalog visible, hire-notice hidden.
|
|
1286
|
+
if (els['hire-notice']) els['hire-notice'].hidden = true;
|
|
1287
|
+
if (els['job-catalog']) els['job-catalog'].hidden = false;
|
|
816
1288
|
// Update the modal heading + subhead to match the filter context.
|
|
817
1289
|
const h = document.querySelector('#step1 .modal-header h2');
|
|
818
1290
|
const p = document.querySelector('#step1 .modal-header p');
|
|
@@ -826,6 +1298,7 @@ function openModal(opts) {
|
|
|
826
1298
|
p.textContent = 'Pick one job. You can always start a new job for different work.';
|
|
827
1299
|
}
|
|
828
1300
|
renderEmployeeSelect();
|
|
1301
|
+
renderModalPersonaFilter();
|
|
829
1302
|
renderJobCatalog();
|
|
830
1303
|
els['modal'].hidden = false;
|
|
831
1304
|
els['modal'].classList.add('open');
|
|
@@ -850,8 +1323,10 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
850
1323
|
const managers = state.bootstrap?.managerTemplates || [];
|
|
851
1324
|
|
|
852
1325
|
// Decide which sources contribute given the active filter.
|
|
1326
|
+
// Manager templates are hidden whenever a persona/free chip is active —
|
|
1327
|
+
// they're meta-management jobs, not persona-specific work.
|
|
853
1328
|
const showEmployee = !filter || filter.kind === 'employee';
|
|
854
|
-
const showManager = !filter || filter.kind === 'manager';
|
|
1329
|
+
const showManager = state.modalPersonaFilter == null && (!filter || filter.kind === 'manager');
|
|
855
1330
|
|
|
856
1331
|
// Group employees by category.
|
|
857
1332
|
const employeeGroups = [];
|
|
@@ -860,7 +1335,13 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
860
1335
|
for (const cat of cats) {
|
|
861
1336
|
const rows = employees.filter((j) =>
|
|
862
1337
|
j.categoryId === cat.id &&
|
|
863
|
-
(!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f))
|
|
1338
|
+
(!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f)) &&
|
|
1339
|
+
// R3+: modal persona filter; null = All, '__free__' = free jobs only,
|
|
1340
|
+
// personaKey = only that persona's jobs (free jobs excluded).
|
|
1341
|
+
(state.modalPersonaFilter == null ||
|
|
1342
|
+
(state.modalPersonaFilter === '__free__'
|
|
1343
|
+
? j.requiredPersonaKey == null
|
|
1344
|
+
: j.requiredPersonaKey === state.modalPersonaFilter))
|
|
864
1345
|
);
|
|
865
1346
|
if (rows.length > 0) employeeGroups.push({ label: cat.label, items: rows });
|
|
866
1347
|
}
|
|
@@ -885,11 +1366,22 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
885
1366
|
const h4 = document.createElement('h4');
|
|
886
1367
|
h4.textContent = label;
|
|
887
1368
|
wrap.appendChild(h4);
|
|
1369
|
+
const personas = state.bootstrap?.personas || [];
|
|
1370
|
+
// Lock enforcement is only active when the workspace has an active subscription.
|
|
1371
|
+
// Legacy workspaces (subscriptionActive = false) see the full catalog with no locks.
|
|
1372
|
+
const personaSystemActive = state.bootstrap?.subscriptionActive ?? false;
|
|
888
1373
|
for (const job of items) {
|
|
889
1374
|
total += 1;
|
|
1375
|
+
// R3.1: a job is locked when it requires a persona with status 'locked',
|
|
1376
|
+
// but only when the persona system is active (subscription present).
|
|
1377
|
+
const isLocked = personaSystemActive && job.requiredPersonaKey != null && personas.some(
|
|
1378
|
+
(p) => p.key === job.requiredPersonaKey && p.status === 'locked'
|
|
1379
|
+
);
|
|
890
1380
|
const btn = document.createElement('button');
|
|
891
1381
|
btn.type = 'button';
|
|
892
|
-
btn.className = 'job-option' +
|
|
1382
|
+
btn.className = 'job-option' +
|
|
1383
|
+
(isLocked ? ' locked' : '') +
|
|
1384
|
+
(state.selectedJob?.id === job.id ? ' selected' : '');
|
|
893
1385
|
btn.dataset.jobId = job.id;
|
|
894
1386
|
const strong = document.createElement('strong');
|
|
895
1387
|
strong.textContent = job.title;
|
|
@@ -897,7 +1389,20 @@ function renderJobCatalog(searchTerm = '') {
|
|
|
897
1389
|
span.textContent = job.intent || '';
|
|
898
1390
|
btn.appendChild(strong);
|
|
899
1391
|
btn.appendChild(span);
|
|
1392
|
+
if (isLocked) {
|
|
1393
|
+
// R3.1: inline lock badge showing persona display name.
|
|
1394
|
+
const lockBadge = document.createElement('span');
|
|
1395
|
+
lockBadge.className = 'lock-badge';
|
|
1396
|
+
const persona = personas.find((p) => p.key === job.requiredPersonaKey);
|
|
1397
|
+
lockBadge.textContent = `🔒 ${persona ? persona.displayName : job.requiredPersonaKey}`;
|
|
1398
|
+
btn.appendChild(lockBadge);
|
|
1399
|
+
}
|
|
900
1400
|
btn.addEventListener('click', () => {
|
|
1401
|
+
if (isLocked) {
|
|
1402
|
+
// R3.2: locked job click shows hire-required notice.
|
|
1403
|
+
showHireNotice(job);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
901
1406
|
state.selectedJob = job;
|
|
902
1407
|
els['next1'].disabled = false;
|
|
903
1408
|
els['job-pick-status'].textContent = `Selected: ${job.title}`;
|
|
@@ -1093,13 +1598,20 @@ async function refreshEmployees() {
|
|
|
1093
1598
|
// string, never just the instructions slice.
|
|
1094
1599
|
function deriveTitle(jobTitle, instructions) {
|
|
1095
1600
|
const trimmedJob = (jobTitle || '').trim();
|
|
1096
|
-
const
|
|
1097
|
-
|
|
1601
|
+
const raw = (instructions || '').trim();
|
|
1602
|
+
// Capture FRAIM job slug before stripping (e.g. "/fraim pricing-strategy-definition" → "pricing-strategy-definition")
|
|
1603
|
+
const fraimSlugMatch = raw.match(/^[/$]fraim\s+([a-z0-9][a-z0-9-]*)(?:\s|$)/i);
|
|
1604
|
+
const stripped = raw
|
|
1098
1605
|
.replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
|
|
1099
1606
|
.replace(/\s+/g, ' ')
|
|
1100
1607
|
.trim();
|
|
1101
1608
|
const words = stripped.split(' ').filter(Boolean).slice(0, 6);
|
|
1102
1609
|
const intent = words.join(' ');
|
|
1610
|
+
// When instructions are solely a FRAIM invocation, the slug IS the job — use it
|
|
1611
|
+
// regardless of jobTitle (which may be the generic "Freeform task" placeholder).
|
|
1612
|
+
if (!intent && fraimSlugMatch) {
|
|
1613
|
+
return fraimSlugMatch[1].split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
1614
|
+
}
|
|
1103
1615
|
if (!trimmedJob && !intent) return 'New job';
|
|
1104
1616
|
if (!intent) return trimmedJob;
|
|
1105
1617
|
// If the job title and the start of the instructions happen to be the
|
|
@@ -1177,6 +1689,8 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1177
1689
|
jobId: job.id,
|
|
1178
1690
|
jobTitle: job.title,
|
|
1179
1691
|
employeeId,
|
|
1692
|
+
// R4: assign the persona key for this job (null for free jobs or freeform).
|
|
1693
|
+
personaKey: (job.requiredPersonaKey != null ? job.requiredPersonaKey : null),
|
|
1180
1694
|
runId: null,
|
|
1181
1695
|
sessionId: null,
|
|
1182
1696
|
status: 'running',
|
|
@@ -1310,6 +1824,10 @@ function foldRunIntoConversation(conv, run) {
|
|
|
1310
1824
|
}
|
|
1311
1825
|
// Track session for resumption.
|
|
1312
1826
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
1827
|
+
// R4: persist the persona key from the server-side run record.
|
|
1828
|
+
if (run.personaKey !== undefined && conv.personaKey == null) {
|
|
1829
|
+
conv.personaKey = run.personaKey;
|
|
1830
|
+
}
|
|
1313
1831
|
// Update status.
|
|
1314
1832
|
if (run.status === 'completed') conv.status = 'completed';
|
|
1315
1833
|
else if (run.status === 'failed') conv.status = 'failed';
|
|
@@ -1438,6 +1956,10 @@ function wireEvents() {
|
|
|
1438
1956
|
els['project-button'].addEventListener('click', pickProject);
|
|
1439
1957
|
els['new-conv-btn'].addEventListener('click', openModal);
|
|
1440
1958
|
els['cancel1'].addEventListener('click', closeModal);
|
|
1959
|
+
// Issue #385: hire-required notice back button.
|
|
1960
|
+
if (els['hire-notice-back']) {
|
|
1961
|
+
els['hire-notice-back'].addEventListener('click', hideHireNotice);
|
|
1962
|
+
}
|
|
1441
1963
|
els['back2'].addEventListener('click', () => {
|
|
1442
1964
|
els['step1'].hidden = false;
|
|
1443
1965
|
els['step2'].hidden = true;
|