fraim-framework 2.0.126 → 2.0.127

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.
Files changed (33) hide show
  1. package/dist/src/ai-hub/catalog.js +280 -44
  2. package/dist/src/ai-hub/desktop-main.js +2 -2
  3. package/dist/src/ai-hub/hosts.js +384 -10
  4. package/dist/src/ai-hub/server.js +255 -9
  5. package/dist/src/cli/commands/add-ide.js +4 -3
  6. package/dist/src/cli/commands/first-run.js +61 -0
  7. package/dist/src/cli/commands/hub.js +4 -4
  8. package/dist/src/cli/commands/init-project.js +4 -4
  9. package/dist/src/cli/commands/setup.js +4 -3
  10. package/dist/src/cli/commands/sync.js +21 -2
  11. package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
  12. package/dist/src/cli/fraim.js +2 -0
  13. package/dist/src/cli/mcp/ide-formats.js +29 -1
  14. package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
  15. package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
  16. package/dist/src/cli/setup/ide-detector.js +32 -1
  17. package/dist/src/cli/setup/ide-global-integration.js +5 -1
  18. package/dist/src/cli/setup/ide-invocation-surfaces.js +14 -0
  19. package/dist/src/cli/setup/mcp-config-generator.js +12 -1
  20. package/dist/src/cli/utils/agent-adapters.js +10 -0
  21. package/dist/src/core/utils/git-utils.js +14 -6
  22. package/dist/src/first-run/install-state.js +68 -0
  23. package/dist/src/first-run/server.js +153 -0
  24. package/dist/src/first-run/session-service.js +302 -0
  25. package/dist/src/first-run/types.js +40 -0
  26. package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
  27. package/dist/src/local-mcp-server/stdio-server.js +41 -9
  28. package/package.json +3 -1
  29. package/public/ai-hub/index.html +149 -102
  30. package/public/ai-hub/script.js +1154 -271
  31. package/public/ai-hub/styles.css +753 -450
  32. package/public/first-run/index.html +221 -0
  33. package/public/first-run/script.js +361 -0
@@ -1,374 +1,1257 @@
1
+ // AI Hub frontend (issue #339 refresh).
2
+ //
3
+ // Backend integration is preserved: bootstrap from /api/ai-hub/bootstrap, start
4
+ // runs at POST /api/ai-hub/runs, continue at POST /api/ai-hub/runs/:id/messages,
5
+ // poll at GET /api/ai-hub/runs/:id, fold project picker via POST
6
+ // /api/ai-hub/project-path/pick.
7
+ //
8
+ // New: per-project conversation list persisted in localStorage. Each
9
+ // conversation maps to one job and (when active) one backend run id. The
10
+ // existing single-active-run backend stays untouched per spec R15.
11
+
12
+ const STORAGE_KEY_CONVERSATIONS = 'fraim.aiHub.conversations.v1';
13
+ const STORAGE_KEY_ACTIVE = 'fraim.aiHub.activeConversation.v1';
14
+
1
15
  const state = {
2
16
  bootstrap: null,
3
- selectedJobId: null,
4
- selectedEmployeeId: null,
5
- selectedCategoryId: null,
6
- activeRunId: null,
17
+ projectPath: '',
18
+ conversations: {}, // { [projectPath]: ConversationSummary[] }
19
+ activeId: null, // active conversation id (any project)
7
20
  pollHandle: null,
8
- autoMessage: '',
21
+ selectedJob: null, // chosen in modal step 1
22
+ selectedEmployeeId: null,
9
23
  };
10
24
 
11
- const elements = {
12
- employeePicker: document.getElementById('employee-picker'),
13
- categoryPicker: document.getElementById('category-picker'),
14
- jobList: document.getElementById('job-list'),
15
- selectedJobTitle: document.getElementById('selected-job-title'),
16
- selectedJobIntent: document.getElementById('selected-job-intent'),
17
- selectedJobOutcomes: document.getElementById('selected-job-outcomes'),
18
- startJob: document.getElementById('start-job'),
19
- conversationTitle: document.getElementById('conversation-title'),
20
- conversationState: document.getElementById('conversation-state'),
21
- timeline: document.getElementById('timeline'),
22
- managerTemplates: document.getElementById('manager-templates'),
23
- managerMessage: document.getElementById('manager-message'),
24
- sendCoaching: document.getElementById('send-coaching'),
25
- rawHistory: document.getElementById('raw-history'),
26
- projectPathInput: document.getElementById('project-path-input'),
27
- projectPathPreview: document.getElementById('project-path-preview'),
28
- projectStatus: document.getElementById('project-status'),
29
- browseProject: document.getElementById('browse-project'),
30
- reloadProject: document.getElementById('reload-project'),
31
- messageTemplate: document.getElementById('timeline-message-template'),
32
- };
25
+ const els = {};
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Bootstrap & DOM refs
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function gatherElements() {
32
+ const ids = [
33
+ 'project-button', 'project-name',
34
+ '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',
39
+ 'modal', 'step1', 'step2',
40
+ 'cancel1', 'next1', 'back2', 'start',
41
+ 'job-search', 'job-catalog', 'job-pick-status',
42
+ 'picked-name', 'picked-desc', 'instructions',
43
+ 'employee-select',
44
+ // Issue #347 additions: tracker, template picker, totals.
45
+ 'tracker', 'tracker-rows', 'tracker-note',
46
+ 'template-picker-btn', 'template-popover',
47
+ 'totals',
48
+ ];
49
+ for (const id of ids) {
50
+ els[id] = document.getElementById(id);
51
+ }
52
+ }
33
53
 
34
54
  async function requestJson(url, options) {
35
55
  const response = await fetch(url, options);
36
- const payload = await response.json();
56
+ let payload = null;
57
+ try { payload = await response.json(); } catch { /* may be empty */ }
37
58
  if (!response.ok) {
38
- throw new Error(payload.error || 'Request failed.');
59
+ const message = (payload && payload.error) || `Request failed (${response.status}).`;
60
+ throw new Error(message);
39
61
  }
40
62
  return payload;
41
63
  }
42
64
 
43
- function formatTimestamp(value) {
65
+ async function loadBootstrap(projectPath) {
66
+ const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
67
+ const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`);
68
+ state.bootstrap = bootstrap;
69
+ state.projectPath = bootstrap.project.path;
70
+ state.selectedEmployeeId = state.selectedEmployeeId || bootstrap.preferences.employeeId;
71
+ // Render header project name + status line.
72
+ els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
73
+ els['status-line'].textContent = bootstrap.project.message || '';
74
+ els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
75
+ // Populate the welcome-line popovers with REAL jobs from the bootstrap.
76
+ populateConceptPopovers();
77
+ // Render rail and active state for this project.
78
+ renderRail();
79
+ renderActive();
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Concept popover population (drives the welcome-line "see ... jobs" lists)
84
+ // ---------------------------------------------------------------------------
85
+
86
+ // Per-concept filter for the new-job picker. Each concept popover's
87
+ // "Browse" link opens the existing picker pre-filtered by these rules,
88
+ // so the popover itself only ever has to render a short hint + one
89
+ // link instead of trying to be a list container.
90
+ const CONCEPT_PICKER_FILTERS = {
91
+ manager: { kind: 'manager', groupIds: ['delegation', 'project-setup', 'productivity', 'hiring', 'setup-guardrails'], label: 'manager' },
92
+ employee: { kind: 'employee', label: 'employee' },
93
+ coach: { kind: 'manager', groupIds: ['coaching', '1-1'], label: 'coaching' },
94
+ verify: { kind: 'manager', groupIds: ['verification'], label: 'verification' },
95
+ learn: { kind: 'manager', groupIds: ['team-learning'], label: 'learning' },
96
+ };
97
+
98
+ // Count how many catalog entries match a concept's filter, used to power
99
+ // the "Browse N <kind> jobs →" link in each popover.
100
+ function countConcept(concept) {
101
+ const filter = CONCEPT_PICKER_FILTERS[concept];
102
+ if (!filter) return 0;
103
+ if (filter.kind === 'employee') return (state.bootstrap?.jobs || []).length;
104
+ return (state.bootstrap?.managerTemplates || []).filter(
105
+ (t) => filter.groupIds.includes(t.groupId)
106
+ ).length;
107
+ }
108
+
109
+ // Each concept popover gets a single, clear browse-link that opens the
110
+ // shared new-job picker pre-filtered by that concept. No more in-popover
111
+ // truncation, no more "+ N more" hint — one pattern, one source of truth.
112
+ function populateConceptPopovers() {
113
+ for (const concept of Object.keys(CONCEPT_PICKER_FILTERS)) {
114
+ const container = document.getElementById(`jobs-${concept}`);
115
+ if (!container) continue;
116
+ container.innerHTML = '';
117
+ const filter = CONCEPT_PICKER_FILTERS[concept];
118
+ const total = countConcept(concept);
119
+ const row = document.createElement('span');
120
+ row.className = 'job-row pop-jobs-more';
121
+ if (total === 0) {
122
+ const s = document.createElement('span');
123
+ s.style.color = 'var(--muted)';
124
+ s.textContent = `No ${filter.label} jobs discovered in this project yet.`;
125
+ row.appendChild(s);
126
+ } else {
127
+ const a = document.createElement('a');
128
+ a.href = '#';
129
+ a.textContent = `Browse ${total} ${filter.label} job${total === 1 ? '' : 's'} →`;
130
+ a.style.color = 'var(--accent)';
131
+ a.style.fontWeight = '500';
132
+ a.addEventListener('click', (e) => {
133
+ e.preventDefault();
134
+ e.stopPropagation();
135
+ document.querySelectorAll('.popover.open').forEach((p) => p.classList.remove('open'));
136
+ document.querySelectorAll('.pop-jobs.open').forEach((p) => p.classList.remove('open'));
137
+ openModal({ concept });
138
+ });
139
+ row.appendChild(a);
140
+ }
141
+ container.appendChild(row);
142
+ }
143
+ }
144
+
145
+ function friendlyProjectName(p) {
146
+ if (!p) return 'Choose a folder';
147
+ const parts = p.split(/[\\/]/).filter(Boolean);
148
+ return parts.slice(-2).join('/') || p;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Conversation persistence (per project, in localStorage)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function loadConversationsFromStorage() {
156
+ try {
157
+ const raw = window.localStorage.getItem(STORAGE_KEY_CONVERSATIONS);
158
+ state.conversations = raw ? JSON.parse(raw) : {};
159
+ } catch {
160
+ state.conversations = {};
161
+ }
44
162
  try {
45
- return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
163
+ state.activeId = window.localStorage.getItem(STORAGE_KEY_ACTIVE) || null;
46
164
  } catch {
47
- return '';
165
+ state.activeId = null;
166
+ }
167
+ }
168
+
169
+ function persistConversations() {
170
+ try {
171
+ window.localStorage.setItem(STORAGE_KEY_CONVERSATIONS, JSON.stringify(state.conversations));
172
+ if (state.activeId) {
173
+ window.localStorage.setItem(STORAGE_KEY_ACTIVE, state.activeId);
174
+ } else {
175
+ window.localStorage.removeItem(STORAGE_KEY_ACTIVE);
176
+ }
177
+ } catch (error) {
178
+ console.warn('Could not persist conversations:', error);
48
179
  }
49
180
  }
50
181
 
51
- function humanMessageRole(role) {
52
- if (role === 'manager') return 'Manager';
53
- if (role === 'employee') return 'Employee';
54
- return 'System';
182
+ function projectConversations() {
183
+ const key = state.projectPath || '';
184
+ return state.conversations[key] || [];
55
185
  }
56
186
 
57
- function selectedJob() {
58
- return state.bootstrap?.jobs.find((job) => job.id === state.selectedJobId) || null;
187
+ function setProjectConversations(list) {
188
+ const key = state.projectPath || '';
189
+ state.conversations[key] = list;
59
190
  }
60
191
 
61
- function renderEmployees() {
62
- elements.employeePicker.innerHTML = '';
63
- for (const employee of state.bootstrap.employees) {
64
- const button = document.createElement('button');
65
- button.type = 'button';
66
- button.className = `employee-chip${state.selectedEmployeeId === employee.id ? ' active' : ''}${employee.available ? '' : ' unavailable'}`;
67
- button.textContent = employee.label;
68
- button.title = employee.detail;
69
- button.disabled = !employee.available;
70
- button.addEventListener('click', () => {
71
- state.selectedEmployeeId = employee.id;
72
- render();
73
- });
74
- elements.employeePicker.appendChild(button);
75
- }
76
- }
77
-
78
- function renderCategories() {
79
- elements.categoryPicker.innerHTML = '';
80
- for (const category of state.bootstrap.categories) {
81
- const button = document.createElement('button');
82
- button.type = 'button';
83
- button.className = `category-chip${state.selectedCategoryId === category.id ? ' active' : ''}`;
84
- button.textContent = category.label;
85
- button.addEventListener('click', () => {
86
- state.selectedCategoryId = category.id;
87
- const firstJob = filteredJobs()[0];
88
- state.selectedJobId = firstJob ? firstJob.id : null;
89
- primeMessage();
90
- render();
91
- });
92
- elements.categoryPicker.appendChild(button);
192
+ function findConversation(id) {
193
+ for (const list of Object.values(state.conversations)) {
194
+ const found = list.find((c) => c.id === id);
195
+ if (found) return found;
93
196
  }
197
+ return null;
94
198
  }
95
199
 
96
- function filteredJobs() {
97
- return state.bootstrap.jobs.filter((job) => job.categoryId === state.selectedCategoryId);
200
+ function activeConversation() {
201
+ if (!state.activeId) return null;
202
+ return findConversation(state.activeId);
98
203
  }
99
204
 
100
- function renderJobList() {
101
- elements.jobList.innerHTML = '';
102
- const jobs = filteredJobs();
103
- if (jobs.length === 0) {
104
- const empty = document.createElement('div');
105
- empty.className = 'card selected-job';
106
- empty.innerHTML = '<p class="muted">No FRAIM jobs were found for this category in the selected project path.</p>';
107
- elements.jobList.appendChild(empty);
205
+ function upsertConversation(conv) {
206
+ const list = projectConversations().slice();
207
+ const idx = list.findIndex((c) => c.id === conv.id);
208
+ if (idx >= 0) list[idx] = conv;
209
+ else list.unshift(conv);
210
+ setProjectConversations(list);
211
+ persistConversations();
212
+ }
213
+
214
+ function newConversationId() {
215
+ return 'c-' + Date.now() + '-' + Math.random().toString(36).slice(2, 7);
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Rail rendering
220
+ // ---------------------------------------------------------------------------
221
+
222
+ function renderRail() {
223
+ els['conv-list'].innerHTML = '';
224
+ const list = projectConversations();
225
+ for (const conv of list) {
226
+ const btn = document.createElement('button');
227
+ btn.type = 'button';
228
+ btn.className = 'conv-item' + (state.activeId === conv.id ? ' active' : '');
229
+ btn.dataset.conv = conv.id;
230
+ btn.innerHTML = `<span class="conv-title"></span><span class="conv-status"></span>`;
231
+ btn.querySelector('.conv-title').textContent = conv.title;
232
+ const status = btn.querySelector('.conv-status');
233
+ status.textContent = statusLabel(conv.status);
234
+ if (conv.status === 'running') status.classList.add('running');
235
+ if (conv.status === 'failed') status.classList.add('failed');
236
+ btn.addEventListener('click', () => switchToConversation(conv.id));
237
+ els['conv-list'].appendChild(btn);
238
+ }
239
+ }
240
+
241
+ function statusLabel(s) {
242
+ if (s === 'running') return 'Running';
243
+ if (s === 'failed') return 'Needs you';
244
+ return 'Done';
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Active conversation rendering
249
+ // ---------------------------------------------------------------------------
250
+
251
+ // Track which conversation is currently rendered + how many message and
252
+ // event rows are already in the DOM. Polling fires every second; without
253
+ // this, every tick wiped the messages list and re-played the slidein
254
+ // animation (= the screen flash the user reported as 'distracting').
255
+ let renderedConvId = null;
256
+ let renderedMessageCount = 0;
257
+ let renderedEventCount = 0;
258
+ let renderedArtifactKey = null;
259
+
260
+ function renderActive() {
261
+ const conv = activeConversation();
262
+ if (!conv) {
263
+ els['empty'].hidden = false;
264
+ els['active-conv'].hidden = true;
265
+ renderedConvId = null;
266
+ renderedMessageCount = 0;
267
+ renderedEventCount = 0;
268
+ renderedArtifactKey = null;
108
269
  return;
109
270
  }
271
+ els['empty'].hidden = true;
272
+ els['active-conv'].hidden = false;
273
+ els['active-title'].textContent = conv.title;
274
+ els['active-job'].textContent = `Job: ${conv.jobTitle}`;
110
275
 
111
- for (const job of jobs) {
112
- const button = document.createElement('button');
113
- button.type = 'button';
114
- button.className = state.selectedJobId === job.id ? 'active' : '';
115
- button.innerHTML = `<div class="job-title">${job.title}</div><div class="job-intent">${job.intent}</div>`;
116
- button.addEventListener('click', () => {
117
- state.selectedJobId = job.id;
118
- primeMessage();
119
- render();
120
- });
121
- elements.jobList.appendChild(button);
276
+ // Progress section. Plain text updates are cheap and don't animate.
277
+ const stage = derivedStage(conv);
278
+ els['stage'].textContent = stage.text;
279
+ els['progress'].classList.remove('done', 'attention', 'failed');
280
+ if (stage.kind) els['progress'].classList.add(stage.kind);
281
+ els['latest'].textContent = derivedLatest(conv);
282
+
283
+ // If we switched conversations (or this is the first render), wipe and
284
+ // start fresh. Otherwise we're going to do an incremental update below.
285
+ const switchedConv = renderedConvId !== conv.id;
286
+ if (switchedConv) {
287
+ els['artifact-slot'].innerHTML = '';
288
+ els['messages'].innerHTML = '';
289
+ els['micro-log'].textContent = '';
290
+ renderedConvId = conv.id;
291
+ renderedMessageCount = 0;
292
+ renderedEventCount = 0;
293
+ renderedArtifactKey = null;
294
+ }
295
+
296
+ // Artifact callout — only re-render when the latest artifact actually
297
+ // changed. Avoids the 'pulse' animation re-firing on every poll tick.
298
+ const latestArtifact = conv.artifacts && conv.artifacts.length > 0
299
+ ? conv.artifacts[conv.artifacts.length - 1]
300
+ : null;
301
+ const artifactKey = latestArtifact ? `${latestArtifact.where}${latestArtifact.name}` : null;
302
+ if (artifactKey !== renderedArtifactKey) {
303
+ els['artifact-slot'].innerHTML = '';
304
+ if (latestArtifact) {
305
+ const span = document.createElement('span');
306
+ span.className = 'artifact';
307
+ span.title = `${latestArtifact.where}${latestArtifact.name}`;
308
+ span.innerHTML = `
309
+ <span class="artifact-dot" aria-hidden="true"></span>
310
+ <span class="artifact-label">file</span>
311
+ <span class="artifact-name"></span>`;
312
+ span.querySelector('.artifact-name').textContent = latestArtifact.name;
313
+ els['artifact-slot'].appendChild(span);
314
+ }
315
+ renderedArtifactKey = artifactKey;
316
+ }
317
+
318
+ // Messages — append only the rows that aren't already in the DOM, so
319
+ // existing rows don't re-animate. If for some reason the data shrunk
320
+ // (server revoked a message), fall back to a full re-render.
321
+ const messages = conv.messages || [];
322
+ if (messages.length < renderedMessageCount) {
323
+ els['messages'].innerHTML = '';
324
+ renderedMessageCount = 0;
325
+ }
326
+ for (let i = renderedMessageCount; i < messages.length; i += 1) {
327
+ appendMessageDom(messages[i].role, messages[i].text);
328
+ }
329
+ renderedMessageCount = messages.length;
330
+ // Keep the scroll pinned to the latest unless the user scrolled up.
331
+ const m = els['messages'];
332
+ if (m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
333
+ m.scrollTop = m.scrollHeight;
334
+ }
335
+
336
+ // Micro-manage — only append new events. textContent assignment on the
337
+ // <pre> wipes the entire log every tick which is wasteful.
338
+ const events = conv.events || [];
339
+ if (events.length < renderedEventCount) {
340
+ els['micro-log'].textContent = '';
341
+ renderedEventCount = 0;
122
342
  }
343
+ for (let i = renderedEventCount; i < events.length; i += 1) {
344
+ const line = `[${events[i].channel || 'system'}] ${events[i].text}\n`;
345
+ els['micro-log'].appendChild(document.createTextNode(line));
346
+ }
347
+ renderedEventCount = events.length;
348
+
349
+ // Coaching state — only enable Send when there's text and the run is resumable.
350
+ syncSendButton();
351
+
352
+ // Issue #347 — tracker, totals, picker. The data lives on conv.run
353
+ // (latest server snapshot folded into the conversation by
354
+ // foldRunIntoConversation); for runs that have not yet polled we
355
+ // simply hide the surfaces.
356
+ renderTracker(conv);
357
+ renderTotals(conv);
358
+ syncTemplatePickerVisibility();
359
+ // Browser-tab title mirrors the active conversation (R3).
360
+ document.title = conv.title ? conv.title : 'AI Hub';
123
361
  }
124
362
 
125
- function renderSelectedJob() {
126
- const job = selectedJob();
127
- if (!job) {
128
- elements.selectedJobTitle.textContent = 'Select a job';
129
- elements.selectedJobIntent.textContent = 'Choose a Marketing or GTM job to frame the employee conversation.';
130
- elements.selectedJobOutcomes.innerHTML = '';
131
- elements.startJob.disabled = true;
363
+ // Issue #347 R1 — render the pizza tracker. Reads conv.run.stages and
364
+ // conv.run.currentPhase from the most recent backend snapshot. Hidden
365
+ // when the active job has no declared phases.
366
+ let renderedTrackerKey = null;
367
+ function renderTracker(conv) {
368
+ const tracker = els['tracker'];
369
+ if (!tracker) return;
370
+ const stages = (conv.run && Array.isArray(conv.run.stages)) ? conv.run.stages : [];
371
+ if (stages.length === 0) {
372
+ tracker.hidden = true;
373
+ renderedTrackerKey = null;
132
374
  return;
133
375
  }
376
+ // Cache key so we only rebuild the DOM when the stage data changes.
377
+ const key = stages.map((s) => `${s.phaseId}:${s.state}`).join('|') +
378
+ '#' + (conv.run.currentPhase || '');
379
+ if (key === renderedTrackerKey) {
380
+ syncTrackerNote(conv);
381
+ return;
382
+ }
383
+ renderedTrackerKey = key;
384
+ tracker.hidden = false;
134
385
 
135
- elements.selectedJobTitle.textContent = job.title;
136
- elements.selectedJobIntent.textContent = job.intent;
137
- elements.selectedJobOutcomes.innerHTML = '';
138
- for (const outcome of job.outcome.slice(0, 3)) {
139
- const item = document.createElement('li');
140
- item.textContent = outcome;
141
- elements.selectedJobOutcomes.appendChild(item);
386
+ const rowsHost = els['tracker-rows'];
387
+ rowsHost.innerHTML = '';
388
+ // Render the standalone active-stage label (visible only at narrow
389
+ // viewports per CSS media query). Insert as a sibling between
390
+ // tracker-rows and tracker-note so the visual order is rows → label
391
+ // friendly note.
392
+ let activeLabelHost = tracker.querySelector('.tracker-active-label');
393
+ if (!activeLabelHost) {
394
+ activeLabelHost = document.createElement('div');
395
+ activeLabelHost.className = 'tracker-active-label';
396
+ rowsHost.parentNode.insertBefore(activeLabelHost, els['tracker-note']);
142
397
  }
398
+ const currentStage = stages.find((s) => s.state === 'current');
399
+ activeLabelHost.textContent = currentStage ? currentStage.label : '';
400
+ // Wrap onto multiple rows when there are more than 7 stages — keeps
401
+ // labels readable at 1080px without horizontal scroll. Balance the
402
+ // per-row count so 12 stages render 6+6 (not 7+5), avoiding the
403
+ // visual rhythm drift where row 2 stages stretch wider than row 1.
404
+ const rowsNeeded = Math.max(1, Math.ceil(stages.length / 7));
405
+ const rowCapacity = Math.ceil(stages.length / rowsNeeded);
406
+ let row = null;
407
+ stages.forEach((stage, index) => {
408
+ if (index % rowCapacity === 0) {
409
+ row = document.createElement('div');
410
+ row.className = 'tracker-row';
411
+ rowsHost.appendChild(row);
412
+ }
413
+ const stageEl = document.createElement('div');
414
+ stageEl.className = 'stage ' + stage.state;
415
+ const tooltipText = buildStageTooltip(conv, stage);
416
+ if (tooltipText) stageEl.setAttribute('title', tooltipText);
417
+ const circle = document.createElement('span');
418
+ circle.className = 'stage-circle';
419
+ if (stage.state === 'done') circle.textContent = '✓';
420
+ else if (stage.state === 'current') circle.textContent = '●';
421
+ else circle.textContent = String(index + 1);
422
+ const label = document.createElement('span');
423
+ label.className = 'stage-label';
424
+ label.textContent = stage.label;
425
+ stageEl.appendChild(circle);
426
+ stageEl.appendChild(label);
427
+ row.appendChild(stageEl);
428
+ });
429
+ syncTrackerNote(conv);
430
+ }
143
431
 
144
- elements.startJob.disabled = !state.selectedEmployeeId || !elements.managerMessage.value.trim();
432
+ function buildStageTooltip(conv, stage) {
433
+ const history = (conv.run && conv.run.phaseHistory) || [];
434
+ const entry = history.find((h) => h.phaseId === stage.phaseId);
435
+ const findings = entry && entry.latestText;
436
+ if (findings) return `${stage.phaseId} · ${findings}`;
437
+ return stage.phaseId;
145
438
  }
146
439
 
147
- function renderTemplates() {
148
- elements.managerTemplates.innerHTML = '';
149
- for (const template of state.bootstrap.managerTemplates) {
150
- const button = document.createElement('button');
151
- button.type = 'button';
152
- button.className = 'template-chip';
153
- button.textContent = template.title;
154
- button.title = template.intent;
155
- button.addEventListener('click', () => {
156
- insertTemplate(template.id);
157
- });
158
- elements.managerTemplates.appendChild(button);
440
+ // R1.5: friendly back-edge note. Shows when the current phase is
441
+ // earlier than the latest visited phase in declaration order. We use
442
+ // the stage list as the source of truth; if the current phase's index
443
+ // is less than max(visited index), we have regressed.
444
+ function syncTrackerNote(conv) {
445
+ const note = els['tracker-note'];
446
+ if (!note) return;
447
+ const stages = (conv.run && conv.run.stages) || [];
448
+ const history = (conv.run && conv.run.phaseHistory) || [];
449
+ const currentPhase = conv.run && conv.run.currentPhase;
450
+ if (!currentPhase || stages.length === 0 || history.length < 2) {
451
+ note.hidden = true;
452
+ note.textContent = '';
453
+ return;
454
+ }
455
+ const indexOfPhase = (phaseId) => stages.findIndex((s) => s.phaseId === phaseId);
456
+ const currentIdx = indexOfPhase(currentPhase);
457
+ const maxVisitedIdx = history
458
+ .map((h) => indexOfPhase(h.phaseId))
459
+ .filter((i) => i >= 0)
460
+ .reduce((a, b) => (a > b ? a : b), -1);
461
+ if (currentIdx >= 0 && maxVisitedIdx > currentIdx) {
462
+ note.hidden = false;
463
+ note.innerHTML = '';
464
+ const glyph = document.createElement('span');
465
+ glyph.className = 'glyph';
466
+ glyph.textContent = '⟳';
467
+ note.appendChild(glyph);
468
+ note.appendChild(document.createTextNode('Re-checking earlier work'));
469
+ } else {
470
+ note.hidden = true;
471
+ note.textContent = '';
159
472
  }
160
473
  }
161
474
 
162
- function renderProjectStatus() {
163
- elements.projectPathInput.value = state.bootstrap.project.path;
164
- const preview = state.bootstrap.project.path.split(/[\\/]/).filter(Boolean).slice(-2).join('/');
165
- elements.projectPathPreview.textContent = preview || 'Choose a folder';
166
- elements.projectStatus.textContent = state.bootstrap.project.message || 'FRAIM jobs are loading from this project path.';
475
+ // Issue #347 R4 — render the totals line below the textarea.
476
+ function renderTotals(conv) {
477
+ const totals = els['totals'];
478
+ if (!totals) return;
479
+ const data = conv.run && conv.run.totals;
480
+ if (!data) {
481
+ totals.hidden = true;
482
+ return;
483
+ }
484
+ totals.hidden = false;
485
+ const tokens = data.tokenTotals || {};
486
+ const tokenLabel = formatTokens(tokens);
487
+ const costLabel = formatCost(tokens);
488
+ totals.innerHTML = '';
489
+ pushTotalsSpan(totals, formatDuration(data.totalDurationMs), 'total', 'total: from start to now');
490
+ pushTotalsSpan(totals, formatDuration(data.workingDurationMs), 'working', 'working: while the employee was running');
491
+ pushTotalsSpan(totals, formatDuration(data.waitingDurationMs), 'waiting', 'waiting: while waiting for you');
492
+ pushTotalsSpan(totals, tokenLabel, 'tokens', 'tokens: from each phase report; some agents do not yet emit usage data');
493
+ pushTotalsSpan(totals, costLabel, '', "cost: derived from token totals and the agent's published per-million rate");
494
+ }
495
+
496
+ function pushTotalsSpan(host, value, suffix, title) {
497
+ if (host.children.length > 0) {
498
+ const sep = document.createElement('span');
499
+ sep.className = 'sep';
500
+ sep.textContent = '·';
501
+ host.appendChild(sep);
502
+ }
503
+ const span = document.createElement('span');
504
+ span.title = title;
505
+ const strong = document.createElement('strong');
506
+ strong.textContent = value;
507
+ span.appendChild(strong);
508
+ if (suffix) span.appendChild(document.createTextNode(' ' + suffix));
509
+ host.appendChild(span);
510
+ }
511
+
512
+ function formatDuration(ms) {
513
+ if (!Number.isFinite(ms) || ms <= 0) return '0m';
514
+ const minutes = Math.floor(ms / 60000);
515
+ if (minutes < 60) return `${minutes}m`;
516
+ const hours = Math.floor(minutes / 60);
517
+ const remainder = minutes % 60;
518
+ return `${hours}h ${remainder}m`;
519
+ }
520
+
521
+ function formatTokens(tokenTotals) {
522
+ // Tokens are real as soon as the host emits any usage data — render
523
+ // them for both 'partial' (Codex: tokens but no cost) and 'complete'
524
+ // (Claude Code: tokens + cost). Only 'unavailable' renders as —.
525
+ if (!tokenTotals || tokenTotals.coverage === 'unavailable') return '—';
526
+ const total = (tokenTotals.inputTokens || 0) + (tokenTotals.outputTokens || 0);
527
+ if (total <= 0) return '—';
528
+ if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`;
529
+ if (total >= 1_000) return `${(total / 1_000).toFixed(1)}k`;
530
+ return String(total);
167
531
  }
168
532
 
169
- function renderRun(run) {
170
- elements.timeline.innerHTML = '';
171
- const messages = run ? run.messages : [];
533
+ function formatCost(tokenTotals) {
534
+ // Cost is only shown when the host emitted it directly (Claude's
535
+ // total_cost_usd on result events). For Codex we leave it as — until
536
+ // the price-table lookup is wired up.
537
+ if (!tokenTotals || tokenTotals.costUsd == null) return '—';
538
+ if (tokenTotals.costUsd < 0.01) return `$${tokenTotals.costUsd.toFixed(4)}`;
539
+ return `$${tokenTotals.costUsd.toFixed(2)}`;
540
+ }
541
+
542
+ // Issue #347 R2 — template picker (in-textarea). Keeps the welcome-line
543
+ // concept popovers untouched.
544
+ const PICKER_GROUPS = [
545
+ { id: 'delegation', label: 'Delegation — kick off new work', sourceGroupIds: ['delegation'] },
546
+ { id: 'coaching', label: 'Coaching — guide work in flight', sourceGroupIds: ['coaching', '1-1'] },
547
+ { id: 'verification', label: 'Verification — check the work', sourceGroupIds: ['verification'] },
548
+ { id: 'learning', label: 'Learning — synthesize lessons', sourceGroupIds: ['team-learning'] },
549
+ ];
172
550
 
173
- if (messages.length === 0) {
174
- const empty = document.createElement('article');
175
- empty.className = 'message system';
176
- empty.innerHTML = '<div class="message-meta"><span class="message-role">System</span></div><p class="message-text">This conversation will show only meaningful manager and employee messages.</p>';
177
- elements.timeline.appendChild(empty);
551
+ function syncTemplatePickerVisibility() {
552
+ const btn = els['template-picker-btn'];
553
+ if (!btn) return;
554
+ const templates = (state.bootstrap && state.bootstrap.managerTemplates) || [];
555
+ // Only show the button if at least one of our four groups has at
556
+ // least one template — empty groups are omitted from the popover but
557
+ // we should not surface an empty button either.
558
+ const anyTemplate = PICKER_GROUPS.some((group) =>
559
+ templates.some((tpl) => group.sourceGroupIds.includes(tpl.groupId))
560
+ );
561
+ btn.hidden = !anyTemplate;
562
+ }
563
+
564
+ function renderTemplatePopover() {
565
+ const popover = els['template-popover'];
566
+ if (!popover) return;
567
+ popover.innerHTML = '';
568
+ const templates = (state.bootstrap && state.bootstrap.managerTemplates) || [];
569
+ for (const group of PICKER_GROUPS) {
570
+ const rows = templates.filter((tpl) => group.sourceGroupIds.includes(tpl.groupId));
571
+ if (rows.length === 0) continue;
572
+ const wrap = document.createElement('div');
573
+ wrap.className = 'group ' + group.id;
574
+ const label = document.createElement('div');
575
+ label.className = 'group-label';
576
+ const dot = document.createElement('span');
577
+ dot.className = 'group-dot';
578
+ label.appendChild(dot);
579
+ label.appendChild(document.createTextNode(group.label));
580
+ wrap.appendChild(label);
581
+ for (const tpl of rows) {
582
+ const row = document.createElement('button');
583
+ row.type = 'button';
584
+ row.className = 'template-row';
585
+ row.setAttribute('role', 'menuitem');
586
+ row.dataset.templateId = tpl.id;
587
+ const strong = document.createElement('strong');
588
+ strong.textContent = tpl.id;
589
+ const desc = document.createElement('span');
590
+ desc.textContent = tpl.intent || '';
591
+ row.appendChild(strong);
592
+ row.appendChild(desc);
593
+ row.addEventListener('click', (e) => {
594
+ e.preventDefault();
595
+ e.stopPropagation();
596
+ applyTemplateInvocation(tpl.id);
597
+ });
598
+ wrap.appendChild(row);
599
+ }
600
+ popover.appendChild(wrap);
178
601
  }
602
+ }
603
+
604
+ function openTemplatePopover() {
605
+ const popover = els['template-popover'];
606
+ const btn = els['template-picker-btn'];
607
+ if (!popover || !btn) return;
608
+ renderTemplatePopover();
609
+ popover.hidden = false;
610
+ btn.setAttribute('aria-expanded', 'true');
611
+ }
612
+
613
+ function closeTemplatePopover() {
614
+ const popover = els['template-popover'];
615
+ const btn = els['template-picker-btn'];
616
+ if (!popover || !btn) return;
617
+ popover.hidden = true;
618
+ btn.setAttribute('aria-expanded', 'false');
619
+ }
620
+
621
+ function applyTemplateInvocation(managerJobId) {
622
+ const conv = activeConversation();
623
+ // Use the conversation's own employee for the invocation symbol, NOT
624
+ // the manager's last selection in another conversation (R2.5).
625
+ const employeeId = (conv && conv.employeeId) || state.selectedEmployeeId || 'claude';
626
+ const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
627
+ const invocation = `${symbol} ${managerJobId}`;
628
+ const textarea = els['coach-text'];
629
+ const prior = textarea.value;
630
+ // Append (with a single space separator if needed) — never replace.
631
+ let combined;
632
+ if (prior.length === 0) combined = invocation;
633
+ else if (/\s$/.test(prior)) combined = prior + invocation;
634
+ else combined = prior + ' ' + invocation;
635
+ textarea.value = combined;
636
+ // Caret at the end.
637
+ textarea.setSelectionRange(combined.length, combined.length);
638
+ textarea.focus();
639
+ syncSendButton();
640
+ closeTemplatePopover();
641
+ }
642
+
643
+ function derivedStage(conv) {
644
+ if (conv.status === 'running') return { text: 'Working on it…', kind: '' };
645
+ if (conv.status === 'failed') return { text: 'Needs your attention', kind: 'failed' };
646
+ return { text: 'Done — please review', kind: 'done' };
647
+ }
179
648
 
180
- for (const message of messages) {
181
- const fragment = elements.messageTemplate.content.cloneNode(true);
182
- const article = fragment.querySelector('.message');
183
- article.classList.add(message.role);
184
- fragment.querySelector('.message-role').textContent = humanMessageRole(message.role);
185
- fragment.querySelector('.message-time').textContent = formatTimestamp(message.createdAt);
186
- fragment.querySelector('.message-text').textContent = message.text;
187
- elements.timeline.appendChild(fragment);
649
+ function derivedLatest(conv) {
650
+ const messages = conv.messages || [];
651
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
652
+ if (messages[i].role === 'employee') return messages[i].text;
188
653
  }
654
+ if (conv.status === 'running') return 'Loading the work and getting started.';
655
+ return '';
656
+ }
189
657
 
190
- elements.timeline.scrollTop = elements.timeline.scrollHeight;
191
- elements.rawHistory.innerHTML = '';
658
+ function appendMessageDom(role, text) {
659
+ const div = document.createElement('div');
660
+ div.className = 'message ' + role;
661
+ const who = document.createElement('span');
662
+ who.className = 'who';
663
+ who.textContent = role === 'manager' ? 'You' : (role === 'employee' ? 'Employee' : 'System');
664
+ div.appendChild(who);
665
+ div.appendChild(document.createTextNode(text));
666
+ els['messages'].appendChild(div);
667
+ }
192
668
 
193
- if (run) {
194
- for (const event of run.events) {
195
- const row = document.createElement('div');
196
- row.className = 'micro-row';
197
- row.textContent = `[${event.channel}] ${event.text}`;
198
- elements.rawHistory.appendChild(row);
199
- }
669
+ function syncSendButton() {
670
+ const conv = activeConversation();
671
+ const hasText = els['coach-text'].value.trim().length > 0;
672
+ // Send is enabled as soon as the host session exists. We deliberately
673
+ // do NOT gate on status='running' the manager should be able to
674
+ // coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
675
+ // is for; the server only requires sessionId).
676
+ const resumable = !!(conv && conv.sessionId);
677
+ els['send'].disabled = !(hasText && resumable);
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // Concept popovers (welcome line)
682
+ // ---------------------------------------------------------------------------
683
+
684
+ function wirePopovers() {
685
+ const popovers = document.querySelectorAll('.popover');
686
+ const infoButtons = document.querySelectorAll('.info');
687
+ const popLinks = document.querySelectorAll('.pop-link');
688
+
689
+ function closePopovers() {
690
+ popovers.forEach((p) => p.classList.remove('open'));
691
+ document.querySelectorAll('.pop-jobs').forEach((j) => j.classList.remove('open'));
692
+ popLinks.forEach((l) => {
693
+ if (l.dataset.original) l.textContent = l.dataset.original;
694
+ });
200
695
  }
201
696
 
202
- const job = selectedJob();
203
- elements.conversationTitle.textContent = job ? job.title : 'No active job';
204
- if (!run) {
205
- elements.conversationState.textContent = 'Select a job and coach your employee toward your desired outcome.';
206
- } else if (run.status === 'running') {
207
- elements.conversationState.textContent = `${humanEmployeeLabel(run.hostId)} is working in ${run.projectPath}.`;
208
- } else if (run.status === 'completed') {
209
- elements.conversationState.textContent = `${humanEmployeeLabel(run.hostId)} finished the latest turn.`;
697
+ infoButtons.forEach((button) => {
698
+ button.addEventListener('click', (e) => {
699
+ e.stopPropagation();
700
+ const target = document.getElementById('pop-' + button.dataset.concept);
701
+ const wasOpen = target.classList.contains('open');
702
+ closePopovers();
703
+ if (!wasOpen) {
704
+ target.style.left = '0';
705
+ target.style.right = 'auto';
706
+ target.classList.add('open');
707
+ const rect = target.getBoundingClientRect();
708
+ if (rect.right > window.innerWidth - 12) {
709
+ target.style.left = 'auto';
710
+ target.style.right = '0';
711
+ }
712
+ }
713
+ });
714
+ });
715
+
716
+ popLinks.forEach((link) => {
717
+ link.dataset.original = link.textContent;
718
+ link.addEventListener('click', (e) => {
719
+ e.preventDefault();
720
+ e.stopPropagation();
721
+ const target = document.getElementById(link.dataset.target);
722
+ const open = target.classList.toggle('open');
723
+ link.textContent = open ? 'Hide ↑' : link.dataset.original;
724
+ });
725
+ });
726
+
727
+ document.addEventListener('click', (e) => {
728
+ if (!e.target.closest('.popover') && !e.target.closest('.info')) {
729
+ closePopovers();
730
+ }
731
+ });
732
+ document.addEventListener('keydown', (e) => {
733
+ if (e.key === 'Escape') {
734
+ if (els['modal'].classList.contains('open')) {
735
+ closeModal();
736
+ } else {
737
+ closePopovers();
738
+ }
739
+ }
740
+ });
741
+ }
742
+
743
+ // ---------------------------------------------------------------------------
744
+ // New job modal
745
+ // ---------------------------------------------------------------------------
746
+
747
+ // `openModal({ concept })` — when concept is set, the picker is
748
+ // pre-filtered to that concept's slice of the catalog. The modal heading
749
+ // + subhead change to match. Clicking any item still kicks off the same
750
+ // Start flow, regardless of whether it's an employee job or a manager
751
+ // template (both are jobs the host can run via /fraim or $fraim).
752
+ function openModal(opts) {
753
+ const concept = opts && opts.concept;
754
+ const filter = concept ? CONCEPT_PICKER_FILTERS[concept] : null;
755
+ state.selectedJob = null;
756
+ state.activeFilter = filter || null;
757
+ els['next1'].disabled = true;
758
+ els['job-pick-status'].textContent = 'Choose a job to continue';
759
+ els['job-search'].value = '';
760
+ els['instructions'].value = '';
761
+ els['start'].disabled = true;
762
+ els['step1'].hidden = false;
763
+ els['step2'].hidden = true;
764
+ // Update the modal heading + subhead to match the filter context.
765
+ const h = document.querySelector('#step1 .modal-header h2');
766
+ const p = document.querySelector('#step1 .modal-header p');
767
+ if (filter) {
768
+ h.textContent = `Browse ${filter.label} jobs`;
769
+ p.textContent = `Pick one to start. ${filter.kind === 'manager'
770
+ ? 'Manager-side jobs run via the host the same way employee jobs do.'
771
+ : 'Pick whatever the employee should work on next.'}`;
210
772
  } else {
211
- elements.conversationState.textContent = `${humanEmployeeLabel(run.hostId)} needs attention before the next turn.`;
773
+ h.textContent = 'What should the employee work on?';
774
+ p.textContent = 'Pick one job. You can always start a new job for different work.';
212
775
  }
213
-
214
- elements.sendCoaching.disabled = !(run && run.sessionId && elements.managerMessage.value.trim());
776
+ renderEmployeeSelect();
777
+ renderJobCatalog();
778
+ els['modal'].hidden = false;
779
+ els['modal'].classList.add('open');
780
+ setTimeout(() => els['job-search'].focus(), 50);
215
781
  }
216
782
 
217
- function humanEmployeeLabel(employeeId) {
218
- return state.bootstrap.employees.find((employee) => employee.id === employeeId)?.label || employeeId;
783
+ function closeModal() {
784
+ els['modal'].classList.remove('open');
785
+ els['modal'].hidden = true;
786
+ state.activeFilter = null;
219
787
  }
220
788
 
221
- function primeMessage() {
222
- const job = selectedJob();
223
- if (!job) return;
789
+ // Build the unified picker list. Employee jobs are organized by their
790
+ // own categories (Marketing, GTM, Compliance, ...). Manager templates
791
+ // are organized by their group (Coaching, Verification, ...). When a
792
+ // concept filter is active, only the matching slice is shown.
793
+ function renderJobCatalog(searchTerm = '') {
794
+ const f = (searchTerm || '').trim().toLowerCase();
795
+ els['job-catalog'].innerHTML = '';
796
+ const filter = state.activeFilter;
797
+ const employees = state.bootstrap?.jobs || [];
798
+ const managers = state.bootstrap?.managerTemplates || [];
224
799
 
225
- const nextAutoMessage = `Use FRAIM job "${job.id}".`;
226
- const current = elements.managerMessage.value.trim();
227
- if (!current || current === state.autoMessage.trim()) {
228
- elements.managerMessage.value = nextAutoMessage;
229
- state.autoMessage = nextAutoMessage;
800
+ // Decide which sources contribute given the active filter.
801
+ const showEmployee = !filter || filter.kind === 'employee';
802
+ const showManager = !filter || filter.kind === 'manager';
803
+
804
+ // Group employees by category.
805
+ const employeeGroups = [];
806
+ if (showEmployee) {
807
+ const cats = state.bootstrap?.categories || [];
808
+ for (const cat of cats) {
809
+ const rows = employees.filter((j) =>
810
+ j.categoryId === cat.id &&
811
+ (!f || j.title.toLowerCase().includes(f) || (j.intent || '').toLowerCase().includes(f))
812
+ );
813
+ if (rows.length > 0) employeeGroups.push({ label: cat.label, items: rows });
814
+ }
815
+ }
816
+
817
+ // Group managers by groupLabel; respect the filter's groupIds list when set.
818
+ const managerGroups = new Map();
819
+ if (showManager) {
820
+ for (const t of managers) {
821
+ if (filter && filter.groupIds && !filter.groupIds.includes(t.groupId)) continue;
822
+ if (f && !t.title.toLowerCase().includes(f) && !(t.intent || '').toLowerCase().includes(f)) continue;
823
+ if (!managerGroups.has(t.groupLabel)) managerGroups.set(t.groupLabel, []);
824
+ managerGroups.get(t.groupLabel).push(t);
825
+ }
826
+ }
827
+
828
+ let total = 0;
829
+
830
+ function renderGroup(label, items) {
831
+ const wrap = document.createElement('div');
832
+ wrap.className = 'job-category';
833
+ const h4 = document.createElement('h4');
834
+ h4.textContent = label;
835
+ wrap.appendChild(h4);
836
+ for (const job of items) {
837
+ total += 1;
838
+ const btn = document.createElement('button');
839
+ btn.type = 'button';
840
+ btn.className = 'job-option' + (state.selectedJob?.id === job.id ? ' selected' : '');
841
+ btn.dataset.jobId = job.id;
842
+ const strong = document.createElement('strong');
843
+ strong.textContent = job.title;
844
+ const span = document.createElement('span');
845
+ span.textContent = job.intent || '';
846
+ btn.appendChild(strong);
847
+ btn.appendChild(span);
848
+ btn.addEventListener('click', () => {
849
+ state.selectedJob = job;
850
+ els['next1'].disabled = false;
851
+ els['job-pick-status'].textContent = `Selected: ${job.title}`;
852
+ renderJobCatalog(els['job-search'].value);
853
+ });
854
+ wrap.appendChild(btn);
855
+ }
856
+ els['job-catalog'].appendChild(wrap);
857
+ }
858
+
859
+ for (const g of employeeGroups) renderGroup(g.label, g.items);
860
+ for (const [label, items] of managerGroups) renderGroup(label, items);
861
+
862
+ if (total === 0) {
863
+ const empty = document.createElement('p');
864
+ empty.style.color = 'var(--muted)';
865
+ empty.style.margin = '0';
866
+ empty.textContent = f
867
+ ? 'No jobs match that search.'
868
+ : 'No jobs in this slice — try clearing the filter.';
869
+ els['job-catalog'].appendChild(empty);
230
870
  }
231
871
  }
232
872
 
233
- function insertTemplate(jobId) {
234
- const fragment = `Use FRAIM job "${jobId}".`;
235
- const current = elements.managerMessage.value.trim();
236
- const nextValue = current ? `${current}\n${fragment}` : fragment;
237
- elements.managerMessage.value = nextValue;
238
- render();
873
+ function renderEmployeeSelect() {
874
+ els['employee-select'].innerHTML = '';
875
+ const employees = state.bootstrap?.employees || [];
876
+ for (const emp of employees) {
877
+ const opt = document.createElement('option');
878
+ opt.value = emp.id;
879
+ opt.textContent = emp.label + (emp.available ? '' : ' (unavailable)');
880
+ if (!emp.available) opt.disabled = true;
881
+ if (state.selectedEmployeeId === emp.id) opt.selected = true;
882
+ els['employee-select'].appendChild(opt);
883
+ }
239
884
  }
240
885
 
241
- async function loadBootstrap(projectPath) {
242
- const query = projectPath ? `?projectPath=${encodeURIComponent(projectPath)}` : '';
243
- state.bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`);
244
- state.selectedEmployeeId = state.selectedEmployeeId || state.bootstrap.preferences.employeeId;
245
- state.selectedCategoryId = state.selectedCategoryId || state.bootstrap.preferences.categoryId;
886
+ // ---------------------------------------------------------------------------
887
+ // Run lifecycle
888
+ // ---------------------------------------------------------------------------
246
889
 
247
- const availableJobs = filteredJobs();
248
- if (!state.selectedJobId || !availableJobs.some((job) => job.id === state.selectedJobId)) {
249
- const recent = state.bootstrap.preferences.recentJobIds.find((jobId) => availableJobs.some((job) => job.id === jobId));
250
- state.selectedJobId = recent || (availableJobs[0] ? availableJobs[0].id : null);
890
+ // Issue #347 R3: title is built from `{jobTitle}: {first ~6 words of
891
+ // instructions}`. The FRAIM invocation prefix (/fraim or $fraim plus
892
+ // optional jobId) is stripped from instructions before counting words.
893
+ // Truncation budget (45 chars + ellipsis) applies to the *combined*
894
+ // string, never just the instructions slice.
895
+ function deriveTitle(jobTitle, instructions) {
896
+ const trimmedJob = (jobTitle || '').trim();
897
+ const stripped = (instructions || '')
898
+ .trim()
899
+ .replace(/^[/$]fraim(?:\s+[a-z0-9-]+)?\s*/i, '')
900
+ .replace(/\s+/g, ' ')
901
+ .trim();
902
+ const words = stripped.split(' ').filter(Boolean).slice(0, 6);
903
+ const intent = words.join(' ');
904
+ if (!trimmedJob && !intent) return 'New job';
905
+ if (!intent) return trimmedJob;
906
+ // If the job title and the start of the instructions happen to be the
907
+ // same, do not double up.
908
+ if (trimmedJob && stripped.toLowerCase().startsWith(trimmedJob.toLowerCase())) {
909
+ return stripped.length > 48 ? stripped.slice(0, 45) + '…' : stripped;
251
910
  }
911
+ const combined = trimmedJob ? `${trimmedJob}: ${intent}` : intent;
912
+ if (combined.length <= 64) return combined;
913
+ return combined.slice(0, 45) + '…';
914
+ }
915
+
916
+ // FRAIM invocation symbol per agent. The right symbol is determined by
917
+ // what the host CLI actually understands, NOT by which turn it is:
918
+ // - Claude Code uses slash commands → /fraim
919
+ // - Codex uses dollar-prefix skill mentions → $fraim
920
+ // (per developers.openai.com/codex/skills: "type $ to mention a skill")
921
+ // - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
922
+ //
923
+ // Every command typed by the manager is prefixed with the agent's FRAIM
924
+ // symbol so the host always sees that this is a FRAIM job, not a freeform
925
+ // prompt. The first turn includes the jobId; follow-up coaching sends just
926
+ // the bare symbol (the host is already inside the FRAIM session).
927
+ const FRAIM_INVOCATION_SYMBOL = {
928
+ codex: '$fraim',
929
+ claude: '/fraim',
930
+ };
252
931
 
253
- state.activeRunId = state.bootstrap.activeRun ? state.bootstrap.activeRun.id : state.activeRunId;
254
- render();
255
- primeMessage();
256
- render();
932
+ function fraimInvocationFor(employeeId, jobId, kind) {
933
+ const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
934
+ if (kind === 'start') {
935
+ return `${symbol} ${jobId}`;
936
+ }
937
+ return symbol;
257
938
  }
258
939
 
259
- function render() {
260
- if (!state.bootstrap) return;
261
- renderEmployees();
262
- renderCategories();
263
- renderJobList();
264
- renderSelectedJob();
265
- renderTemplates();
266
- renderProjectStatus();
940
+ // Wrap the manager's typed instructions with the host-appropriate FRAIM
941
+ // invocation. The wrapped text is what we ACTUALLY send to the host CLI
942
+ // AND what we show in the timeline so the manager sees what the agent
943
+ // received.
944
+ function buildAgentMessage(employeeId, jobId, kind, instructions) {
945
+ const invocation = fraimInvocationFor(employeeId, jobId, kind);
946
+ const trimmed = (instructions || '').trim();
947
+ if (!trimmed) return invocation;
948
+ return `${invocation}\n\n${trimmed}`;
949
+ }
267
950
 
268
- const run = state.bootstrap.activeRun && state.activeRunId === state.bootstrap.activeRun.id
269
- ? state.bootstrap.activeRun
270
- : null;
271
- renderRun(run);
272
- }
273
-
274
- async function startRun() {
275
- const job = selectedJob();
276
- if (!job) return;
277
-
278
- const payload = await requestJson('/api/ai-hub/runs', {
279
- method: 'POST',
280
- headers: { 'Content-Type': 'application/json' },
281
- body: JSON.stringify({
282
- projectPath: elements.projectPathInput.value,
283
- hostId: state.selectedEmployeeId,
284
- jobId: job.id,
285
- message: elements.managerMessage.value,
286
- }),
287
- });
951
+ async function startRun(job, instructions, employeeId) {
952
+ // Prefix the manager's typed instructions with the FRAIM invocation so
953
+ // the underlying host actually launches the right job. The prefixed
954
+ // text is what the host receives AND what we show in the timeline.
955
+ const agentMessage = buildAgentMessage(employeeId, job.id, 'start', instructions);
956
+ const conv = {
957
+ id: newConversationId(),
958
+ projectPath: state.projectPath,
959
+ title: deriveTitle(job.title, instructions),
960
+ jobId: job.id,
961
+ jobTitle: job.title,
962
+ employeeId,
963
+ runId: null,
964
+ sessionId: null,
965
+ status: 'running',
966
+ messages: [{ role: 'manager', text: agentMessage, at: Date.now() }],
967
+ events: [],
968
+ artifacts: [],
969
+ lastUpdatedAt: Date.now(),
970
+ };
971
+ upsertConversation(conv);
972
+ state.activeId = conv.id;
973
+ persistConversations();
974
+ renderRail();
975
+ renderActive();
288
976
 
289
- state.bootstrap.activeRun = payload;
290
- state.activeRunId = payload.id;
291
- startPolling();
292
- render();
977
+ try {
978
+ const run = await requestJson('/api/ai-hub/runs', {
979
+ method: 'POST',
980
+ headers: { 'Content-Type': 'application/json' },
981
+ body: JSON.stringify({
982
+ projectPath: state.projectPath,
983
+ hostId: employeeId,
984
+ jobId: job.id,
985
+ message: agentMessage,
986
+ }),
987
+ });
988
+ conv.runId = run.id;
989
+ foldRunIntoConversation(conv, run);
990
+ upsertConversation(conv);
991
+ renderRail();
992
+ renderActive();
993
+ startPolling();
994
+ } catch (error) {
995
+ conv.status = 'failed';
996
+ conv.events.push({ channel: 'system', text: error.message });
997
+ upsertConversation(conv);
998
+ renderRail();
999
+ renderActive();
1000
+ showStatus(error.message, true);
1001
+ }
293
1002
  }
294
1003
 
295
- async function sendCoaching() {
296
- if (!state.activeRunId) return;
1004
+ async function continueRun(text) {
1005
+ const conv = activeConversation();
1006
+ if (!conv || !conv.runId) return;
1007
+ // Prefix follow-up coaching with @fraim so the host knows this is a
1008
+ // continuation of the active FRAIM session rather than a fresh prompt.
1009
+ const agentMessage = buildAgentMessage(conv.employeeId, conv.jobId, 'continue', text);
1010
+ conv.status = 'running';
1011
+ conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
1012
+ upsertConversation(conv);
1013
+ renderRail();
1014
+ renderActive();
1015
+ try {
1016
+ const run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
1017
+ method: 'POST',
1018
+ headers: { 'Content-Type': 'application/json' },
1019
+ body: JSON.stringify({ message: agentMessage }),
1020
+ });
1021
+ foldRunIntoConversation(conv, run);
1022
+ upsertConversation(conv);
1023
+ renderRail();
1024
+ renderActive();
1025
+ startPolling();
1026
+ } catch (error) {
1027
+ conv.status = 'failed';
1028
+ conv.events.push({ channel: 'system', text: error.message });
1029
+ upsertConversation(conv);
1030
+ renderRail();
1031
+ renderActive();
1032
+ showStatus(error.message, true);
1033
+ }
1034
+ }
297
1035
 
298
- const payload = await requestJson(`/api/ai-hub/runs/${state.activeRunId}/messages`, {
299
- method: 'POST',
300
- headers: { 'Content-Type': 'application/json' },
301
- body: JSON.stringify({ message: elements.managerMessage.value }),
302
- });
1036
+ function foldRunIntoConversation(conv, run) {
1037
+ // Issue #347: keep the latest server snapshot under conv.run so the
1038
+ // tracker / totals renderers can read it without re-doing work. Stages,
1039
+ // currentPhase, phaseHistory, and totals are all server-derived per
1040
+ // poll — the client is a pure projection.
1041
+ conv.run = {
1042
+ stages: run.stages || [],
1043
+ currentPhase: run.currentPhase || null,
1044
+ phaseHistory: run.phaseHistory || [],
1045
+ totals: run.totals || null,
1046
+ };
1047
+ // Replace the conversation's events with the run's events (single source of truth on the server).
1048
+ conv.events = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
1049
+ // Append any employee messages we don't already have.
1050
+ const existingEmployeeTexts = new Set(conv.messages.filter((m) => m.role === 'employee').map((m) => m.text));
1051
+ for (const m of run.messages || []) {
1052
+ if (m.role === 'employee' && !existingEmployeeTexts.has(m.text)) {
1053
+ conv.messages.push({ role: 'employee', text: m.text, at: Date.now() });
1054
+ existingEmployeeTexts.add(m.text);
1055
+ }
1056
+ }
1057
+ // Track session for resumption.
1058
+ if (run.sessionId) conv.sessionId = run.sessionId;
1059
+ // Update status.
1060
+ if (run.status === 'completed') conv.status = 'completed';
1061
+ else if (run.status === 'failed') conv.status = 'failed';
1062
+ else conv.status = 'running';
1063
+ // Extract artifacts from new events. Conservative: only take file paths under known output dirs.
1064
+ for (const e of conv.events) {
1065
+ const found = extractArtifact(e.text);
1066
+ if (found && !conv.artifacts.some((a) => a.name === found.name && a.where === found.where)) {
1067
+ conv.artifacts.push(found);
1068
+ }
1069
+ }
1070
+ conv.lastUpdatedAt = Date.now();
1071
+ }
303
1072
 
304
- state.bootstrap.activeRun = payload;
305
- startPolling();
306
- render();
1073
+ const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-\.\/]*?(?:docs|public|src|tests)\/[A-Za-z0-9_\-\.\/]+\.[A-Za-z0-9]+)/;
1074
+ // Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
1075
+ // raw learnings, evidence dumps, mock files), not deliverables the
1076
+ // manager should be drawn to. Excluding them keeps the artifact callout
1077
+ // meaningful — it should mean "the employee produced this file for you".
1078
+ const ARTIFACT_EXCLUDE_RE = /(^|\/)(retrospectives|evidence|learnings|mocks|raw|archive)\//i;
1079
+ function extractArtifact(text) {
1080
+ if (!text) return null;
1081
+ const match = text.match(ARTIFACT_PATH_RE);
1082
+ if (!match) return null;
1083
+ const fullPath = match[1];
1084
+ if (ARTIFACT_EXCLUDE_RE.test(fullPath)) return null;
1085
+ const segments = fullPath.split('/');
1086
+ const name = segments[segments.length - 1];
1087
+ const where = segments.slice(0, -1).join('/') + '/';
1088
+ return { name, where };
307
1089
  }
308
1090
 
309
1091
  function startPolling() {
310
1092
  if (state.pollHandle) window.clearInterval(state.pollHandle);
311
1093
  state.pollHandle = window.setInterval(async () => {
312
- if (!state.activeRunId) return;
1094
+ const conv = activeConversation();
1095
+ if (!conv || !conv.runId) return;
1096
+ if (conv.status !== 'running') {
1097
+ window.clearInterval(state.pollHandle);
1098
+ state.pollHandle = null;
1099
+ return;
1100
+ }
313
1101
  try {
314
- const run = await requestJson(`/api/ai-hub/runs/${state.activeRunId}`);
315
- state.bootstrap.activeRun = run;
316
- render();
317
- if (run.status !== 'running') {
1102
+ const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
1103
+ foldRunIntoConversation(conv, run);
1104
+ upsertConversation(conv);
1105
+ renderRail();
1106
+ renderActive();
1107
+ if (conv.status !== 'running') {
318
1108
  window.clearInterval(state.pollHandle);
319
1109
  state.pollHandle = null;
320
1110
  }
321
1111
  } catch (error) {
322
- console.error(error);
1112
+ console.warn('Polling failed:', error);
323
1113
  }
324
1114
  }, 1000);
325
1115
  }
326
1116
 
327
- async function chooseProjectFolder() {
328
- const response = await fetch('/api/ai-hub/project-path/pick', { method: 'POST' });
329
- if (response.status === 204) {
330
- return;
331
- }
332
- const payload = await response.json();
333
- if (!response.ok) {
334
- throw new Error(payload.error || 'Could not choose a project folder.');
335
- }
336
- if (payload.path) {
337
- elements.projectPathInput.value = payload.path;
1117
+ function switchToConversation(id) {
1118
+ state.activeId = id;
1119
+ persistConversations();
1120
+ renderRail();
1121
+ renderActive();
1122
+ const conv = activeConversation();
1123
+ if (conv && conv.status === 'running' && conv.runId) {
1124
+ startPolling();
1125
+ } else if (state.pollHandle) {
1126
+ window.clearInterval(state.pollHandle);
1127
+ state.pollHandle = null;
338
1128
  }
339
1129
  }
340
1130
 
341
- elements.startJob.addEventListener('click', async () => {
342
- try {
343
- await startRun();
344
- } catch (error) {
345
- elements.projectStatus.textContent = error.message;
346
- }
347
- });
1131
+ // ---------------------------------------------------------------------------
1132
+ // Status line + project picker
1133
+ // ---------------------------------------------------------------------------
1134
+
1135
+ function showStatus(text, isError) {
1136
+ els['status-line'].textContent = text || '';
1137
+ els['status-line'].classList.toggle('error', !!isError);
1138
+ }
348
1139
 
349
- elements.sendCoaching.addEventListener('click', async () => {
1140
+ async function pickProject() {
350
1141
  try {
351
- await sendCoaching();
1142
+ const response = await fetch('/api/ai-hub/project-path/pick', { method: 'POST' });
1143
+ if (response.status === 204) return;
1144
+ const payload = await response.json();
1145
+ if (!response.ok) throw new Error(payload.error || 'Could not choose a folder.');
1146
+ if (payload.path) {
1147
+ await loadBootstrap(payload.path);
1148
+ }
352
1149
  } catch (error) {
353
- elements.projectStatus.textContent = error.message;
1150
+ showStatus(error.message, true);
354
1151
  }
355
- });
1152
+ }
356
1153
 
357
- elements.managerMessage.addEventListener('input', () => render());
358
- elements.reloadProject.addEventListener('click', async () => {
359
- try {
360
- await loadBootstrap(elements.projectPathInput.value);
361
- } catch (error) {
362
- elements.projectStatus.textContent = error.message;
1154
+ // ---------------------------------------------------------------------------
1155
+ // Wire up
1156
+ // ---------------------------------------------------------------------------
1157
+
1158
+ function wireEvents() {
1159
+ els['project-button'].addEventListener('click', pickProject);
1160
+ els['new-conv-btn'].addEventListener('click', openModal);
1161
+ els['cancel1'].addEventListener('click', closeModal);
1162
+ els['back2'].addEventListener('click', () => {
1163
+ els['step1'].hidden = false;
1164
+ els['step2'].hidden = true;
1165
+ });
1166
+ els['next1'].addEventListener('click', () => {
1167
+ if (!state.selectedJob) return;
1168
+ els['picked-name'].textContent = state.selectedJob.title;
1169
+ els['picked-desc'].textContent = state.selectedJob.intent || '';
1170
+ els['instructions'].value = '';
1171
+ els['start'].disabled = true;
1172
+ els['step1'].hidden = true;
1173
+ els['step2'].hidden = false;
1174
+ setTimeout(() => els['instructions'].focus(), 50);
1175
+ });
1176
+ els['instructions'].addEventListener('input', () => {
1177
+ els['start'].disabled = els['instructions'].value.trim().length === 0;
1178
+ });
1179
+ els['start'].addEventListener('click', async () => {
1180
+ const job = state.selectedJob;
1181
+ const text = els['instructions'].value.trim();
1182
+ if (!job || !text) return;
1183
+ const employeeId = els['employee-select'].value || state.selectedEmployeeId;
1184
+ state.selectedEmployeeId = employeeId;
1185
+ closeModal();
1186
+ await startRun(job, text, employeeId);
1187
+ });
1188
+ els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
1189
+ els['modal'].addEventListener('click', (e) => {
1190
+ if (e.target === els['modal']) closeModal();
1191
+ });
1192
+
1193
+ els['coach-text'].addEventListener('input', syncSendButton);
1194
+ els['send'].addEventListener('click', async () => {
1195
+ const text = els['coach-text'].value.trim();
1196
+ if (!text) return;
1197
+ els['coach-text'].value = '';
1198
+ syncSendButton();
1199
+ await continueRun(text);
1200
+ });
1201
+
1202
+ // Issue #347 R2 — template picker.
1203
+ if (els['template-picker-btn']) {
1204
+ els['template-picker-btn'].addEventListener('click', (e) => {
1205
+ e.stopPropagation();
1206
+ const popover = els['template-popover'];
1207
+ if (popover && popover.hidden === false) closeTemplatePopover();
1208
+ else openTemplatePopover();
1209
+ });
1210
+ document.addEventListener('click', (e) => {
1211
+ const popover = els['template-popover'];
1212
+ if (!popover || popover.hidden) return;
1213
+ if (!e.target.closest('#template-popover') && !e.target.closest('#template-picker-btn')) {
1214
+ closeTemplatePopover();
1215
+ }
1216
+ });
1217
+ }
1218
+ // Enter sends; Shift+Enter (or Cmd/Ctrl+Enter) inserts a newline.
1219
+ // Standard chat UX. We click the existing button so the Send pipeline
1220
+ // (disabled-state, continueRun, render) stays in one place.
1221
+ function bindEnterToButton(textarea, button) {
1222
+ textarea.addEventListener('keydown', (e) => {
1223
+ const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey;
1224
+ if (!isPlainEnter) return;
1225
+ if (e.isComposing || e.keyCode === 229) return;
1226
+ e.preventDefault();
1227
+ if (!button.disabled) button.click();
1228
+ });
363
1229
  }
364
- });
1230
+ bindEnterToButton(els['coach-text'], els['send']);
1231
+ bindEnterToButton(els['instructions'], els['start']);
1232
+ }
1233
+
1234
+ // ---------------------------------------------------------------------------
1235
+ // Init
1236
+ // ---------------------------------------------------------------------------
365
1237
 
366
- elements.browseProject.addEventListener('click', async () => {
1238
+ (async function init() {
1239
+ gatherElements();
1240
+ loadConversationsFromStorage();
1241
+ wirePopovers();
1242
+ wireEvents();
367
1243
  try {
368
- await chooseProjectFolder();
1244
+ await loadBootstrap();
369
1245
  } catch (error) {
370
- console.error(error);
1246
+ showStatus(error.message, true);
371
1247
  }
372
- });
373
-
374
- loadBootstrap();
1248
+ // If an active conversation belongs to the loaded project and is still running, resume polling.
1249
+ const conv = activeConversation();
1250
+ if (conv && conv.projectPath === state.projectPath) {
1251
+ if (conv.status === 'running' && conv.runId) startPolling();
1252
+ } else {
1253
+ state.activeId = null;
1254
+ persistConversations();
1255
+ renderActive();
1256
+ }
1257
+ })();