fraim 2.0.154 → 2.0.160
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/README.md +1 -1
- package/dist/src/ai-hub/cert-store.js +70 -0
- package/dist/src/ai-hub/desktop-main.js +225 -50
- package/dist/src/ai-hub/hosts.js +135 -8
- package/dist/src/ai-hub/manager-turns.js +38 -0
- package/dist/src/ai-hub/office-sideload.js +138 -0
- package/dist/src/ai-hub/openclaw-bridge.js +239 -0
- package/dist/src/ai-hub/server.js +479 -48
- package/dist/src/ai-hub/word-sideload.js +95 -0
- package/dist/src/cli/commands/add-ide.js +9 -0
- package/dist/src/cli/commands/init-project.js +46 -34
- package/dist/src/cli/commands/login.js +1 -2
- package/dist/src/cli/commands/setup.js +0 -2
- package/dist/src/cli/commands/sync.js +41 -11
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +66 -2
- package/dist/src/cli/doctor/checks/workflow-checks.js +1 -65
- package/dist/src/cli/mcp/fraim-mcp-latest-launcher.js +136 -0
- package/dist/src/cli/mcp/mcp-server-registry.js +14 -10
- package/dist/src/cli/setup/auto-mcp-setup.js +1 -1
- package/dist/src/cli/setup/ide-invocation-surfaces.js +2 -2
- package/dist/src/cli/utils/fraim-gitignore.js +11 -0
- package/dist/src/cli/utils/github-workflow-sync.js +231 -0
- package/dist/src/cli/utils/managed-agent-paths.js +1 -1
- package/dist/src/cli/utils/project-bootstrap.js +6 -3
- package/dist/src/cli/utils/remote-sync.js +1 -1
- package/dist/src/core/ai-mentor.js +46 -37
- package/dist/src/core/config-loader.js +69 -2
- package/dist/src/core/fraim-config-schema.generated.js +267 -6
- package/dist/src/core/types.js +0 -1
- package/dist/src/core/utils/fraim-labels.js +182 -0
- package/dist/src/core/utils/git-utils.js +22 -1
- package/dist/src/core/utils/project-fraim-paths.js +58 -0
- package/dist/src/first-run/session-service.js +3 -3
- package/dist/src/first-run/types.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +77 -52
- package/dist/src/local-mcp-server/stdio-server.js +212 -13
- package/package.json +6 -2
- package/public/ai-hub/index.html +289 -229
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +235 -0
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +30 -0
- package/public/ai-hub/script.js +1155 -586
- package/public/ai-hub/styles.css +1226 -722
- package/public/first-run/index.html +35 -35
- package/public/first-run/script.js +667 -667
package/public/ai-hub/script.js
CHANGED
|
@@ -21,10 +21,17 @@ const state = {
|
|
|
21
21
|
selectedJob: null, // chosen in modal step 1
|
|
22
22
|
selectedEmployeeId: null,
|
|
23
23
|
selectedPersonaKey: null, // R4: null = "All employees"
|
|
24
|
-
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
25
|
-
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
26
|
-
panelState: {}, // { [convId]: { coach?: boolean } }
|
|
27
|
-
|
|
24
|
+
modalPersonaFilter: null, // R3+: filter inside new-job modal; null = "All jobs"
|
|
25
|
+
storedApiKey: null, // R2: loaded from preferences, sent on bootstrap
|
|
26
|
+
panelState: {}, // { [convId]: { coach?: boolean } }
|
|
27
|
+
wordContext: null, // WordContext pushed from taskpane.html via postMessage
|
|
28
|
+
// Pending coaching job selected via a quick-coach button or template picker.
|
|
29
|
+
// Sent as coachingJobId in the next continueRun call and then cleared.
|
|
30
|
+
// The invocation prefix ($fraim / /fraim) is added server-side based on
|
|
31
|
+
// the active employee; the user never sees the raw invocation syntax.
|
|
32
|
+
pendingCoachingJobId: null,
|
|
33
|
+
pendingCoachingLabel: null,
|
|
34
|
+
};
|
|
28
35
|
|
|
29
36
|
const els = {};
|
|
30
37
|
|
|
@@ -37,13 +44,14 @@ function gatherElements() {
|
|
|
37
44
|
'project-button', 'project-name',
|
|
38
45
|
'new-conv-btn', 'conv-list',
|
|
39
46
|
// Issue #385: team roster
|
|
40
|
-
'team-roster',
|
|
41
|
-
'empty', 'active-conv', 'active-title', 'active-
|
|
42
|
-
'
|
|
43
|
-
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
44
|
-
'status-line', 'coach-note',
|
|
45
|
-
'coach-panel', 'coach-summary',
|
|
46
|
-
'
|
|
47
|
+
'team-roster',
|
|
48
|
+
'empty', 'active-conv', 'active-title', 'active-identity', 'run-state-pill',
|
|
49
|
+
'messages',
|
|
50
|
+
'coach-text', 'send', 'micro-manage', 'micro-log',
|
|
51
|
+
'status-line', 'coach-note',
|
|
52
|
+
'coach-panel', 'coach-summary',
|
|
53
|
+
'thread-panel', 'quick-coach-btns', 'other-manager-jobs-btn',
|
|
54
|
+
'modal', 'step1', 'step2',
|
|
47
55
|
'cancel1', 'next1', 'back2', 'start',
|
|
48
56
|
'job-search', 'job-catalog', 'job-pick-status',
|
|
49
57
|
// Issue #385: hire-required notice, persona job filter
|
|
@@ -56,6 +64,12 @@ function gatherElements() {
|
|
|
56
64
|
'tracker', 'tracker-rows', 'tracker-note',
|
|
57
65
|
'template-picker-btn', 'template-popover',
|
|
58
66
|
'totals',
|
|
67
|
+
// Issue #442: A/B mode elements.
|
|
68
|
+
'ab-toggle-wrap', 'ab-direct-panel',
|
|
69
|
+
'ab-direct-totals', 'ab-direct-progress', 'ab-direct-send',
|
|
70
|
+
// Issue #489: Word context elements.
|
|
71
|
+
'word-context-bar', 'word-ctx-text', 'word-ctx-refresh',
|
|
72
|
+
'word-context-card', 'word-ctx-card-label', 'word-ctx-card-body', 'word-ctx-card-toggle',
|
|
59
73
|
];
|
|
60
74
|
for (const id of ids) {
|
|
61
75
|
els[id] = document.getElementById(id);
|
|
@@ -63,6 +77,14 @@ function gatherElements() {
|
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
async function requestJson(url, options) {
|
|
80
|
+
// On localhost inject x-api-key: local-dev so the dev server auth bypass fires.
|
|
81
|
+
// The server's isLocalDev check already gates this to NODE_ENV !== 'production',
|
|
82
|
+
// so this header is harmless if accidentally sent to a non-dev server.
|
|
83
|
+
const isLocalHost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
|
84
|
+
if (isLocalHost) {
|
|
85
|
+
const headers = { 'x-api-key': 'local-dev', ...((options && options.headers) || {}) };
|
|
86
|
+
options = { ...(options || {}), headers };
|
|
87
|
+
}
|
|
66
88
|
const response = await fetch(url, options);
|
|
67
89
|
let payload = null;
|
|
68
90
|
try { payload = await response.json(); } catch { /* may be empty */ }
|
|
@@ -74,10 +96,13 @@ async function requestJson(url, options) {
|
|
|
74
96
|
return payload;
|
|
75
97
|
}
|
|
76
98
|
|
|
77
|
-
async function loadBootstrap(projectPath) {
|
|
78
|
-
const
|
|
99
|
+
async function loadBootstrap(projectPath, docUrl) {
|
|
100
|
+
const params = new URLSearchParams();
|
|
101
|
+
if (projectPath) params.set('projectPath', projectPath);
|
|
102
|
+
if (docUrl) params.set('docUrl', docUrl);
|
|
103
|
+
const query = params.toString() ? '?' + params.toString() : '';
|
|
79
104
|
const fetchOptions = {};
|
|
80
|
-
if (state.storedApiKey) fetchOptions.headers = { 'x-
|
|
105
|
+
if (state.storedApiKey) fetchOptions.headers = { 'x-api-key': state.storedApiKey };
|
|
81
106
|
const bootstrap = await requestJson(`/api/ai-hub/bootstrap${query}`, fetchOptions);
|
|
82
107
|
state.bootstrap = bootstrap;
|
|
83
108
|
state.projectPath = bootstrap.project.path;
|
|
@@ -90,6 +115,20 @@ async function loadBootstrap(projectPath) {
|
|
|
90
115
|
els['project-name'].textContent = friendlyProjectName(bootstrap.project.path);
|
|
91
116
|
els['status-line'].textContent = bootstrap.project.message || '';
|
|
92
117
|
els['status-line'].classList.toggle('error', !bootstrap.project.exists || !bootstrap.project.hasFraim);
|
|
118
|
+
// Update the compact project label shown in task-pane surface rail.
|
|
119
|
+
const tpLabel = document.getElementById('tp-project-label');
|
|
120
|
+
if (tpLabel) {
|
|
121
|
+
const projectName = friendlyProjectName(bootstrap.project.path);
|
|
122
|
+
const resolvedDocUrl = docUrl || new URLSearchParams(window.location.search).get('docUrl') || '';
|
|
123
|
+
let docName = '';
|
|
124
|
+
if (resolvedDocUrl) {
|
|
125
|
+
try {
|
|
126
|
+
const rawName = resolvedDocUrl.replace(/\\/g, '/').split('/').pop() || '';
|
|
127
|
+
docName = decodeURIComponent(rawName).replace(/\.[^.]+$/, '');
|
|
128
|
+
} catch {}
|
|
129
|
+
}
|
|
130
|
+
tpLabel.textContent = docName ? `${projectName} · ${docName}` : projectName;
|
|
131
|
+
}
|
|
93
132
|
// Populate the welcome-line popovers with REAL jobs from the bootstrap.
|
|
94
133
|
populateConceptPopovers();
|
|
95
134
|
// Render rail and active state for this project.
|
|
@@ -97,6 +136,121 @@ async function loadBootstrap(projectPath) {
|
|
|
97
136
|
renderActive();
|
|
98
137
|
}
|
|
99
138
|
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Word context bridge (postMessage from taskpane.html parent frame)
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
// Apply a full or partial Word context update and re-render dependent UI.
|
|
144
|
+
// Called by the postMessage bridge and usable directly in tests.
|
|
145
|
+
function applyWordContext(ctx, partial) {
|
|
146
|
+
if (partial) {
|
|
147
|
+
if (!state.wordContext) state.wordContext = {};
|
|
148
|
+
Object.assign(state.wordContext, ctx || {});
|
|
149
|
+
} else {
|
|
150
|
+
state.wordContext = ctx || null;
|
|
151
|
+
}
|
|
152
|
+
renderWordContextBar();
|
|
153
|
+
renderWordContextInModal();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setupWordBridge() {
|
|
157
|
+
if (window.parent === window) return; // not inside an iframe — no bridge needed
|
|
158
|
+
window.addEventListener('message', function(event) {
|
|
159
|
+
if (event.source !== window.parent) return;
|
|
160
|
+
const msg = event.data || {};
|
|
161
|
+
if (msg.type === 'word-context') {
|
|
162
|
+
applyWordContext(msg.payload, false);
|
|
163
|
+
} else if (msg.type === 'word-context-update') {
|
|
164
|
+
applyWordContext(msg.payload, true);
|
|
165
|
+
}
|
|
166
|
+
// word-response messages are handled by per-request listeners in requestWordContext()
|
|
167
|
+
});
|
|
168
|
+
// Signal taskpane.html that Hub is ready to receive context.
|
|
169
|
+
window.parent.postMessage({ type: 'hub-ready' }, '*');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Send a word-request to taskpane.html and await the word-response.
|
|
173
|
+
// Resolves null if not in iframe, no response, or timeout (3 s).
|
|
174
|
+
function requestWordContext(action, payload) {
|
|
175
|
+
return new Promise(function(resolve) {
|
|
176
|
+
if (window.parent === window) { resolve(null); return; }
|
|
177
|
+
const requestId = Math.random().toString(36).slice(2);
|
|
178
|
+
const timer = setTimeout(function() { cleanup(); resolve(null); }, 3000);
|
|
179
|
+
function onMsg(event) {
|
|
180
|
+
if (event.source !== window.parent) return;
|
|
181
|
+
const m = event.data || {};
|
|
182
|
+
if (m.type === 'word-response' && m.requestId === requestId) {
|
|
183
|
+
cleanup(); resolve(m.payload || null);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function cleanup() { clearTimeout(timer); window.removeEventListener('message', onMsg); }
|
|
187
|
+
window.addEventListener('message', onMsg);
|
|
188
|
+
window.parent.postMessage({ type: 'word-request', requestId, action, payload: payload || {} }, '*');
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function buildWordContextBlock(wc) {
|
|
193
|
+
if (!wc) return '';
|
|
194
|
+
const parts = [];
|
|
195
|
+
if (wc.docTitle) parts.push(`Document: ${wc.docTitle}`);
|
|
196
|
+
if (wc.selection) {
|
|
197
|
+
const wordCount = wc.selection.split(/\s+/).filter(Boolean).length;
|
|
198
|
+
parts.push(`Selected text (${wordCount} word${wordCount !== 1 ? 's' : ''}):\n"""\n${wc.selection.slice(0, 500)}\n"""`);
|
|
199
|
+
} else if (wc.bodyPreview) {
|
|
200
|
+
parts.push(`Document preview:\n"""\n${wc.bodyPreview.slice(0, 800)}\n"""`);
|
|
201
|
+
}
|
|
202
|
+
if (wc.wordCount) parts.push(`Document length: ~${wc.wordCount} words`);
|
|
203
|
+
if (wc.comments && wc.comments.length > 0) {
|
|
204
|
+
const lines = wc.comments.slice(0, 5).map(c => `- ${c.author || 'Author'}: ${c.text}`).join('\n');
|
|
205
|
+
parts.push(`Document comments (${wc.comments.length}):\n${lines}`);
|
|
206
|
+
}
|
|
207
|
+
if (parts.length === 0) return '';
|
|
208
|
+
return `[Word Document Context]\n${parts.join('\n\n')}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function renderWordContextBar() {
|
|
212
|
+
const bar = els['word-context-bar'];
|
|
213
|
+
const txt = els['word-ctx-text'];
|
|
214
|
+
if (!bar || !txt) return;
|
|
215
|
+
const wc = state.wordContext;
|
|
216
|
+
if (!wc || (!wc.docTitle && !wc.selection && !wc.bodyPreview)) {
|
|
217
|
+
bar.hidden = true;
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
bar.hidden = false;
|
|
221
|
+
if (wc.selection) {
|
|
222
|
+
const words = wc.selection.split(/\s+/).filter(Boolean).length;
|
|
223
|
+
txt.textContent = `${words} word${words !== 1 ? 's' : ''} selected — "${wc.selection.slice(0, 60)}${wc.selection.length > 60 ? '…' : ''}"`;
|
|
224
|
+
} else if (wc.bodyPreview) {
|
|
225
|
+
const words = wc.wordCount || 0;
|
|
226
|
+
txt.textContent = `${wc.docTitle || 'Document'} · ${words} words — full doc context active`;
|
|
227
|
+
} else {
|
|
228
|
+
txt.textContent = wc.docTitle || 'Document context active';
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderWordContextInModal() {
|
|
233
|
+
const card = els['word-context-card'];
|
|
234
|
+
const label = els['word-ctx-card-label'];
|
|
235
|
+
const body = els['word-ctx-card-body'];
|
|
236
|
+
if (!card || !label || !body) return;
|
|
237
|
+
const wc = state.wordContext;
|
|
238
|
+
if (!wc || (!wc.selection && !wc.bodyPreview && !wc.comments?.length)) {
|
|
239
|
+
card.hidden = true;
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
card.hidden = false;
|
|
243
|
+
// Label line: "3 words selected · MyDoc" or "MyDoc · 1,234 words"
|
|
244
|
+
if (wc.selection) {
|
|
245
|
+
const words = wc.selection.split(/\s+/).filter(Boolean).length;
|
|
246
|
+
label.textContent = `${words} word${words !== 1 ? 's' : ''} selected${wc.docTitle ? ' · ' + wc.docTitle : ''}`;
|
|
247
|
+
} else {
|
|
248
|
+
label.textContent = (wc.docTitle || 'Document') + (wc.wordCount ? ` · ${wc.wordCount} words` : '');
|
|
249
|
+
}
|
|
250
|
+
// Body: shows block that will be sent to the agent
|
|
251
|
+
body.textContent = buildWordContextBlock(wc);
|
|
252
|
+
}
|
|
253
|
+
|
|
100
254
|
// ---------------------------------------------------------------------------
|
|
101
255
|
// Concept popover population (drives the welcome-line "see ... jobs" lists)
|
|
102
256
|
// ---------------------------------------------------------------------------
|
|
@@ -226,34 +380,129 @@ function findConversation(id) {
|
|
|
226
380
|
return null;
|
|
227
381
|
}
|
|
228
382
|
|
|
229
|
-
function activeConversation() {
|
|
230
|
-
if (!state.activeId) return null;
|
|
231
|
-
return findConversation(state.activeId);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
function panelStateFor(convId) {
|
|
235
|
-
if (!convId) return {};
|
|
236
|
-
if (!state.panelState[convId]) state.panelState[convId] = {};
|
|
237
|
-
return state.panelState[convId];
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function defaultCoachOpen(conv) {
|
|
241
|
-
return true;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if (!conv
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
}
|
|
251
|
-
if (
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
}
|
|
383
|
+
function activeConversation() {
|
|
384
|
+
if (!state.activeId) return null;
|
|
385
|
+
return findConversation(state.activeId);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function panelStateFor(convId) {
|
|
389
|
+
if (!convId) return {};
|
|
390
|
+
if (!state.panelState[convId]) state.panelState[convId] = {};
|
|
391
|
+
return state.panelState[convId];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function defaultCoachOpen(conv) {
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// R4: resolve employee display name for the coach label.
|
|
399
|
+
function coachEmployeeLabel(conv) {
|
|
400
|
+
if (!conv) return 'Maestro';
|
|
401
|
+
if (conv.personaKey) {
|
|
402
|
+
const persona = (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey);
|
|
403
|
+
if (persona) return persona.displayName;
|
|
404
|
+
}
|
|
405
|
+
if (conv.employeeId) {
|
|
406
|
+
const emp = getEmployeeStatus(conv.employeeId);
|
|
407
|
+
if (emp) return emp.label;
|
|
408
|
+
}
|
|
409
|
+
return 'Maestro';
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// R4: update coach label with employee name + initial badge.
|
|
413
|
+
function syncCoachEmployeeLabel(conv) {
|
|
414
|
+
const label = document.querySelector('.active-employee-label');
|
|
415
|
+
if (!label) return;
|
|
416
|
+
const name = coachEmployeeLabel(conv);
|
|
417
|
+
label.textContent = '';
|
|
418
|
+
// Use persona avatar image when available (matches renderConversationIdentity).
|
|
419
|
+
const persona = conv && conv.personaKey
|
|
420
|
+
? (state.bootstrap && state.bootstrap.personas || []).find((p) => p.key === conv.personaKey)
|
|
421
|
+
: null;
|
|
422
|
+
let badge;
|
|
423
|
+
if (persona && persona.avatarUrl) {
|
|
424
|
+
badge = document.createElement('img');
|
|
425
|
+
badge.className = 'coach-employee-badge coach-employee-avatar';
|
|
426
|
+
badge.src = persona.avatarUrl;
|
|
427
|
+
badge.alt = name;
|
|
428
|
+
} else {
|
|
429
|
+
badge = document.createElement('span');
|
|
430
|
+
badge.className = 'coach-employee-badge';
|
|
431
|
+
badge.textContent = name.slice(0, 1).toUpperCase();
|
|
432
|
+
}
|
|
433
|
+
const nameSpan = document.createElement('span');
|
|
434
|
+
nameSpan.className = 'coach-label-name';
|
|
435
|
+
nameSpan.textContent = name;
|
|
436
|
+
label.appendChild(badge);
|
|
437
|
+
label.appendChild(nameSpan);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// R5: show/hide quick-access coaching buttons based on managerTemplates.
|
|
441
|
+
function syncQuickCoachButtons(conv) {
|
|
442
|
+
const row = els['quick-coach-btns'];
|
|
443
|
+
if (!row) return;
|
|
444
|
+
const hasTemplates = state.bootstrap && state.bootstrap.managerTemplates && state.bootstrap.managerTemplates.length > 0;
|
|
445
|
+
const hasRun = conv && (conv.status === 'running' || conv.status === 'completed');
|
|
446
|
+
row.hidden = !(hasTemplates && hasRun);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// R8: render markdown subset safely. HTML is escaped first.
|
|
450
|
+
function formatEmployeeText(text) {
|
|
451
|
+
if (!text) return '';
|
|
452
|
+
// 1. Escape HTML entities.
|
|
453
|
+
let s = text
|
|
454
|
+
.replace(/&/g, '&')
|
|
455
|
+
.replace(/</g, '<')
|
|
456
|
+
.replace(/>/g, '>')
|
|
457
|
+
.replace(/"/g, '"');
|
|
458
|
+
// 2. Fenced code blocks (triple backtick).
|
|
459
|
+
s = s.replace(/```[\w]*\n?([\s\S]*?)```/g, (_, code) => `<pre><code>${code.trimEnd()}</code></pre>`);
|
|
460
|
+
// 3. Inline code (single backtick, non-greedy, no newlines).
|
|
461
|
+
s = s.replace(/`([^`\n]+)`/g, '<code>$1</code>');
|
|
462
|
+
// 4. Bold (**text**).
|
|
463
|
+
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
464
|
+
// 5–7. Process line by line for lists and paragraphs.
|
|
465
|
+
const lines = s.split('\n');
|
|
466
|
+
const out = [];
|
|
467
|
+
let ulOpen = false;
|
|
468
|
+
let olOpen = false;
|
|
469
|
+
for (const line of lines) {
|
|
470
|
+
const ulMatch = line.match(/^[-*] (.+)/);
|
|
471
|
+
const olMatch = line.match(/^\d+\. (.+)/);
|
|
472
|
+
if (ulMatch) {
|
|
473
|
+
if (olOpen) { out.push('</ol>'); olOpen = false; }
|
|
474
|
+
if (!ulOpen) { out.push('<ul>'); ulOpen = true; }
|
|
475
|
+
out.push(`<li>${ulMatch[1]}</li>`);
|
|
476
|
+
} else if (olMatch) {
|
|
477
|
+
if (ulOpen) { out.push('</ul>'); ulOpen = false; }
|
|
478
|
+
if (!olOpen) { out.push('<ol>'); olOpen = true; }
|
|
479
|
+
out.push(`<li>${olMatch[1]}</li>`);
|
|
480
|
+
} else {
|
|
481
|
+
if (ulOpen) { out.push('</ul>'); ulOpen = false; }
|
|
482
|
+
if (olOpen) { out.push('</ol>'); olOpen = false; }
|
|
483
|
+
if (line.trim()) out.push(`<p>${line}</p>`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (ulOpen) out.push('</ul>');
|
|
487
|
+
if (olOpen) out.push('</ol>');
|
|
488
|
+
return out.join('\n');
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function syncConversationPanels(conv, switchedConv) {
|
|
492
|
+
const coach = els['coach-panel'];
|
|
493
|
+
if (!conv || !coach) return;
|
|
494
|
+
const panelState = panelStateFor(conv.id);
|
|
495
|
+
if (switchedConv) {
|
|
496
|
+
coach.open = panelState.coach ?? defaultCoachOpen(conv);
|
|
497
|
+
if (els['thread-panel']) {
|
|
498
|
+
els['thread-panel'].open = panelState.thread ?? true;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
// R3.3: hide the summary hint when the coach panel is open.
|
|
502
|
+
if (els['coach-summary']) {
|
|
503
|
+
els['coach-summary'].hidden = coach.open;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
257
506
|
|
|
258
507
|
function upsertConversation(conv) {
|
|
259
508
|
const list = projectConversations().slice();
|
|
@@ -327,54 +576,61 @@ function renderRail() {
|
|
|
327
576
|
if (conv.status === 'running') statusSpan.classList.add('running');
|
|
328
577
|
if (conv.status === 'failed') statusSpan.classList.add('failed');
|
|
329
578
|
btn.appendChild(statusSpan);
|
|
579
|
+
// Issue #442: A/B badge on rail entry.
|
|
580
|
+
if (conv.compareMode === 'ab') {
|
|
581
|
+
const badge = document.createElement('span');
|
|
582
|
+
badge.className = 'ab-badge';
|
|
583
|
+
badge.textContent = 'A/B';
|
|
584
|
+
btn.appendChild(badge);
|
|
585
|
+
}
|
|
330
586
|
btn.addEventListener('click', () => switchToConversation(conv.id));
|
|
331
587
|
els['conv-list'].appendChild(btn);
|
|
332
588
|
}
|
|
333
589
|
}
|
|
334
590
|
|
|
335
|
-
function statusLabel(s) {
|
|
336
|
-
if (s === 'running') return 'Running';
|
|
337
|
-
if (s === 'failed') return 'Needs you';
|
|
338
|
-
return 'Done';
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
function personaMap() {
|
|
342
|
-
const map = new Map();
|
|
343
|
-
for (const persona of state.bootstrap?.personas || []) {
|
|
344
|
-
map.set(persona.key, persona);
|
|
345
|
-
}
|
|
346
|
-
return map;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
function getConversationPersona(conv) {
|
|
350
|
-
if (!conv || !conv.personaKey) return null;
|
|
351
|
-
return personaMap().get(conv.personaKey) || null;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function getEmployeeStatus(employeeId) {
|
|
355
|
-
return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function getEmployeeTitle(conv) {
|
|
359
|
-
const persona = getConversationPersona(conv);
|
|
360
|
-
if (persona) return persona.role;
|
|
361
|
-
const employee = getEmployeeStatus(conv?.employeeId);
|
|
362
|
-
return employee ? employee.label : 'AI Employee';
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
function roleLabel(role, conv) {
|
|
366
|
-
if (role === 'manager') return 'Manager';
|
|
367
|
-
if (role === 'employee') {
|
|
368
|
-
const persona = getConversationPersona(conv);
|
|
369
|
-
return persona ? persona.displayName : 'Employee';
|
|
370
|
-
}
|
|
371
|
-
return 'System';
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function initialBadge(text) {
|
|
375
|
-
const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
|
|
376
|
-
return (cleaned.slice(0, 2) || 'FH');
|
|
377
|
-
}
|
|
591
|
+
function statusLabel(s) {
|
|
592
|
+
if (s === 'running') return 'Running';
|
|
593
|
+
if (s === 'failed') return 'Needs you';
|
|
594
|
+
return 'Done';
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function personaMap() {
|
|
598
|
+
const map = new Map();
|
|
599
|
+
for (const persona of state.bootstrap?.personas || []) {
|
|
600
|
+
map.set(persona.key, persona);
|
|
601
|
+
}
|
|
602
|
+
return map;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function getConversationPersona(conv) {
|
|
606
|
+
if (!conv || !conv.personaKey) return null;
|
|
607
|
+
return personaMap().get(conv.personaKey) || null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function getEmployeeStatus(employeeId) {
|
|
611
|
+
return (state.bootstrap?.employees || []).find((employee) => employee.id === employeeId) || null;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function getEmployeeTitle(conv) {
|
|
615
|
+
const persona = getConversationPersona(conv);
|
|
616
|
+
if (persona) return persona.role;
|
|
617
|
+
const employee = getEmployeeStatus(conv?.employeeId);
|
|
618
|
+
return employee ? employee.label : 'AI Employee';
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function roleLabel(role, conv) {
|
|
622
|
+
if (role === 'manager') return 'Manager';
|
|
623
|
+
if (role === 'employee') {
|
|
624
|
+
const persona = getConversationPersona(conv);
|
|
625
|
+
return persona ? persona.displayName : 'Employee';
|
|
626
|
+
}
|
|
627
|
+
return 'System';
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function initialBadge(text) {
|
|
631
|
+
const cleaned = String(text || '').replace(/[^A-Za-z]/g, '').toUpperCase();
|
|
632
|
+
return (cleaned.slice(0, 2) || 'FH');
|
|
633
|
+
}
|
|
378
634
|
|
|
379
635
|
// ---------------------------------------------------------------------------
|
|
380
636
|
// Issue #385 — Persona UI (R3 + R4)
|
|
@@ -393,62 +649,62 @@ function conversationTitle(conv) {
|
|
|
393
649
|
|
|
394
650
|
// R4.2 — team roster: one avatar chip per hired persona above the conv list.
|
|
395
651
|
// Only rendered when at least one persona is hired (subscription active).
|
|
396
|
-
function renderTeamRoster() {
|
|
397
|
-
const roster = els['team-roster'];
|
|
398
|
-
if (!roster) return;
|
|
399
|
-
const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
|
|
400
|
-
if (personas.length === 0) {
|
|
652
|
+
function renderTeamRoster() {
|
|
653
|
+
const roster = els['team-roster'];
|
|
654
|
+
if (!roster) return;
|
|
655
|
+
const personas = (state.bootstrap?.personas || []).filter((p) => p.status === 'hired');
|
|
656
|
+
if (personas.length === 0) {
|
|
401
657
|
roster.hidden = true;
|
|
402
658
|
// Reset persona selection when there's no active subscription.
|
|
403
659
|
if (state.selectedPersonaKey) state.selectedPersonaKey = null;
|
|
404
660
|
return;
|
|
405
|
-
}
|
|
406
|
-
roster.hidden = false;
|
|
407
|
-
roster.innerHTML = '';
|
|
408
|
-
const allChip = document.createElement('button');
|
|
409
|
-
allChip.type = 'button';
|
|
410
|
-
allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
|
|
411
|
-
allChip.title = 'All employees';
|
|
412
|
-
allChip.setAttribute('aria-label', 'All employees');
|
|
413
|
-
allChip.innerHTML = `
|
|
414
|
-
<span class="roster-avatar">All</span>
|
|
415
|
-
<span class="roster-copy">
|
|
416
|
-
<strong>All employees</strong>
|
|
417
|
-
<small>Show every hired employee</small>
|
|
418
|
-
</span>
|
|
419
|
-
`;
|
|
420
|
-
allChip.addEventListener('click', () => setSelectedPersona(null));
|
|
421
|
-
roster.appendChild(allChip);
|
|
422
|
-
for (const persona of personas) {
|
|
423
|
-
const chip = document.createElement('button');
|
|
424
|
-
chip.type = 'button';
|
|
425
|
-
chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
|
|
426
|
-
chip.title = persona.displayName;
|
|
427
|
-
chip.setAttribute('aria-label', persona.displayName);
|
|
428
|
-
const avatar = document.createElement('span');
|
|
429
|
-
avatar.className = 'roster-avatar';
|
|
430
|
-
if (persona.avatarUrl) {
|
|
431
|
-
const img = document.createElement('img');
|
|
432
|
-
img.src = persona.avatarUrl;
|
|
433
|
-
img.alt = persona.displayName;
|
|
434
|
-
avatar.appendChild(img);
|
|
435
|
-
} else {
|
|
436
|
-
avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
|
|
437
|
-
}
|
|
438
|
-
const copy = document.createElement('span');
|
|
439
|
-
copy.className = 'roster-copy';
|
|
440
|
-
const name = document.createElement('strong');
|
|
441
|
-
name.textContent = persona.displayName;
|
|
442
|
-
const role = document.createElement('small');
|
|
443
|
-
role.textContent = persona.role || 'AI Employee';
|
|
444
|
-
copy.appendChild(name);
|
|
445
|
-
copy.appendChild(role);
|
|
446
|
-
chip.appendChild(avatar);
|
|
447
|
-
chip.appendChild(copy);
|
|
448
|
-
chip.addEventListener('click', () => setSelectedPersona(persona.key));
|
|
449
|
-
roster.appendChild(chip);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
661
|
+
}
|
|
662
|
+
roster.hidden = false;
|
|
663
|
+
roster.innerHTML = '';
|
|
664
|
+
const allChip = document.createElement('button');
|
|
665
|
+
allChip.type = 'button';
|
|
666
|
+
allChip.className = 'roster-chip roster-chip--all' + (!state.selectedPersonaKey ? ' active' : '');
|
|
667
|
+
allChip.title = 'All employees';
|
|
668
|
+
allChip.setAttribute('aria-label', 'All employees');
|
|
669
|
+
allChip.innerHTML = `
|
|
670
|
+
<span class="roster-avatar">All</span>
|
|
671
|
+
<span class="roster-copy">
|
|
672
|
+
<strong>All employees</strong>
|
|
673
|
+
<small>Show every hired employee</small>
|
|
674
|
+
</span>
|
|
675
|
+
`;
|
|
676
|
+
allChip.addEventListener('click', () => setSelectedPersona(null));
|
|
677
|
+
roster.appendChild(allChip);
|
|
678
|
+
for (const persona of personas) {
|
|
679
|
+
const chip = document.createElement('button');
|
|
680
|
+
chip.type = 'button';
|
|
681
|
+
chip.className = 'roster-chip' + (state.selectedPersonaKey === persona.key ? ' active' : '');
|
|
682
|
+
chip.title = persona.displayName;
|
|
683
|
+
chip.setAttribute('aria-label', persona.displayName);
|
|
684
|
+
const avatar = document.createElement('span');
|
|
685
|
+
avatar.className = 'roster-avatar';
|
|
686
|
+
if (persona.avatarUrl) {
|
|
687
|
+
const img = document.createElement('img');
|
|
688
|
+
img.src = persona.avatarUrl;
|
|
689
|
+
img.alt = persona.displayName;
|
|
690
|
+
avatar.appendChild(img);
|
|
691
|
+
} else {
|
|
692
|
+
avatar.textContent = persona.displayName.slice(0, 2).toUpperCase();
|
|
693
|
+
}
|
|
694
|
+
const copy = document.createElement('span');
|
|
695
|
+
copy.className = 'roster-copy';
|
|
696
|
+
const name = document.createElement('strong');
|
|
697
|
+
name.textContent = persona.displayName;
|
|
698
|
+
const role = document.createElement('small');
|
|
699
|
+
role.textContent = persona.role || 'AI Employee';
|
|
700
|
+
copy.appendChild(name);
|
|
701
|
+
copy.appendChild(role);
|
|
702
|
+
chip.appendChild(avatar);
|
|
703
|
+
chip.appendChild(copy);
|
|
704
|
+
chip.addEventListener('click', () => setSelectedPersona(persona.key));
|
|
705
|
+
roster.appendChild(chip);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
452
708
|
|
|
453
709
|
// R4.3 — employee selector: compact dropdown above conv list (shown when ≥1 hired persona).
|
|
454
710
|
function buildAvatarChip(persona, size) {
|
|
@@ -566,101 +822,111 @@ function renderModalPersonaFilter() {
|
|
|
566
822
|
// event rows are already in the DOM. Polling fires every second; without
|
|
567
823
|
// this, every tick wiped the messages list and re-played the slidein
|
|
568
824
|
// animation (= the screen flash the user reported as 'distracting').
|
|
569
|
-
let renderedConvId = null;
|
|
570
|
-
let renderedMessageCount = 0;
|
|
571
|
-
let renderedEventCount = 0;
|
|
572
|
-
let
|
|
573
|
-
let
|
|
574
|
-
|
|
575
|
-
function renderActive() {
|
|
576
|
-
const conv = activeConversation();
|
|
577
|
-
if (!conv) {
|
|
578
|
-
els['empty'].hidden = false;
|
|
579
|
-
els['active-conv'].hidden = true;
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
els['
|
|
591
|
-
|
|
592
|
-
els['active-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const stage = derivedStage(conv);
|
|
600
|
-
els['stage'].textContent = stage.text;
|
|
601
|
-
els['progress'].classList.remove('done', 'attention', 'failed');
|
|
602
|
-
if (stage.kind) els['progress'].classList.add(stage.kind);
|
|
603
|
-
els['latest'].textContent = derivedLatest(conv);
|
|
825
|
+
let renderedConvId = null;
|
|
826
|
+
let renderedMessageCount = 0;
|
|
827
|
+
let renderedEventCount = 0;
|
|
828
|
+
let renderedStatus = null;
|
|
829
|
+
let renderedDirectEventCount = 0;
|
|
830
|
+
|
|
831
|
+
function renderActive() {
|
|
832
|
+
const conv = activeConversation();
|
|
833
|
+
if (!conv) {
|
|
834
|
+
els['empty'].hidden = false;
|
|
835
|
+
els['active-conv'].hidden = true;
|
|
836
|
+
const _c = document.getElementById('conversation');
|
|
837
|
+
if (_c) _c.classList.remove('ab-mode');
|
|
838
|
+
if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
|
|
839
|
+
renderedConvId = null;
|
|
840
|
+
renderedMessageCount = 0;
|
|
841
|
+
renderedEventCount = 0;
|
|
842
|
+
renderedStatus = null;
|
|
843
|
+
renderedDirectEventCount = 0;
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
els['empty'].hidden = true;
|
|
847
|
+
els['active-conv'].hidden = false;
|
|
848
|
+
els['active-title'].textContent = conversationTitle(conv);
|
|
849
|
+
renderConversationIdentity(conv);
|
|
850
|
+
renderRunStatePill(conv);
|
|
851
|
+
syncCoachEmployeeLabel(conv);
|
|
852
|
+
els['coach-note'].textContent = conv.status === 'running'
|
|
853
|
+
? 'The employee is still working. Add coaching here to tighten the next step without losing context.'
|
|
854
|
+
: 'The employee is waiting on you. Send the next instruction to continue this run.';
|
|
604
855
|
|
|
605
856
|
// If we switched conversations (or this is the first render), wipe and
|
|
606
857
|
// start fresh. Otherwise we're going to do an incremental update below.
|
|
607
|
-
const switchedConv = renderedConvId !== conv.id;
|
|
608
|
-
if (switchedConv) {
|
|
609
|
-
els['artifact-slot'].innerHTML = '';
|
|
858
|
+
const switchedConv = renderedConvId !== conv.id;
|
|
859
|
+
if (switchedConv) {
|
|
610
860
|
els['messages'].innerHTML = '';
|
|
861
|
+
els['messages'].className = 'messages';
|
|
611
862
|
els['micro-log'].textContent = '';
|
|
612
863
|
renderedConvId = conv.id;
|
|
613
864
|
renderedMessageCount = 0;
|
|
614
|
-
renderedEventCount = 0;
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
// Artifact callout — only re-render when the latest artifact actually
|
|
621
|
-
// changed. Avoids the 'pulse' animation re-firing on every poll tick.
|
|
622
|
-
const latestArtifact = conv.artifacts && conv.artifacts.length > 0
|
|
623
|
-
? conv.artifacts[conv.artifacts.length - 1]
|
|
624
|
-
: null;
|
|
625
|
-
const artifactKey = latestArtifact ? `${latestArtifact.where}${latestArtifact.name}` : null;
|
|
626
|
-
if (artifactKey !== renderedArtifactKey) {
|
|
627
|
-
els['artifact-slot'].innerHTML = '';
|
|
628
|
-
if (latestArtifact) {
|
|
629
|
-
const span = document.createElement('span');
|
|
630
|
-
span.className = 'artifact';
|
|
631
|
-
span.title = `${latestArtifact.where}${latestArtifact.name}`;
|
|
632
|
-
span.innerHTML = `
|
|
633
|
-
<span class="artifact-dot" aria-hidden="true"></span>
|
|
634
|
-
<span class="artifact-label">file</span>
|
|
635
|
-
<span class="artifact-name"></span>`;
|
|
636
|
-
span.querySelector('.artifact-name').textContent = latestArtifact.name;
|
|
637
|
-
els['artifact-slot'].appendChild(span);
|
|
638
|
-
}
|
|
639
|
-
renderedArtifactKey = artifactKey;
|
|
865
|
+
renderedEventCount = 0;
|
|
866
|
+
renderedDirectEventCount = 0;
|
|
867
|
+
// A pending coaching job is scoped to the active conversation — discard it
|
|
868
|
+
// whenever the user switches to a different conversation.
|
|
869
|
+
clearPendingCoachingJob();
|
|
640
870
|
}
|
|
871
|
+
const statusChanged = renderedStatus !== conv.status;
|
|
872
|
+
syncConversationPanels(conv, switchedConv);
|
|
873
|
+
syncQuickCoachButtons(conv);
|
|
641
874
|
|
|
642
875
|
// Messages — append only the rows that aren't already in the DOM, so
|
|
643
876
|
// existing rows don't re-animate. If for some reason the data shrunk
|
|
644
877
|
// (server revoked a message), fall back to a full re-render.
|
|
645
878
|
const messages = conv.messages || [];
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
879
|
+
const isABConv = conv.compareMode === 'ab';
|
|
880
|
+
|
|
881
|
+
// Issue #442: A/B split — full panel. #conversation becomes a flex row;
|
|
882
|
+
// FRAIM side is the unchanged #active-conv; Direct side is #ab-direct-panel.
|
|
883
|
+
const _container = document.getElementById('conversation');
|
|
884
|
+
if (isABConv) {
|
|
885
|
+
if (_container) _container.classList.add('ab-mode');
|
|
886
|
+
if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = false;
|
|
887
|
+
if (switchedConv) {
|
|
888
|
+
const dl = document.getElementById('ab-direct-log');
|
|
889
|
+
if (dl) dl.textContent = '';
|
|
890
|
+
renderedDirectEventCount = 0;
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
if (_container) _container.classList.remove('ab-mode');
|
|
894
|
+
if (els['ab-direct-panel']) els['ab-direct-panel'].hidden = true;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// FRAIM messages — render into #messages via appendMessageDom (same for AB and non-AB).
|
|
898
|
+
if (messages.length < renderedMessageCount) {
|
|
899
|
+
els['messages'].innerHTML = '';
|
|
900
|
+
renderedMessageCount = 0;
|
|
901
|
+
}
|
|
902
|
+
for (let i = renderedMessageCount; i < messages.length; i += 1) {
|
|
903
|
+
appendMessageDom(messages[i].role, messages[i].text, conv);
|
|
904
|
+
}
|
|
905
|
+
const appendedMessages = messages.length - renderedMessageCount;
|
|
906
|
+
renderedMessageCount = messages.length;
|
|
907
|
+
const m = els['messages'];
|
|
908
|
+
if (conv.status === 'running' && m.scrollHeight - m.scrollTop - m.clientHeight < 80) {
|
|
909
|
+
m.scrollTop = m.scrollHeight;
|
|
910
|
+
} else if (switchedConv || statusChanged || appendedMessages > 0) {
|
|
911
|
+
scrollThreadForReview(conv);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Direct raw event log — append-only like the FRAIM micro-log, shows all
|
|
915
|
+
// stdout/stderr/system events from the B run so the user sees everything.
|
|
916
|
+
if (isABConv) {
|
|
917
|
+
const directEvents = (conv.compareRun && conv.compareRun.events) || [];
|
|
918
|
+
const directLog = document.getElementById('ab-direct-log');
|
|
919
|
+
if (directLog) {
|
|
920
|
+
if (directEvents.length < renderedDirectEventCount) { directLog.textContent = ''; renderedDirectEventCount = 0; }
|
|
921
|
+
for (let i = renderedDirectEventCount; i < directEvents.length; i += 1) {
|
|
922
|
+
const line = `[${directEvents[i].channel || 'system'}] ${directEvents[i].text}\n`;
|
|
923
|
+
directLog.appendChild(document.createTextNode(line));
|
|
924
|
+
}
|
|
925
|
+
renderedDirectEventCount = directEvents.length;
|
|
926
|
+
const ds = (conv.compareRun && conv.compareRun.status) || 'running';
|
|
927
|
+
if (ds === 'running') directLog.scrollTop = directLog.scrollHeight;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
664
930
|
|
|
665
931
|
// Micro-manage — only append new events. textContent assignment on the
|
|
666
932
|
// <pre> wipes the entire log every tick which is wasteful.
|
|
@@ -686,64 +952,104 @@ function renderActive() {
|
|
|
686
952
|
// foldRunIntoConversation); for runs that have not yet polled we
|
|
687
953
|
// simply hide the surfaces.
|
|
688
954
|
renderTracker(conv);
|
|
689
|
-
renderTotals(conv);
|
|
690
|
-
syncTemplatePickerVisibility();
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
955
|
+
renderTotals(conv);
|
|
956
|
+
syncTemplatePickerVisibility();
|
|
957
|
+
// Issue #442: Direct panel status pill, simple progress indicator, and totals.
|
|
958
|
+
if (isABConv) {
|
|
959
|
+
// Mirror the job title from A into the B topline.
|
|
960
|
+
const directTitleEl = document.querySelector('#ab-direct-panel .conv-job-title');
|
|
961
|
+
if (directTitleEl) directTitleEl.textContent = conv.title || '';
|
|
962
|
+
|
|
963
|
+
const ds = (conv.compareRun && conv.compareRun.status) || 'running';
|
|
964
|
+
const dpill = document.getElementById('ab-direct-pill');
|
|
965
|
+
if (dpill) {
|
|
966
|
+
dpill.textContent = statusLabel(ds).toUpperCase();
|
|
967
|
+
dpill.className = `run-state-pill ${ds}`;
|
|
968
|
+
}
|
|
969
|
+
// Simple Running→Done progress row (mirrors FRAIM pizza tracker position).
|
|
970
|
+
const dprogress = els['ab-direct-progress'];
|
|
971
|
+
if (dprogress) {
|
|
972
|
+
if (ds === 'running') { dprogress.textContent = '● Running'; dprogress.className = 'ab-direct-progress running'; }
|
|
973
|
+
else if (ds === 'completed') { dprogress.textContent = '✓ Done'; dprogress.className = 'ab-direct-progress done'; }
|
|
974
|
+
else if (ds === 'failed') { dprogress.textContent = '✗ Failed'; dprogress.className = 'ab-direct-progress failed'; }
|
|
975
|
+
else { dprogress.textContent = statusLabel(ds); dprogress.className = 'ab-direct-progress'; }
|
|
976
|
+
}
|
|
977
|
+
// Direct totals row — same format as FRAIM's totals row.
|
|
978
|
+
renderDirectTotals(conv);
|
|
979
|
+
// Direct send button — enabled only when idle and session is resumable.
|
|
980
|
+
const dsend = els['ab-direct-send'];
|
|
981
|
+
if (dsend) {
|
|
982
|
+
const dinput = document.getElementById('ab-direct-input');
|
|
983
|
+
const hasSession = !!(conv.compareRun && conv.compareRun.sessionId);
|
|
984
|
+
const notRunning = ds !== 'running';
|
|
985
|
+
dsend.disabled = !hasSession || !notRunning || !(dinput && dinput.value.trim());
|
|
986
|
+
}
|
|
987
|
+
// Align B's header rows to A's so the thread panels start at the same Y.
|
|
988
|
+
requestAnimationFrame(() => {
|
|
989
|
+
const aTopline = document.querySelector('#active-conv > .conv-topline');
|
|
990
|
+
const aStatus = document.querySelector('#active-conv > .conversation-status');
|
|
991
|
+
const bTopline = document.querySelector('#ab-direct-panel > .conv-topline');
|
|
992
|
+
const bStatus = document.querySelector('#ab-direct-panel > .conversation-status');
|
|
993
|
+
if (aTopline && bTopline) bTopline.style.minHeight = aTopline.offsetHeight + 'px';
|
|
994
|
+
if (aStatus && bStatus) bStatus.style.minHeight = aStatus.offsetHeight + 'px';
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
// Browser-tab title mirrors the active conversation (R3).
|
|
998
|
+
document.title = conv.title ? conv.title : 'AI Hub';
|
|
999
|
+
renderedStatus = conv.status;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function renderConversationIdentity(conv) {
|
|
1003
|
+
const host = els['active-identity'];
|
|
1004
|
+
if (!host) return;
|
|
1005
|
+
const persona = getConversationPersona(conv);
|
|
1006
|
+
const employee = getEmployeeStatus(conv.employeeId);
|
|
1007
|
+
host.innerHTML = '';
|
|
1008
|
+
|
|
1009
|
+
const avatar = document.createElement(persona && persona.avatarUrl ? 'img' : 'span');
|
|
1010
|
+
avatar.className = 'identity-avatar';
|
|
1011
|
+
if (persona && persona.avatarUrl) {
|
|
1012
|
+
avatar.src = persona.avatarUrl;
|
|
1013
|
+
avatar.alt = persona.displayName;
|
|
1014
|
+
} else {
|
|
1015
|
+
avatar.textContent = initialBadge(employee?.label || 'Hub');
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const text = document.createElement('span');
|
|
1019
|
+
text.className = 'identity-copy';
|
|
1020
|
+
|
|
1021
|
+
const name = document.createElement('strong');
|
|
1022
|
+
name.textContent = persona ? persona.displayName : (employee ? employee.label : 'AI Employee');
|
|
1023
|
+
|
|
1024
|
+
const title = document.createElement('small');
|
|
1025
|
+
title.textContent = getEmployeeTitle(conv);
|
|
1026
|
+
|
|
1027
|
+
text.appendChild(name);
|
|
1028
|
+
text.appendChild(title);
|
|
1029
|
+
host.appendChild(avatar);
|
|
1030
|
+
host.appendChild(text);
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function renderRunStatePill(conv) {
|
|
1034
|
+
const pill = els['run-state-pill'];
|
|
1035
|
+
if (!pill) return;
|
|
1036
|
+
pill.textContent = statusLabel(conv.status).toUpperCase();
|
|
1037
|
+
pill.className = `run-state-pill ${conv.status}`;
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
function buildConversationSummary(conv) {
|
|
1041
|
+
const employeeReply = latestEmployeeSurfaceText(conv);
|
|
1042
|
+
if (employeeReply) return clampSummaryText(employeeReply);
|
|
1043
|
+
if (conv.status === 'running') return 'The employee is working through your request.';
|
|
1044
|
+
if (conv.status === 'failed') return 'This run needs your attention before it can continue.';
|
|
1045
|
+
return 'The latest work is ready for review.';
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
function clampSummaryText(text, maxChars = 260) {
|
|
1049
|
+
const raw = String(text || '').replace(/\s+/g, ' ').trim();
|
|
1050
|
+
if (raw.length <= maxChars) return raw;
|
|
1051
|
+
return raw.slice(0, maxChars - 1).trimEnd() + '…';
|
|
1052
|
+
}
|
|
747
1053
|
|
|
748
1054
|
// Render the inline employee selector shown in the coach section of an
|
|
749
1055
|
// active conversation. Allows switching agents without reopening the modal.
|
|
@@ -905,7 +1211,7 @@ function syncTrackerNote(conv) {
|
|
|
905
1211
|
}
|
|
906
1212
|
}
|
|
907
1213
|
|
|
908
|
-
// Issue #347 R4 — render the totals line
|
|
1214
|
+
// Issue #347 R4 — render the totals line inside the tracker section.
|
|
909
1215
|
function renderTotals(conv) {
|
|
910
1216
|
const totals = els['totals'];
|
|
911
1217
|
if (!totals) return;
|
|
@@ -914,18 +1220,44 @@ function renderTotals(conv) {
|
|
|
914
1220
|
totals.hidden = true;
|
|
915
1221
|
return;
|
|
916
1222
|
}
|
|
1223
|
+
// Totals now live inside the tracker section; only render when visible.
|
|
1224
|
+
const tracker = els['tracker'];
|
|
1225
|
+
if (tracker && tracker.hidden) {
|
|
1226
|
+
totals.hidden = true;
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
917
1229
|
totals.hidden = false;
|
|
918
1230
|
const tokens = data.tokenTotals || {};
|
|
919
1231
|
const tokenLabel = formatTokens(tokens);
|
|
920
1232
|
const costLabel = formatCost(tokens);
|
|
921
1233
|
totals.innerHTML = '';
|
|
922
|
-
|
|
923
|
-
pushTotalsSpan(totals,
|
|
924
|
-
pushTotalsSpan(totals,
|
|
1234
|
+
const fmtDur = (ms) => ms >= 60000 ? formatDuration(ms) : ms > 0 ? `${Math.round(ms / 1000)}s` : '—';
|
|
1235
|
+
pushTotalsSpan(totals, fmtDur(data.totalDurationMs), 'total', 'total: from start to now');
|
|
1236
|
+
pushTotalsSpan(totals, fmtDur(data.workingDurationMs), 'working', 'working: while the employee was running');
|
|
1237
|
+
pushTotalsSpan(totals, fmtDur(data.waitingDurationMs), 'waiting', 'waiting: while waiting for you');
|
|
925
1238
|
pushTotalsSpan(totals, tokenLabel, 'tokens', 'tokens: from each phase report; some agents do not yet emit usage data');
|
|
926
1239
|
pushTotalsSpan(totals, costLabel, '', "cost: derived from token totals and the agent's published per-million rate");
|
|
927
1240
|
}
|
|
928
1241
|
|
|
1242
|
+
// Issue #442: render Direct totals row using the same pushTotalsSpan helper as FRAIM.
|
|
1243
|
+
function renderDirectTotals(conv) {
|
|
1244
|
+
const dtotals = els['ab-direct-totals'];
|
|
1245
|
+
if (!dtotals) return;
|
|
1246
|
+
const data = conv.compareRun && conv.compareRun.totals;
|
|
1247
|
+
if (!data) { dtotals.hidden = true; return; }
|
|
1248
|
+
dtotals.hidden = false;
|
|
1249
|
+
dtotals.innerHTML = '';
|
|
1250
|
+
const tokens = data.tokenTotals || {};
|
|
1251
|
+
const durMs = data.totalDurationMs || 0;
|
|
1252
|
+
const durLabel = durMs >= 60000 ? formatDuration(durMs) : durMs > 0 ? `${Math.round(durMs / 1000)}s` : '—';
|
|
1253
|
+
pushTotalsSpan(dtotals, durLabel, 'total', 'total: from start to now');
|
|
1254
|
+
pushTotalsSpan(dtotals, formatTokens(tokens), 'tokens', 'tokens from Direct run');
|
|
1255
|
+
pushTotalsSpan(dtotals, formatCost(tokens), '', 'cost of Direct run');
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
|
|
929
1261
|
function pushTotalsSpan(host, value, suffix, title) {
|
|
930
1262
|
if (host.children.length > 0) {
|
|
931
1263
|
const sep = document.createElement('span');
|
|
@@ -1036,42 +1368,44 @@ function renderTemplatePopover() {
|
|
|
1036
1368
|
|
|
1037
1369
|
function openTemplatePopover() {
|
|
1038
1370
|
const popover = els['template-popover'];
|
|
1039
|
-
|
|
1040
|
-
if (!popover || !btn) return;
|
|
1371
|
+
if (!popover) return;
|
|
1041
1372
|
renderTemplatePopover();
|
|
1373
|
+
// Anchor fixed popover above the trigger button, right-aligned to its right edge.
|
|
1374
|
+
const trigger = els['other-manager-jobs-btn'];
|
|
1375
|
+
if (trigger) {
|
|
1376
|
+
const rect = trigger.getBoundingClientRect();
|
|
1377
|
+
popover.style.right = Math.max(8, window.innerWidth - rect.right) + 'px';
|
|
1378
|
+
popover.style.bottom = Math.max(8, window.innerHeight - rect.top + 8) + 'px';
|
|
1379
|
+
popover.style.left = 'auto';
|
|
1380
|
+
popover.style.top = 'auto';
|
|
1381
|
+
}
|
|
1042
1382
|
popover.hidden = false;
|
|
1043
|
-
btn
|
|
1383
|
+
els['template-picker-btn']?.setAttribute('aria-expanded', 'true');
|
|
1044
1384
|
}
|
|
1045
1385
|
|
|
1046
1386
|
function closeTemplatePopover() {
|
|
1047
1387
|
const popover = els['template-popover'];
|
|
1048
|
-
|
|
1049
|
-
if (!popover || !btn) return;
|
|
1388
|
+
if (!popover) return;
|
|
1050
1389
|
popover.hidden = true;
|
|
1051
|
-
btn
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
function applyTemplateInvocation(managerJobId) {
|
|
1055
|
-
|
|
1056
|
-
//
|
|
1057
|
-
// the
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
const
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
combined = strippedPrior ? `${invocation}\n\n${strippedPrior}` : invocation;
|
|
1069
|
-
}
|
|
1070
|
-
textarea.value = combined;
|
|
1071
|
-
// Caret at the end.
|
|
1072
|
-
textarea.setSelectionRange(combined.length, combined.length);
|
|
1390
|
+
els['template-picker-btn']?.setAttribute('aria-expanded', 'false');
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
function applyTemplateInvocation(managerJobId) {
|
|
1394
|
+
// The invocation prefix ($fraim / /fraim) is an implementation detail that
|
|
1395
|
+
// the server adds based on the active employee — the user should never see it.
|
|
1396
|
+
// Store the job ID as a pending coaching job. The #coach-note shows a
|
|
1397
|
+
// human-readable label; the textarea stays focused for optional extra context.
|
|
1398
|
+
// When the user clicks Send, continueRun sends { coachingJobId, instructions }.
|
|
1399
|
+
const label = managerJobId.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
1400
|
+
// Strip any prior /fraim invocation text already in the textarea (backward-compat
|
|
1401
|
+
// for sessions that stored raw invocation text before this change).
|
|
1402
|
+
const textarea = els['coach-text'];
|
|
1403
|
+
textarea.value = textarea.value
|
|
1404
|
+
.replace(/(?:^|\n|\s)[/$]fraim\s+[a-z0-9-]+(?:\s|$)/ig, ' ')
|
|
1405
|
+
.replace(/\s+/g, ' ')
|
|
1406
|
+
.trim();
|
|
1073
1407
|
textarea.focus();
|
|
1074
|
-
|
|
1408
|
+
setPendingCoachingJob(managerJobId, label);
|
|
1075
1409
|
closeTemplatePopover();
|
|
1076
1410
|
}
|
|
1077
1411
|
|
|
@@ -1081,174 +1415,167 @@ function derivedStage(conv) {
|
|
|
1081
1415
|
return { text: 'Done — please review', kind: 'done' };
|
|
1082
1416
|
}
|
|
1083
1417
|
|
|
1084
|
-
function derivedLatest(conv) {
|
|
1085
|
-
if (conv.status !== 'running') return '';
|
|
1086
|
-
const employeeReply = latestEmployeeSurfaceText(conv);
|
|
1087
|
-
if (employeeReply) return employeeReply;
|
|
1088
|
-
if (conv.status === 'running') return 'Working on it…';
|
|
1089
|
-
return '';
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
function humanizeSlug(slug) {
|
|
1093
|
-
return String(slug || '')
|
|
1094
|
-
.split('-')
|
|
1095
|
-
.filter(Boolean)
|
|
1096
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1097
|
-
.join(' ');
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
function stripStubReference(text) {
|
|
1101
|
-
return String(text || '')
|
|
1102
|
-
.replace(/\n?\[Job stub:[^\]]+\]/gi, '')
|
|
1103
|
-
.replace(/\s+/g, ' ')
|
|
1104
|
-
.trim();
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
function surfaceText(role, text, conv) {
|
|
1108
|
-
const raw = stripStubReference(text);
|
|
1109
|
-
if (!raw) return '';
|
|
1110
|
-
|
|
1111
|
-
if (role === 'manager') {
|
|
1112
|
-
const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
|
|
1113
|
-
if (invocationOnly) return
|
|
1114
|
-
const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
|
|
1115
|
-
const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
1116
|
-
return raw
|
|
1117
|
-
.replace(invocationWithSlug, '')
|
|
1118
|
-
.replace(/^(?:[$/]fraim)\s*/i, '')
|
|
1119
|
-
.trim();
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
if (role === 'employee') {
|
|
1123
|
-
const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
|
|
1124
|
-
if (startedMatch) {
|
|
1125
|
-
const cleaned = surfaceText('manager', startedMatch[1], conv);
|
|
1126
|
-
if (conv.status === 'completed') return 'Done — please review.';
|
|
1127
|
-
return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
if (role === 'employee') {
|
|
1132
|
-
const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
|
|
1133
|
-
if (resumedMatch) {
|
|
1134
|
-
const cleaned = surfaceText('manager', resumedMatch[1], conv);
|
|
1135
|
-
if (conv.status === 'completed') return 'Done - please review.';
|
|
1136
|
-
return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
|
|
1140
|
-
return raw;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
function
|
|
1144
|
-
const
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
return
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
);
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
|
|
1246
|
-
const reviewOffset = Math.max(24, host.clientHeight * 0.16);
|
|
1247
|
-
const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
|
|
1248
|
-
host.scrollTo({ top: desiredTop, behavior: 'smooth' });
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
function syncSendButton() {
|
|
1418
|
+
function derivedLatest(conv) {
|
|
1419
|
+
if (conv.status !== 'running') return '';
|
|
1420
|
+
const employeeReply = latestEmployeeSurfaceText(conv);
|
|
1421
|
+
if (employeeReply) return employeeReply;
|
|
1422
|
+
if (conv.status === 'running') return 'Working on it…';
|
|
1423
|
+
return '';
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
function humanizeSlug(slug) {
|
|
1427
|
+
return String(slug || '')
|
|
1428
|
+
.split('-')
|
|
1429
|
+
.filter(Boolean)
|
|
1430
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
1431
|
+
.join(' ');
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function stripStubReference(text) {
|
|
1435
|
+
return String(text || '')
|
|
1436
|
+
.replace(/\n?\[Job stub:[^\]]+\]/gi, '')
|
|
1437
|
+
.replace(/\s+/g, ' ')
|
|
1438
|
+
.trim();
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function surfaceText(role, text, conv) {
|
|
1442
|
+
const raw = stripStubReference(text);
|
|
1443
|
+
if (!raw) return '';
|
|
1444
|
+
|
|
1445
|
+
if (role === 'manager') {
|
|
1446
|
+
const invocationOnly = raw.match(/^(?:[$/]fraim)\s+([a-z0-9-]+)\s*$/i);
|
|
1447
|
+
if (invocationOnly) return raw;
|
|
1448
|
+
const slugPattern = '[a-z0-9]+(?:-[a-z0-9]+)+';
|
|
1449
|
+
const invocationWithSlug = new RegExp(`^(?:[$/]fraim)\\s+(${slugPattern})\\s*`, 'i');
|
|
1450
|
+
return raw
|
|
1451
|
+
.replace(invocationWithSlug, '')
|
|
1452
|
+
.replace(/^(?:[$/]fraim)\s*/i, '')
|
|
1453
|
+
.trim();
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (role === 'employee') {
|
|
1457
|
+
const startedMatch = raw.match(/^Started\s+\w+:\s*(.*)$/i);
|
|
1458
|
+
if (startedMatch) {
|
|
1459
|
+
const cleaned = surfaceText('manager', startedMatch[1], conv);
|
|
1460
|
+
if (conv.status === 'completed') return 'Done — please review.';
|
|
1461
|
+
return cleaned ? `Working on: ${cleaned}` : 'Working on it…';
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
if (role === 'employee') {
|
|
1466
|
+
const resumedMatch = raw.match(/^Resumed\s+\w+\s+session\s+[a-f0-9-]+:\s*(.*)$/i);
|
|
1467
|
+
if (resumedMatch) {
|
|
1468
|
+
const cleaned = surfaceText('manager', resumedMatch[1], conv);
|
|
1469
|
+
if (conv.status === 'completed') return 'Done - please review.';
|
|
1470
|
+
return cleaned ? `Working on: ${cleaned}` : 'Working on it...';
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
return raw;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function latestEmployeeSurfaceText(conv) {
|
|
1478
|
+
const messages = conv.messages || [];
|
|
1479
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
1480
|
+
if (messages[i].role !== 'employee') continue;
|
|
1481
|
+
const cleaned = surfaceText('employee', messages[i].text, conv);
|
|
1482
|
+
if (cleaned) return cleaned;
|
|
1483
|
+
}
|
|
1484
|
+
return '';
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
function appendMessageDom(role, text, conv) {
|
|
1488
|
+
const article = document.createElement('article');
|
|
1489
|
+
article.className = 'message ' + role;
|
|
1490
|
+
|
|
1491
|
+
const meta = document.createElement('div');
|
|
1492
|
+
meta.className = 'message-meta';
|
|
1493
|
+
|
|
1494
|
+
const avatar = document.createElement('span');
|
|
1495
|
+
avatar.className = `message-avatar ${role}`;
|
|
1496
|
+
|
|
1497
|
+
if (role === 'employee') {
|
|
1498
|
+
const persona = getConversationPersona(conv);
|
|
1499
|
+
if (persona && persona.avatarUrl) {
|
|
1500
|
+
const img = document.createElement('img');
|
|
1501
|
+
img.src = persona.avatarUrl;
|
|
1502
|
+
img.alt = persona.displayName;
|
|
1503
|
+
avatar.appendChild(img);
|
|
1504
|
+
} else {
|
|
1505
|
+
avatar.textContent = initialBadge(roleLabel(role, conv));
|
|
1506
|
+
}
|
|
1507
|
+
} else if (role === 'manager') {
|
|
1508
|
+
avatar.textContent = 'M';
|
|
1509
|
+
} else {
|
|
1510
|
+
avatar.textContent = '•';
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
const who = document.createElement('span');
|
|
1514
|
+
who.className = 'who';
|
|
1515
|
+
who.textContent = roleLabel(role, conv);
|
|
1516
|
+
|
|
1517
|
+
const lane = document.createElement('span');
|
|
1518
|
+
lane.className = 'lane-label';
|
|
1519
|
+
lane.textContent = role === 'manager'
|
|
1520
|
+
? 'Manager direction'
|
|
1521
|
+
: role === 'employee'
|
|
1522
|
+
? 'Employee response'
|
|
1523
|
+
: 'System update';
|
|
1524
|
+
|
|
1525
|
+
meta.appendChild(avatar);
|
|
1526
|
+
meta.appendChild(who);
|
|
1527
|
+
meta.appendChild(lane);
|
|
1528
|
+
article.appendChild(meta);
|
|
1529
|
+
|
|
1530
|
+
const bubble = document.createElement('div');
|
|
1531
|
+
bubble.className = 'bubble';
|
|
1532
|
+
if (role === 'employee') {
|
|
1533
|
+
const surfaced = surfaceText(role, text, conv);
|
|
1534
|
+
const raw = String(text || '');
|
|
1535
|
+
// surfaceText collapses whitespace which destroys markdown. Only use it
|
|
1536
|
+
// when it returned a transformed value (Started/Resumed prefix overrides).
|
|
1537
|
+
const useSurfaced = surfaced && surfaced !== raw.replace(/\s+/g, ' ').trim();
|
|
1538
|
+
const content = useSurfaced ? surfaced : raw;
|
|
1539
|
+
bubble.innerHTML = formatEmployeeText(content || (conv.status === 'completed' ? 'Done — please review.' : 'Working on it…'));
|
|
1540
|
+
} else {
|
|
1541
|
+
const surfaced = surfaceText(role, text, conv);
|
|
1542
|
+
bubble.textContent = surfaced || text;
|
|
1543
|
+
}
|
|
1544
|
+
if (role === 'manager') {
|
|
1545
|
+
const raw = document.createElement('span');
|
|
1546
|
+
raw.className = 'transport-raw';
|
|
1547
|
+
raw.textContent = text;
|
|
1548
|
+
article.appendChild(raw);
|
|
1549
|
+
}
|
|
1550
|
+
article.appendChild(bubble);
|
|
1551
|
+
els['messages'].appendChild(article);
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function scrollThreadForReview(conv) {
|
|
1555
|
+
const host = els['messages'];
|
|
1556
|
+
if (!host) return;
|
|
1557
|
+
const nodes = [...host.querySelectorAll('.message')];
|
|
1558
|
+
if (nodes.length === 0) return;
|
|
1559
|
+
|
|
1560
|
+
if (conv.status === 'running') {
|
|
1561
|
+
host.scrollTop = host.scrollHeight;
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
const target = [...nodes].reverse().find((node) =>
|
|
1566
|
+
node.classList.contains('employee') || node.classList.contains('system')
|
|
1567
|
+
) || nodes[nodes.length - 1];
|
|
1568
|
+
|
|
1569
|
+
const hostRect = host.getBoundingClientRect();
|
|
1570
|
+
const targetRect = target.getBoundingClientRect();
|
|
1571
|
+
const currentTop = host.scrollTop;
|
|
1572
|
+
const targetTopInsideHost = currentTop + (targetRect.top - hostRect.top);
|
|
1573
|
+
const reviewOffset = Math.max(24, host.clientHeight * 0.16);
|
|
1574
|
+
const desiredTop = Math.max(0, targetTopInsideHost - reviewOffset);
|
|
1575
|
+
host.scrollTo({ top: desiredTop, behavior: 'smooth' });
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
function syncSendButton() {
|
|
1252
1579
|
const conv = activeConversation();
|
|
1253
1580
|
const hasText = els['coach-text'].value.trim().length > 0;
|
|
1254
1581
|
// Send is enabled as soon as the host session exists. We deliberately
|
|
@@ -1256,7 +1583,39 @@ function syncSendButton() {
|
|
|
1256
1583
|
// coach mid-run (which is exactly what /api/ai-hub/runs/:id/messages
|
|
1257
1584
|
// is for; the server only requires sessionId).
|
|
1258
1585
|
const resumable = !!(conv && conv.sessionId);
|
|
1259
|
-
|
|
1586
|
+
// A pending coaching job (set by quick-coach buttons or template picker)
|
|
1587
|
+
// is enough to enable Send even with an empty textarea — the user just
|
|
1588
|
+
// wants to fire that coaching directive, optionally with added context.
|
|
1589
|
+
const hasPendingJob = !!state.pendingCoachingJobId;
|
|
1590
|
+
els['send'].disabled = !((hasText || hasPendingJob) && resumable);
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// ---------------------------------------------------------------------------
|
|
1594
|
+
// Pending coaching job helpers
|
|
1595
|
+
// ---------------------------------------------------------------------------
|
|
1596
|
+
|
|
1597
|
+
// Set a pending coaching job selected via a quick-coach button or template
|
|
1598
|
+
// picker. The invocation is NOT put into the textarea — the server adds it
|
|
1599
|
+
// based on the active employee. The coach-note shows a human-readable label
|
|
1600
|
+
// so the user knows what will fire when they hit Send.
|
|
1601
|
+
function setPendingCoachingJob(jobId, label) {
|
|
1602
|
+
state.pendingCoachingJobId = jobId;
|
|
1603
|
+
state.pendingCoachingLabel = label;
|
|
1604
|
+
if (els['coach-note']) {
|
|
1605
|
+
els['coach-note'].textContent = `▶ ${label} — add context below (optional) then send`;
|
|
1606
|
+
els['coach-note'].classList.add('pending-job');
|
|
1607
|
+
}
|
|
1608
|
+
syncSendButton();
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
function clearPendingCoachingJob() {
|
|
1612
|
+
state.pendingCoachingJobId = null;
|
|
1613
|
+
state.pendingCoachingLabel = null;
|
|
1614
|
+
if (els['coach-note']) {
|
|
1615
|
+
els['coach-note'].classList.remove('pending-job');
|
|
1616
|
+
// Note text is restored to normal status by the next renderActive call.
|
|
1617
|
+
}
|
|
1618
|
+
syncSendButton();
|
|
1260
1619
|
}
|
|
1261
1620
|
|
|
1262
1621
|
// ---------------------------------------------------------------------------
|
|
@@ -1502,6 +1861,16 @@ function renderEmployeeSelect() {
|
|
|
1502
1861
|
els['employee-select'].appendChild(opt);
|
|
1503
1862
|
}
|
|
1504
1863
|
renderAgentInstallPanel();
|
|
1864
|
+
updateAbToggleVisibility();
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
function updateAbToggleVisibility() {
|
|
1868
|
+
const wrap = els['ab-toggle-wrap'];
|
|
1869
|
+
if (!wrap) return;
|
|
1870
|
+
const sel = document.getElementById('employee-select');
|
|
1871
|
+
const empId = sel ? sel.value : (state.selectedEmployeeId || 'claude');
|
|
1872
|
+
const emp = (state.bootstrap && state.bootstrap.employees || []).find(e => e.id === empId);
|
|
1873
|
+
wrap.hidden = !(emp && emp.supportsRaw);
|
|
1505
1874
|
}
|
|
1506
1875
|
|
|
1507
1876
|
// ---------------------------------------------------------------------------
|
|
@@ -1694,50 +2063,26 @@ function deriveTitle(jobTitle, instructions) {
|
|
|
1694
2063
|
// (per developers.openai.com/codex/skills: "type $ to mention a skill")
|
|
1695
2064
|
// - Project-canonical mapping at src/cli/setup/ide-invocation-surfaces.ts.
|
|
1696
2065
|
//
|
|
1697
|
-
//
|
|
1698
|
-
//
|
|
1699
|
-
//
|
|
1700
|
-
// lose which FRAIM workflow the manager meant to run.
|
|
1701
|
-
const FRAIM_INVOCATION_SYMBOL = {
|
|
1702
|
-
codex: '$fraim',
|
|
1703
|
-
claude: '/fraim',
|
|
1704
|
-
gemini: '/fraim',
|
|
1705
|
-
};
|
|
1706
|
-
|
|
1707
|
-
function fraimInvocationFor(employeeId, jobId, kind) {
|
|
1708
|
-
if (jobId === '__freeform__') return null;
|
|
1709
|
-
const symbol = FRAIM_INVOCATION_SYMBOL[employeeId] || '/fraim';
|
|
1710
|
-
return `${symbol} ${jobId}`;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
// Wrap the manager's typed instructions with the host-appropriate FRAIM
|
|
1714
|
-
// invocation. The wrapped text is what we ACTUALLY send to the host CLI
|
|
1715
|
-
// AND what we show in the timeline so the manager sees what the agent
|
|
1716
|
-
// received. For freeform jobs (no FRAIM job assigned), the instructions
|
|
1717
|
-
// are sent verbatim with no invocation prefix.
|
|
1718
|
-
function buildAgentMessage(employeeId, jobId, kind, instructions, stubPath) {
|
|
1719
|
-
const trimmed = (instructions || '').trim();
|
|
1720
|
-
const explicit = extractExplicitFraimInvocation(trimmed);
|
|
1721
|
-
const effectiveJobId = explicit?.jobId || jobId;
|
|
1722
|
-
const invocation = fraimInvocationFor(employeeId, effectiveJobId, kind);
|
|
1723
|
-
// Freeform: no FRAIM prefix, no stub reference — just the raw instructions.
|
|
1724
|
-
if (!invocation) return explicit?.remainder || trimmed;
|
|
1725
|
-
const remainder = explicit ? explicit.remainder : trimmed;
|
|
1726
|
-
const stub = (kind === 'start' && stubPath) ? `\n[Job stub: ${stubPath}]` : '';
|
|
1727
|
-
if (!remainder) return `${invocation}${stub}`;
|
|
1728
|
-
return `${invocation}${stub}\n\n${remainder}`;
|
|
1729
|
-
}
|
|
1730
|
-
|
|
2066
|
+
// The browser sends raw manager instructions. AI Hub normalizes assigned
|
|
2067
|
+
// FRAIM jobs into host-facing invocations so the Hub UI and channel callers
|
|
2068
|
+
// share one start/continue contract.
|
|
1731
2069
|
async function startRun(job, instructions, employeeId) {
|
|
1732
|
-
// Prefix the manager's typed instructions with the FRAIM invocation so
|
|
1733
|
-
// the underlying host actually launches the right job. The prefixed
|
|
1734
|
-
// text is what the host receives AND what we show in the timeline.
|
|
1735
|
-
// Freeform jobs carry no job stub and no FRAIM invocation prefix.
|
|
1736
2070
|
const isFreeform = job.id === '__freeform__';
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
2071
|
+
// In task-pane/extension mode: get a fresh selection snapshot and prepend
|
|
2072
|
+
// document context to the instructions so the agent knows what the user is
|
|
2073
|
+
// looking at. stubPath resolution and FRAIM invocation are now server-side.
|
|
2074
|
+
let effectiveInstructions = instructions || '';
|
|
2075
|
+
if (document.body.dataset.surface === 'task-pane' || document.body.dataset.surface === 'extension') {
|
|
2076
|
+
const fresh = await requestWordContext('get-selection').catch(() => null);
|
|
2077
|
+
const wc = (fresh && (fresh.selection || fresh.hasSelection)) ? { ...state.wordContext, ...fresh } : state.wordContext;
|
|
2078
|
+
const ctxBlock = buildWordContextBlock(wc);
|
|
2079
|
+
if (ctxBlock) effectiveInstructions = ctxBlock + '\n\n---\n\n' + effectiveInstructions;
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Issue #442: read the A/B toggle state from the modal before it closes.
|
|
2083
|
+
const abToggle = document.getElementById('ab-toggle');
|
|
2084
|
+
const isAB = !isFreeform && abToggle && abToggle.checked;
|
|
2085
|
+
|
|
1741
2086
|
const conv = {
|
|
1742
2087
|
id: newConversationId(),
|
|
1743
2088
|
projectPath: state.projectPath,
|
|
@@ -1750,10 +2095,16 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1750
2095
|
runId: null,
|
|
1751
2096
|
sessionId: null,
|
|
1752
2097
|
status: 'running',
|
|
1753
|
-
messages: [
|
|
2098
|
+
messages: [],
|
|
1754
2099
|
events: [],
|
|
1755
2100
|
artifacts: [],
|
|
1756
2101
|
lastUpdatedAt: Date.now(),
|
|
2102
|
+
// Issue #442: A/B mode fields (only set when toggle was on).
|
|
2103
|
+
compareMode: isAB ? 'ab' : undefined,
|
|
2104
|
+
compareRunId: null,
|
|
2105
|
+
compareRun: null,
|
|
2106
|
+
// Issue #489: capture selection state at job-start so write-back knows insert-after vs append.
|
|
2107
|
+
wordStartedWithSelection: document.body.dataset.surface === 'task-pane' && !!(state.wordContext && state.wordContext.hasSelection),
|
|
1757
2108
|
};
|
|
1758
2109
|
upsertConversation(conv);
|
|
1759
2110
|
state.activeId = conv.id;
|
|
@@ -1769,11 +2120,17 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1769
2120
|
projectPath: state.projectPath,
|
|
1770
2121
|
hostId: employeeId,
|
|
1771
2122
|
jobId: job.id,
|
|
1772
|
-
|
|
2123
|
+
instructions: effectiveInstructions,
|
|
2124
|
+
...(isAB ? { compareMode: 'ab', directInstructions: effectiveInstructions } : {}),
|
|
1773
2125
|
}),
|
|
1774
2126
|
});
|
|
1775
2127
|
conv.runId = run.id;
|
|
1776
2128
|
foldRunIntoConversation(conv, run);
|
|
2129
|
+
// Issue #442: when the server created a paired Direct run, capture it.
|
|
2130
|
+
if (run.compareRunId) {
|
|
2131
|
+
conv.compareRunId = run.compareRunId;
|
|
2132
|
+
if (run.compareRun) foldCompareRunIntoConversation(conv, run.compareRun);
|
|
2133
|
+
}
|
|
1777
2134
|
upsertConversation(conv);
|
|
1778
2135
|
renderRail();
|
|
1779
2136
|
renderActive();
|
|
@@ -1791,10 +2148,14 @@ async function startRun(job, instructions, employeeId) {
|
|
|
1791
2148
|
// Start a NEW run on an existing conversation with a different agent.
|
|
1792
2149
|
// The conversation's message history stays intact; only the run restarts.
|
|
1793
2150
|
async function restartConvWithAgent(conv, newAgentId, text) {
|
|
1794
|
-
|
|
2151
|
+
// Snapshot both the message history and the event log so they survive the
|
|
2152
|
+
// restart. foldRunIntoConversation replaces these arrays on every call
|
|
2153
|
+
// (including poll ticks), so we must store them on the conv object itself —
|
|
2154
|
+
// not as local variables that only live until the first poll fires.
|
|
2155
|
+
conv._priorMessages = Array.isArray(conv.messages) ? [...conv.messages] : [];
|
|
2156
|
+
conv._priorEvents = Array.isArray(conv.events) ? [...conv.events] : [];
|
|
1795
2157
|
conv.status = 'running';
|
|
1796
2158
|
conv.sessionId = null;
|
|
1797
|
-
conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
|
|
1798
2159
|
upsertConversation(conv);
|
|
1799
2160
|
renderRail();
|
|
1800
2161
|
renderActive();
|
|
@@ -1806,11 +2167,13 @@ async function restartConvWithAgent(conv, newAgentId, text) {
|
|
|
1806
2167
|
projectPath: state.projectPath,
|
|
1807
2168
|
hostId: newAgentId,
|
|
1808
2169
|
jobId: conv.jobId,
|
|
1809
|
-
|
|
2170
|
+
instructions: text,
|
|
1810
2171
|
}),
|
|
1811
2172
|
});
|
|
1812
2173
|
conv.runId = run.id;
|
|
1813
2174
|
foldRunIntoConversation(conv, run);
|
|
2175
|
+
// Note: foldRunIntoConversation now prepends _priorMessages/_priorEvents
|
|
2176
|
+
// on every call, so the history is preserved through poll ticks too.
|
|
1814
2177
|
upsertConversation(conv);
|
|
1815
2178
|
renderRail();
|
|
1816
2179
|
renderActive();
|
|
@@ -1828,11 +2191,12 @@ async function restartConvWithAgent(conv, newAgentId, text) {
|
|
|
1828
2191
|
async function continueRun(text) {
|
|
1829
2192
|
const conv = activeConversation();
|
|
1830
2193
|
if (!conv || !conv.runId) return;
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
|
|
2194
|
+
// Consume any pending coaching job selected via quick-coach or template picker.
|
|
2195
|
+
// The server adds the correct invocation prefix ($fraim / /fraim) based on
|
|
2196
|
+
// the active employee — the user never sees raw invocation syntax.
|
|
2197
|
+
const coachingJobId = state.pendingCoachingJobId || undefined;
|
|
2198
|
+
clearPendingCoachingJob();
|
|
1834
2199
|
conv.status = 'running';
|
|
1835
|
-
conv.messages.push({ role: 'manager', text: agentMessage, at: Date.now() });
|
|
1836
2200
|
upsertConversation(conv);
|
|
1837
2201
|
renderRail();
|
|
1838
2202
|
renderActive();
|
|
@@ -1840,7 +2204,7 @@ async function continueRun(text) {
|
|
|
1840
2204
|
const run = await requestJson(`/api/ai-hub/runs/${conv.runId}/messages`, {
|
|
1841
2205
|
method: 'POST',
|
|
1842
2206
|
headers: { 'Content-Type': 'application/json' },
|
|
1843
|
-
body: JSON.stringify({
|
|
2207
|
+
body: JSON.stringify({ instructions: text, ...(coachingJobId ? { coachingJobId } : {}) }),
|
|
1844
2208
|
});
|
|
1845
2209
|
foldRunIntoConversation(conv, run);
|
|
1846
2210
|
upsertConversation(conv);
|
|
@@ -1869,15 +2233,19 @@ function foldRunIntoConversation(conv, run) {
|
|
|
1869
2233
|
totals: run.totals || null,
|
|
1870
2234
|
};
|
|
1871
2235
|
// Replace the conversation's events with the run's events (single source of truth on the server).
|
|
1872
|
-
|
|
1873
|
-
//
|
|
1874
|
-
const
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
2236
|
+
// If this conversation was restarted on a different agent, prepend the prior run's events so
|
|
2237
|
+
// the micro-log shows the full diagnostic history across all run attempts (Issue #495).
|
|
2238
|
+
const newEvents = (run.events || []).map((e) => ({ channel: e.channel, text: e.text }));
|
|
2239
|
+
const priorEvents = Array.isArray(conv._priorEvents) ? conv._priorEvents : [];
|
|
2240
|
+
conv.events = priorEvents.length > 0 ? [...priorEvents, ...newEvents] : newEvents;
|
|
2241
|
+
// Same treatment for messages: prepend any cross-run history captured at restart time.
|
|
2242
|
+
const newMessages = (run.messages || []).map((m) => ({
|
|
2243
|
+
role: m.role,
|
|
2244
|
+
text: m.text,
|
|
2245
|
+
at: Date.parse(m.createdAt) || Date.now(),
|
|
2246
|
+
}));
|
|
2247
|
+
const priorMessages = Array.isArray(conv._priorMessages) ? conv._priorMessages : [];
|
|
2248
|
+
conv.messages = priorMessages.length > 0 ? [...priorMessages, ...newMessages] : newMessages;
|
|
1881
2249
|
// Track session for resumption.
|
|
1882
2250
|
if (run.sessionId) conv.sessionId = run.sessionId;
|
|
1883
2251
|
// R4: persist the persona key from the server-side run record.
|
|
@@ -1898,6 +2266,21 @@ function foldRunIntoConversation(conv, run) {
|
|
|
1898
2266
|
conv.lastUpdatedAt = Date.now();
|
|
1899
2267
|
}
|
|
1900
2268
|
|
|
2269
|
+
// Issue #442: fold the Direct (B) side run into the conversation's compareRun slot.
|
|
2270
|
+
function foldCompareRunIntoConversation(conv, compareRun) {
|
|
2271
|
+
if (!compareRun) return;
|
|
2272
|
+
conv.compareRun = {
|
|
2273
|
+
id: compareRun.id,
|
|
2274
|
+
sessionId: compareRun.sessionId || null,
|
|
2275
|
+
status: compareRun.status || 'running',
|
|
2276
|
+
stages: compareRun.stages || [],
|
|
2277
|
+
currentPhase: compareRun.currentPhase || null,
|
|
2278
|
+
totals: compareRun.totals || null,
|
|
2279
|
+
messages: compareRun.messages || [],
|
|
2280
|
+
events: compareRun.events || [],
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
|
|
1901
2284
|
const ARTIFACT_PATH_RE = /([A-Za-z0-9_\-\.\/]*?(?:docs|public|src|tests)\/[A-Za-z0-9_\-\.\/]+\.[A-Za-z0-9]+)/;
|
|
1902
2285
|
// Paths under these directories are FRAIM lifecycle bookkeeping (RCAs,
|
|
1903
2286
|
// raw learnings, evidence dumps, mock files), not deliverables the
|
|
@@ -1921,20 +2304,33 @@ function startPolling() {
|
|
|
1921
2304
|
state.pollHandle = window.setInterval(async () => {
|
|
1922
2305
|
const conv = activeConversation();
|
|
1923
2306
|
if (!conv || !conv.runId) return;
|
|
1924
|
-
|
|
2307
|
+
// Issue #442: for A/B conversations keep polling until BOTH sides are done.
|
|
2308
|
+
const fraimDone = conv.status !== 'running';
|
|
2309
|
+
const directDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
|
|
2310
|
+
if (fraimDone && directDone) {
|
|
1925
2311
|
window.clearInterval(state.pollHandle);
|
|
1926
2312
|
state.pollHandle = null;
|
|
1927
2313
|
return;
|
|
1928
2314
|
}
|
|
1929
2315
|
try {
|
|
1930
|
-
|
|
1931
|
-
|
|
2316
|
+
if (!fraimDone) {
|
|
2317
|
+
const run = await requestJson(`/api/ai-hub/runs/${conv.runId}`);
|
|
2318
|
+
foldRunIntoConversation(conv, run);
|
|
2319
|
+
}
|
|
2320
|
+
// Issue #442: also poll the compare run when present and not yet terminal.
|
|
2321
|
+
if (conv.compareRunId && !directDone) {
|
|
2322
|
+
const compareRun = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}`);
|
|
2323
|
+
foldCompareRunIntoConversation(conv, compareRun);
|
|
2324
|
+
}
|
|
1932
2325
|
upsertConversation(conv);
|
|
1933
2326
|
renderRail();
|
|
1934
2327
|
renderActive();
|
|
1935
|
-
|
|
2328
|
+
const nowFraimDone = conv.status !== 'running';
|
|
2329
|
+
const nowDirectDone = !conv.compareRunId || (conv.compareRun && conv.compareRun.status !== 'running');
|
|
2330
|
+
if (nowFraimDone && nowDirectDone) {
|
|
1936
2331
|
window.clearInterval(state.pollHandle);
|
|
1937
2332
|
state.pollHandle = null;
|
|
2333
|
+
tryWordWriteBack(conv);
|
|
1938
2334
|
}
|
|
1939
2335
|
} catch (error) {
|
|
1940
2336
|
console.warn('Polling failed:', error);
|
|
@@ -1942,13 +2338,49 @@ function startPolling() {
|
|
|
1942
2338
|
}, 1000);
|
|
1943
2339
|
}
|
|
1944
2340
|
|
|
2341
|
+
// Issue #489: When a job completes in Word task-pane surface, write the final
|
|
2342
|
+
// agent output back into the document with tracked changes so the user can
|
|
2343
|
+
// accept/reject via Word's normal review flow.
|
|
2344
|
+
async function tryWordWriteBack(conv) {
|
|
2345
|
+
if (conv.wordWrittenBack) return;
|
|
2346
|
+
if (document.body.dataset.surface !== 'task-pane') return;
|
|
2347
|
+
if (!state.wordContext || !state.wordContext.docTitle) return;
|
|
2348
|
+
if (conv.status !== 'completed') return;
|
|
2349
|
+
|
|
2350
|
+
const employeeMessages = (conv.messages || []).filter(m => m.role === 'employee');
|
|
2351
|
+
if (employeeMessages.length === 0) return;
|
|
2352
|
+
const outputText = employeeMessages[employeeMessages.length - 1].text;
|
|
2353
|
+
if (!outputText || !outputText.trim()) return;
|
|
2354
|
+
|
|
2355
|
+
// Mark before async calls to guard against concurrent poll ticks.
|
|
2356
|
+
conv.wordWrittenBack = true;
|
|
2357
|
+
upsertConversation(conv);
|
|
2358
|
+
|
|
2359
|
+
try {
|
|
2360
|
+
await requestWordContext('track-changes-on');
|
|
2361
|
+
const action = conv.wordStartedWithSelection ? 'insert-after' : 'append-to-doc';
|
|
2362
|
+
await requestWordContext(action, { text: outputText });
|
|
2363
|
+
await requestWordContext('track-changes-off');
|
|
2364
|
+
showStatus('Result written to document — review tracked changes in Word.');
|
|
2365
|
+
} catch (e) {
|
|
2366
|
+
console.warn('Word write-back failed:', e);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function convNeedsPolling(conv) {
|
|
2371
|
+
if (!conv || !conv.runId) return false;
|
|
2372
|
+
const fraimRunning = conv.status === 'running';
|
|
2373
|
+
const directRunning = !!conv.compareRunId && (!conv.compareRun || conv.compareRun.status === 'running');
|
|
2374
|
+
return fraimRunning || directRunning;
|
|
2375
|
+
}
|
|
2376
|
+
|
|
1945
2377
|
function switchToConversation(id) {
|
|
1946
2378
|
state.activeId = id;
|
|
1947
2379
|
persistConversations();
|
|
1948
2380
|
renderRail();
|
|
1949
2381
|
renderActive();
|
|
1950
2382
|
const conv = activeConversation();
|
|
1951
|
-
if (conv
|
|
2383
|
+
if (convNeedsPolling(conv)) {
|
|
1952
2384
|
startPolling();
|
|
1953
2385
|
} else if (state.pollHandle) {
|
|
1954
2386
|
window.clearInterval(state.pollHandle);
|
|
@@ -1967,11 +2399,9 @@ function showStatus(text, isError) {
|
|
|
1967
2399
|
|
|
1968
2400
|
async function pickProject() {
|
|
1969
2401
|
try {
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
if (!response.ok) throw new Error(payload.error || 'Could not choose a folder.');
|
|
1974
|
-
if (payload.path) {
|
|
2402
|
+
// 204 = user cancelled the OS folder picker; requestJson returns null for empty bodies.
|
|
2403
|
+
const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
|
|
2404
|
+
if (payload && payload.path) {
|
|
1975
2405
|
await loadBootstrap(payload.path);
|
|
1976
2406
|
}
|
|
1977
2407
|
} catch (error) {
|
|
@@ -2001,6 +2431,7 @@ function showStep2(job) {
|
|
|
2001
2431
|
els['start'].disabled = true;
|
|
2002
2432
|
els['step1'].hidden = true;
|
|
2003
2433
|
els['step2'].hidden = false;
|
|
2434
|
+
renderWordContextInModal();
|
|
2004
2435
|
setTimeout(() => els['instructions'].focus(), 50);
|
|
2005
2436
|
}
|
|
2006
2437
|
|
|
@@ -2041,6 +2472,11 @@ function wireEvents() {
|
|
|
2041
2472
|
const employeeId = els['employee-select'].value || state.selectedEmployeeId;
|
|
2042
2473
|
state.selectedEmployeeId = employeeId;
|
|
2043
2474
|
closeModal();
|
|
2475
|
+
// R2: if no project path is set, show the inline project picker instead.
|
|
2476
|
+
if (!state.projectPath) {
|
|
2477
|
+
renderFirstRunLanding('hub');
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2044
2480
|
await startRun(job, text, employeeId);
|
|
2045
2481
|
});
|
|
2046
2482
|
els['job-search'].addEventListener('input', () => renderJobCatalog(els['job-search'].value));
|
|
@@ -2051,7 +2487,9 @@ function wireEvents() {
|
|
|
2051
2487
|
els['coach-text'].addEventListener('input', syncSendButton);
|
|
2052
2488
|
els['send'].addEventListener('click', async () => {
|
|
2053
2489
|
const text = els['coach-text'].value.trim();
|
|
2054
|
-
|
|
2490
|
+
// Allow send when textarea is empty IFF a coaching job is pending — the
|
|
2491
|
+
// user clicked a quick-coach button and just wants to fire that job.
|
|
2492
|
+
if (!text && !state.pendingCoachingJobId) return;
|
|
2055
2493
|
els['coach-text'].value = '';
|
|
2056
2494
|
syncSendButton();
|
|
2057
2495
|
// If the user changed the agent via the inline selector, restart with the
|
|
@@ -2068,25 +2506,109 @@ function wireEvents() {
|
|
|
2068
2506
|
}
|
|
2069
2507
|
});
|
|
2070
2508
|
|
|
2071
|
-
if (els['active-employee-select']) {
|
|
2072
|
-
els['active-employee-select'].addEventListener('change', () => {
|
|
2509
|
+
if (els['active-employee-select']) {
|
|
2510
|
+
els['active-employee-select'].addEventListener('change', () => {
|
|
2073
2511
|
// Only update the global preference here. Do NOT update conv.employeeId —
|
|
2074
2512
|
// the send handler compares sel.value vs conv.employeeId to detect a
|
|
2075
2513
|
// switch; updating conv here would make them equal and the restart would
|
|
2076
2514
|
// never fire.
|
|
2077
|
-
state.selectedEmployeeId = els['active-employee-select'].value;
|
|
2078
|
-
});
|
|
2079
|
-
}
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2515
|
+
state.selectedEmployeeId = els['active-employee-select'].value;
|
|
2516
|
+
});
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
// Issue #442: keep A/B toggle visible only when the selected employee supports
|
|
2520
|
+
// direct-path invocation (supportsRaw). Also wire the explanation paragraph.
|
|
2521
|
+
if (els['employee-select']) {
|
|
2522
|
+
els['employee-select'].addEventListener('change', updateAbToggleVisibility);
|
|
2523
|
+
}
|
|
2524
|
+
const abToggleCheckbox = document.getElementById('ab-toggle');
|
|
2525
|
+
if (abToggleCheckbox) {
|
|
2526
|
+
abToggleCheckbox.addEventListener('change', () => {
|
|
2527
|
+
const exp = document.getElementById('ab-toggle-explanation');
|
|
2528
|
+
if (exp) exp.hidden = !abToggleCheckbox.checked;
|
|
2529
|
+
// R1.3: Start button label changes to "Start A/B test" when toggle is on.
|
|
2530
|
+
if (els['start']) els['start'].textContent = abToggleCheckbox.checked ? 'Start A/B test' : 'Start';
|
|
2531
|
+
});
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
// Issue #442: Direct panel send button.
|
|
2535
|
+
const abDirectInput = document.getElementById('ab-direct-input');
|
|
2536
|
+
if (abDirectInput && els['ab-direct-send']) {
|
|
2537
|
+
abDirectInput.addEventListener('input', () => {
|
|
2538
|
+
const conv = activeConversation();
|
|
2539
|
+
const hasSession = !!(conv && conv.compareRun && conv.compareRun.sessionId);
|
|
2540
|
+
const notRunning = !conv || (conv.compareRun && conv.compareRun.status) !== 'running';
|
|
2541
|
+
els['ab-direct-send'].disabled = !hasSession || !notRunning || !abDirectInput.value.trim();
|
|
2542
|
+
});
|
|
2543
|
+
abDirectInput.addEventListener('keydown', (e) => {
|
|
2544
|
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!els['ab-direct-send'].disabled) els['ab-direct-send'].click(); }
|
|
2545
|
+
});
|
|
2546
|
+
els['ab-direct-send'].addEventListener('click', async () => {
|
|
2547
|
+
const conv = activeConversation();
|
|
2548
|
+
if (!conv || !conv.compareRunId) return;
|
|
2549
|
+
const text = abDirectInput.value.trim();
|
|
2550
|
+
if (!text) return;
|
|
2551
|
+
abDirectInput.value = '';
|
|
2552
|
+
els['ab-direct-send'].disabled = true;
|
|
2553
|
+
try {
|
|
2554
|
+
const updated = await requestJson(`/api/ai-hub/runs/${conv.compareRunId}/direct-messages`, {
|
|
2555
|
+
method: 'POST',
|
|
2556
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2557
|
+
body: JSON.stringify({ message: text }),
|
|
2558
|
+
});
|
|
2559
|
+
foldCompareRunIntoConversation(conv, updated);
|
|
2560
|
+
renderActive();
|
|
2561
|
+
} catch (err) {
|
|
2562
|
+
console.error('Direct send failed', err);
|
|
2563
|
+
}
|
|
2564
|
+
});
|
|
2565
|
+
}
|
|
2566
|
+
|
|
2567
|
+
if (els['coach-panel']) {
|
|
2568
|
+
els['coach-panel'].addEventListener('toggle', () => {
|
|
2569
|
+
const conv = activeConversation();
|
|
2570
|
+
if (!conv) return;
|
|
2571
|
+
panelStateFor(conv.id).coach = els['coach-panel'].open;
|
|
2572
|
+
// R3.3: keep coach-summary visibility in sync when toggled directly.
|
|
2573
|
+
if (els['coach-summary']) els['coach-summary'].hidden = els['coach-panel'].open;
|
|
2574
|
+
});
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
// R6: thread panel toggle — persist open/close state per conversation.
|
|
2578
|
+
if (els['thread-panel']) {
|
|
2579
|
+
els['thread-panel'].addEventListener('toggle', () => {
|
|
2580
|
+
const conv = activeConversation();
|
|
2581
|
+
if (!conv) return;
|
|
2582
|
+
panelStateFor(conv.id).thread = els['thread-panel'].open;
|
|
2583
|
+
});
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
// R5: quick-access coaching buttons.
|
|
2587
|
+
// The invocation syntax ($fraim / /fraim) stays out of the UI entirely —
|
|
2588
|
+
// the server adds it based on the active employee. Clicking a button sets a
|
|
2589
|
+
// pending coaching job (shown in #coach-note); the user can add optional
|
|
2590
|
+
// context in the textarea and then click Send.
|
|
2591
|
+
if (els['quick-coach-btns']) {
|
|
2592
|
+
els['quick-coach-btns'].addEventListener('click', (e) => {
|
|
2593
|
+
const btn = e.target.closest('.quick-coach-btn');
|
|
2594
|
+
if (!btn) return;
|
|
2595
|
+
const jobId = btn.dataset.job;
|
|
2596
|
+
if (!jobId) return;
|
|
2597
|
+
const label = btn.textContent.trim();
|
|
2598
|
+
setPendingCoachingJob(jobId, label);
|
|
2599
|
+
els['coach-text'].focus();
|
|
2600
|
+
});
|
|
2601
|
+
}
|
|
2602
|
+
if (els['other-manager-jobs-btn']) {
|
|
2603
|
+
els['other-manager-jobs-btn'].addEventListener('click', (e) => {
|
|
2604
|
+
e.stopPropagation();
|
|
2605
|
+
const popover = els['template-popover'];
|
|
2606
|
+
if (popover && popover.hidden === false) closeTemplatePopover();
|
|
2607
|
+
else openTemplatePopover();
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// Issue #347 R2 — template picker.
|
|
2090
2612
|
if (els['template-picker-btn']) {
|
|
2091
2613
|
els['template-picker-btn'].addEventListener('click', (e) => {
|
|
2092
2614
|
e.stopPropagation();
|
|
@@ -2097,7 +2619,7 @@ function wireEvents() {
|
|
|
2097
2619
|
document.addEventListener('click', (e) => {
|
|
2098
2620
|
const popover = els['template-popover'];
|
|
2099
2621
|
if (!popover || popover.hidden) return;
|
|
2100
|
-
if (!e.target.closest('#template-popover') && !e.target.closest('#
|
|
2622
|
+
if (!e.target.closest('#template-popover') && !e.target.closest('#other-manager-jobs-btn')) {
|
|
2101
2623
|
closeTemplatePopover();
|
|
2102
2624
|
}
|
|
2103
2625
|
});
|
|
@@ -2116,6 +2638,24 @@ function wireEvents() {
|
|
|
2116
2638
|
}
|
|
2117
2639
|
bindEnterToButton(els['coach-text'], els['send']);
|
|
2118
2640
|
bindEnterToButton(els['instructions'], els['start']);
|
|
2641
|
+
|
|
2642
|
+
// Word context bar refresh button.
|
|
2643
|
+
if (els['word-ctx-refresh']) {
|
|
2644
|
+
els['word-ctx-refresh'].addEventListener('click', async () => {
|
|
2645
|
+
const fresh = await requestWordContext('get-context');
|
|
2646
|
+
if (fresh) { state.wordContext = fresh; renderWordContextBar(); renderWordContextInModal(); }
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
// Word context card expand/collapse toggle.
|
|
2650
|
+
if (els['word-ctx-card-toggle']) {
|
|
2651
|
+
els['word-ctx-card-toggle'].addEventListener('click', () => {
|
|
2652
|
+
const body = els['word-ctx-card-body'];
|
|
2653
|
+
if (!body) return;
|
|
2654
|
+
const expanded = !body.hidden;
|
|
2655
|
+
body.hidden = expanded;
|
|
2656
|
+
els['word-ctx-card-toggle'].textContent = expanded ? '▸' : '▾';
|
|
2657
|
+
});
|
|
2658
|
+
}
|
|
2119
2659
|
}
|
|
2120
2660
|
|
|
2121
2661
|
// ---------------------------------------------------------------------------
|
|
@@ -2123,6 +2663,9 @@ function wireEvents() {
|
|
|
2123
2663
|
// ---------------------------------------------------------------------------
|
|
2124
2664
|
|
|
2125
2665
|
(async function init() {
|
|
2666
|
+
// html.electron is already set by the inline script in <head> for zero-flash.
|
|
2667
|
+
// This is a belt-and-suspenders fallback in case the inline script was skipped.
|
|
2668
|
+
|
|
2126
2669
|
gatherElements();
|
|
2127
2670
|
loadConversationsFromStorage();
|
|
2128
2671
|
wirePopovers();
|
|
@@ -2130,14 +2673,34 @@ function wireEvents() {
|
|
|
2130
2673
|
|
|
2131
2674
|
const urlParams = new URLSearchParams(window.location.search);
|
|
2132
2675
|
const isFirstRun = urlParams.get('firstRun') === 'true';
|
|
2676
|
+
const surface = urlParams.get('surface') || 'hub';
|
|
2677
|
+
const docUrl = urlParams.get('docUrl') || '';
|
|
2678
|
+
|
|
2679
|
+
// Apply surface class before bootstrap so CSS takes effect immediately.
|
|
2680
|
+
if (surface !== 'hub') document.body.dataset.surface = surface;
|
|
2681
|
+
|
|
2682
|
+
// Register the Word context bridge (no-op outside iframe context).
|
|
2683
|
+
setupWordBridge();
|
|
2133
2684
|
|
|
2134
2685
|
try {
|
|
2135
|
-
await loadBootstrap();
|
|
2686
|
+
await loadBootstrap(null, docUrl);
|
|
2136
2687
|
} catch (error) {
|
|
2137
2688
|
showStatus(error.message, true);
|
|
2138
2689
|
return;
|
|
2139
2690
|
}
|
|
2140
2691
|
|
|
2692
|
+
// Task-pane and extension surfaces: skip welcome/onboarding, show active job
|
|
2693
|
+
// or open the new-job picker. Layout is compact (rail hidden via CSS).
|
|
2694
|
+
if (surface === 'task-pane' || surface === 'extension') {
|
|
2695
|
+
const conv = activeConversation();
|
|
2696
|
+
if (conv && conv.projectPath === state.projectPath) {
|
|
2697
|
+
if (convNeedsPolling(conv)) startPolling();
|
|
2698
|
+
} else {
|
|
2699
|
+
openModal();
|
|
2700
|
+
}
|
|
2701
|
+
return;
|
|
2702
|
+
}
|
|
2703
|
+
|
|
2141
2704
|
if (isFirstRun) {
|
|
2142
2705
|
renderFirstRunLanding();
|
|
2143
2706
|
return;
|
|
@@ -2156,7 +2719,7 @@ function wireEvents() {
|
|
|
2156
2719
|
// If an active conversation belongs to the loaded project and is still running, resume polling.
|
|
2157
2720
|
const conv = activeConversation();
|
|
2158
2721
|
if (conv && conv.projectPath === state.projectPath) {
|
|
2159
|
-
if (conv
|
|
2722
|
+
if (convNeedsPolling(conv)) startPolling();
|
|
2160
2723
|
} else {
|
|
2161
2724
|
state.activeId = null;
|
|
2162
2725
|
persistConversations();
|
|
@@ -2211,10 +2774,11 @@ async function autoOnboardProject() {
|
|
|
2211
2774
|
}
|
|
2212
2775
|
}
|
|
2213
2776
|
|
|
2214
|
-
function renderFirstRunLanding() {
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2777
|
+
function renderFirstRunLanding(mode) {
|
|
2778
|
+
// mode === 'hub' → R2: shown when a job is started without a project path.
|
|
2779
|
+
// After picking, reload bootstrap and return to the hub.
|
|
2780
|
+
// mode === 'firstrun' → legacy path: shown on ?firstRun=true; auto-starts onboarding.
|
|
2781
|
+
// Default: 'firstrun' for backward compatibility.
|
|
2218
2782
|
const existing = document.getElementById('fraim-first-run-landing');
|
|
2219
2783
|
if (existing) existing.remove();
|
|
2220
2784
|
|
|
@@ -2234,14 +2798,14 @@ function renderFirstRunLanding() {
|
|
|
2234
2798
|
'box-shadow:0 1px 2px rgba(20,40,30,.04);',
|
|
2235
2799
|
].join('');
|
|
2236
2800
|
|
|
2237
|
-
const title = document.createElement('
|
|
2238
|
-
title.textContent =
|
|
2801
|
+
const title = document.createElement('h2');
|
|
2802
|
+
title.textContent = 'Pick a project folder';
|
|
2239
2803
|
title.style.cssText = 'font-size:22px;font-weight:600;margin:0;';
|
|
2240
2804
|
card.appendChild(title);
|
|
2241
2805
|
|
|
2242
2806
|
const desc = document.createElement('p');
|
|
2243
|
-
desc.textContent = "
|
|
2244
|
-
desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;';
|
|
2807
|
+
desc.textContent = "Your AI employees need a project to work in. Pick the folder where your code lives. Think of it like onboarding a new hire — you're giving them a home and asking them to learn everything they can about it.";
|
|
2808
|
+
desc.style.cssText = 'color:var(--muted,#6b7a72);margin:0;font-size:15px;line-height:1.55;';
|
|
2245
2809
|
card.appendChild(desc);
|
|
2246
2810
|
|
|
2247
2811
|
const folderLabel = document.createElement('label');
|
|
@@ -2277,11 +2841,8 @@ function renderFirstRunLanding() {
|
|
|
2277
2841
|
].join('');
|
|
2278
2842
|
browseBtn.addEventListener('click', async () => {
|
|
2279
2843
|
try {
|
|
2280
|
-
const
|
|
2281
|
-
if (
|
|
2282
|
-
const payload = await response.json();
|
|
2283
|
-
if (!response.ok) throw new Error(payload.error || 'Could not pick folder.');
|
|
2284
|
-
if (payload.path) {
|
|
2844
|
+
const payload = await requestJson('/api/ai-hub/project-path/pick', { method: 'POST' });
|
|
2845
|
+
if (payload && payload.path) {
|
|
2285
2846
|
await loadBootstrap(payload.path);
|
|
2286
2847
|
folderInput.value = state.projectPath;
|
|
2287
2848
|
folderInput.style.color = 'var(--text,#1f2a24)';
|
|
@@ -2300,10 +2861,10 @@ function renderFirstRunLanding() {
|
|
|
2300
2861
|
|
|
2301
2862
|
const startBtn = document.createElement('button');
|
|
2302
2863
|
startBtn.type = 'button';
|
|
2303
|
-
startBtn.textContent = 'Start
|
|
2864
|
+
startBtn.textContent = 'Start in this project →';
|
|
2304
2865
|
startBtn.disabled = !state.projectPath;
|
|
2305
2866
|
startBtn.style.cssText = [
|
|
2306
|
-
'background:var(--accent,#
|
|
2867
|
+
'background:var(--accent,#1f437d);color:#fff;border:none;border-radius:8px;',
|
|
2307
2868
|
'padding:11px 20px;font-size:14px;font-weight:600;cursor:pointer;',
|
|
2308
2869
|
'opacity:' + (state.projectPath ? '1' : '0.5') + ';',
|
|
2309
2870
|
'transition:opacity .15s;',
|
|
@@ -2311,16 +2872,24 @@ function renderFirstRunLanding() {
|
|
|
2311
2872
|
startBtn.addEventListener('click', async () => {
|
|
2312
2873
|
if (!state.projectPath) return;
|
|
2313
2874
|
startBtn.disabled = true;
|
|
2314
|
-
startBtn.textContent = 'Starting…';
|
|
2315
2875
|
overlay.remove();
|
|
2876
|
+
if (mode === 'hub') {
|
|
2877
|
+
// R2: reload bootstrap for the chosen project and return to hub.
|
|
2878
|
+
try {
|
|
2879
|
+
await loadBootstrap(state.projectPath);
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
showStatus(err.message, true);
|
|
2882
|
+
}
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
// Legacy first-run path: auto-start project-onboarding.
|
|
2886
|
+
startBtn.textContent = 'Starting…';
|
|
2316
2887
|
renderActive();
|
|
2317
|
-
// Find the project-onboarding job from bootstrap, fall back to direct POST.
|
|
2318
2888
|
const job = (state.bootstrap && state.bootstrap.jobs || []).find((j) => j.id === 'project-onboarding');
|
|
2319
2889
|
const employeeId = state.selectedEmployeeId || 'claude';
|
|
2320
2890
|
if (job) {
|
|
2321
2891
|
await startRun(job, 'Onboard this project', employeeId);
|
|
2322
2892
|
} else {
|
|
2323
|
-
// Job not in catalog yet (project not initialized) — POST directly.
|
|
2324
2893
|
try {
|
|
2325
2894
|
const run = await requestJson('/api/ai-hub/runs', {
|
|
2326
2895
|
method: 'POST',
|