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.
@@ -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
- 'empty', 'active-conv', 'active-title', 'active-job',
36
- 'progress', 'stage', 'latest', 'artifact-slot', 'messages',
37
- 'coach-text', 'send', 'micro-manage', 'micro-log',
38
- 'status-line',
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 message = (payload && payload.error) || `Request failed (${response.status}).`;
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 bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`);
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
- const list = projectConversations();
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
- btn.innerHTML = `<span class="conv-title"></span><span class="conv-status"></span>`;
232
- btn.querySelector('.conv-title').textContent = conv.title;
233
- const status = btn.querySelector('.conv-status');
234
- status.textContent = statusLabel(conv.status);
235
- if (conv.status === 'running') status.classList.add('running');
236
- if (conv.status === 'failed') status.classList.add('failed');
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
- function renderActive() {
262
- const conv = activeConversation();
263
- if (!conv) {
264
- els['empty'].hidden = false;
265
- els['active-conv'].hidden = true;
266
- renderedConvId = null;
267
- renderedMessageCount = 0;
268
- renderedEventCount = 0;
269
- renderedArtifactKey = null;
270
- return;
271
- }
272
- els['empty'].hidden = true;
273
- els['active-conv'].hidden = false;
274
- els['active-title'].textContent = conv.title;
275
- els['active-job'].textContent = `Job: ${conv.jobTitle}`;
276
-
277
- // Progress section. Plain text updates are cheap and don't animate.
278
- const stage = derivedStage(conv);
279
- els['stage'].textContent = stage.text;
543
+ let renderedConvId = null;
544
+ let renderedMessageCount = 0;
545
+ let renderedEventCount = 0;
546
+ let renderedArtifactKey = null;
547
+ let renderedStatus = null;
548
+
549
+ function renderActive() {
550
+ const conv = activeConversation();
551
+ if (!conv) {
552
+ els['empty'].hidden = false;
553
+ els['active-conv'].hidden = true;
554
+ renderedConvId = null;
555
+ renderedMessageCount = 0;
556
+ renderedEventCount = 0;
557
+ renderedArtifactKey = null;
558
+ renderedStatus = null;
559
+ return;
560
+ }
561
+ els['empty'].hidden = true;
562
+ els['active-conv'].hidden = false;
563
+ els['active-title'].textContent = conversationTitle(conv);
564
+ els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)} · ${getEmployeeStatus(conv.employeeId)?.label || conv.employeeId}`;
565
+ renderConversationIdentity(conv);
566
+ els['active-job'].textContent = `${conv.jobTitle} · ${friendlyProjectName(conv.projectPath || state.projectPath)}`;
567
+ renderRunStatePill(conv);
568
+ els['summary-strip'].textContent = buildConversationSummary(conv);
569
+ els['coach-note'].textContent = conv.status === 'running'
570
+ ? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
571
+ : 'The employee is waiting on you. Send the next instruction to continue this run.';
572
+
573
+ // Progress section. Plain text updates are cheap and don't animate.
574
+ const stage = derivedStage(conv);
575
+ els['stage'].textContent = stage.text;
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
- renderedMessageCount = messages.length;
331
- // Keep the scroll pinned to the latest unless the user scrolled up.
332
- const m = els['messages'];
333
- if (m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
334
- m.scrollTop = m.scrollHeight;
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
- const messages = conv.messages || [];
703
- for (let i = messages.length - 1; i >= 0; i -= 1) {
704
- if (messages[i].role === 'employee') return messages[i].text;
705
- }
706
- if (conv.status === 'running') return 'Loading the work and getting started.';
707
- return '';
708
- }
709
-
710
- function appendMessageDom(role, text) {
711
- const div = document.createElement('div');
712
- div.className = 'message ' + role;
713
- const who = document.createElement('span');
714
- who.className = 'who';
715
- who.textContent = role === 'manager' ? 'You' : (role === 'employee' ? 'Employee' : 'System');
716
- div.appendChild(who);
717
- div.appendChild(document.createTextNode(text));
718
- els['messages'].appendChild(div);
719
- }
720
-
721
- function syncSendButton() {
1050
+ function derivedLatest(conv) {
1051
+ if (conv.status !== 'running') return '';
1052
+ const employeeReply = latestEmployeeSurfaceText(conv);
1053
+ if (employeeReply) return employeeReply;
1054
+ if (conv.status === 'running') return 'Working on it…';
1055
+ return '';
1056
+ }
1057
+
1058
+ function humanizeSlug(slug) {
1059
+ return String(slug || '')
1060
+ .split('-')
1061
+ .filter(Boolean)
1062
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1063
+ .join(' ');
1064
+ }
1065
+
1066
+ function stripStubReference(text) {
1067
+ return String(text || '')
1068
+ .replace(/\n?\[Job stub:[^\]]+\]/gi, '')
1069
+ .replace(/\s+/g, ' ')
1070
+ .trim();
1071
+ }
1072
+
1073
+ function surfaceText(role, text, conv) {
1074
+ const raw = stripStubReference(text);
1075
+ if (!raw) return '';
1076
+
1077
+ if (role === 'manager') {
1078
+ const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
1079
+ if (invocationOnly) return `Run ${humanizeSlug(invocationOnly[1])}.`;
1080
+ const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
1081
+ const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
1082
+ return raw
1083
+ .replace(invocationWithSlug, '')
1084
+ .replace(/^(?:[$/]fraim)\s*/i, '')
1085
+ .trim();
1086
+ }
1087
+
1088
+ if (role === 'employee') {
1089
+ const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
1090
+ if (startedMatch) {
1091
+ const cleaned = surfaceText('manager', startedMatch[1], conv);
1092
+ if (conv.status === 'completed') return 'Done — please review.';
1093
+ return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
1094
+ }
1095
+ }
1096
+
1097
+ return raw;
1098
+ }
1099
+
1100
+ function latestEmployeeSurfaceText(conv) {
1101
+ const messages = conv.messages || [];
1102
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
1103
+ if (messages[i].role !== 'employee') continue;
1104
+ const cleaned = surfaceText('employee', messages[i].text, conv);
1105
+ if (cleaned) return cleaned;
1106
+ }
1107
+ return '';
1108
+ }
1109
+
1110
+ function appendMessageDom(role, text, conv) {
1111
+ const article = document.createElement('article');
1112
+ article.className = 'message ' + role;
1113
+
1114
+ const meta = document.createElement('div');
1115
+ meta.className = 'message-meta';
1116
+
1117
+ const avatar = document.createElement('span');
1118
+ avatar.className = `message-avatar ${role}`;
1119
+
1120
+ if (role === 'employee') {
1121
+ const persona = getConversationPersona(conv);
1122
+ if (persona && persona.avatarUrl) {
1123
+ const img = document.createElement('img');
1124
+ img.src = persona.avatarUrl;
1125
+ img.alt = persona.displayName;
1126
+ avatar.appendChild(img);
1127
+ } else {
1128
+ avatar.textContent = initialBadge(roleLabel(role, conv));
1129
+ }
1130
+ } else if (role === 'manager') {
1131
+ avatar.textContent = 'M';
1132
+ } else {
1133
+ avatar.textContent = '•';
1134
+ }
1135
+
1136
+ const who = document.createElement('span');
1137
+ who.className = 'who';
1138
+ who.textContent = roleLabel(role, conv);
1139
+
1140
+ const lane = document.createElement('span');
1141
+ lane.className = 'lane-label';
1142
+ lane.textContent = role === 'manager'
1143
+ ? 'Manager direction'
1144
+ : role === 'employee'
1145
+ ? 'Employee response'
1146
+ : 'System update';
1147
+
1148
+ meta.appendChild(avatar);
1149
+ meta.appendChild(who);
1150
+ meta.appendChild(lane);
1151
+ article.appendChild(meta);
1152
+
1153
+ const bubble = document.createElement('div');
1154
+ bubble.className = 'bubble';
1155
+ bubble.textContent = surfaceText(role, text, conv) || (
1156
+ role === 'employee'
1157
+ ? (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…')
1158
+ : text
1159
+ );
1160
+ if (role === 'manager') {
1161
+ const raw = document.createElement('span');
1162
+ raw.className = 'transport-raw';
1163
+ raw.textContent = text;
1164
+ article.appendChild(raw);
1165
+ }
1166
+ article.appendChild(bubble);
1167
+ els['messages'].appendChild(article);
1168
+ }
1169
+
1170
+ function scrollThreadForReview(conv) {
1171
+ const host = els['messages'];
1172
+ if (!host) return;
1173
+ const nodes = [...host.querySelectorAll('.message')];
1174
+ if (nodes.length === 0) return;
1175
+
1176
+ if (conv.status === 'running') {
1177
+ host.scrollTop = host.scrollHeight;
1178
+ return;
1179
+ }
1180
+
1181
+ const target = [...nodes].reverse().find((node) =>
1182
+ node.classList.contains('employee') || node.classList.contains('system')
1183
+ ) || nodes[nodes.length - 1];
1184
+
1185
+ target.scrollIntoView({ block: 'center', behavior: 'smooth' });
1186
+ }
1187
+
1188
+ function syncSendButton() {
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' + (state.selectedJob?.id === job.id ? ' selected' : '');
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 stripped = (instructions || '')
1097
- .trim()
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;