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.
- package/dist/src/ai-hub/catalog.js +280 -44
- package/dist/src/ai-hub/desktop-main.js +2 -2
- package/dist/src/ai-hub/hosts.js +384 -10
- package/dist/src/ai-hub/server.js +255 -9
- package/dist/src/cli/commands/add-ide.js +4 -3
- package/dist/src/cli/commands/first-run.js +61 -0
- package/dist/src/cli/commands/hub.js +4 -4
- package/dist/src/cli/commands/init-project.js +4 -4
- package/dist/src/cli/commands/setup.js +4 -3
- package/dist/src/cli/commands/sync.js +21 -2
- package/dist/src/cli/doctor/checks/ide-config-checks.js +20 -2
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +29 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +1 -0
- package/dist/src/cli/setup/auto-mcp-setup.js +14 -8
- package/dist/src/cli/setup/ide-detector.js +32 -1
- package/dist/src/cli/setup/ide-global-integration.js +5 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +14 -0
- package/dist/src/cli/setup/mcp-config-generator.js +12 -1
- package/dist/src/cli/utils/agent-adapters.js +10 -0
- package/dist/src/core/utils/git-utils.js +14 -6
- package/dist/src/first-run/install-state.js +68 -0
- package/dist/src/first-run/server.js +153 -0
- package/dist/src/first-run/session-service.js +302 -0
- package/dist/src/first-run/types.js +40 -0
- package/dist/src/local-mcp-server/otlp-metrics-receiver.js +7 -1
- package/dist/src/local-mcp-server/stdio-server.js +41 -9
- package/package.json +3 -1
- package/public/ai-hub/index.html +149 -102
- package/public/ai-hub/script.js +1154 -271
- package/public/ai-hub/styles.css +753 -450
- package/public/first-run/index.html +221 -0
- package/public/first-run/script.js +361 -0
package/public/ai-hub/script.js
CHANGED
|
@@ -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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
activeRunId: null,
|
|
17
|
+
projectPath: '',
|
|
18
|
+
conversations: {}, // { [projectPath]: ConversationSummary[] }
|
|
19
|
+
activeId: null, // active conversation id (any project)
|
|
7
20
|
pollHandle: null,
|
|
8
|
-
|
|
21
|
+
selectedJob: null, // chosen in modal step 1
|
|
22
|
+
selectedEmployeeId: null,
|
|
9
23
|
};
|
|
10
24
|
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
56
|
+
let payload = null;
|
|
57
|
+
try { payload = await response.json(); } catch { /* may be empty */ }
|
|
37
58
|
if (!response.ok) {
|
|
38
|
-
|
|
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
|
|
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
|
-
|
|
163
|
+
state.activeId = window.localStorage.getItem(STORAGE_KEY_ACTIVE) || null;
|
|
46
164
|
} catch {
|
|
47
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return 'System';
|
|
182
|
+
function projectConversations() {
|
|
183
|
+
const key = state.projectPath || '';
|
|
184
|
+
return state.conversations[key] || [];
|
|
55
185
|
}
|
|
56
186
|
|
|
57
|
-
function
|
|
58
|
-
|
|
187
|
+
function setProjectConversations(list) {
|
|
188
|
+
const key = state.projectPath || '';
|
|
189
|
+
state.conversations[key] = list;
|
|
59
190
|
}
|
|
60
191
|
|
|
61
|
-
function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
97
|
-
|
|
200
|
+
function activeConversation() {
|
|
201
|
+
if (!state.activeId) return null;
|
|
202
|
+
return findConversation(state.activeId);
|
|
98
203
|
}
|
|
99
204
|
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
783
|
+
function closeModal() {
|
|
784
|
+
els['modal'].classList.remove('open');
|
|
785
|
+
els['modal'].hidden = true;
|
|
786
|
+
state.activeFilter = null;
|
|
219
787
|
}
|
|
220
788
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
234
|
-
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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/${
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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.
|
|
1112
|
+
console.warn('Polling failed:', error);
|
|
323
1113
|
}
|
|
324
1114
|
}, 1000);
|
|
325
1115
|
}
|
|
326
1116
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
1140
|
+
async function pickProject() {
|
|
350
1141
|
try {
|
|
351
|
-
await
|
|
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
|
-
|
|
1150
|
+
showStatus(error.message, true);
|
|
354
1151
|
}
|
|
355
|
-
}
|
|
1152
|
+
}
|
|
356
1153
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
1238
|
+
(async function init() {
|
|
1239
|
+
gatherElements();
|
|
1240
|
+
loadConversationsFromStorage();
|
|
1241
|
+
wirePopovers();
|
|
1242
|
+
wireEvents();
|
|
367
1243
|
try {
|
|
368
|
-
await
|
|
1244
|
+
await loadBootstrap();
|
|
369
1245
|
} catch (error) {
|
|
370
|
-
|
|
1246
|
+
showStatus(error.message, true);
|
|
371
1247
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
})();
|