ai-advisory-board 0.1.0

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/gui/app.js ADDED
@@ -0,0 +1,4555 @@
1
+ /**
2
+ * AI Advisory Board UI client.
3
+ *
4
+ * Single-file vanilla JS app. Talks to the local Express server via REST
5
+ * for read/write and WebSocket (ws://host:port/ws) for live discussion
6
+ * progress. No build step — served directly by the server.
7
+ */
8
+
9
+ import { rewriteWikiLinks, renderWikiBody, setKnowledgeState, refreshKnowledgeState } from './wikilinks.js';
10
+
11
+ const $ = (sel, root = document) => root.querySelector(sel);
12
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
13
+
14
+ // ------------------------------------------------------------------
15
+ // State
16
+ // ------------------------------------------------------------------
17
+
18
+ const state = {
19
+ workspace: null,
20
+ settings: null,
21
+ members: [],
22
+ principles: [],
23
+ actionItems: [],
24
+ discussions: [],
25
+ currentDiscussion: null,
26
+ /** Track in-flight typing bubbles per discussion-pending scope. */
27
+ pendingTyping: new Map(), // memberName -> DOM element
28
+ ws: null,
29
+ route: 'discussions',
30
+ followUpComposerOpen: false,
31
+ };
32
+
33
+ // Member colors mirror the agent file's `color:` field — we get them from
34
+ // the API. Fallback rotates through the known palette deterministically.
35
+ const FALLBACK_PALETTE = ['cyan', 'green', 'yellow', 'magenta', 'blue', 'red', 'orange', 'pink', 'purple'];
36
+ function colorForMember(name) {
37
+ let h = 2166136261;
38
+ for (let i = 0; i < name.length; i++) {
39
+ h ^= name.charCodeAt(i);
40
+ h = (h * 16777619) >>> 0;
41
+ }
42
+ return FALLBACK_PALETTE[h % FALLBACK_PALETTE.length];
43
+ }
44
+
45
+ // ------------------------------------------------------------------
46
+ // Bootstrap
47
+ // ------------------------------------------------------------------
48
+
49
+ async function bootstrap() {
50
+ await refreshState({ silent: true });
51
+ renderWorkspaceCard();
52
+ connectWebSocket();
53
+ setupNav();
54
+ setupModal();
55
+ setupEditModal();
56
+ setupConfirmModal();
57
+ setupExplorerModal();
58
+ navigate('discussions');
59
+ }
60
+
61
+ async function refreshState(opts = {}) {
62
+ try {
63
+ const data = await fetchJSON('/api/state');
64
+ Object.assign(state, {
65
+ workspace: data.workspace,
66
+ settings: data.settings,
67
+ members: data.members || [],
68
+ principles: data.principles || [],
69
+ actionItems: data.actionItems || [],
70
+ discussions: data.discussions || [],
71
+ });
72
+ if (data.workspace?.id) $('#workspace-label').textContent = data.workspace.id;
73
+ } catch (e) {
74
+ if (!opts.silent) toast('Failed to refresh state: ' + e.message, 'err');
75
+ else toast('Failed to load workspace state: ' + e.message, 'err');
76
+ }
77
+ }
78
+
79
+ function renderWorkspaceCard() {
80
+ const card = $('#workspace-card');
81
+ if (!card || !state.workspace) return;
82
+ card.hidden = false;
83
+ const activeCount = (state.members || []).filter((m) => m.isActive).length;
84
+ $('#ws-scope-pill').textContent = state.workspace.scope === 'project' ? 'project' : 'home';
85
+ $('#ws-scope-pill').className = 'ws-pill ' + (state.workspace.scope === 'project' ? 'project' : 'home');
86
+ $('#ws-member-count').textContent = `${activeCount}/${state.members.length} active`;
87
+ $('#ws-root-path').textContent = state.workspace.root || '';
88
+ $('#ws-root-path').title = state.workspace.root || '';
89
+ }
90
+
91
+ function connectWebSocket() {
92
+ const url = `ws://${location.host}/ws`;
93
+ const ws = new WebSocket(url);
94
+ state.ws = ws;
95
+ ws.addEventListener('open', () => setWsStatus('connected', 'ok'));
96
+ ws.addEventListener('close', () => {
97
+ setWsStatus('disconnected', 'err');
98
+ setTimeout(connectWebSocket, 2000);
99
+ });
100
+ ws.addEventListener('error', () => setWsStatus('error', 'err'));
101
+ ws.addEventListener('message', (ev) => {
102
+ try {
103
+ const msg = JSON.parse(ev.data);
104
+ handleWsMessage(msg);
105
+ } catch {
106
+ /* ignore */
107
+ }
108
+ });
109
+ }
110
+
111
+ function setWsStatus(label, kind) {
112
+ $('#ws-label').textContent = label;
113
+ $('#ws-dot').className = 'status-dot ' + (kind || '');
114
+ }
115
+
116
+ // ------------------------------------------------------------------
117
+ // WebSocket handlers
118
+ // ------------------------------------------------------------------
119
+
120
+ function handleWsMessage(msg) {
121
+ // Forward wiki events to the Knowledge view (decoupled via a custom event)
122
+ if (typeof msg.type === 'string' && msg.type.startsWith('wiki_')) {
123
+ window.dispatchEvent(new CustomEvent('aab-wiki-event', { detail: msg }));
124
+ return;
125
+ }
126
+ if (msg.type === 'member_thinking') {
127
+ addTypingBubble(msg.memberName);
128
+ } else if (msg.type === 'member_activity') {
129
+ updateTypingActivity(msg.memberName, msg.activity, msg.detail);
130
+ } else if (msg.type === 'member_response') {
131
+ // The server includes the name at top-level too for symmetry, but the
132
+ // canonical source is `msg.response.memberName`.
133
+ const name = msg.memberName || msg.response?.memberName;
134
+ replaceTypingWithResponse(name, msg.response);
135
+ } else if (msg.type === 'orchestrator_thinking') {
136
+ // Optional: show a system "Orchestrator analyzing..." line
137
+ addSystemLine('Orchestrator analyzing the round…');
138
+ } else if (msg.type === 'orchestrator_decision') {
139
+ addOrchestratorDecision(msg.decision, msg.roundNumber);
140
+ } else if (msg.type === 'discussion_gated') {
141
+ // Pre-round gate fired — no members actually spawned. Clear any
142
+ // optimistic typing bubbles so they don't sit forever.
143
+ state.pendingTyping.forEach((b) => b.remove());
144
+ state.pendingTyping.clear();
145
+ state.currentDiscussion = msg.discussion;
146
+ updateDiscussionList(msg.discussion);
147
+ finalizeChat(msg, { gated: true });
148
+ } else if (msg.type === 'discussion_completed') {
149
+ state.currentDiscussion = msg.discussion;
150
+ updateDiscussionList(msg.discussion);
151
+ finalizeChat(msg);
152
+ } else if (msg.type === 'error') {
153
+ toast(msg.message, 'err');
154
+ // Re-enable any disabled action buttons so the user can retry
155
+ setActionButtonsBusy(false);
156
+ } else if (
157
+ msg.type === 'member_enhance_started' ||
158
+ msg.type === 'member_enhance_progress' ||
159
+ msg.type === 'member_enhance_done' ||
160
+ msg.type === 'member_enhance_failed' ||
161
+ msg.type === 'member_voice_started' ||
162
+ msg.type === 'member_voice_done' ||
163
+ msg.type === 'member_voice_preview' ||
164
+ msg.type === 'members_sync_done' ||
165
+ msg.type === 'principles_seeded' ||
166
+ msg.type === 'principle_explorer_thinking' ||
167
+ msg.type === 'principle_explorer_reply'
168
+ ) {
169
+ window.dispatchEvent(new CustomEvent('aab-member-event', { detail: msg }));
170
+ } else if (msg.type && msg.type.startsWith('coach_')) {
171
+ window.dispatchEvent(new CustomEvent('aab-coach-event', { detail: msg }));
172
+ } else if (msg.type && msg.type.startsWith('sparring_')) {
173
+ window.dispatchEvent(new CustomEvent('aab-sparring-event', { detail: msg }));
174
+ } else if (
175
+ msg.type === 'action_created' ||
176
+ msg.type === 'action_updated' ||
177
+ msg.type === 'action_deleted' ||
178
+ msg.type === 'actions_extracted'
179
+ ) {
180
+ // Light refresh: pull the latest state and, when we're on the Actions
181
+ // route, re-render the kanban. Avoids a heavy diff loop and keeps the
182
+ // server as the source of truth.
183
+ refreshState({ silent: true }).then(() => {
184
+ if (state.route === 'actions') navigate('actions');
185
+ });
186
+ } else if (
187
+ typeof msg.type === 'string' &&
188
+ (msg.type.startsWith('planner_') || msg.type.startsWith('skill_run_'))
189
+ ) {
190
+ // Phase 5 — Skill Planner + skill-creator orchestration events.
191
+ window.dispatchEvent(new CustomEvent('aab-planner-event', { detail: msg }));
192
+ }
193
+ }
194
+
195
+ // ------------------------------------------------------------------
196
+ // Navigation
197
+ // ------------------------------------------------------------------
198
+
199
+ function setupNav() {
200
+ $$('.nav-item').forEach((btn) => {
201
+ btn.addEventListener('click', () => navigate(btn.dataset.route));
202
+ });
203
+ }
204
+
205
+ function navigate(route) {
206
+ state.route = route;
207
+ $$('.nav-item').forEach((btn) => btn.classList.toggle('active', btn.dataset.route === route));
208
+ const main = $('#main');
209
+ main.innerHTML = '';
210
+ if (route === 'discussions') renderDiscussionsView(main);
211
+ else if (route === 'members') renderMembersView(main);
212
+ else if (route === 'actions') renderActionsView(main);
213
+ else if (route === 'principles') renderPrinciplesView(main);
214
+ else if (route === 'knowledge') renderKnowledgeView(main);
215
+ else if (route === 'coach') renderCoachView(main);
216
+ else if (route === 'skills') renderSkillsView(main);
217
+ else if (route === 'usage') renderUsageView(main);
218
+ else if (route === 'settings') renderSettingsView(main);
219
+ closeSidebar();
220
+ }
221
+
222
+ // ------------------------------------------------------------------
223
+ // Discussions list view
224
+ // ------------------------------------------------------------------
225
+
226
+ function renderDiscussionsView(main) {
227
+ const view = h('div', { class: 'view' });
228
+
229
+ const header = h('div', { class: 'view-header' });
230
+ header.appendChild(
231
+ h('div', {}, [
232
+ h('div', { class: 'view-title' }, 'Discussions'),
233
+ h('div', { class: 'view-subtitle' }, `${state.discussions.length} saved`),
234
+ ]),
235
+ );
236
+ const newBtn = h('button', { class: 'btn-primary', 'data-testid': 'new-discussion' }, '+ New discussion');
237
+ newBtn.addEventListener('click', openNewDiscussionModal);
238
+ header.appendChild(newBtn);
239
+ view.appendChild(header);
240
+
241
+ const body = h('div', { class: 'view-body' });
242
+
243
+ if (state.discussions.length === 0) {
244
+ body.appendChild(emptyState('💬', 'No discussions yet', 'Click "New discussion" to convene the board.'));
245
+ } else {
246
+ const list = h('div', { class: 'discussion-list', 'data-testid': 'discussion-list' });
247
+ state.discussions.forEach((d) => list.appendChild(renderDiscussionCard(d)));
248
+ body.appendChild(list);
249
+ }
250
+
251
+ view.appendChild(body);
252
+ main.appendChild(view);
253
+ }
254
+
255
+ function renderDiscussionCard(d) {
256
+ const card = h('div', {
257
+ class: 'discussion-card',
258
+ 'data-testid': `discussion-row-${shortIdOf(d.id)}`,
259
+ 'data-discussion-id': d.id,
260
+ role: 'button',
261
+ tabindex: '0',
262
+ });
263
+ card.appendChild(h('div', { class: 'discussion-card-q' }, d.question));
264
+
265
+ const status = d.completedAt ? 'done' : d.pendingUserRequest ? 'awaiting' : 'open';
266
+ const statusLabel = status === 'done' ? 'concluded' : status === 'awaiting' ? 'awaiting input' : 'open';
267
+
268
+ const meta = h('div', { class: 'discussion-card-meta' });
269
+ meta.appendChild(h('span', { class: `status-pill ${status}` }, statusLabel));
270
+ meta.appendChild(h('span', {}, `${d.rounds.length} round${d.rounds.length === 1 ? '' : 's'}`));
271
+ meta.appendChild(h('span', {}, `${d.totalTurns} turn${d.totalTurns === 1 ? '' : 's'}`));
272
+ meta.appendChild(h('span', {}, formatRelative(d.createdAt)));
273
+ card.appendChild(meta);
274
+
275
+ card.addEventListener('click', () => openChatView(d));
276
+ return card;
277
+ }
278
+
279
+ // ------------------------------------------------------------------
280
+ // Chat view
281
+ // ------------------------------------------------------------------
282
+
283
+ function openChatView(discussion) {
284
+ state.currentDiscussion = discussion;
285
+ state.pendingTyping.clear();
286
+ state.followUpComposerOpen = false;
287
+ const main = $('#main');
288
+ main.innerHTML = '';
289
+
290
+ const view = h('div', { class: 'view chat-view' });
291
+
292
+ // Header
293
+ const header = h('div', { class: 'view-header' });
294
+ const back = h('button', { class: 'btn-secondary' }, '← Back');
295
+ back.addEventListener('click', () => navigate('discussions'));
296
+ header.appendChild(back);
297
+ header.appendChild(h('div', { class: 'chat-header-q' }, discussion.question));
298
+ const headerActions = h('div', { class: 'chat-header-actions' });
299
+ const sparListBtn = h(
300
+ 'button',
301
+ {
302
+ class: 'btn-secondary',
303
+ type: 'button',
304
+ 'data-testid': 'sparring-sessions-btn',
305
+ title: 'Open the sparring sessions list for this discussion',
306
+ },
307
+ '⚔ Sparring',
308
+ );
309
+ sparListBtn.addEventListener('click', () => openSparringListModal(discussion));
310
+ headerActions.appendChild(sparListBtn);
311
+ header.appendChild(headerActions);
312
+ view.appendChild(header);
313
+
314
+ // Stream
315
+ const stream = h('div', { class: 'chat-stream', id: 'chat-stream', 'data-testid': 'chat-stream', role: 'log', 'aria-live': 'polite', 'aria-relevant': 'additions' });
316
+ for (const node of discussionTimeline(discussion)) stream.appendChild(node);
317
+ if (discussion.pendingUserRequest) {
318
+ stream.appendChild(pendingRequestBubble(discussion.pendingUserRequest));
319
+ }
320
+ view.appendChild(stream);
321
+
322
+ // Action footer (continue / respond)
323
+ view.appendChild(renderChatFooter(discussion));
324
+
325
+ main.appendChild(view);
326
+ scrollChat();
327
+ }
328
+
329
+ // Walk a saved discussion and return DOM nodes in chat-app order:
330
+ // - the user's initial question
331
+ // - per round: round divider, any user reply that triggered the round,
332
+ // each member response, then the orchestrator decision
333
+ function discussionTimeline(discussion) {
334
+ const nodes = [];
335
+ const userResponses = discussion.userResponses || [];
336
+
337
+ const initial = userResponses.find((u) => u.type === 'initial_question');
338
+ if (initial) nodes.push(userBubble(initial.content, 'Question'));
339
+ else if (discussion.question) nodes.push(userBubble(discussion.question, 'Question'));
340
+
341
+ for (const round of discussion.rounds || []) {
342
+ nodes.push(roundDivider(round.roundNumber));
343
+
344
+ // HITL reply that triggered THIS round was attached with
345
+ // roundNumber = previous round's number.
346
+ const hitlReply = userResponses.find(
347
+ (u) => u.type === 'advisory_board_requested' && u.roundNumber === round.roundNumber - 1,
348
+ );
349
+ if (hitlReply && round.roundNumber > 1) {
350
+ nodes.push(userBubble(hitlReply.content, 'Your reply', hitlReply.selectedOption));
351
+ }
352
+
353
+ if (round.userResponse && round.userResponse.type === 'follow_up_question') {
354
+ const target =
355
+ round.followUpTargetType === 'specific'
356
+ ? '· targeted'
357
+ : round.followUpTargetType === 'subset'
358
+ ? '· subset'
359
+ : '';
360
+ nodes.push(userBubble(round.userResponse.content, `Follow-up ${target}`.trim()));
361
+ }
362
+
363
+ for (const r of round.responses) nodes.push(messageBubble(r));
364
+ if (round.orchestratorDecision) nodes.push(orchestratorBubble(round.orchestratorDecision, round.roundNumber));
365
+
366
+ // Sparring injections attached to THIS round (anywhere in userResponses
367
+ // whose roundNumber matches and type === 'sparring_injection').
368
+ const injections = userResponses.filter(
369
+ (u) => u.type === 'sparring_injection' && u.roundNumber === round.roundNumber,
370
+ );
371
+ for (const inj of injections) {
372
+ const member = state.members.find((m) => m.id === inj.selectedMemberId);
373
+ const memberLabel = member?.name || 'Board member';
374
+ nodes.push(userBubble(inj.content, `Sparring insight injected (via ${memberLabel})`));
375
+ }
376
+ }
377
+ return nodes;
378
+ }
379
+
380
+ function renderChatFooter(discussion) {
381
+ const footer = h('div', { class: 'chat-footer', id: 'chat-footer' });
382
+ if (discussion.completedAt) {
383
+ const row = h('div', { class: 'chat-actions' });
384
+ row.appendChild(
385
+ h(
386
+ 'div',
387
+ { class: 'message-meta', 'data-testid': 'discussion-concluded', role: 'status' },
388
+ '✓ Discussion concluded.',
389
+ ),
390
+ );
391
+ const extractBtn = h(
392
+ 'button',
393
+ {
394
+ class: 'btn-secondary',
395
+ type: 'button',
396
+ 'data-testid': 'extract-actions-btn',
397
+ title: 'Auto-extract action items from this concluded discussion',
398
+ },
399
+ '📋 Extract actions',
400
+ );
401
+ extractBtn.addEventListener('click', () => openExtractActionsModal(discussion));
402
+ row.appendChild(extractBtn);
403
+ footer.appendChild(row);
404
+ return footer;
405
+ }
406
+ if (discussion.pendingUserRequest) {
407
+ footer.appendChild(renderRespondForm(discussion));
408
+ return footer;
409
+ }
410
+ // Open discussion → Continue + Follow up buttons (or active follow-up composer)
411
+ if (state.followUpComposerOpen) {
412
+ footer.appendChild(renderFollowUpComposer(discussion));
413
+ return footer;
414
+ }
415
+ const row = h('div', { class: 'chat-actions' });
416
+ const cont = h(
417
+ 'button',
418
+ { class: 'btn-primary', id: 'btn-continue', 'data-testid': 'discussion-continue' },
419
+ '▸ Continue discussion',
420
+ );
421
+ cont.addEventListener('click', () => triggerContinue(discussion));
422
+ row.appendChild(cont);
423
+ const followBtn = h(
424
+ 'button',
425
+ { class: 'btn-secondary', id: 'btn-follow-up', 'data-testid': 'discussion-followup-open' },
426
+ '↳ Follow up',
427
+ );
428
+ followBtn.addEventListener('click', () => {
429
+ state.followUpComposerOpen = true;
430
+ refreshChatFooter(discussion);
431
+ });
432
+ row.appendChild(followBtn);
433
+ footer.appendChild(row);
434
+
435
+ const turnsLeft = (discussion.maxTurns ?? 0) - (discussion.totalTurns ?? 0);
436
+ footer.appendChild(
437
+ h(
438
+ 'div',
439
+ { class: 'message-meta', style: 'margin-top:6px' },
440
+ `${discussion.totalTurns}/${discussion.maxTurns} turns used · ${turnsLeft} left`,
441
+ ),
442
+ );
443
+ return footer;
444
+ }
445
+
446
+ function renderFollowUpComposer(discussion) {
447
+ const wrap = h('div', { class: 'respond-form follow-up-form' });
448
+ wrap.appendChild(h('div', { class: 'struct-section-title' }, 'Follow-up question'));
449
+
450
+ const textarea = h('textarea', {
451
+ id: 'follow-up-input',
452
+ 'data-testid': 'discussion-followup-input',
453
+ 'aria-label': 'Follow-up question',
454
+ rows: '3',
455
+ placeholder: 'Ask the board a sharper question, or push back on a specific point…',
456
+ });
457
+ wrap.appendChild(textarea);
458
+
459
+ // Member selector — defaults to all of the discussion's members selected.
460
+ const allowedIds = new Set(discussion.selectedMemberIds ?? state.members.map((m) => m.id));
461
+ const candidates = state.members.filter((m) => allowedIds.has(m.id) && m.isActive);
462
+ const selectedSet = new Set(candidates.map((m) => m.id));
463
+
464
+ wrap.appendChild(
465
+ h('div', { class: 'message-meta', style: 'margin-top:4px' }, 'Who should answer? (deselect to narrow)'),
466
+ );
467
+ const chips = h('div', { class: 'respond-options follow-up-chips' });
468
+ candidates.forEach((m) => {
469
+ const chip = h('button', { class: 'chip selected', type: 'button', 'data-member-id': m.id });
470
+ chip.appendChild(h('div', { class: 'avatar', 'data-color': m.color || colorForMember(m.name) }, m.initials || initialsOf(m.name)));
471
+ chip.appendChild(h('span', {}, m.name));
472
+ chip.addEventListener('click', () => {
473
+ if (selectedSet.has(m.id)) {
474
+ selectedSet.delete(m.id);
475
+ chip.classList.remove('selected');
476
+ } else {
477
+ selectedSet.add(m.id);
478
+ chip.classList.add('selected');
479
+ }
480
+ });
481
+ chips.appendChild(chip);
482
+ });
483
+ wrap.appendChild(chips);
484
+
485
+ const actions = h('div', { class: 'chat-actions', style: 'justify-content:flex-end;width:100%' });
486
+ const cancel = h('button', { class: 'btn-secondary' }, 'Cancel');
487
+ cancel.addEventListener('click', () => {
488
+ state.followUpComposerOpen = false;
489
+ refreshChatFooter(discussion);
490
+ });
491
+ actions.appendChild(cancel);
492
+
493
+ const submit = h(
494
+ 'button',
495
+ { class: 'btn-primary', id: 'btn-follow-up-submit', 'data-testid': 'discussion-followup-send' },
496
+ '↳ Send follow-up',
497
+ );
498
+ submit.addEventListener('click', () => {
499
+ const question = textarea.value.trim();
500
+ if (!question) {
501
+ toast('Type a follow-up question first.', 'err');
502
+ return;
503
+ }
504
+ if (selectedSet.size === 0) {
505
+ toast('Pick at least one member to answer.', 'err');
506
+ return;
507
+ }
508
+ let targetType = 'all';
509
+ let payload = { question };
510
+ if (selectedSet.size === candidates.length) {
511
+ targetType = 'all';
512
+ } else if (selectedSet.size === 1) {
513
+ targetType = 'specific';
514
+ payload.selectedMemberId = [...selectedSet][0];
515
+ } else {
516
+ targetType = 'subset';
517
+ payload.selectedMemberIds = [...selectedSet];
518
+ }
519
+ payload.targetType = targetType;
520
+ triggerFollowUp(discussion, payload);
521
+ });
522
+ actions.appendChild(submit);
523
+ wrap.appendChild(actions);
524
+
525
+ return wrap;
526
+ }
527
+
528
+ async function triggerFollowUp(discussion, payload) {
529
+ setActionButtonsBusy(true);
530
+ state.followUpComposerOpen = false;
531
+ try {
532
+ // Show the user's follow-up as a bubble + a fresh round divider
533
+ const targetLabel =
534
+ payload.targetType === 'specific'
535
+ ? 'Follow-up · targeted'
536
+ : payload.targetType === 'subset'
537
+ ? 'Follow-up · subset'
538
+ : 'Follow-up';
539
+ appendToStream(userBubble(payload.question, targetLabel));
540
+ const nextRound = (discussion.rounds[discussion.rounds.length - 1]?.roundNumber ?? 0) + 1;
541
+ appendToStream(roundDivider(nextRound));
542
+ await fetchJSON(`/api/discussions/${discussion.id}/follow-up`, {
543
+ method: 'POST',
544
+ headers: { 'content-type': 'application/json' },
545
+ body: JSON.stringify(payload),
546
+ });
547
+ toast('Follow-up dispatched — board is responding…', 'ok');
548
+ // Replace the composer footer with a busy footer until WS lands the result
549
+ const footer = $('#chat-footer');
550
+ if (footer) {
551
+ footer.replaceWith(h('div', { class: 'chat-footer', id: 'chat-footer' }, h('div', { class: 'message-meta' }, '… orchestrator deciding')));
552
+ }
553
+ } catch (e) {
554
+ toast('Follow-up failed: ' + e.message, 'err');
555
+ setActionButtonsBusy(false);
556
+ state.followUpComposerOpen = true;
557
+ refreshChatFooter(discussion);
558
+ }
559
+ }
560
+
561
+ function appendToStream(node) {
562
+ const stream = $('#chat-stream');
563
+ if (stream) {
564
+ stream.appendChild(node);
565
+ scrollChat();
566
+ }
567
+ }
568
+
569
+ function renderRespondForm(discussion) {
570
+ const req = discussion.pendingUserRequest;
571
+ const wrap = h('div', {
572
+ class: 'respond-form',
573
+ role: 'dialog',
574
+ 'aria-modal': 'true',
575
+ 'aria-labelledby': 'hitl-reply-heading',
576
+ 'data-testid': 'hitl-panel',
577
+ });
578
+
579
+ wrap.appendChild(h('div', { class: 'struct-section-title', id: 'hitl-reply-heading' }, 'Your reply'));
580
+
581
+ let selectedOption = null;
582
+ if (req.options?.length) {
583
+ const opts = h('div', { class: 'respond-options' });
584
+ req.options.forEach((opt, idx) => {
585
+ const chip = h(
586
+ 'button',
587
+ {
588
+ class: 'chip respond-option',
589
+ type: 'button',
590
+ 'data-testid': `hitl-option-${idx}`,
591
+ 'aria-label': `Option ${idx + 1}: ${opt}`,
592
+ },
593
+ `${idx + 1}. ${opt}`,
594
+ );
595
+ chip.addEventListener('click', () => {
596
+ selectedOption = opt;
597
+ $$('.respond-option', opts).forEach((c) => c.classList.remove('selected'));
598
+ chip.classList.add('selected');
599
+ });
600
+ opts.appendChild(chip);
601
+ });
602
+ wrap.appendChild(opts);
603
+ }
604
+
605
+ const textarea = h('textarea', {
606
+ id: 'respond-input',
607
+ 'data-testid': 'hitl-reply-input',
608
+ 'aria-label': 'Reply to the board',
609
+ rows: '3',
610
+ placeholder: req.options?.length
611
+ ? 'Pick an option above and/or write your answer…'
612
+ : 'Type your answer to the board…',
613
+ });
614
+ wrap.appendChild(textarea);
615
+
616
+ const submit = h(
617
+ 'button',
618
+ { class: 'btn-primary', id: 'btn-respond', 'data-testid': 'hitl-reply-submit' },
619
+ '↳ Send reply',
620
+ );
621
+ submit.addEventListener('click', () => {
622
+ const content = textarea.value.trim();
623
+ if (!content && !selectedOption) {
624
+ toast('Type a reply or pick an option first.', 'err');
625
+ return;
626
+ }
627
+ const finalContent = content || selectedOption || '';
628
+ triggerRespond(discussion, finalContent, selectedOption);
629
+ });
630
+ wrap.appendChild(submit);
631
+ return wrap;
632
+ }
633
+
634
+ async function triggerContinue(discussion) {
635
+ setActionButtonsBusy(true);
636
+ try {
637
+ addSystemLine('Continuing the discussion…');
638
+ await fetchJSON(`/api/discussions/${discussion.id}/continue`, { method: 'POST' });
639
+ toast('Continuing — orchestrator deciding…', 'ok');
640
+ } catch (e) {
641
+ toast('Continue failed: ' + e.message, 'err');
642
+ setActionButtonsBusy(false);
643
+ }
644
+ }
645
+
646
+ async function triggerRespond(discussion, content, selectedOption) {
647
+ setActionButtonsBusy(true);
648
+ try {
649
+ // Show the user's reply as a bubble immediately, plus a fresh round divider
650
+ appendToStream(userBubble(content, 'Your reply', selectedOption));
651
+ const nextRound = (discussion.rounds[discussion.rounds.length - 1]?.roundNumber ?? 0) + 1;
652
+ appendToStream(roundDivider(nextRound));
653
+ await fetchJSON(`/api/discussions/${discussion.id}/respond`, {
654
+ method: 'POST',
655
+ headers: { 'content-type': 'application/json' },
656
+ body: JSON.stringify({ content, selectedOption: selectedOption || undefined }),
657
+ });
658
+ toast('Reply sent — board is responding…', 'ok');
659
+ } catch (e) {
660
+ toast('Reply failed: ' + e.message, 'err');
661
+ setActionButtonsBusy(false);
662
+ }
663
+ }
664
+
665
+ function setActionButtonsBusy(busy) {
666
+ const btns = [$('#btn-continue'), $('#btn-respond')].filter(Boolean);
667
+ for (const b of btns) {
668
+ b.disabled = busy;
669
+ if (busy && b.id === 'btn-continue') b.textContent = '… working';
670
+ if (busy && b.id === 'btn-respond') b.textContent = '… sending';
671
+ }
672
+ }
673
+
674
+ function truncatePreview(s, n) {
675
+ if (!s) return '';
676
+ return s.length <= n ? s : s.slice(0, n) + '…';
677
+ }
678
+
679
+ function startNewChatView(question, members) {
680
+ state.pendingTyping.clear();
681
+ const main = $('#main');
682
+ main.innerHTML = '';
683
+ const view = h('div', { class: 'view chat-view' });
684
+
685
+ const header = h('div', { class: 'view-header' });
686
+ const back = h('button', { class: 'btn-secondary' }, '← Back');
687
+ back.addEventListener('click', () => navigate('discussions'));
688
+ header.appendChild(back);
689
+ header.appendChild(h('div', { class: 'chat-header-q' }, question));
690
+ header.appendChild(h('div', {}, ''));
691
+ view.appendChild(header);
692
+
693
+ const stream = h('div', { class: 'chat-stream', id: 'chat-stream', 'data-testid': 'chat-stream', role: 'log', 'aria-live': 'polite', 'aria-relevant': 'additions' });
694
+ // User's question as the first bubble — like sending a message in a chat app
695
+ stream.appendChild(userBubble(question, 'Question'));
696
+ stream.appendChild(roundDivider(1));
697
+ view.appendChild(stream);
698
+
699
+ // Placeholder footer — will be replaced by refreshChatFooter() when the
700
+ // discussion lands via WS `discussion_completed` or `discussion_gated`.
701
+ view.appendChild(h('div', { class: 'chat-footer', id: 'chat-footer' }));
702
+
703
+ main.appendChild(view);
704
+ }
705
+
706
+ // User message bubble — right-aligned, like sent messages in iMessage/WhatsApp.
707
+ function userBubble(text, label, selectedOption) {
708
+ const wrap = h('div', { class: 'message message-user' });
709
+
710
+ const body = h('div', { class: 'message-body user-body' });
711
+ const name = h('div', { class: 'message-name' }, label || 'You');
712
+ body.appendChild(name);
713
+
714
+ const bubble = h('div', { class: 'bubble user-bubble' }, text);
715
+ body.appendChild(bubble);
716
+
717
+ if (selectedOption) {
718
+ body.appendChild(h('div', { class: 'message-meta', style: 'margin-top:4px' }, `↳ chose: ${selectedOption}`));
719
+ }
720
+
721
+ wrap.appendChild(body);
722
+ // Avatar on the right (mirrored from member layout)
723
+ wrap.appendChild(h('div', { class: 'avatar avatar-user', 'data-color': 'brand' }, '👤'));
724
+ return wrap;
725
+ }
726
+
727
+ function roundDivider(n) {
728
+ return h('div', { class: 'round-divider' }, `Round ${n}`);
729
+ }
730
+
731
+ function messageBubble(r) {
732
+ const member = state.members.find((m) => m.id === r.memberId) || { name: r.memberName };
733
+ const color = member.color || colorForMember(r.memberName);
734
+ const initials = member.initials || initialsOf(r.memberName);
735
+ const slug = memberSlug(r.memberName);
736
+ const turn = r.turnNumber || 0;
737
+
738
+ const wrap = h('div', {
739
+ class: 'message',
740
+ 'data-testid': `member-message-${slug}-${turn}`,
741
+ 'data-testid-kind': 'response-card',
742
+ 'data-member-id': r.memberId || '',
743
+ 'data-round': r.roundNumber || '',
744
+ 'data-turn': turn,
745
+ });
746
+ wrap.appendChild(h('div', { class: 'avatar', 'data-color': color, 'aria-hidden': 'true' }, initials));
747
+
748
+ const body = h('div', { class: 'message-body' });
749
+ const nameRow = h('div', { class: 'message-name-row' });
750
+ const name = h('div', { class: 'message-name' }, r.memberName);
751
+ if (r.turnNumber) {
752
+ name.appendChild(h('span', { class: 'message-meta' }, `turn ${r.turnNumber}`));
753
+ }
754
+ nameRow.appendChild(name);
755
+
756
+ if (r.memberId && state.currentDiscussion) {
757
+ const sparBtn = h(
758
+ 'button',
759
+ {
760
+ class: 'btn-ghost btn-spar',
761
+ type: 'button',
762
+ title: `Open 1:1 deep dive with ${r.memberName}`,
763
+ 'data-testid': 'spar-btn',
764
+ 'data-member-id': r.memberId,
765
+ 'data-round': r.roundNumber || '',
766
+ 'data-turn': r.turnNumber || '',
767
+ },
768
+ '⚔ Spar',
769
+ );
770
+ sparBtn.addEventListener('click', () =>
771
+ openSparringPanel({
772
+ discussion: state.currentDiscussion,
773
+ memberId: r.memberId,
774
+ memberName: r.memberName,
775
+ anchorRoundNumber: r.roundNumber,
776
+ anchorTurnNumber: r.turnNumber,
777
+ }),
778
+ );
779
+ nameRow.appendChild(sparBtn);
780
+ }
781
+ body.appendChild(nameRow);
782
+
783
+ const bubble = h('div', { class: 'bubble' }, r.content);
784
+ body.appendChild(bubble);
785
+
786
+ // Structured details
787
+ const sd = r.structuredData;
788
+ if (sd && (sd.keyPoints?.length || sd.questionsForOthers?.length || sd.actionSteps?.length || sd.confidence != null)) {
789
+ const struct = h('div', { class: 'struct' });
790
+ if (sd.keyPoints?.length) struct.appendChild(structSection('Key points', sd.keyPoints, '•'));
791
+ if (sd.questionsForOthers?.length) struct.appendChild(structSection('Questions for others', sd.questionsForOthers, '?'));
792
+ if (sd.actionSteps?.length) struct.appendChild(structSection('Action steps', sd.actionSteps, '→'));
793
+ if (typeof sd.confidence === 'number') {
794
+ const row = h('div', { class: 'confidence-row' });
795
+ row.appendChild(h('span', {}, `Confidence ${sd.confidence}%`));
796
+ const bar = h('div', { class: 'confidence-bar' });
797
+ bar.appendChild(h('div', { class: 'confidence-bar-fill', style: `width: ${sd.confidence}%` }));
798
+ row.appendChild(bar);
799
+ struct.appendChild(row);
800
+ }
801
+ body.appendChild(struct);
802
+ }
803
+
804
+ wrap.appendChild(body);
805
+ return wrap;
806
+ }
807
+
808
+ function structSection(title, items, marker) {
809
+ const sec = h('div', { class: 'struct-section' });
810
+ sec.appendChild(h('div', { class: 'struct-section-title' }, title));
811
+ const ul = h('ul');
812
+ items.forEach((it) => ul.appendChild(h('li', {}, `${marker ? marker + ' ' : ''}${it}`)));
813
+ sec.appendChild(ul);
814
+ return sec;
815
+ }
816
+
817
+ function typingBubble(memberName) {
818
+ const member = state.members.find((m) => m.name === memberName) || { name: memberName };
819
+ const color = member.color || colorForMember(memberName);
820
+ const initials = member.initials || initialsOf(memberName);
821
+ const slug = memberSlug(memberName);
822
+
823
+ const wrap = h('div', {
824
+ class: 'message',
825
+ 'data-typing-for': memberName,
826
+ 'data-testid': `member-typing-${slug}`,
827
+ role: 'status',
828
+ 'aria-live': 'polite',
829
+ 'aria-label': `${memberName} is thinking`,
830
+ });
831
+ wrap.appendChild(h('div', { class: 'avatar', 'data-color': color, 'aria-hidden': 'true' }, initials));
832
+
833
+ const body = h('div', { class: 'message-body' });
834
+ body.appendChild(h('div', { class: 'message-name' }, memberName));
835
+
836
+ // Inline activity label + animated dots — both inside the bubble shape so
837
+ // the shape doesn't shift when the label changes.
838
+ const bubble = h('div', { class: 'typing-bubble' });
839
+ const activityLabel = h(
840
+ 'span',
841
+ { class: 'typing-activity', 'data-activity-for': memberName },
842
+ 'thinking',
843
+ );
844
+ bubble.appendChild(activityLabel);
845
+ const dots = h('div', { class: 'typing' });
846
+ dots.appendChild(h('span'));
847
+ dots.appendChild(h('span'));
848
+ dots.appendChild(h('span'));
849
+ bubble.appendChild(dots);
850
+ body.appendChild(bubble);
851
+
852
+ // Optional secondary line (e.g. the search query, the file path)
853
+ body.appendChild(h('div', { class: 'typing-detail', 'data-detail-for': memberName }));
854
+
855
+ wrap.appendChild(body);
856
+ return wrap;
857
+ }
858
+
859
+ function updateTypingActivity(memberName, activity, detail) {
860
+ const label = document.querySelector(`[data-activity-for="${cssEscape(memberName)}"]`);
861
+ if (label) label.textContent = (activity || 'thinking').replace(/[.…]+$/, '');
862
+ const detailEl = document.querySelector(`[data-detail-for="${cssEscape(memberName)}"]`);
863
+ if (detailEl) {
864
+ if (detail) {
865
+ const truncated = String(detail).length > 80 ? String(detail).slice(0, 80) + '…' : String(detail);
866
+ detailEl.textContent = truncated;
867
+ detailEl.style.display = 'block';
868
+ } else {
869
+ detailEl.textContent = '';
870
+ detailEl.style.display = 'none';
871
+ }
872
+ }
873
+ }
874
+
875
+ function cssEscape(s) {
876
+ // Minimal CSS attribute-value escape for double-quote selectors.
877
+ return String(s).replace(/(["\\])/g, '\\$1');
878
+ }
879
+
880
+ function orchestratorBubble(decision, roundNumber) {
881
+ const text = decisionLabel(decision);
882
+ const round = roundNumber ?? decision.roundNumber ?? '';
883
+ return h(
884
+ 'div',
885
+ {
886
+ class: 'message',
887
+ 'data-testid': round !== '' ? `orchestrator-decision-${round}` : 'orchestrator-decision',
888
+ 'data-action': decision.action || '',
889
+ role: 'status',
890
+ 'aria-live': 'polite',
891
+ },
892
+ [
893
+ h('div', { class: 'avatar', 'data-color': 'purple', 'aria-hidden': 'true' }, '⚙'),
894
+ h('div', { class: 'message-body' }, [
895
+ h('div', { class: 'message-name' }, [
896
+ 'Orchestrator',
897
+ h('span', { class: 'message-meta' }, `confidence ${decision.confidence ?? '–'}%`),
898
+ ]),
899
+ h('div', { class: 'system-bubble' }, text),
900
+ ]),
901
+ ],
902
+ );
903
+ }
904
+
905
+ function pendingRequestBubble(req) {
906
+ const lines = [
907
+ h('div', { class: 'message-name' }, '⚠ The board is asking you a question'),
908
+ h('div', { class: 'bubble' }, [
909
+ h('strong', {}, req.question),
910
+ ...(req.context ? [h('div', { class: 'message-meta', style: 'display:block;margin-top:6px' }, req.context)] : []),
911
+ ...(req.options?.length
912
+ ? [
913
+ h('div', { style: 'margin-top: 10px' }, [
914
+ h('div', { class: 'struct-section-title' }, 'Options'),
915
+ h(
916
+ 'ol',
917
+ { style: 'padding-left: 22px; margin: 0' },
918
+ req.options.map((o) => h('li', {}, o)),
919
+ ),
920
+ ]),
921
+ ]
922
+ : []),
923
+ ]),
924
+ ];
925
+ return h(
926
+ 'div',
927
+ {
928
+ class: 'message',
929
+ 'data-testid': 'hitl-prompt',
930
+ role: 'status',
931
+ 'aria-live': 'polite',
932
+ },
933
+ [
934
+ h('div', { class: 'avatar', 'data-color': 'yellow', 'aria-hidden': 'true' }, '!'),
935
+ h('div', { class: 'message-body' }, lines),
936
+ ],
937
+ );
938
+ }
939
+
940
+ function decisionLabel(d) {
941
+ const action = (d.action || 'continue').toUpperCase();
942
+ return `${action}${d.reasoning ? ' — ' + d.reasoning : ''}`;
943
+ }
944
+
945
+ function addTypingBubble(memberName) {
946
+ const stream = $('#chat-stream');
947
+ if (!stream) return;
948
+ const existing = state.pendingTyping.get(memberName);
949
+ if (existing) return; // already showing
950
+ const bubble = typingBubble(memberName);
951
+ stream.appendChild(bubble);
952
+ state.pendingTyping.set(memberName, bubble);
953
+ scrollChat();
954
+ }
955
+
956
+ function replaceTypingWithResponse(memberName, response) {
957
+ const stream = $('#chat-stream');
958
+ if (!stream) return;
959
+ // Primary: state.pendingTyping (Map keyed by name). Fallback: DOM search
960
+ // by data-typing-for attribute (in case the Map ever drifts out of sync —
961
+ // belt and suspenders).
962
+ let typing = memberName ? state.pendingTyping.get(memberName) : null;
963
+ if (!typing && memberName) {
964
+ typing = stream.querySelector(`[data-typing-for="${cssEscape(memberName)}"]`);
965
+ }
966
+ const bubble = messageBubble(response);
967
+ if (typing) {
968
+ typing.replaceWith(bubble);
969
+ if (memberName) state.pendingTyping.delete(memberName);
970
+ } else {
971
+ stream.appendChild(bubble);
972
+ }
973
+ scrollChat();
974
+ }
975
+
976
+ function addSystemLine(text) {
977
+ const stream = $('#chat-stream');
978
+ if (!stream) return;
979
+ stream.appendChild(
980
+ h('div', { class: 'message' }, [
981
+ h('div', { class: 'avatar', 'data-color': 'purple' }, '⚙'),
982
+ h('div', { class: 'message-body' }, [h('div', { class: 'system-bubble' }, text)]),
983
+ ]),
984
+ );
985
+ scrollChat();
986
+ }
987
+
988
+ function addOrchestratorDecision(decision, roundNumber) {
989
+ const stream = $('#chat-stream');
990
+ if (!stream) return;
991
+ // Remove the "analyzing" system line if present
992
+ const lastSys = $$('.system-bubble', stream).pop();
993
+ if (lastSys && lastSys.textContent.includes('analyzing')) {
994
+ lastSys.closest('.message')?.remove();
995
+ }
996
+ stream.appendChild(orchestratorBubble(decision, roundNumber));
997
+ scrollChat();
998
+ }
999
+
1000
+ function finalizeChat(msg, opts = {}) {
1001
+ // Any typing bubbles that never got a matching member_response (e.g., a
1002
+ // member failed silently) become orphans — clear them so the user isn't
1003
+ // staring at perpetually-thinking dots.
1004
+ state.pendingTyping.forEach((bubble, name) => {
1005
+ bubble.replaceWith(
1006
+ h('div', { class: 'message' }, [
1007
+ h('div', { class: 'avatar', 'data-color': 'red' }, '✗'),
1008
+ h('div', { class: 'message-body' }, [
1009
+ h('div', { class: 'message-name' }, name),
1010
+ h('div', { class: 'system-bubble' }, 'No response — this member failed or timed out.'),
1011
+ ]),
1012
+ ]),
1013
+ );
1014
+ });
1015
+ state.pendingTyping.clear();
1016
+
1017
+ // Re-fetch the bootstrap state so the discussion list refreshes
1018
+ fetchJSON('/api/state').then((data) => {
1019
+ state.discussions = data.discussions;
1020
+ });
1021
+ const stream = $('#chat-stream');
1022
+ if (stream && !opts.gated) {
1023
+ const cost = msg.costUsd != null ? `$${Number(msg.costUsd).toFixed(4)}` : '$0.00';
1024
+ const dur = msg.durationMs ? formatDuration(msg.durationMs) : '';
1025
+ stream.appendChild(
1026
+ h(
1027
+ 'div',
1028
+ { class: 'message' },
1029
+ [
1030
+ h('div', { class: 'avatar', 'data-color': 'green' }, '✓'),
1031
+ h('div', { class: 'message-body' }, [h('div', { class: 'system-bubble' }, `Round saved · ${dur} · ${cost}`)]),
1032
+ ],
1033
+ ),
1034
+ );
1035
+ scrollChat();
1036
+ }
1037
+ if (msg.discussion?.pendingUserRequest && stream) {
1038
+ stream.appendChild(pendingRequestBubble(msg.discussion.pendingUserRequest));
1039
+ scrollChat();
1040
+ }
1041
+ // Refresh the action footer so Continue / Respond / concluded state matches
1042
+ // the current discussion — the engine may have just changed any of them.
1043
+ if (msg.discussion) {
1044
+ refreshChatFooter(msg.discussion);
1045
+ }
1046
+ setActionButtonsBusy(false);
1047
+ }
1048
+
1049
+ function refreshChatFooter(discussion) {
1050
+ const oldFooter = $('#chat-footer');
1051
+ if (!oldFooter) return;
1052
+ oldFooter.replaceWith(renderChatFooter(discussion));
1053
+ }
1054
+
1055
+ function scrollChat() {
1056
+ requestAnimationFrame(() => {
1057
+ const stream = $('#chat-stream');
1058
+ if (stream) stream.scrollTop = stream.scrollHeight;
1059
+ });
1060
+ }
1061
+
1062
+ function updateDiscussionList(discussion) {
1063
+ const idx = state.discussions.findIndex((d) => d.id === discussion.id);
1064
+ if (idx >= 0) state.discussions[idx] = discussion;
1065
+ else state.discussions.unshift(discussion);
1066
+ }
1067
+
1068
+ // ------------------------------------------------------------------
1069
+ // New-discussion modal
1070
+ // ------------------------------------------------------------------
1071
+
1072
+ function setupModal() {
1073
+ $('#modal-close').addEventListener('click', closeModal);
1074
+ $('#modal-cancel').addEventListener('click', closeModal);
1075
+ $('#modal-submit').addEventListener('click', submitNewDiscussion);
1076
+ }
1077
+
1078
+ async function openNewDiscussionModal() {
1079
+ // Always refresh members from server first — protects against stale state
1080
+ // if the user just edited members in another tab.
1081
+ await refreshState({ silent: true });
1082
+ renderWorkspaceCard();
1083
+
1084
+ const modal = $('#new-discussion-modal');
1085
+ modal.hidden = false;
1086
+ $('#new-question').value = '';
1087
+
1088
+ const chips = $('#member-chips');
1089
+ chips.innerHTML = '';
1090
+ const submit = $('#modal-submit');
1091
+ const active = (state.members || []).filter((m) => m.isActive);
1092
+
1093
+ if (active.length === 0) {
1094
+ submit.disabled = true;
1095
+ const wsRoot = state.workspace?.root || '<unknown>';
1096
+ const wsId = state.workspace?.id || '<unknown>';
1097
+ chips.appendChild(
1098
+ h('div', { class: 'modal-empty' }, [
1099
+ h('div', { class: 'modal-empty-title' }, '⚠ No active members in this workspace'),
1100
+ h('div', { class: 'modal-empty-meta' }, `Workspace: ${wsId}`),
1101
+ h('div', { class: 'modal-empty-meta', style: 'font-family: var(--font-mono); font-size:11px' }, wsRoot),
1102
+ h('div', { class: 'modal-empty-meta', style: 'margin-top:8px' },
1103
+ 'Either this is the wrong workspace, or no members were seeded. Click "Board members" in the sidebar to add one, or run `aab init` from this directory.'),
1104
+ ]),
1105
+ );
1106
+ $('#new-question').focus();
1107
+ return;
1108
+ }
1109
+
1110
+ submit.disabled = false;
1111
+ for (const m of active) {
1112
+ const chip = h('button', {
1113
+ class: 'chip selected',
1114
+ type: 'button',
1115
+ role: 'checkbox',
1116
+ 'aria-checked': 'true',
1117
+ 'aria-label': `Toggle ${m.name}`,
1118
+ 'data-member-id': m.id,
1119
+ 'data-testid': `new-discussion-member-${memberSlug(m.name)}`,
1120
+ });
1121
+ chip.appendChild(
1122
+ h('div', { class: 'avatar', 'data-color': m.color || colorForMember(m.name), 'aria-hidden': 'true' }, m.initials || initialsOf(m.name)),
1123
+ );
1124
+ chip.appendChild(h('span', {}, m.name));
1125
+ chip.addEventListener('click', () => {
1126
+ chip.classList.toggle('selected');
1127
+ chip.setAttribute('aria-checked', chip.classList.contains('selected') ? 'true' : 'false');
1128
+ });
1129
+ chips.appendChild(chip);
1130
+ }
1131
+ $('#new-question').focus();
1132
+ }
1133
+
1134
+ function closeModal() {
1135
+ $('#new-discussion-modal').hidden = true;
1136
+ }
1137
+
1138
+ async function submitNewDiscussion() {
1139
+ const question = $('#new-question').value.trim();
1140
+ if (!question) {
1141
+ toast('Please enter a question.', 'err');
1142
+ return;
1143
+ }
1144
+ const memberIds = $$('#member-chips .chip.selected').map((c) => c.dataset.memberId);
1145
+ if (memberIds.length === 0) {
1146
+ toast('Select at least one member.', 'err');
1147
+ return;
1148
+ }
1149
+
1150
+ const submit = $('#modal-submit');
1151
+ submit.disabled = true;
1152
+ submit.textContent = 'Starting…';
1153
+
1154
+ // Open the chat view BEFORE the request so #chat-stream exists by the time
1155
+ // any `member_thinking` events arrive over WS. Pre-create the typing
1156
+ // bubbles too so the user sees activity immediately, not a blank stream.
1157
+ closeModal();
1158
+ const selectedMembers = state.members.filter((m) => memberIds.includes(m.id));
1159
+ startNewChatView(question, selectedMembers);
1160
+ for (const m of selectedMembers) addTypingBubble(m.name);
1161
+
1162
+ try {
1163
+ await fetchJSON('/api/discussions', {
1164
+ method: 'POST',
1165
+ headers: { 'content-type': 'application/json' },
1166
+ body: JSON.stringify({ question, memberIds }),
1167
+ });
1168
+ toast('Discussion started — members are thinking…', 'ok');
1169
+ } catch (e) {
1170
+ toast('Failed to start: ' + e.message, 'err');
1171
+ // Server rejected — clear the optimistic typing bubbles
1172
+ state.pendingTyping.forEach((b) => b.remove());
1173
+ state.pendingTyping.clear();
1174
+ appendToStream(
1175
+ h('div', { class: 'message' }, [
1176
+ h('div', { class: 'avatar', 'data-color': 'red' }, '✗'),
1177
+ h('div', { class: 'message-body' }, [
1178
+ h('div', { class: 'system-bubble' }, 'Could not start the discussion: ' + e.message),
1179
+ ]),
1180
+ ]),
1181
+ );
1182
+ } finally {
1183
+ submit.disabled = false;
1184
+ submit.textContent = 'Start discussion';
1185
+ }
1186
+ }
1187
+
1188
+ // ------------------------------------------------------------------
1189
+ // Members view
1190
+ // ------------------------------------------------------------------
1191
+
1192
+ function renderMembersView(main) {
1193
+ const view = h('div', { class: 'view' });
1194
+ const activeCount = state.members.filter((m) => m.isActive).length;
1195
+ const header = h('div', { class: 'view-header' }, [
1196
+ h('div', {}, [
1197
+ h('div', { class: 'view-title' }, 'Board members'),
1198
+ h('div', { class: 'view-subtitle' }, `${activeCount} active · ${state.members.length} total`),
1199
+ ]),
1200
+ ]);
1201
+ const headerActions = h('div', { class: 'header-actions' });
1202
+ const syncBtn = h('button', { class: 'btn-secondary', 'data-testid': 'members-sync-btn', title: 'Regenerate all .claude/agents/<slug>.md files' }, '↻ Regenerate agent files');
1203
+ syncBtn.addEventListener('click', async () => {
1204
+ syncBtn.disabled = true;
1205
+ const prev = syncBtn.textContent;
1206
+ syncBtn.textContent = 'Regenerating…';
1207
+ try {
1208
+ const r = await fetchJSON('/api/members/sync-agents', {
1209
+ method: 'POST',
1210
+ headers: { 'content-type': 'application/json' },
1211
+ body: JSON.stringify({ all: false }),
1212
+ });
1213
+ toast(`Wrote ${r.written}/${r.total} agent files (${r.skipped} skipped).`, 'ok');
1214
+ } catch (e) {
1215
+ toast('Sync failed: ' + e.message, 'err');
1216
+ } finally {
1217
+ syncBtn.disabled = false;
1218
+ syncBtn.textContent = prev;
1219
+ }
1220
+ });
1221
+ const addBtn = h('button', { class: 'btn-primary', 'data-testid': 'members-add-btn' }, '+ Add member');
1222
+ addBtn.addEventListener('click', () => openMemberEditModal(null));
1223
+ headerActions.appendChild(syncBtn);
1224
+ headerActions.appendChild(addBtn);
1225
+ header.appendChild(headerActions);
1226
+ view.appendChild(header);
1227
+
1228
+ const body = h('div', { class: 'view-body' });
1229
+ if (state.members.length === 0) {
1230
+ const empty = emptyState(
1231
+ '👥',
1232
+ 'No members in this workspace',
1233
+ `Add one now, or run \`aab init\` to seed Elon, Julian, and Alexandra.`,
1234
+ );
1235
+ const seedBtn = h('button', { class: 'btn-primary', style: 'margin-top:12px' }, '+ Add a member');
1236
+ seedBtn.addEventListener('click', () => openMemberEditModal(null));
1237
+ empty.appendChild(seedBtn);
1238
+ body.appendChild(empty);
1239
+ } else {
1240
+ const grid = h('div', { class: 'members-grid' });
1241
+ for (const m of state.members) grid.appendChild(memberCard(m));
1242
+ body.appendChild(grid);
1243
+ }
1244
+ view.appendChild(body);
1245
+ main.appendChild(view);
1246
+ }
1247
+
1248
+ function memberCard(m) {
1249
+ const card = h('div', { class: 'member-card' + (m.isActive ? '' : ' is-inactive') });
1250
+
1251
+ const head = h('div', { class: 'member-card-head' });
1252
+ head.appendChild(h('div', { class: 'avatar', 'data-color': m.color || colorForMember(m.name) }, m.initials || initialsOf(m.name)));
1253
+ head.appendChild(
1254
+ h('div', { class: 'member-card-headtext' }, [
1255
+ h('div', { class: 'member-card-name' }, m.name),
1256
+ h('div', { class: 'member-card-title' }, m.title),
1257
+ ]),
1258
+ );
1259
+ // Active toggle (top-right of card)
1260
+ const toggle = h(
1261
+ 'label',
1262
+ { class: 'switch', title: m.isActive ? 'Deactivate' : 'Activate' },
1263
+ [
1264
+ h('input', { type: 'checkbox' }),
1265
+ h('span', { class: 'switch-track' }),
1266
+ ],
1267
+ );
1268
+ const checkbox = toggle.querySelector('input');
1269
+ checkbox.checked = !!m.isActive;
1270
+ checkbox.addEventListener('change', async () => {
1271
+ try {
1272
+ await fetchJSON(`/api/members/${m.id}`, {
1273
+ method: 'PATCH',
1274
+ headers: { 'content-type': 'application/json' },
1275
+ body: JSON.stringify({ isActive: checkbox.checked }),
1276
+ });
1277
+ m.isActive = checkbox.checked;
1278
+ // Refresh the card class without re-rendering everything
1279
+ card.classList.toggle('is-inactive', !m.isActive);
1280
+ renderWorkspaceCard();
1281
+ toast(`${m.name} ${m.isActive ? 'activated' : 'deactivated'}.`, 'ok');
1282
+ } catch (e) {
1283
+ checkbox.checked = !checkbox.checked;
1284
+ toast('Could not update: ' + e.message, 'err');
1285
+ }
1286
+ });
1287
+ head.appendChild(toggle);
1288
+ card.appendChild(head);
1289
+
1290
+ const exp = h('div', { class: 'expertise' });
1291
+ (m.expertise || []).forEach((e) => exp.appendChild(h('span', { class: 'expertise-tag' }, e)));
1292
+ card.appendChild(exp);
1293
+
1294
+ card.appendChild(h('div', { class: 'persona-preview' }, m.persona));
1295
+
1296
+ // Action buttons row
1297
+ const actions = h('div', { class: 'card-actions' });
1298
+ const editBtn = h('button', { class: 'btn-secondary', 'data-testid': 'member-edit-btn' }, 'Edit');
1299
+ editBtn.addEventListener('click', () => openMemberEditModal(m));
1300
+ const voiceBtn = h('button', { class: 'btn-secondary', 'data-testid': 'member-voice-btn', title: 'Regenerate voice guide with the fast model' }, '🔊 Voice');
1301
+ voiceBtn.addEventListener('click', async (ev) => {
1302
+ ev.stopPropagation();
1303
+ voiceBtn.disabled = true;
1304
+ const prev = voiceBtn.textContent;
1305
+ voiceBtn.textContent = 'Refreshing…';
1306
+ try {
1307
+ const r = await fetchJSON(`/api/members/${m.id}/regenerate-voice`, {
1308
+ method: 'POST',
1309
+ headers: { 'content-type': 'application/json' },
1310
+ body: JSON.stringify({ preview: true }),
1311
+ });
1312
+ const ok = window.confirm(`New voice guide:\n\n${r.voiceGuide}\n\nSave?`);
1313
+ if (ok) {
1314
+ await fetchJSON(`/api/members/${m.id}/regenerate-voice`, { method: 'POST' });
1315
+ toast(`${m.name}: voice guide saved.`, 'ok');
1316
+ await refreshState({ silent: true });
1317
+ if (state.route === 'members') navigate('members');
1318
+ } else {
1319
+ toast('Voice guide preview only — not saved.', '');
1320
+ }
1321
+ } catch (e) {
1322
+ toast('Voice failed: ' + e.message, 'err');
1323
+ } finally {
1324
+ voiceBtn.disabled = false;
1325
+ voiceBtn.textContent = prev;
1326
+ }
1327
+ });
1328
+ const delBtn = h('button', { class: 'btn-danger-ghost', 'data-testid': 'member-delete-btn' }, 'Delete');
1329
+ delBtn.addEventListener('click', () =>
1330
+ openConfirmModal({
1331
+ title: `Delete ${m.name}?`,
1332
+ message: `This removes the member, their .claude/agents/${m.slug || initialsOf(m.name)}.md file (if AAB-generated), and they won't appear in future discussions.`,
1333
+ okLabel: 'Delete',
1334
+ onOk: async () => {
1335
+ try {
1336
+ await fetchJSON(`/api/members/${m.id}`, { method: 'DELETE' });
1337
+ await refreshState({ silent: true });
1338
+ renderWorkspaceCard();
1339
+ navigate('members');
1340
+ toast(`${m.name} deleted.`, 'ok');
1341
+ } catch (e) {
1342
+ toast('Could not delete: ' + e.message, 'err');
1343
+ }
1344
+ },
1345
+ }),
1346
+ );
1347
+ actions.appendChild(editBtn);
1348
+ actions.appendChild(voiceBtn);
1349
+ actions.appendChild(delBtn);
1350
+ card.appendChild(actions);
1351
+
1352
+ return card;
1353
+ }
1354
+
1355
+ // ------------------------------------------------------------------
1356
+ // Actions (kanban) view
1357
+ // ------------------------------------------------------------------
1358
+
1359
+ function renderActionsView(main) {
1360
+ const view = h('div', { class: 'view', 'data-testid': 'actions-view' });
1361
+ const header = h('div', { class: 'view-header' });
1362
+ header.appendChild(
1363
+ h('div', {}, [
1364
+ h('div', { class: 'view-title' }, 'Action Board'),
1365
+ h(
1366
+ 'div',
1367
+ { class: 'view-subtitle' },
1368
+ `${state.actionItems.length} action item${state.actionItems.length === 1 ? '' : 's'}`,
1369
+ ),
1370
+ ]),
1371
+ );
1372
+ const headerActions = h('div', { class: 'header-actions' });
1373
+ const addBtn = h(
1374
+ 'button',
1375
+ { class: 'btn-primary', 'data-testid': 'actions-add-btn' },
1376
+ '+ Add action',
1377
+ );
1378
+ addBtn.addEventListener('click', () => openActionEditModal(null));
1379
+ headerActions.appendChild(addBtn);
1380
+ header.appendChild(headerActions);
1381
+ view.appendChild(header);
1382
+
1383
+ const body = h('div', { class: 'view-body' });
1384
+
1385
+ if (state.actionItems.length === 0) {
1386
+ body.appendChild(
1387
+ emptyState(
1388
+ '📋',
1389
+ 'No action items yet',
1390
+ 'Click "+ Add action", or open a concluded discussion and use "Extract actions".',
1391
+ ),
1392
+ );
1393
+ } else {
1394
+ body.appendChild(renderKanbanBoard(state.actionItems));
1395
+ }
1396
+
1397
+ view.appendChild(body);
1398
+ main.appendChild(view);
1399
+ }
1400
+
1401
+ function renderKanbanBoard(items) {
1402
+ const board = h('div', { class: 'kanban', 'data-testid': 'kanban-board' });
1403
+ for (const status of ['pending', 'in-progress', 'completed']) {
1404
+ const colItems = items.filter((a) => a.status === status);
1405
+ const col = h('div', {
1406
+ class: 'kanban-col',
1407
+ 'data-testid': `kanban-col-${status}`,
1408
+ 'data-status': status,
1409
+ });
1410
+ col.appendChild(
1411
+ h('div', { class: 'kanban-col-head' }, [
1412
+ h('span', {}, status),
1413
+ h('span', { class: 'kanban-col-count' }, String(colItems.length)),
1414
+ ]),
1415
+ );
1416
+ const cards = h('div', { class: 'kanban-cards', 'data-status': status });
1417
+ for (const a of colItems) cards.appendChild(actionCard(a));
1418
+ col.appendChild(cards);
1419
+ wireDropTarget(col, status);
1420
+ wireDropTarget(cards, status);
1421
+ board.appendChild(col);
1422
+ }
1423
+ return board;
1424
+ }
1425
+
1426
+ function actionCard(a) {
1427
+ const card = h('div', {
1428
+ class: 'kanban-card',
1429
+ 'data-testid': 'kanban-card',
1430
+ 'data-action-id': a.id,
1431
+ 'data-priority': a.priority,
1432
+ draggable: 'true',
1433
+ });
1434
+ card.appendChild(
1435
+ h('div', { class: 'kanban-card-title', 'data-testid': 'kanban-card-title' }, a.title),
1436
+ );
1437
+ if (a.description) {
1438
+ card.appendChild(
1439
+ h('div', { class: 'kanban-card-desc' }, ellipsisJs(a.description, 140)),
1440
+ );
1441
+ }
1442
+ const meta = h('div', { class: 'kanban-card-meta' });
1443
+ meta.appendChild(h('span', { class: 'priority-mark ' + a.priority }));
1444
+ meta.appendChild(h('span', {}, a.priority));
1445
+ if (a.dueDate) meta.appendChild(h('span', {}, '· due ' + a.dueDate.slice(0, 10)));
1446
+ if (a.assignedTo) meta.appendChild(h('span', {}, '· ' + a.assignedTo));
1447
+ card.appendChild(meta);
1448
+ if (a.linkedSkill) {
1449
+ card.appendChild(h('div', { class: 'message-meta' }, `🧠 skill: ${a.linkedSkill.name}`));
1450
+ }
1451
+ // Phase 5 — Plan + Solve buttons (visible on every action card).
1452
+ const actionsRow = h('div', { class: 'kanban-card-actions' });
1453
+ const planBtn = h('button', {
1454
+ class: 'kanban-card-action btn-secondary',
1455
+ 'data-testid': 'plan-btn',
1456
+ 'data-action-id': a.id,
1457
+ type: 'button',
1458
+ }, '🔭 Plan');
1459
+ planBtn.addEventListener('click', (ev) => { ev.stopPropagation(); launchSkillPlan(a); });
1460
+ const solveBtn = h('button', {
1461
+ class: 'kanban-card-action btn-primary',
1462
+ 'data-testid': 'solve-btn',
1463
+ 'data-action-id': a.id,
1464
+ type: 'button',
1465
+ }, '⚡ Solve');
1466
+ solveBtn.addEventListener('click', (ev) => { ev.stopPropagation(); launchSkillSolve(a); });
1467
+ actionsRow.appendChild(planBtn);
1468
+ actionsRow.appendChild(solveBtn);
1469
+ card.appendChild(actionsRow);
1470
+ card.addEventListener('click', (ev) => {
1471
+ if (ev.target.closest('.kanban-card-action')) return;
1472
+ openActionEditModal(a);
1473
+ });
1474
+ card.addEventListener('dragstart', (ev) => {
1475
+ card.classList.add('dragging');
1476
+ ev.dataTransfer.setData('text/plain', a.id);
1477
+ ev.dataTransfer.effectAllowed = 'move';
1478
+ });
1479
+ card.addEventListener('dragend', () => {
1480
+ card.classList.remove('dragging');
1481
+ });
1482
+ return card;
1483
+ }
1484
+
1485
+ function wireDropTarget(el, status) {
1486
+ el.addEventListener('dragover', (ev) => {
1487
+ ev.preventDefault();
1488
+ ev.dataTransfer.dropEffect = 'move';
1489
+ el.classList.add('drop-target');
1490
+ });
1491
+ el.addEventListener('dragleave', () => el.classList.remove('drop-target'));
1492
+ el.addEventListener('drop', async (ev) => {
1493
+ ev.preventDefault();
1494
+ el.classList.remove('drop-target');
1495
+ const id = ev.dataTransfer.getData('text/plain');
1496
+ if (!id) return;
1497
+ const item = state.actionItems.find((a) => a.id === id);
1498
+ if (!item || item.status === status) return;
1499
+ // Optimistic update.
1500
+ const prevStatus = item.status;
1501
+ item.status = status;
1502
+ try {
1503
+ navigate('actions');
1504
+ await fetchJSON(`/api/actions/${id}`, {
1505
+ method: 'PATCH',
1506
+ headers: { 'content-type': 'application/json' },
1507
+ body: JSON.stringify({ status }),
1508
+ });
1509
+ toast(`Moved to ${status}`, 'ok');
1510
+ } catch (e) {
1511
+ item.status = prevStatus;
1512
+ toast('Move failed: ' + e.message, 'err');
1513
+ navigate('actions');
1514
+ }
1515
+ });
1516
+ }
1517
+
1518
+ function ellipsisJs(s, max) {
1519
+ return s.length > max ? s.slice(0, Math.max(0, max - 1)) + '…' : s;
1520
+ }
1521
+
1522
+ // Action-item add / edit modal — single-form panel covering all CRUD fields.
1523
+ function openActionEditModal(item) {
1524
+ const isEdit = !!item;
1525
+ const backdrop = h('div', {
1526
+ class: 'modal-backdrop',
1527
+ 'data-testid': 'action-edit-modal',
1528
+ });
1529
+ const close = () => backdrop.remove();
1530
+ const inner = h('div', { class: 'modal' });
1531
+
1532
+ const titleInput = h('input', {
1533
+ type: 'text',
1534
+ class: 'modal-input',
1535
+ 'data-testid': 'action-title-input',
1536
+ placeholder: 'Action title',
1537
+ value: item?.title || '',
1538
+ });
1539
+ const descInput = h('textarea', {
1540
+ rows: '4',
1541
+ 'data-testid': 'action-desc-input',
1542
+ placeholder: 'Description (what needs doing?)',
1543
+ });
1544
+ descInput.value = item?.description || '';
1545
+ const prioritySelect = h('select', {
1546
+ class: 'modal-select',
1547
+ 'data-testid': 'action-priority-select',
1548
+ });
1549
+ for (const p of ['low', 'medium', 'high']) {
1550
+ const opt = h('option', { value: p }, p);
1551
+ if ((item?.priority || 'medium') === p) opt.selected = true;
1552
+ prioritySelect.appendChild(opt);
1553
+ }
1554
+ const statusSelect = h('select', { class: 'modal-select', 'data-testid': 'action-status-select' });
1555
+ for (const s of ['pending', 'in-progress', 'completed']) {
1556
+ const opt = h('option', { value: s }, s);
1557
+ if ((item?.status || 'pending') === s) opt.selected = true;
1558
+ statusSelect.appendChild(opt);
1559
+ }
1560
+ const dueInput = h('input', {
1561
+ type: 'date',
1562
+ class: 'modal-input',
1563
+ 'data-testid': 'action-due-input',
1564
+ value: item?.dueDate?.slice(0, 10) || '',
1565
+ });
1566
+ const assigneeInput = h('input', {
1567
+ type: 'text',
1568
+ class: 'modal-input',
1569
+ placeholder: 'Assignee (optional)',
1570
+ 'data-testid': 'action-assignee-input',
1571
+ value: item?.assignedTo || '',
1572
+ });
1573
+
1574
+ const header = h('div', { class: 'modal-header' });
1575
+ header.appendChild(h('h2', {}, isEdit ? 'Edit action' : 'New action item'));
1576
+ const closeBtn = h(
1577
+ 'button',
1578
+ { class: 'icon-btn', 'aria-label': 'Close', type: 'button', 'data-testid': 'action-edit-close' },
1579
+ '×',
1580
+ );
1581
+ closeBtn.addEventListener('click', close);
1582
+ header.appendChild(closeBtn);
1583
+ inner.appendChild(header);
1584
+
1585
+ const body = h('div', { class: 'modal-body' });
1586
+ body.appendChild(h('label', { class: 'field-label' }, 'Title'));
1587
+ body.appendChild(titleInput);
1588
+ body.appendChild(h('label', { class: 'field-label' }, 'Description'));
1589
+ body.appendChild(descInput);
1590
+ const row1 = h('div', { class: 'modal-row' });
1591
+ row1.appendChild(
1592
+ h('div', {}, [h('label', { class: 'field-label' }, 'Priority'), prioritySelect]),
1593
+ );
1594
+ row1.appendChild(h('div', {}, [h('label', { class: 'field-label' }, 'Status'), statusSelect]));
1595
+ body.appendChild(row1);
1596
+ const row2 = h('div', { class: 'modal-row' });
1597
+ row2.appendChild(h('div', {}, [h('label', { class: 'field-label' }, 'Due date'), dueInput]));
1598
+ row2.appendChild(h('div', {}, [h('label', { class: 'field-label' }, 'Assignee'), assigneeInput]));
1599
+ body.appendChild(row2);
1600
+ inner.appendChild(body);
1601
+
1602
+ const foot = h('div', { class: 'modal-footer' });
1603
+ if (isEdit) {
1604
+ const delBtn = h(
1605
+ 'button',
1606
+ { class: 'btn-secondary', type: 'button', 'data-testid': 'action-delete-btn' },
1607
+ 'Delete',
1608
+ );
1609
+ delBtn.addEventListener('click', async () => {
1610
+ if (!window.confirm('Delete this action item?')) return;
1611
+ try {
1612
+ await fetchJSON(`/api/actions/${item.id}`, { method: 'DELETE' });
1613
+ toast('Deleted', 'ok');
1614
+ await refreshState({ silent: true });
1615
+ close();
1616
+ navigate('actions');
1617
+ } catch (e) {
1618
+ toast('Delete failed: ' + e.message, 'err');
1619
+ }
1620
+ });
1621
+ foot.appendChild(delBtn);
1622
+ foot.appendChild(h('div', { style: 'flex:1' }));
1623
+ }
1624
+ const cancelBtn = h(
1625
+ 'button',
1626
+ { class: 'btn-secondary', type: 'button' },
1627
+ 'Cancel',
1628
+ );
1629
+ cancelBtn.addEventListener('click', close);
1630
+ foot.appendChild(cancelBtn);
1631
+ const saveBtn = h(
1632
+ 'button',
1633
+ { class: 'btn-primary', type: 'button', 'data-testid': 'action-save-btn' },
1634
+ isEdit ? 'Save' : 'Create',
1635
+ );
1636
+ saveBtn.addEventListener('click', async () => {
1637
+ const title = titleInput.value.trim();
1638
+ if (!title) {
1639
+ toast('Title is required.', 'err');
1640
+ return;
1641
+ }
1642
+ const payload = {
1643
+ title,
1644
+ description: descInput.value,
1645
+ priority: prioritySelect.value,
1646
+ status: statusSelect.value,
1647
+ dueDate: dueInput.value || '',
1648
+ assignedTo: assigneeInput.value || '',
1649
+ };
1650
+ saveBtn.disabled = true;
1651
+ try {
1652
+ if (isEdit) {
1653
+ await fetchJSON(`/api/actions/${item.id}`, {
1654
+ method: 'PATCH',
1655
+ headers: { 'content-type': 'application/json' },
1656
+ body: JSON.stringify(payload),
1657
+ });
1658
+ toast('Updated', 'ok');
1659
+ } else {
1660
+ await fetchJSON('/api/actions', {
1661
+ method: 'POST',
1662
+ headers: { 'content-type': 'application/json' },
1663
+ body: JSON.stringify(payload),
1664
+ });
1665
+ toast('Created', 'ok');
1666
+ }
1667
+ await refreshState({ silent: true });
1668
+ close();
1669
+ navigate('actions');
1670
+ } catch (e) {
1671
+ toast('Save failed: ' + e.message, 'err');
1672
+ saveBtn.disabled = false;
1673
+ }
1674
+ });
1675
+ foot.appendChild(saveBtn);
1676
+ inner.appendChild(foot);
1677
+
1678
+ backdrop.appendChild(inner);
1679
+ backdrop.addEventListener('click', (ev) => {
1680
+ if (ev.target === backdrop) close();
1681
+ });
1682
+ document.body.appendChild(backdrop);
1683
+ setTimeout(() => titleInput.focus(), 0);
1684
+ }
1685
+
1686
+ // "Extract actions" modal — runs the analyzer, then lets the user accept/reject each candidate.
1687
+ async function openExtractActionsModal(discussion) {
1688
+ const backdrop = h('div', {
1689
+ class: 'modal-backdrop',
1690
+ 'data-testid': 'extract-actions-modal',
1691
+ });
1692
+ const close = () => backdrop.remove();
1693
+ const inner = h('div', { class: 'modal modal-wide' });
1694
+
1695
+ const header = h('div', { class: 'modal-header' });
1696
+ header.appendChild(h('h2', {}, 'Extract action items'));
1697
+ const closeBtn = h(
1698
+ 'button',
1699
+ { class: 'icon-btn', 'aria-label': 'Close', type: 'button', 'data-testid': 'extract-close-btn' },
1700
+ '×',
1701
+ );
1702
+ closeBtn.addEventListener('click', close);
1703
+ header.appendChild(closeBtn);
1704
+ inner.appendChild(header);
1705
+
1706
+ const body = h('div', { class: 'modal-body', 'data-testid': 'extract-body' });
1707
+ const statusLine = h(
1708
+ 'div',
1709
+ { class: 'message-meta', 'data-testid': 'extract-status' },
1710
+ 'Running analyzer…',
1711
+ );
1712
+ body.appendChild(statusLine);
1713
+ const list = h('div', { class: 'extract-candidates', 'data-testid': 'extract-list' });
1714
+ body.appendChild(list);
1715
+ inner.appendChild(body);
1716
+
1717
+ const foot = h('div', { class: 'modal-footer' });
1718
+ const cancel = h('button', { class: 'btn-secondary', type: 'button' }, 'Close');
1719
+ cancel.addEventListener('click', close);
1720
+ foot.appendChild(cancel);
1721
+ const acceptBtn = h(
1722
+ 'button',
1723
+ {
1724
+ class: 'btn-primary',
1725
+ type: 'button',
1726
+ 'data-testid': 'extract-accept-btn',
1727
+ disabled: 'disabled',
1728
+ },
1729
+ 'Accept selected',
1730
+ );
1731
+ foot.appendChild(acceptBtn);
1732
+ inner.appendChild(foot);
1733
+
1734
+ backdrop.appendChild(inner);
1735
+ backdrop.addEventListener('click', (ev) => {
1736
+ if (ev.target === backdrop) close();
1737
+ });
1738
+ document.body.appendChild(backdrop);
1739
+
1740
+ let candidates = [];
1741
+ const selected = new Set();
1742
+ try {
1743
+ const result = await fetchJSON(`/api/discussions/${discussion.id}/actions/extract`, {
1744
+ method: 'POST',
1745
+ headers: { 'content-type': 'application/json' },
1746
+ body: JSON.stringify({}),
1747
+ });
1748
+ candidates = result.candidates || [];
1749
+ statusLine.textContent = `${candidates.length} candidate${candidates.length === 1 ? '' : 's'} via ${result.method} (conf ${result.analysisConfidence}/100)`;
1750
+ if (candidates.length === 0) {
1751
+ list.appendChild(emptyState('🪶', 'No candidates', 'No structured signal — and the LLM fallback produced nothing actionable.'));
1752
+ }
1753
+ candidates.forEach((cand, idx) => {
1754
+ const row = h('div', {
1755
+ class: 'extract-row',
1756
+ 'data-testid': 'extract-row',
1757
+ 'data-index': String(idx),
1758
+ });
1759
+ const cb = h('input', {
1760
+ type: 'checkbox',
1761
+ class: 'extract-checkbox',
1762
+ 'data-testid': 'extract-checkbox',
1763
+ });
1764
+ cb.checked = true;
1765
+ selected.add(idx);
1766
+ cb.addEventListener('change', () => {
1767
+ if (cb.checked) selected.add(idx);
1768
+ else selected.delete(idx);
1769
+ acceptBtn.disabled = selected.size === 0;
1770
+ });
1771
+ const main = h('div', { class: 'extract-row-main' });
1772
+ main.appendChild(
1773
+ h('div', { class: 'extract-row-title' }, cand.title || '(untitled)'),
1774
+ );
1775
+ if (cand.description) {
1776
+ main.appendChild(h('div', { class: 'extract-row-desc' }, cand.description));
1777
+ }
1778
+ main.appendChild(
1779
+ h(
1780
+ 'div',
1781
+ { class: 'message-meta' },
1782
+ `${cand.priority} · ${cand.category} · conf ${cand.confidence}${cand.suggestedAssignee ? ' · ' + cand.suggestedAssignee : ''}${cand.suggestedDueDate ? ' · ' + cand.suggestedDueDate : ''}`,
1783
+ ),
1784
+ );
1785
+ row.appendChild(cb);
1786
+ row.appendChild(main);
1787
+ list.appendChild(row);
1788
+ });
1789
+ acceptBtn.disabled = selected.size === 0;
1790
+ } catch (e) {
1791
+ statusLine.textContent = 'Extract failed: ' + e.message;
1792
+ statusLine.classList.add('error');
1793
+ }
1794
+
1795
+ acceptBtn.addEventListener('click', async () => {
1796
+ const accepted = [...selected].map((i) => candidates[i]).filter(Boolean);
1797
+ if (accepted.length === 0) return;
1798
+ acceptBtn.disabled = true;
1799
+ try {
1800
+ const result = await fetchJSON(`/api/discussions/${discussion.id}/actions/extract`, {
1801
+ method: 'POST',
1802
+ headers: { 'content-type': 'application/json' },
1803
+ body: JSON.stringify({ accept: accepted }),
1804
+ });
1805
+ toast(`Created ${result.created.length} action item${result.created.length === 1 ? '' : 's'}`, 'ok');
1806
+ await refreshState({ silent: true });
1807
+ close();
1808
+ navigate('actions');
1809
+ } catch (e) {
1810
+ toast('Save failed: ' + e.message, 'err');
1811
+ acceptBtn.disabled = false;
1812
+ }
1813
+ });
1814
+ }
1815
+
1816
+ // ------------------------------------------------------------------
1817
+ // Principles view
1818
+ // ------------------------------------------------------------------
1819
+
1820
+ function renderPrinciplesView(main) {
1821
+ const view = h('div', { class: 'view' });
1822
+ const activeCount = state.principles.filter((p) => p.isActive).length;
1823
+ const header = h('div', { class: 'view-header' }, [
1824
+ h('div', {}, [
1825
+ h('div', { class: 'view-title' }, 'Principles'),
1826
+ h(
1827
+ 'div',
1828
+ { class: 'view-subtitle' },
1829
+ `${activeCount} active · ${state.principles.length} total`,
1830
+ ),
1831
+ ]),
1832
+ ]);
1833
+ const headerActions = h('div', { class: 'header-actions' });
1834
+ const seedBtn = h(
1835
+ 'button',
1836
+ {
1837
+ class: 'btn-secondary',
1838
+ 'data-testid': 'principles-seed-btn',
1839
+ title: state.principles.length > 0 ? 'Already seeded; disabled' : 'Seed 8 Dalio-inspired starter principles',
1840
+ },
1841
+ '🌱 Seed starters',
1842
+ );
1843
+ if (state.principles.length > 0) seedBtn.disabled = true;
1844
+ seedBtn.addEventListener('click', async () => {
1845
+ if (seedBtn.disabled) return;
1846
+ seedBtn.disabled = true;
1847
+ const prev = seedBtn.textContent;
1848
+ seedBtn.textContent = 'Seeding…';
1849
+ try {
1850
+ const r = await fetchJSON('/api/principles/seed-starters', {
1851
+ method: 'POST',
1852
+ headers: { 'content-type': 'application/json' },
1853
+ body: JSON.stringify({}),
1854
+ });
1855
+ toast(`Seeded ${r.added} starter principle${r.added === 1 ? '' : 's'}.`, 'ok');
1856
+ await refreshState({ silent: true });
1857
+ navigate('principles');
1858
+ } catch (e) {
1859
+ toast('Seed failed: ' + e.message, 'err');
1860
+ seedBtn.disabled = false;
1861
+ seedBtn.textContent = prev;
1862
+ }
1863
+ });
1864
+ const addBtn = h('button', { class: 'btn-primary', 'data-testid': 'principles-add-btn' }, '+ Add principle');
1865
+ addBtn.addEventListener('click', () => openPrincipleEditModal(null));
1866
+ headerActions.appendChild(seedBtn);
1867
+ headerActions.appendChild(addBtn);
1868
+ header.appendChild(headerActions);
1869
+ view.appendChild(header);
1870
+
1871
+ const body = h('div', { class: 'view-body' });
1872
+ if (state.principles.length === 0) {
1873
+ const empty = emptyState(
1874
+ '🧭',
1875
+ 'No principles yet',
1876
+ 'Add one, or run `aab init` to seed Dalio-inspired starters.',
1877
+ );
1878
+ const seedBtn = h('button', { class: 'btn-primary', style: 'margin-top:12px' }, '+ Add a principle');
1879
+ seedBtn.addEventListener('click', () => openPrincipleEditModal(null));
1880
+ empty.appendChild(seedBtn);
1881
+ body.appendChild(empty);
1882
+ } else {
1883
+ const grid = h('div', { class: 'principles-grid' });
1884
+ for (const p of state.principles) grid.appendChild(principleCard(p));
1885
+ body.appendChild(grid);
1886
+ }
1887
+ view.appendChild(body);
1888
+ main.appendChild(view);
1889
+ }
1890
+
1891
+ function principleCard(p) {
1892
+ const card = h('div', { class: 'principle-card' + (p.isActive ? '' : ' is-inactive') });
1893
+ const head = h('div', { class: 'principle-head' }, [
1894
+ h('div', { class: 'principle-title' }, p.title),
1895
+ h('div', { class: 'principle-cat' }, p.category),
1896
+ ]);
1897
+ // Switch
1898
+ const toggle = h('label', { class: 'switch' });
1899
+ const cb = h('input', { type: 'checkbox' });
1900
+ cb.checked = !!p.isActive;
1901
+ cb.addEventListener('click', (ev) => ev.stopPropagation());
1902
+ cb.addEventListener('change', async () => {
1903
+ try {
1904
+ await fetchJSON(`/api/principles/${p.id}`, {
1905
+ method: 'PATCH',
1906
+ headers: { 'content-type': 'application/json' },
1907
+ body: JSON.stringify({ isActive: cb.checked }),
1908
+ });
1909
+ p.isActive = cb.checked;
1910
+ card.classList.toggle('is-inactive', !p.isActive);
1911
+ toast(`Principle ${p.isActive ? 'activated' : 'deactivated'}.`, 'ok');
1912
+ } catch (e) {
1913
+ cb.checked = !cb.checked;
1914
+ toast('Could not update: ' + e.message, 'err');
1915
+ }
1916
+ });
1917
+ toggle.appendChild(cb);
1918
+ toggle.appendChild(h('span', { class: 'switch-track' }));
1919
+ head.appendChild(toggle);
1920
+ card.appendChild(head);
1921
+
1922
+ card.appendChild(h('div', { class: 'principle-desc' }, p.description));
1923
+ const row = h('div', { class: 'priority-row' });
1924
+ row.appendChild(h('span', {}, `priority ${p.priority}/10`));
1925
+ const bar = h('div', { class: 'priority-bar' });
1926
+ bar.appendChild(h('div', { class: 'priority-bar-fill', style: `width: ${p.priority * 10}%` }));
1927
+ row.appendChild(bar);
1928
+ card.appendChild(row);
1929
+
1930
+ const actions = h('div', { class: 'card-actions' });
1931
+ const exploreBtn = h(
1932
+ 'button',
1933
+ {
1934
+ class: 'btn-secondary',
1935
+ 'data-testid': 'principle-explore-btn',
1936
+ title: '5-step Socratic wizard to refine behavior / anti-pattern / triggers / examples / priority',
1937
+ },
1938
+ '🔎 Explore',
1939
+ );
1940
+ exploreBtn.addEventListener('click', (ev) => {
1941
+ ev.stopPropagation();
1942
+ openExplorerWizard(p);
1943
+ });
1944
+ actions.appendChild(exploreBtn);
1945
+ card.appendChild(actions);
1946
+
1947
+ card.addEventListener('click', () => openPrincipleEditModal(p));
1948
+ card.style.cursor = 'pointer';
1949
+ return card;
1950
+ }
1951
+
1952
+ // ------------------------------------------------------------------
1953
+ // Settings view
1954
+ // ------------------------------------------------------------------
1955
+
1956
+ function renderSettingsView(main) {
1957
+ const view = h('div', { class: 'view' });
1958
+ view.appendChild(
1959
+ h('div', { class: 'view-header' }, [
1960
+ h('div', {}, [
1961
+ h('div', { class: 'view-title' }, 'Settings'),
1962
+ h('div', { class: 'view-subtitle' }, 'Workspace-level configuration'),
1963
+ ]),
1964
+ ]),
1965
+ );
1966
+ const body = h('div', { class: 'view-body' });
1967
+ const form = h('div', { class: 'settings-form' });
1968
+
1969
+ const s = state.settings || {};
1970
+
1971
+ const fieldDefs = [
1972
+ { key: 'boardTitle', label: 'Board title', type: 'text', help: 'Shown at the top of the dashboard.' },
1973
+ { key: 'maxMembersPerDiscussion', label: 'Max members per discussion', type: 'number', min: 1, max: 12 },
1974
+ { key: 'maxTurnsPerDiscussion', label: 'Max turns per discussion', type: 'number', min: 2, max: 30, help: 'When totalTurns hits this, the discussion auto-concludes.' },
1975
+ {
1976
+ key: 'orchestratorPromptStyle',
1977
+ label: 'Orchestrator style',
1978
+ type: 'select',
1979
+ options: ['analytical', 'creative', 'balanced'],
1980
+ },
1981
+ { key: 'autoSummarization', label: 'Auto-summarize on conclude', type: 'switch' },
1982
+ { key: 'consensusThreshold', label: 'Consensus threshold (%)', type: 'number', min: 0, max: 100 },
1983
+ { key: 'enableUserInteraction', label: 'Enable HITL (orchestrator can ask you questions)', type: 'switch' },
1984
+ {
1985
+ key: 'primaryModel',
1986
+ label: 'Primary model (members)',
1987
+ type: 'select',
1988
+ options: ['inherit', 'opus', 'sonnet', 'haiku', 'claude-opus-4-7', 'claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-haiku-4-5-20251001'],
1989
+ },
1990
+ {
1991
+ key: 'researchModel',
1992
+ label: 'Research model (skill task research, sparring)',
1993
+ type: 'select',
1994
+ options: ['inherit', 'opus', 'sonnet', 'haiku', 'claude-opus-4-7', 'claude-sonnet-4-6'],
1995
+ },
1996
+ {
1997
+ key: 'fastModel',
1998
+ label: 'Fast model (orchestrator)',
1999
+ type: 'select',
2000
+ options: ['inherit', 'opus', 'sonnet', 'haiku', 'claude-haiku-4-5-20251001'],
2001
+ },
2002
+ { key: 'perCallBudgetUsd', label: 'Per-call budget (USD)', type: 'number', step: '0.5', min: 0, help: 'Passed to claude --max-budget-usd.' },
2003
+ { key: 'locale', label: 'Locale', type: 'text' },
2004
+ ];
2005
+
2006
+ const inputs = {};
2007
+ for (const f of fieldDefs) {
2008
+ const wrap = h('div', { class: 'form-field' });
2009
+ wrap.appendChild(h('label', { class: 'field-label' }, f.label));
2010
+ if (f.type === 'switch') {
2011
+ const lbl = h('label', { class: 'switch' });
2012
+ const cb = h('input', { type: 'checkbox' });
2013
+ cb.checked = !!s[f.key];
2014
+ lbl.appendChild(cb);
2015
+ lbl.appendChild(h('span', { class: 'switch-track' }));
2016
+ wrap.appendChild(lbl);
2017
+ inputs[f.key] = cb;
2018
+ } else if (f.type === 'select') {
2019
+ const sel = h('select');
2020
+ for (const opt of f.options) {
2021
+ const o = h('option', { value: opt }, opt);
2022
+ if (s[f.key] === opt) o.selected = true;
2023
+ sel.appendChild(o);
2024
+ }
2025
+ wrap.appendChild(sel);
2026
+ inputs[f.key] = sel;
2027
+ } else {
2028
+ const attrs = { type: f.type };
2029
+ if (f.min != null) attrs.min = String(f.min);
2030
+ if (f.max != null) attrs.max = String(f.max);
2031
+ if (f.step) attrs.step = f.step;
2032
+ const inp = h('input', attrs);
2033
+ inp.value = s[f.key] != null ? String(s[f.key]) : '';
2034
+ wrap.appendChild(inp);
2035
+ inputs[f.key] = inp;
2036
+ }
2037
+ if (f.help) wrap.appendChild(h('div', { class: 'field-help' }, f.help));
2038
+ form.appendChild(wrap);
2039
+ }
2040
+
2041
+ const actions = h('div', { class: 'form-actions' });
2042
+ const saveBtn = h('button', { class: 'btn-primary' }, 'Save settings');
2043
+ const resetBtn = h('button', { class: 'btn-secondary' }, 'Reset form');
2044
+ resetBtn.addEventListener('click', () => navigate('settings'));
2045
+ saveBtn.addEventListener('click', async () => {
2046
+ const payload = {};
2047
+ for (const f of fieldDefs) {
2048
+ const inp = inputs[f.key];
2049
+ if (f.type === 'switch') payload[f.key] = inp.checked;
2050
+ else if (f.type === 'number') {
2051
+ const v = inp.value;
2052
+ if (v === '' || v == null) continue;
2053
+ payload[f.key] = Number(v);
2054
+ } else {
2055
+ payload[f.key] = inp.value;
2056
+ }
2057
+ }
2058
+ saveBtn.disabled = true;
2059
+ saveBtn.textContent = 'Saving…';
2060
+ try {
2061
+ const updated = await fetchJSON('/api/settings', {
2062
+ method: 'PATCH',
2063
+ headers: { 'content-type': 'application/json' },
2064
+ body: JSON.stringify(payload),
2065
+ });
2066
+ state.settings = updated;
2067
+ toast('Settings saved.', 'ok');
2068
+ } catch (e) {
2069
+ toast('Save failed: ' + e.message, 'err');
2070
+ } finally {
2071
+ saveBtn.disabled = false;
2072
+ saveBtn.textContent = 'Save settings';
2073
+ }
2074
+ });
2075
+ actions.appendChild(saveBtn);
2076
+ actions.appendChild(resetBtn);
2077
+ form.appendChild(actions);
2078
+
2079
+ body.appendChild(form);
2080
+ view.appendChild(body);
2081
+ main.appendChild(view);
2082
+ }
2083
+
2084
+ // ------------------------------------------------------------------
2085
+ // Edit modal (members + principles share this)
2086
+ // ------------------------------------------------------------------
2087
+
2088
+ let editModalOnSave = null;
2089
+ let editModalOnDelete = null;
2090
+
2091
+ function setupEditModal() {
2092
+ $('#edit-modal-close').addEventListener('click', closeEditModal);
2093
+ $('#edit-modal-cancel').addEventListener('click', closeEditModal);
2094
+ $('#edit-modal-save').addEventListener('click', async () => {
2095
+ if (!editModalOnSave) return;
2096
+ const btn = $('#edit-modal-save');
2097
+ btn.disabled = true;
2098
+ btn.textContent = 'Saving…';
2099
+ try {
2100
+ await editModalOnSave();
2101
+ closeEditModal();
2102
+ } catch (e) {
2103
+ toast('Save failed: ' + e.message, 'err');
2104
+ } finally {
2105
+ btn.disabled = false;
2106
+ btn.textContent = 'Save';
2107
+ }
2108
+ });
2109
+ $('#edit-modal-delete').addEventListener('click', () => {
2110
+ if (editModalOnDelete) editModalOnDelete();
2111
+ });
2112
+ }
2113
+
2114
+ function closeEditModal() {
2115
+ $('#edit-modal').hidden = true;
2116
+ $('#edit-modal-body').innerHTML = '';
2117
+ editModalOnSave = null;
2118
+ editModalOnDelete = null;
2119
+ $('#edit-modal-delete').hidden = true;
2120
+ }
2121
+
2122
+ function openMemberEditModal(member) {
2123
+ const isNew = !member;
2124
+ $('#edit-modal-title').textContent = isNew ? 'Add board member' : `Edit ${member.name}`;
2125
+ const body = $('#edit-modal-body');
2126
+ body.innerHTML = '';
2127
+
2128
+ const fields = {
2129
+ name: input('Name', member?.name || '', 'e.g. Sam Altman'),
2130
+ title: input('Title', member?.title || '', 'e.g. CEO, OpenAI'),
2131
+ expertise: input(
2132
+ 'Expertise (comma-separated)',
2133
+ (member?.expertise || []).join(', '),
2134
+ 'e.g. AI strategy, scaling, product',
2135
+ ),
2136
+ persona: textarea(
2137
+ 'Persona (1-2 paragraphs)',
2138
+ member?.persona || '',
2139
+ "How they think, what they're known for. Used in the agent's system prompt.",
2140
+ 6,
2141
+ ),
2142
+ voiceGuide: textarea(
2143
+ 'Voice guide (optional)',
2144
+ member?.voiceGuide || '',
2145
+ 'Style, vocabulary, characteristic phrases. Helps the LLM sound like them.',
2146
+ 3,
2147
+ ),
2148
+ };
2149
+ for (const [k, frag] of Object.entries(fields)) body.appendChild(frag.wrap);
2150
+
2151
+ // ------- AI enhance row -------
2152
+ const enhanceRow = h('div', { class: 'form-field' });
2153
+ enhanceRow.appendChild(h('label', { class: 'field-label' }, 'AI enhance (fills persona + voice)'));
2154
+ const enhanceWrap = h('div', { class: 'enhance-row' });
2155
+ const enhanceTypeSel = h('select', { 'data-testid': 'enhance-type-select' });
2156
+ for (const opt of [
2157
+ { v: 'non-famous', label: 'Practitioner' },
2158
+ { v: 'expert', label: 'Top-1% expert' },
2159
+ { v: 'famous', label: 'Famous leader' },
2160
+ ]) {
2161
+ const o = h('option', { value: opt.v }, opt.label);
2162
+ enhanceTypeSel.appendChild(o);
2163
+ }
2164
+ const enhanceBtn = h('button', { class: 'btn-secondary', 'data-testid': 'enhance-with-ai-btn' }, '✨ Enhance with AI');
2165
+ const enhanceStatus = h('span', { class: 'field-help' }, '');
2166
+ enhanceBtn.addEventListener('click', async () => {
2167
+ const name = fields.name.input.value.trim();
2168
+ const title = fields.title.input.value.trim();
2169
+ if (!name || !title) {
2170
+ toast('Set name + title before enhancing.', 'err');
2171
+ return;
2172
+ }
2173
+ if (!member) {
2174
+ // Need to create a draft first.
2175
+ toast('Save the member first, then click "Enhance with AI" from the edit modal.', 'err');
2176
+ return;
2177
+ }
2178
+ enhanceBtn.disabled = true;
2179
+ enhanceBtn.textContent = 'Enhancing…';
2180
+ enhanceStatus.textContent = 'Calling claude…';
2181
+ try {
2182
+ // Fire-and-watch via WS — server returns 202 + memberId.
2183
+ await fetchJSON(`/api/members/${member.id}/enhance`, {
2184
+ method: 'POST',
2185
+ headers: { 'content-type': 'application/json' },
2186
+ body: JSON.stringify({ type: enhanceTypeSel.value, keepVoice: false }),
2187
+ });
2188
+ // Listen for the member_enhance_done WS event.
2189
+ const onEvent = (ev) => {
2190
+ const d = ev.detail || {};
2191
+ if (d.memberId !== member.id) return;
2192
+ if (d.type === 'member_enhance_progress') {
2193
+ enhanceStatus.textContent = `Working… ${d.event?.type || ''}`;
2194
+ } else if (d.type === 'member_enhance_done') {
2195
+ enhanceStatus.textContent = 'Done.';
2196
+ // Fill the modal fields with the new content (don't auto-close).
2197
+ if (d.member?.persona) fields.persona.input.value = d.member.persona;
2198
+ if (d.member?.voiceGuide) fields.voiceGuide.input.value = d.member.voiceGuide;
2199
+ enhanceBtn.disabled = false;
2200
+ enhanceBtn.textContent = '✨ Enhance with AI';
2201
+ window.removeEventListener('aab-member-event', onEvent);
2202
+ refreshState({ silent: true });
2203
+ toast(`${member.name}: AI-enhanced persona ready.`, 'ok');
2204
+ } else if (d.type === 'member_enhance_failed') {
2205
+ enhanceStatus.textContent = 'Failed: ' + (d.message || 'unknown');
2206
+ enhanceBtn.disabled = false;
2207
+ enhanceBtn.textContent = '✨ Enhance with AI';
2208
+ window.removeEventListener('aab-member-event', onEvent);
2209
+ toast('Enhance failed: ' + d.message, 'err');
2210
+ }
2211
+ };
2212
+ window.addEventListener('aab-member-event', onEvent);
2213
+ } catch (e) {
2214
+ enhanceBtn.disabled = false;
2215
+ enhanceBtn.textContent = '✨ Enhance with AI';
2216
+ enhanceStatus.textContent = '';
2217
+ toast('Enhance failed: ' + e.message, 'err');
2218
+ }
2219
+ });
2220
+ enhanceWrap.appendChild(enhanceTypeSel);
2221
+ enhanceWrap.appendChild(enhanceBtn);
2222
+ enhanceWrap.appendChild(enhanceStatus);
2223
+ enhanceRow.appendChild(enhanceWrap);
2224
+ body.appendChild(enhanceRow);
2225
+
2226
+ // ------- Tools allowlist editor -------
2227
+ const toolsRow = h('div', { class: 'form-field' });
2228
+ toolsRow.appendChild(h('label', { class: 'field-label' }, 'Allowed tools (per-member override)'));
2229
+ const toolsHelp = h('div', { class: 'field-help' }, 'Leave all unchecked to use the workspace default (WebSearch, WebFetch, Read, Grep, Glob).');
2230
+ toolsRow.appendChild(toolsHelp);
2231
+ const toolsBox = h('div', { class: 'tools-chips', 'data-testid': 'member-tools-allowlist' });
2232
+ const TOOL_PALETTE = ['WebSearch', 'WebFetch', 'Read', 'Grep', 'Glob'];
2233
+ const currentAllowed = new Set(member?.allowedTools ?? []);
2234
+ const toolInputs = {};
2235
+ for (const tool of TOOL_PALETTE) {
2236
+ const lbl = h('label', { class: 'tool-chip' });
2237
+ const cb = h('input', { type: 'checkbox', 'data-tool': tool });
2238
+ cb.checked = currentAllowed.has(tool);
2239
+ lbl.appendChild(cb);
2240
+ lbl.appendChild(h('span', {}, tool));
2241
+ toolsBox.appendChild(lbl);
2242
+ toolInputs[tool] = cb;
2243
+ }
2244
+ toolsRow.appendChild(toolsBox);
2245
+ body.appendChild(toolsRow);
2246
+ fields._tools = toolInputs;
2247
+
2248
+ if (!isNew) {
2249
+ body.appendChild(
2250
+ h('label', { class: 'switch-row' }, [
2251
+ h('span', {}, 'Active'),
2252
+ (() => {
2253
+ const lbl = h('label', { class: 'switch' });
2254
+ const cb = h('input', { type: 'checkbox' });
2255
+ cb.checked = !!member.isActive;
2256
+ lbl.appendChild(cb);
2257
+ lbl.appendChild(h('span', { class: 'switch-track' }));
2258
+ fields.isActive = { input: cb };
2259
+ return lbl;
2260
+ })(),
2261
+ ]),
2262
+ );
2263
+ $('#edit-modal-delete').hidden = false;
2264
+ editModalOnDelete = () =>
2265
+ openConfirmModal({
2266
+ title: `Delete ${member.name}?`,
2267
+ message: 'This will also remove the corresponding agent file if it was AAB-generated.',
2268
+ okLabel: 'Delete',
2269
+ onOk: async () => {
2270
+ try {
2271
+ await fetchJSON(`/api/members/${member.id}`, { method: 'DELETE' });
2272
+ await refreshState({ silent: true });
2273
+ renderWorkspaceCard();
2274
+ closeEditModal();
2275
+ navigate('members');
2276
+ toast(`${member.name} deleted.`, 'ok');
2277
+ } catch (e) {
2278
+ toast('Could not delete: ' + e.message, 'err');
2279
+ }
2280
+ },
2281
+ });
2282
+ }
2283
+
2284
+ $('#edit-modal').hidden = false;
2285
+ fields.name.input.focus();
2286
+
2287
+ editModalOnSave = async () => {
2288
+ const name = fields.name.input.value.trim();
2289
+ if (!name) {
2290
+ toast('Name is required.', 'err');
2291
+ throw new Error('Name is required.');
2292
+ }
2293
+ const payload = {
2294
+ name,
2295
+ title: fields.title.input.value.trim(),
2296
+ expertise: fields.expertise.input.value
2297
+ .split(',')
2298
+ .map((s) => s.trim())
2299
+ .filter(Boolean),
2300
+ persona: fields.persona.input.value.trim(),
2301
+ voiceGuide: fields.voiceGuide.input.value.trim() || undefined,
2302
+ };
2303
+ if (!isNew) payload.isActive = fields.isActive.input.checked;
2304
+ // Tools allowlist — only send if the user explicitly checked some tools
2305
+ // (an empty `allowedTools` means "use the workspace default").
2306
+ const pickedTools = Object.entries(fields._tools || {})
2307
+ .filter(([, cb]) => cb.checked)
2308
+ .map(([t]) => t);
2309
+ if (pickedTools.length > 0) payload.allowedTools = pickedTools;
2310
+ else if (!isNew) payload.allowedTools = []; // explicit clear
2311
+
2312
+ if (isNew) {
2313
+ await fetchJSON('/api/members', {
2314
+ method: 'POST',
2315
+ headers: { 'content-type': 'application/json' },
2316
+ body: JSON.stringify(payload),
2317
+ });
2318
+ toast(`${name} added.`, 'ok');
2319
+ } else {
2320
+ await fetchJSON(`/api/members/${member.id}`, {
2321
+ method: 'PATCH',
2322
+ headers: { 'content-type': 'application/json' },
2323
+ body: JSON.stringify(payload),
2324
+ });
2325
+ toast(`${name} updated.`, 'ok');
2326
+ }
2327
+ await refreshState({ silent: true });
2328
+ renderWorkspaceCard();
2329
+ if (state.route === 'members') navigate('members');
2330
+ };
2331
+ }
2332
+
2333
+ function openPrincipleEditModal(principle) {
2334
+ const isNew = !principle;
2335
+ $('#edit-modal-title').textContent = isNew ? 'Add principle' : `Edit ${principle.title}`;
2336
+ const body = $('#edit-modal-body');
2337
+ body.innerHTML = '';
2338
+
2339
+ const fields = {
2340
+ title: input('Title', principle?.title || '', 'e.g. Embrace Radical Truth'),
2341
+ description: textarea(
2342
+ 'Description',
2343
+ principle?.description || '',
2344
+ 'What this principle means in plain language.',
2345
+ 4,
2346
+ ),
2347
+ behavior: textarea(
2348
+ 'Behavior (when applied well)',
2349
+ principle?.behavior || '',
2350
+ 'What it looks like in action.',
2351
+ 3,
2352
+ ),
2353
+ category: select(
2354
+ 'Category',
2355
+ ['life', 'work', 'relationships', 'health', 'finance', 'meta'],
2356
+ principle?.category || 'meta',
2357
+ ),
2358
+ priority: input('Priority (1-10)', String(principle?.priority ?? 5), '5', 'number'),
2359
+ };
2360
+ for (const frag of Object.values(fields)) body.appendChild(frag.wrap);
2361
+
2362
+ if (!isNew) {
2363
+ body.appendChild(
2364
+ h('label', { class: 'switch-row' }, [
2365
+ h('span', {}, 'Active'),
2366
+ (() => {
2367
+ const lbl = h('label', { class: 'switch' });
2368
+ const cb = h('input', { type: 'checkbox' });
2369
+ cb.checked = !!principle.isActive;
2370
+ lbl.appendChild(cb);
2371
+ lbl.appendChild(h('span', { class: 'switch-track' }));
2372
+ fields.isActive = { input: cb };
2373
+ return lbl;
2374
+ })(),
2375
+ ]),
2376
+ );
2377
+ $('#edit-modal-delete').hidden = false;
2378
+ editModalOnDelete = () =>
2379
+ openConfirmModal({
2380
+ title: `Delete "${principle.title}"?`,
2381
+ message: 'This removes the principle from the workspace.',
2382
+ okLabel: 'Delete',
2383
+ onOk: async () => {
2384
+ try {
2385
+ await fetchJSON(`/api/principles/${principle.id}`, { method: 'DELETE' });
2386
+ await refreshState({ silent: true });
2387
+ closeEditModal();
2388
+ navigate('principles');
2389
+ toast(`Principle deleted.`, 'ok');
2390
+ } catch (e) {
2391
+ toast('Could not delete: ' + e.message, 'err');
2392
+ }
2393
+ },
2394
+ });
2395
+ }
2396
+
2397
+ $('#edit-modal').hidden = false;
2398
+ fields.title.input.focus();
2399
+
2400
+ editModalOnSave = async () => {
2401
+ const title = fields.title.input.value.trim();
2402
+ if (!title) {
2403
+ toast('Title is required.', 'err');
2404
+ throw new Error('Title is required.');
2405
+ }
2406
+ const payload = {
2407
+ title,
2408
+ description: fields.description.input.value.trim(),
2409
+ behavior: fields.behavior.input.value.trim(),
2410
+ category: fields.category.input.value,
2411
+ priority: Number(fields.priority.input.value) || 5,
2412
+ };
2413
+ if (!isNew) payload.isActive = fields.isActive.input.checked;
2414
+
2415
+ if (isNew) {
2416
+ await fetchJSON('/api/principles', {
2417
+ method: 'POST',
2418
+ headers: { 'content-type': 'application/json' },
2419
+ body: JSON.stringify(payload),
2420
+ });
2421
+ toast(`"${title}" added.`, 'ok');
2422
+ } else {
2423
+ await fetchJSON(`/api/principles/${principle.id}`, {
2424
+ method: 'PATCH',
2425
+ headers: { 'content-type': 'application/json' },
2426
+ body: JSON.stringify(payload),
2427
+ });
2428
+ toast(`"${title}" updated.`, 'ok');
2429
+ }
2430
+ await refreshState({ silent: true });
2431
+ if (state.route === 'principles') navigate('principles');
2432
+ };
2433
+ }
2434
+
2435
+ // Form-field helpers used by edit modals
2436
+ function input(label, value, placeholder, type = 'text') {
2437
+ const wrap = h('div', { class: 'form-field' });
2438
+ wrap.appendChild(h('label', { class: 'field-label' }, label));
2439
+ const inp = h('input', { type, placeholder: placeholder || '' });
2440
+ inp.value = value || '';
2441
+ wrap.appendChild(inp);
2442
+ return { wrap, input: inp };
2443
+ }
2444
+
2445
+ function textarea(label, value, placeholder, rows = 3) {
2446
+ const wrap = h('div', { class: 'form-field' });
2447
+ wrap.appendChild(h('label', { class: 'field-label' }, label));
2448
+ const ta = h('textarea', { rows: String(rows), placeholder: placeholder || '' });
2449
+ ta.value = value || '';
2450
+ wrap.appendChild(ta);
2451
+ return { wrap, input: ta };
2452
+ }
2453
+
2454
+ function select(label, options, value) {
2455
+ const wrap = h('div', { class: 'form-field' });
2456
+ wrap.appendChild(h('label', { class: 'field-label' }, label));
2457
+ const sel = h('select');
2458
+ for (const opt of options) {
2459
+ const o = h('option', { value: opt }, opt);
2460
+ if (opt === value) o.selected = true;
2461
+ sel.appendChild(o);
2462
+ }
2463
+ wrap.appendChild(sel);
2464
+ return { wrap, input: sel };
2465
+ }
2466
+
2467
+ // ------------------------------------------------------------------
2468
+ // Confirm modal
2469
+ // ------------------------------------------------------------------
2470
+
2471
+ let confirmOnOk = null;
2472
+
2473
+ function setupConfirmModal() {
2474
+ $('#confirm-modal-close').addEventListener('click', closeConfirmModal);
2475
+ $('#confirm-modal-cancel').addEventListener('click', closeConfirmModal);
2476
+ $('#confirm-modal-ok').addEventListener('click', async () => {
2477
+ const ok = $('#confirm-modal-ok');
2478
+ ok.disabled = true;
2479
+ try {
2480
+ if (confirmOnOk) await confirmOnOk();
2481
+ } finally {
2482
+ ok.disabled = false;
2483
+ closeConfirmModal();
2484
+ }
2485
+ });
2486
+ }
2487
+
2488
+ function openConfirmModal({ title, message, okLabel = 'Confirm', onOk }) {
2489
+ $('#confirm-modal-title').textContent = title || 'Are you sure?';
2490
+ $('#confirm-modal-message').textContent = message || '';
2491
+ $('#confirm-modal-ok').textContent = okLabel;
2492
+ confirmOnOk = onOk || null;
2493
+ $('#confirm-modal').hidden = false;
2494
+ }
2495
+
2496
+ function closeConfirmModal() {
2497
+ $('#confirm-modal').hidden = true;
2498
+ confirmOnOk = null;
2499
+ }
2500
+
2501
+ // ------------------------------------------------------------------
2502
+ // Helpers
2503
+ // ------------------------------------------------------------------
2504
+
2505
+ function h(tag, attrs = {}, children = []) {
2506
+ const el = document.createElement(tag);
2507
+ for (const [k, v] of Object.entries(attrs)) {
2508
+ if (k === 'class') el.className = v;
2509
+ else if (k === 'style') el.setAttribute('style', v);
2510
+ else el.setAttribute(k, v);
2511
+ }
2512
+ if (typeof children === 'string') {
2513
+ el.textContent = children;
2514
+ } else if (Array.isArray(children)) {
2515
+ children.forEach((c) => {
2516
+ if (c == null) return;
2517
+ if (typeof c === 'string') el.appendChild(document.createTextNode(c));
2518
+ else el.appendChild(c);
2519
+ });
2520
+ } else if (children) {
2521
+ el.appendChild(children);
2522
+ }
2523
+ return el;
2524
+ }
2525
+
2526
+ function emptyState(emoji, title, subtitle) {
2527
+ return h('div', { class: 'empty-state' }, [
2528
+ h('div', { class: 'empty-state-emoji' }, emoji),
2529
+ h('div', { class: 'empty-state-title' }, title),
2530
+ h('div', {}, subtitle),
2531
+ ]);
2532
+ }
2533
+
2534
+ function initialsOf(name) {
2535
+ const parts = name.split(/\s+/).filter(Boolean);
2536
+ if (parts.length === 0) return '?';
2537
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
2538
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
2539
+ }
2540
+
2541
+ // Mirrors `memberAgentSlug()` in src/agents/emit-member-agent.ts — used to
2542
+ // derive stable data-testid suffixes for member-scoped UI elements.
2543
+ function memberSlug(name) {
2544
+ return String(name || '')
2545
+ .toLowerCase()
2546
+ .normalize('NFKD')
2547
+ .replace(/[^a-z0-9]+/g, '-')
2548
+ .replace(/^-+|-+$/g, '');
2549
+ }
2550
+
2551
+ function shortIdOf(id) {
2552
+ return String(id || '').slice(0, 6);
2553
+ }
2554
+
2555
+ function formatRelative(iso) {
2556
+ const ms = Date.now() - new Date(iso).getTime();
2557
+ const min = Math.round(ms / 60000);
2558
+ if (min < 1) return 'just now';
2559
+ if (min < 60) return `${min}m ago`;
2560
+ const h = Math.round(min / 60);
2561
+ if (h < 24) return `${h}h ago`;
2562
+ const d = Math.round(h / 24);
2563
+ if (d < 7) return `${d}d ago`;
2564
+ return iso.slice(0, 10);
2565
+ }
2566
+
2567
+ function formatDuration(ms) {
2568
+ if (ms < 1000) return `${Math.round(ms)}ms`;
2569
+ const s = ms / 1000;
2570
+ if (s < 60) return `${s.toFixed(1)}s`;
2571
+ const m = Math.floor(s / 60);
2572
+ const r = Math.round(s - m * 60);
2573
+ return `${m}m ${r}s`;
2574
+ }
2575
+
2576
+ async function fetchJSON(url, init) {
2577
+ const res = await fetch(url, init);
2578
+ if (!res.ok) {
2579
+ let msg = res.statusText;
2580
+ try {
2581
+ const data = await res.json();
2582
+ if (data?.error) msg = data.error;
2583
+ } catch {
2584
+ /* ignore */
2585
+ }
2586
+ throw new Error(msg);
2587
+ }
2588
+ if (res.status === 204) return null;
2589
+ return res.json();
2590
+ }
2591
+
2592
+ function toast(message, kind = '') {
2593
+ const t = h('div', { class: `toast ${kind}` }, message);
2594
+ $('#toasts').appendChild(t);
2595
+ setTimeout(() => t.remove(), 4500);
2596
+ }
2597
+
2598
+ // ------------------------------------------------------------------
2599
+ // Knowledge Wiki view (Phase 1.5 chunk 8)
2600
+ // ------------------------------------------------------------------
2601
+
2602
+ const wikiState = {
2603
+ pages: [],
2604
+ currentSlug: null,
2605
+ currentPage: null, // { frontmatter, body, backlinks }
2606
+ filter: 'all',
2607
+ search: '',
2608
+ ingesting: false,
2609
+ querying: false,
2610
+ linting: false,
2611
+ };
2612
+
2613
+ function renderKnowledgeView(main) {
2614
+ // Trigger an async load + slug-map refresh, render skeleton synchronously.
2615
+ refreshKnowledgeState();
2616
+ fetch('/api/knowledge/pages')
2617
+ .then((r) => r.json())
2618
+ .then((data) => {
2619
+ wikiState.pages = data.pages || [];
2620
+ rerenderKnowledge();
2621
+ })
2622
+ .catch((err) => toast(`Failed to load wiki pages: ${err.message}`, 'error'));
2623
+
2624
+ main.innerHTML = `
2625
+ <div class="view view-knowledge">
2626
+ <header class="view-header">
2627
+ <div>
2628
+ <h1>Knowledge</h1>
2629
+ <p class="view-sub">Karpathy-style LLM wiki — your advisors read it. <code>[[wikilinks]]</code> render as clickable links.</p>
2630
+ </div>
2631
+ <div class="view-actions">
2632
+ <input type="search" id="wiki-search" placeholder="Search slug / title / summary..." />
2633
+ <button class="btn-secondary" id="wiki-lint-btn">Lint</button>
2634
+ <button class="btn-primary" id="wiki-ingest-btn">+ Ingest</button>
2635
+ </div>
2636
+ </header>
2637
+ <div class="wiki-layout">
2638
+ <aside class="wiki-sidebar">
2639
+ <div class="wiki-filters">
2640
+ <button class="chip-filter active" data-filter="all">All</button>
2641
+ <button class="chip-filter" data-filter="concept">Concepts</button>
2642
+ <button class="chip-filter" data-filter="entity">Entities</button>
2643
+ <button class="chip-filter" data-filter="decision">Decisions</button>
2644
+ <button class="chip-filter" data-filter="source-summary">Sources</button>
2645
+ <button class="chip-filter" data-filter="comparison">Comparisons</button>
2646
+ </div>
2647
+ <div class="wiki-page-list" id="wiki-page-list">
2648
+ <div class="hint">Loading…</div>
2649
+ </div>
2650
+ </aside>
2651
+ <section class="wiki-detail" id="wiki-detail">
2652
+ <div class="wiki-detail-empty">
2653
+ <h2>Pick a page</h2>
2654
+ <p>Or run <code>aab knowledge query "..."</code> from the terminal, or click <strong>+ Ingest</strong> above.</p>
2655
+ </div>
2656
+ </section>
2657
+ </div>
2658
+ <div class="wiki-ingest-panel" id="wiki-ingest-panel" hidden>
2659
+ <div class="panel-header">
2660
+ <h2>Ingest</h2>
2661
+ <button class="icon-btn" id="wiki-ingest-close" aria-label="Close">×</button>
2662
+ </div>
2663
+ <div class="panel-body">
2664
+ <div class="field-group">
2665
+ <label class="field-label" for="wiki-ingest-paste">Paste markdown / text</label>
2666
+ <textarea id="wiki-ingest-paste" rows="8" placeholder="Drop any context, notes, or research here…"></textarea>
2667
+ </div>
2668
+ <div class="field-group">
2669
+ <label class="field-label" for="wiki-ingest-url">…or a URL</label>
2670
+ <input type="url" id="wiki-ingest-url" placeholder="https://example.com/article" />
2671
+ </div>
2672
+ <div class="panel-actions">
2673
+ <button class="btn-secondary" id="wiki-ingest-cancel">Cancel</button>
2674
+ <button class="btn-primary" id="wiki-ingest-go">Ingest</button>
2675
+ </div>
2676
+ </div>
2677
+ </div>
2678
+ <div class="wiki-query-bar">
2679
+ <input type="text" id="wiki-query-input" placeholder="Ask the wiki a question…" />
2680
+ <button class="btn-primary" id="wiki-query-go">Ask</button>
2681
+ </div>
2682
+ <div class="wiki-query-answer" id="wiki-query-answer" hidden></div>
2683
+ </div>
2684
+ `;
2685
+ // Wire events
2686
+ $('#wiki-search').addEventListener('input', (e) => {
2687
+ wikiState.search = e.target.value.trim().toLowerCase();
2688
+ rerenderKnowledge();
2689
+ });
2690
+ $$('.chip-filter').forEach((b) => {
2691
+ b.addEventListener('click', () => {
2692
+ $$('.chip-filter').forEach((x) => x.classList.toggle('active', x === b));
2693
+ wikiState.filter = b.dataset.filter;
2694
+ rerenderKnowledge();
2695
+ });
2696
+ });
2697
+ $('#wiki-ingest-btn').addEventListener('click', () => $('#wiki-ingest-panel').hidden = false);
2698
+ $('#wiki-ingest-close').addEventListener('click', () => $('#wiki-ingest-panel').hidden = true);
2699
+ $('#wiki-ingest-cancel').addEventListener('click', () => $('#wiki-ingest-panel').hidden = true);
2700
+ $('#wiki-ingest-go').addEventListener('click', () => doIngest());
2701
+ $('#wiki-lint-btn').addEventListener('click', () => doLint());
2702
+ $('#wiki-query-go').addEventListener('click', () => doQuery());
2703
+ $('#wiki-query-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') doQuery(); });
2704
+ }
2705
+
2706
+ function rerenderKnowledge() {
2707
+ const list = $('#wiki-page-list');
2708
+ if (!list) return;
2709
+ let pages = wikiState.pages;
2710
+ if (wikiState.filter !== 'all') {
2711
+ pages = pages.filter((p) => p.type === wikiState.filter);
2712
+ }
2713
+ if (wikiState.search) {
2714
+ pages = pages.filter((p) =>
2715
+ (p.slug || '').toLowerCase().includes(wikiState.search) ||
2716
+ (p.title || '').toLowerCase().includes(wikiState.search) ||
2717
+ (p.summary || '').toLowerCase().includes(wikiState.search)
2718
+ );
2719
+ }
2720
+ if (pages.length === 0) {
2721
+ list.innerHTML = '<div class="hint">No pages match. Try <strong>+ Ingest</strong> above.</div>';
2722
+ return;
2723
+ }
2724
+ list.innerHTML = pages
2725
+ .sort((a, b) => (a.slug || '').localeCompare(b.slug || ''))
2726
+ .map((p) => {
2727
+ const tag = p.userEdited ? '<span class="badge badge-warn">user-edited</span>' : '';
2728
+ const active = p.slug === wikiState.currentSlug ? ' active' : '';
2729
+ return `<button class="wiki-page-item${active}" data-slug="${escAttr(p.slug)}">
2730
+ <span class="wiki-page-type type-${p.type}">${escHtml(p.type || '?')}</span>
2731
+ <span class="wiki-page-slug">${escHtml(p.slug || '?')}</span>
2732
+ ${tag}
2733
+ ${p.summary ? `<span class="wiki-page-summary">${escHtml(truncate(p.summary, 80))}</span>` : ''}
2734
+ </button>`;
2735
+ })
2736
+ .join('');
2737
+ $$('.wiki-page-item', list).forEach((btn) => {
2738
+ btn.addEventListener('click', () => loadPage(btn.dataset.slug));
2739
+ });
2740
+ }
2741
+
2742
+ function loadPage(slug) {
2743
+ wikiState.currentSlug = slug;
2744
+ rerenderKnowledge();
2745
+ const detail = $('#wiki-detail');
2746
+ detail.innerHTML = '<div class="hint">Loading…</div>';
2747
+ fetch(`/api/knowledge/pages/${encodeURIComponent(slug)}`)
2748
+ .then((r) => r.json())
2749
+ .then((data) => {
2750
+ if (data.error) {
2751
+ detail.innerHTML = `<div class="hint">${escHtml(data.error)}</div>`;
2752
+ return;
2753
+ }
2754
+ wikiState.currentPage = data;
2755
+ const fm = data.frontmatter || {};
2756
+ const bodyHtml = renderWikiBody(data.body || '');
2757
+ detail.innerHTML = `
2758
+ <header class="wiki-page-header">
2759
+ <h1>${escHtml(fm.title || data.slug)}</h1>
2760
+ <div class="wiki-page-meta">
2761
+ <span class="badge badge-type type-${escAttr(fm.type)}">${escHtml(fm.type || '')}</span>
2762
+ ${fm.confidence ? `<span class="badge">${escHtml(fm.confidence)} confidence</span>` : ''}
2763
+ ${fm.provenance ? `<span class="badge">${escHtml(fm.provenance)}</span>` : ''}
2764
+ ${fm.updated ? `<span class="hint">updated ${escHtml(fm.updated)}</span>` : ''}
2765
+ </div>
2766
+ ${fm.summary ? `<p class="wiki-page-summary-large">${escHtml(fm.summary)}</p>` : ''}
2767
+ </header>
2768
+ <div class="wiki-page-content">${bodyHtml}</div>
2769
+ ${renderSidecar(fm, data)}
2770
+ `;
2771
+ // Hook clicks on resolved wikilinks to load their page.
2772
+ $$('a.wiki-link', detail).forEach((a) => {
2773
+ a.addEventListener('click', (e) => {
2774
+ e.preventDefault();
2775
+ const targetSlug = a.dataset.slug;
2776
+ if (targetSlug) loadPage(targetSlug);
2777
+ });
2778
+ });
2779
+ $$('span.wiki-unresolved', detail).forEach((s) => {
2780
+ s.addEventListener('click', () => {
2781
+ const slug = s.dataset.slug;
2782
+ if (!slug) return;
2783
+ $('#wiki-ingest-panel').hidden = false;
2784
+ $('#wiki-ingest-paste').value = `# ${slug.replace(/-/g, ' ')}\n\n(Filled in stub — replace with real content and click Ingest to create the page.)\n`;
2785
+ $('#wiki-ingest-paste').focus();
2786
+ });
2787
+ });
2788
+ })
2789
+ .catch((err) => {
2790
+ detail.innerHTML = `<div class="hint">Failed: ${escHtml(err.message)}</div>`;
2791
+ });
2792
+ }
2793
+
2794
+ function renderSidecar(fm, data) {
2795
+ const out = [];
2796
+ out.push('<aside class="wiki-sidecar">');
2797
+ if (Array.isArray(fm.tags) && fm.tags.length > 0) {
2798
+ out.push('<section><h3>Tags</h3><div class="wiki-tag-row">');
2799
+ for (const t of fm.tags) out.push(`<span class="wiki-tag">${escHtml(t)}</span>`);
2800
+ out.push('</div></section>');
2801
+ }
2802
+ if (Array.isArray(fm.aliases) && fm.aliases.length > 0) {
2803
+ out.push('<section><h3>Aliases</h3><div class="wiki-tag-row">');
2804
+ for (const a of fm.aliases) out.push(`<span class="wiki-tag">${escHtml(a)}</span>`);
2805
+ out.push('</div></section>');
2806
+ }
2807
+ if (Array.isArray(fm.sources) && fm.sources.length > 0) {
2808
+ out.push('<section><h3>Sources</h3><ul class="wiki-source-list">');
2809
+ for (const s of fm.sources) out.push(`<li><code>${escHtml(s)}</code></li>`);
2810
+ out.push('</ul></section>');
2811
+ }
2812
+ if (Array.isArray(fm.related) && fm.related.length > 0) {
2813
+ out.push('<section><h3>Related</h3><div class="wiki-related">');
2814
+ for (const r of fm.related) {
2815
+ const html = rewriteWikiLinks(typeof r === 'string' ? r : String(r));
2816
+ out.push(`<div>${html}</div>`);
2817
+ }
2818
+ out.push('</div></section>');
2819
+ }
2820
+ if (data.backlinks) {
2821
+ out.push('<section><h3>Backlinks</h3><div class="wiki-backlinks">');
2822
+ out.push(rewriteWikiLinks(data.backlinks));
2823
+ out.push('</div></section>');
2824
+ }
2825
+ out.push('</aside>');
2826
+ return out.join('');
2827
+ }
2828
+
2829
+ async function doIngest() {
2830
+ if (wikiState.ingesting) return;
2831
+ const paste = $('#wiki-ingest-paste').value.trim();
2832
+ const url = $('#wiki-ingest-url').value.trim();
2833
+ if (!paste && !url) {
2834
+ toast('Provide pasted text or a URL', 'error');
2835
+ return;
2836
+ }
2837
+ wikiState.ingesting = true;
2838
+ const btn = $('#wiki-ingest-go');
2839
+ btn.disabled = true;
2840
+ btn.textContent = 'Ingesting…';
2841
+ try {
2842
+ const res = await fetch('/api/knowledge/ingest', {
2843
+ method: 'POST',
2844
+ headers: { 'content-type': 'application/json' },
2845
+ body: JSON.stringify(paste ? { paste } : { url }),
2846
+ });
2847
+ const data = await res.json();
2848
+ if (data.error) throw new Error(data.error);
2849
+ const total = (data.producedPages?.length || 0) + (data.updatedPages?.length || 0);
2850
+ toast(`Ingest complete — ${total} pages touched`, 'ok');
2851
+ $('#wiki-ingest-panel').hidden = true;
2852
+ $('#wiki-ingest-paste').value = '';
2853
+ $('#wiki-ingest-url').value = '';
2854
+ await refreshKnowledgeState();
2855
+ const pages = await fetch('/api/knowledge/pages').then((r) => r.json());
2856
+ wikiState.pages = pages.pages || [];
2857
+ rerenderKnowledge();
2858
+ } catch (err) {
2859
+ toast(`Ingest failed: ${err.message}`, 'error');
2860
+ } finally {
2861
+ wikiState.ingesting = false;
2862
+ btn.disabled = false;
2863
+ btn.textContent = 'Ingest';
2864
+ }
2865
+ }
2866
+
2867
+ async function doQuery() {
2868
+ if (wikiState.querying) return;
2869
+ const question = $('#wiki-query-input').value.trim();
2870
+ if (!question) return;
2871
+ wikiState.querying = true;
2872
+ const ans = $('#wiki-query-answer');
2873
+ ans.hidden = false;
2874
+ ans.innerHTML = '<div class="hint">Asking…</div>';
2875
+ try {
2876
+ const res = await fetch('/api/knowledge/query', {
2877
+ method: 'POST',
2878
+ headers: { 'content-type': 'application/json' },
2879
+ body: JSON.stringify({ question }),
2880
+ });
2881
+ const data = await res.json();
2882
+ if (data.error) throw new Error(data.error);
2883
+ const citationsHtml = Array.isArray(data.citations) && data.citations.length > 0
2884
+ ? `<div class="wiki-citations">${data.citations.map((c) => `<a href="#" data-slug="${escAttr(c)}" class="wiki-link wiki-citation">[[${escHtml(c)}]]</a>`).join(' ')}</div>`
2885
+ : '';
2886
+ ans.innerHTML = `
2887
+ <div class="wiki-answer-body">${renderWikiBody(data.answer || '')}</div>
2888
+ ${citationsHtml}
2889
+ <div class="hint">cost: ${(data.costUsd || 0).toFixed(4)} USD</div>
2890
+ `;
2891
+ $$('.wiki-citation', ans).forEach((a) => {
2892
+ a.addEventListener('click', (e) => {
2893
+ e.preventDefault();
2894
+ loadPage(a.dataset.slug);
2895
+ });
2896
+ });
2897
+ } catch (err) {
2898
+ ans.innerHTML = `<div class="hint">Query failed: ${escHtml(err.message)}</div>`;
2899
+ } finally {
2900
+ wikiState.querying = false;
2901
+ }
2902
+ }
2903
+
2904
+ async function doLint() {
2905
+ if (wikiState.linting) return;
2906
+ wikiState.linting = true;
2907
+ const btn = $('#wiki-lint-btn');
2908
+ btn.disabled = true;
2909
+ btn.textContent = 'Linting…';
2910
+ try {
2911
+ const res = await fetch('/api/knowledge/lint', {
2912
+ method: 'POST',
2913
+ headers: { 'content-type': 'application/json' },
2914
+ body: JSON.stringify({ runLlm: false }),
2915
+ });
2916
+ const data = await res.json();
2917
+ if (data.error) throw new Error(data.error);
2918
+ const errs = (data.findings || []).filter((f) => f.severity === 'error').length;
2919
+ const warns = (data.findings || []).filter((f) => f.severity === 'warn').length;
2920
+ toast(`Lint complete — ${errs} errors, ${warns} warnings, slug-map refreshed`, errs > 0 ? 'warn' : 'ok');
2921
+ } catch (err) {
2922
+ toast(`Lint failed: ${err.message}`, 'error');
2923
+ } finally {
2924
+ wikiState.linting = false;
2925
+ btn.disabled = false;
2926
+ btn.textContent = 'Lint';
2927
+ }
2928
+ }
2929
+
2930
+ function escHtml(s) {
2931
+ return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2932
+ }
2933
+ function escAttr(s) {
2934
+ return escHtml(s).replace(/"/g, '&quot;');
2935
+ }
2936
+ function truncate(s, n) {
2937
+ const str = String(s ?? '');
2938
+ return str.length <= n ? str : str.slice(0, n - 1) + '…';
2939
+ }
2940
+
2941
+ // Refresh slug-map on relevant WS events.
2942
+ window.addEventListener('aab-wiki-event', (ev) => {
2943
+ if (!ev || !ev.detail) return;
2944
+ const t = ev.detail.type;
2945
+ if (t === 'wiki_ingest_done' || t === 'wiki_lint_done' || t === 'wiki_renamed') {
2946
+ refreshKnowledgeState();
2947
+ if (state.route === 'knowledge') {
2948
+ fetch('/api/knowledge/pages').then((r) => r.json()).then((data) => {
2949
+ wikiState.pages = data.pages || [];
2950
+ rerenderKnowledge();
2951
+ });
2952
+ }
2953
+ }
2954
+ });
2955
+
2956
+ // ------------------------------------------------------------------
2957
+ // Principle Explorer wizard
2958
+ // ------------------------------------------------------------------
2959
+
2960
+ const EXPLORER_STEPS = ['behavior', 'antipattern', 'triggers', 'examples', 'priority'];
2961
+ const STEP_LABELS = {
2962
+ behavior: 'Behavior',
2963
+ antipattern: 'Anti-pattern',
2964
+ triggers: 'Trigger questions',
2965
+ examples: 'Examples',
2966
+ priority: 'Priority',
2967
+ };
2968
+
2969
+ const explorerState = {
2970
+ /** Working principle draft (mutated as we apply step values). */
2971
+ principle: null,
2972
+ /** Cross-step ExplorerTurn[] log. */
2973
+ history: [],
2974
+ /** Same-step turns (re-set when stepping). */
2975
+ currentStepTurns: [],
2976
+ step: 'behavior',
2977
+ isFirstMessage: true,
2978
+ pendingSuggested: null,
2979
+ /** id of the existing principle (when refining one) or null for new draft. */
2980
+ existingId: null,
2981
+ };
2982
+
2983
+ function setupExplorerModal() {
2984
+ $('#explorer-modal-close').addEventListener('click', closeExplorerModal);
2985
+ $('#explorer-modal-skip').addEventListener('click', () => advanceExplorerStep({ skip: true }));
2986
+ $('#explorer-modal-next').addEventListener('click', () => advanceExplorerStep({}));
2987
+ }
2988
+
2989
+ function openExplorerWizard(principle) {
2990
+ explorerState.principle = {
2991
+ id: principle?.id,
2992
+ title: principle?.title || '',
2993
+ description: principle?.description || '',
2994
+ category: principle?.category || 'meta',
2995
+ priority: principle?.priority ?? 5,
2996
+ behavior: principle?.behavior || '',
2997
+ antiPattern: principle?.antiPattern,
2998
+ triggerQuestions: principle?.triggerQuestions,
2999
+ examples: principle?.examples,
3000
+ isActive: principle?.isActive ?? true,
3001
+ };
3002
+ explorerState.history = [];
3003
+ explorerState.currentStepTurns = [];
3004
+ explorerState.step = 'behavior';
3005
+ explorerState.isFirstMessage = true;
3006
+ explorerState.pendingSuggested = null;
3007
+ explorerState.existingId = principle?.id || null;
3008
+
3009
+ $('#explorer-modal-title').textContent =
3010
+ `Explore: ${explorerState.principle.title || '(new draft)'} — ${STEP_LABELS[explorerState.step]}`;
3011
+ $('#explorer-modal').hidden = false;
3012
+ $('#explorer-modal-next').hidden = true;
3013
+ renderExplorerStep();
3014
+ // Auto-fire the opener turn.
3015
+ fireExplorerStep('');
3016
+ }
3017
+
3018
+ function closeExplorerModal() {
3019
+ $('#explorer-modal').hidden = true;
3020
+ $('#explorer-modal-body').innerHTML = '';
3021
+ }
3022
+
3023
+ function renderExplorerStep() {
3024
+ const body = $('#explorer-modal-body');
3025
+ body.innerHTML = '';
3026
+ const wrap = h('div', { class: 'explorer-wrap', 'data-testid': `explorer-step-${explorerState.step}` });
3027
+ // Step indicator
3028
+ const steps = h('div', { class: 'explorer-steps' });
3029
+ for (const s of EXPLORER_STEPS) {
3030
+ const dot = h('span', { class: 'explorer-step-dot' + (s === explorerState.step ? ' active' : '') }, STEP_LABELS[s]);
3031
+ steps.appendChild(dot);
3032
+ }
3033
+ wrap.appendChild(steps);
3034
+ // Transcript
3035
+ const transcript = h('div', { class: 'explorer-transcript' });
3036
+ for (const turn of explorerState.currentStepTurns) {
3037
+ const div = h('div', { class: `explorer-msg ${turn.role}` });
3038
+ div.appendChild(h('span', { class: 'explorer-role' }, turn.role === 'user' ? 'you' : 'coach'));
3039
+ const content = h('div', { class: 'explorer-content' });
3040
+ content.textContent = turn.content;
3041
+ div.appendChild(content);
3042
+ transcript.appendChild(div);
3043
+ }
3044
+ wrap.appendChild(transcript);
3045
+ // Composer
3046
+ const composer = h('div', { class: 'explorer-composer' });
3047
+ const input = h('textarea', { rows: '3', placeholder: 'Type your answer…', 'data-testid': 'explorer-input' });
3048
+ const sendBtn = h('button', { class: 'btn-primary', 'data-testid': 'explorer-send' }, 'Send');
3049
+ sendBtn.addEventListener('click', () => {
3050
+ const text = input.value.trim();
3051
+ if (!text) return;
3052
+ explorerState.currentStepTurns.push({ step: explorerState.step, role: 'user', content: text });
3053
+ explorerState.isFirstMessage = false;
3054
+ input.value = '';
3055
+ renderExplorerStep();
3056
+ fireExplorerStep(text);
3057
+ });
3058
+ composer.appendChild(input);
3059
+ composer.appendChild(sendBtn);
3060
+ wrap.appendChild(composer);
3061
+ body.appendChild(wrap);
3062
+ // Working draft summary
3063
+ const draft = h('div', { class: 'explorer-draft' });
3064
+ const p = explorerState.principle;
3065
+ const lines = [
3066
+ `Title: ${p.title}`,
3067
+ `Category: ${p.category}`,
3068
+ `Priority: ${p.priority}/10`,
3069
+ p.behavior ? `Behavior: ${truncateForExplorer(p.behavior, 100)}` : '',
3070
+ p.antiPattern ? `Anti-pattern: ${truncateForExplorer(p.antiPattern, 100)}` : '',
3071
+ Array.isArray(p.triggerQuestions) && p.triggerQuestions.length > 0 ? `Triggers: ${p.triggerQuestions.length} q` : '',
3072
+ Array.isArray(p.examples) && p.examples.length > 0 ? `Examples: ${p.examples.length}` : '',
3073
+ ].filter(Boolean);
3074
+ draft.innerHTML = `<strong>Working draft</strong><br>` + lines.map((l) => `<span>${escHtml(l)}</span>`).join('<br>');
3075
+ body.appendChild(draft);
3076
+ }
3077
+
3078
+ function truncateForExplorer(s, max) {
3079
+ if (!s) return '';
3080
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
3081
+ }
3082
+
3083
+ async function fireExplorerStep(userMessage) {
3084
+ const body = $('#explorer-modal-body');
3085
+ const thinking = h('div', { class: 'explorer-thinking' }, 'Coach thinking…');
3086
+ body.appendChild(thinking);
3087
+ try {
3088
+ const result = await fetchJSON('/api/principles/explore-step', {
3089
+ method: 'POST',
3090
+ headers: { 'content-type': 'application/json' },
3091
+ body: JSON.stringify({
3092
+ principle: explorerState.principle,
3093
+ history: explorerState.history,
3094
+ step: explorerState.step,
3095
+ userMessage,
3096
+ isFirstMessage: explorerState.isFirstMessage,
3097
+ }),
3098
+ });
3099
+ explorerState.currentStepTurns.push({ step: explorerState.step, role: 'assistant', content: result.reply });
3100
+ explorerState.isFirstMessage = false;
3101
+ if (result.synthesised && result.suggested) {
3102
+ explorerState.pendingSuggested = result.suggested;
3103
+ $('#explorer-modal-next').hidden = false;
3104
+ }
3105
+ renderExplorerStep();
3106
+ } catch (e) {
3107
+ thinking.remove();
3108
+ toast('Coach failed: ' + e.message, 'err');
3109
+ }
3110
+ }
3111
+
3112
+ async function advanceExplorerStep(opts) {
3113
+ // Apply the pending suggestion (if any and not skipped).
3114
+ if (!opts.skip && explorerState.pendingSuggested) {
3115
+ try {
3116
+ const r = await fetchJSON('/api/principles/apply-step', {
3117
+ method: 'POST',
3118
+ headers: { 'content-type': 'application/json' },
3119
+ body: JSON.stringify({
3120
+ principle: explorerState.principle,
3121
+ step: explorerState.step,
3122
+ value: explorerState.pendingSuggested,
3123
+ }),
3124
+ });
3125
+ explorerState.principle = { ...explorerState.principle, ...r.principle };
3126
+ } catch (e) {
3127
+ toast('Apply failed: ' + e.message, 'err');
3128
+ return;
3129
+ }
3130
+ }
3131
+ // Move the same-step turns into the global history so the next step has them.
3132
+ explorerState.history = [...explorerState.history, ...explorerState.currentStepTurns];
3133
+ explorerState.currentStepTurns = [];
3134
+ explorerState.pendingSuggested = null;
3135
+ $('#explorer-modal-next').hidden = true;
3136
+ const idx = EXPLORER_STEPS.indexOf(explorerState.step);
3137
+ if (idx === EXPLORER_STEPS.length - 1) {
3138
+ // All steps done — save the principle.
3139
+ await saveExploredPrinciple();
3140
+ return;
3141
+ }
3142
+ explorerState.step = EXPLORER_STEPS[idx + 1];
3143
+ explorerState.isFirstMessage = true;
3144
+ $('#explorer-modal-title').textContent =
3145
+ `Explore: ${explorerState.principle.title || '(new draft)'} — ${STEP_LABELS[explorerState.step]}`;
3146
+ renderExplorerStep();
3147
+ fireExplorerStep('');
3148
+ }
3149
+
3150
+ async function saveExploredPrinciple() {
3151
+ const p = explorerState.principle;
3152
+ try {
3153
+ if (explorerState.existingId) {
3154
+ await fetchJSON(`/api/principles/${explorerState.existingId}`, {
3155
+ method: 'PATCH',
3156
+ headers: { 'content-type': 'application/json' },
3157
+ body: JSON.stringify({
3158
+ behavior: p.behavior,
3159
+ antiPattern: p.antiPattern,
3160
+ triggerQuestions: p.triggerQuestions,
3161
+ examples: p.examples,
3162
+ priority: p.priority,
3163
+ }),
3164
+ });
3165
+ toast(`Saved refined principle "${p.title}".`, 'ok');
3166
+ } else {
3167
+ await fetchJSON('/api/principles', {
3168
+ method: 'POST',
3169
+ headers: { 'content-type': 'application/json' },
3170
+ body: JSON.stringify({
3171
+ title: p.title,
3172
+ description: p.description,
3173
+ category: p.category,
3174
+ behavior: p.behavior,
3175
+ antiPattern: p.antiPattern,
3176
+ triggerQuestions: p.triggerQuestions,
3177
+ examples: p.examples,
3178
+ priority: p.priority,
3179
+ }),
3180
+ });
3181
+ toast(`Created new principle "${p.title}".`, 'ok');
3182
+ }
3183
+ closeExplorerModal();
3184
+ await refreshState({ silent: true });
3185
+ if (state.route === 'principles') navigate('principles');
3186
+ } catch (e) {
3187
+ toast('Save failed: ' + e.message, 'err');
3188
+ }
3189
+ }
3190
+
3191
+ // ------------------------------------------------------------------
3192
+ // Decision Coach view
3193
+ // ------------------------------------------------------------------
3194
+
3195
+ const coachState = {
3196
+ sessions: [],
3197
+ currentSessionId: null,
3198
+ currentSession: null,
3199
+ thinking: false,
3200
+ };
3201
+
3202
+ function renderCoachView(main) {
3203
+ const view = h('div', { class: 'view coach-view', 'data-testid': 'coach-view' });
3204
+ view.appendChild(
3205
+ h('div', { class: 'view-header' }, [
3206
+ h('div', {}, [
3207
+ h('div', { class: 'view-title' }, 'Decision Coach'),
3208
+ h('div', { class: 'view-subtitle' }, 'Principle-based decision conversations (Dalio-style).'),
3209
+ ]),
3210
+ (() => {
3211
+ const wrap = h('div', { class: 'header-actions' });
3212
+ const newBtn = h('button', { class: 'btn-primary', 'data-testid': 'coach-new-session-btn' }, '+ New session');
3213
+ newBtn.addEventListener('click', openNewCoachSessionModal);
3214
+ wrap.appendChild(newBtn);
3215
+ return wrap;
3216
+ })(),
3217
+ ]),
3218
+ );
3219
+
3220
+ const body = h('div', { class: 'coach-layout' });
3221
+ const sidebar = h('aside', { class: 'coach-sidebar', 'data-testid': 'coach-session-list' });
3222
+ sidebar.appendChild(h('div', { class: 'coach-sidebar-head' }, 'Sessions'));
3223
+ const sidebarList = h('div', { class: 'coach-session-list' });
3224
+ sidebarList.id = 'coach-session-list';
3225
+ sidebar.appendChild(sidebarList);
3226
+ body.appendChild(sidebar);
3227
+
3228
+ const detail = h('section', { class: 'coach-detail', id: 'coach-detail', 'data-testid': 'coach-detail' });
3229
+ detail.appendChild(h('div', { class: 'coach-empty' }, [
3230
+ h('h2', {}, 'Pick a session, or start a new one'),
3231
+ h('p', {}, 'The coach references your active principles to help you think through hard decisions.'),
3232
+ ]));
3233
+ body.appendChild(detail);
3234
+ view.appendChild(body);
3235
+ main.appendChild(view);
3236
+
3237
+ refreshCoachSessions();
3238
+ }
3239
+
3240
+ async function refreshCoachSessions() {
3241
+ try {
3242
+ const r = await fetchJSON('/api/coach/sessions');
3243
+ coachState.sessions = r.sessions || [];
3244
+ renderCoachSidebar();
3245
+ } catch (e) {
3246
+ toast('Could not load sessions: ' + e.message, 'err');
3247
+ }
3248
+ }
3249
+
3250
+ function renderCoachSidebar() {
3251
+ const list = $('#coach-session-list');
3252
+ if (!list) return;
3253
+ list.innerHTML = '';
3254
+ if (coachState.sessions.length === 0) {
3255
+ list.appendChild(h('div', { class: 'hint' }, 'No sessions yet. Click "New session".'));
3256
+ return;
3257
+ }
3258
+ for (const s of coachState.sessions) {
3259
+ const row = h('button', {
3260
+ class: 'coach-session-row' + (s.id === coachState.currentSessionId ? ' active' : ''),
3261
+ 'data-testid': 'coach-session-row',
3262
+ 'data-session-id': s.id,
3263
+ });
3264
+ const title = s.title || s.situation.slice(0, 60);
3265
+ row.appendChild(h('div', { class: 'coach-session-title' }, title));
3266
+ const meta = h('div', { class: 'coach-session-meta' });
3267
+ meta.appendChild(h('span', {}, `${s.messages.length} msg`));
3268
+ meta.appendChild(h('span', {}, ' · '));
3269
+ meta.appendChild(h('span', {}, s.status));
3270
+ meta.appendChild(h('span', {}, ' · '));
3271
+ meta.appendChild(h('span', {}, formatRelative(s.updatedAt)));
3272
+ row.appendChild(meta);
3273
+ row.addEventListener('click', () => loadCoachSession(s.id));
3274
+ list.appendChild(row);
3275
+ }
3276
+ }
3277
+
3278
+ async function loadCoachSession(id) {
3279
+ coachState.currentSessionId = id;
3280
+ renderCoachSidebar();
3281
+ const detail = $('#coach-detail');
3282
+ if (!detail) return;
3283
+ detail.innerHTML = '<div class="hint">Loading…</div>';
3284
+ try {
3285
+ const session = await fetchJSON('/api/coach/sessions/' + encodeURIComponent(id));
3286
+ coachState.currentSession = session;
3287
+ renderCoachChat();
3288
+ } catch (e) {
3289
+ detail.innerHTML = `<div class="hint err">${escHtml(e.message)}</div>`;
3290
+ }
3291
+ }
3292
+
3293
+ function renderCoachChat() {
3294
+ const detail = $('#coach-detail');
3295
+ if (!detail || !coachState.currentSession) return;
3296
+ const s = coachState.currentSession;
3297
+ detail.innerHTML = '';
3298
+ const head = h('div', { class: 'coach-chat-head' });
3299
+ head.appendChild(h('div', { class: 'coach-chat-title' }, s.title || 'Decision session'));
3300
+ head.appendChild(h('div', { class: 'coach-chat-situation' }, s.situation));
3301
+ const headRow = h('div', { class: 'coach-chat-meta' });
3302
+ headRow.appendChild(h('span', {}, s.status));
3303
+ headRow.appendChild(h('span', {}, ' · '));
3304
+ headRow.appendChild(h('span', {}, formatRelative(s.updatedAt)));
3305
+ headRow.appendChild(h('span', {}, ' · '));
3306
+ const delBtn = h('button', { class: 'btn-danger-ghost', 'data-testid': 'coach-delete-btn' }, 'Delete');
3307
+ delBtn.addEventListener('click', () =>
3308
+ openConfirmModal({
3309
+ title: 'Delete this coach session?',
3310
+ message: 'This cannot be undone.',
3311
+ okLabel: 'Delete',
3312
+ onOk: async () => {
3313
+ await fetchJSON('/api/coach/sessions/' + s.id, { method: 'DELETE' });
3314
+ toast('Session deleted.', 'ok');
3315
+ coachState.currentSession = null;
3316
+ coachState.currentSessionId = null;
3317
+ await refreshCoachSessions();
3318
+ renderCoachView($('#main'));
3319
+ },
3320
+ }),
3321
+ );
3322
+ headRow.appendChild(delBtn);
3323
+ head.appendChild(headRow);
3324
+ detail.appendChild(head);
3325
+
3326
+ const stream = h('div', { class: 'coach-stream', 'data-testid': 'coach-stream' });
3327
+ for (const m of s.messages) {
3328
+ const bubble = h('div', { class: `coach-msg ${m.role}` });
3329
+ bubble.appendChild(h('span', { class: 'coach-role' }, m.role === 'user' ? 'you' : 'coach'));
3330
+ const body = h('div', { class: 'coach-msg-body' });
3331
+ body.textContent = m.content;
3332
+ bubble.appendChild(body);
3333
+ if (m.principlesReferenced && m.principlesReferenced.length > 0) {
3334
+ const refs = h('div', { class: 'coach-msg-refs' });
3335
+ const names = m.principlesReferenced
3336
+ .map((pid) => state.principles.find((p) => p.id === pid)?.title)
3337
+ .filter(Boolean);
3338
+ if (names.length > 0) {
3339
+ refs.textContent = 'principles: ' + names.join(', ');
3340
+ bubble.appendChild(refs);
3341
+ }
3342
+ }
3343
+ stream.appendChild(bubble);
3344
+ }
3345
+ if (coachState.thinking) {
3346
+ stream.appendChild(h('div', { class: 'coach-thinking' }, 'Coach thinking…'));
3347
+ }
3348
+ detail.appendChild(stream);
3349
+
3350
+ const composer = h('div', { class: 'coach-composer' });
3351
+ const input = h('textarea', {
3352
+ rows: '2',
3353
+ placeholder: 'Type your message…',
3354
+ 'data-testid': 'coach-input',
3355
+ });
3356
+ const sendBtn = h('button', { class: 'btn-primary', 'data-testid': 'coach-send-btn' }, 'Send');
3357
+ const send = async () => {
3358
+ const text = input.value.trim();
3359
+ if (!text) return;
3360
+ input.value = '';
3361
+ sendBtn.disabled = true;
3362
+ coachState.thinking = true;
3363
+ renderCoachChat();
3364
+ try {
3365
+ await fetchJSON(`/api/coach/sessions/${s.id}/messages`, {
3366
+ method: 'POST',
3367
+ headers: { 'content-type': 'application/json' },
3368
+ body: JSON.stringify({ content: text }),
3369
+ });
3370
+ // The actual reply arrives via WS coach_message.
3371
+ } catch (e) {
3372
+ coachState.thinking = false;
3373
+ toast('Send failed: ' + e.message, 'err');
3374
+ renderCoachChat();
3375
+ } finally {
3376
+ sendBtn.disabled = false;
3377
+ }
3378
+ };
3379
+ sendBtn.addEventListener('click', send);
3380
+ input.addEventListener('keydown', (e) => {
3381
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
3382
+ e.preventDefault();
3383
+ send();
3384
+ }
3385
+ });
3386
+ composer.appendChild(input);
3387
+ composer.appendChild(sendBtn);
3388
+ detail.appendChild(composer);
3389
+
3390
+ // Auto-scroll to bottom
3391
+ stream.scrollTop = stream.scrollHeight;
3392
+ }
3393
+
3394
+ function openNewCoachSessionModal() {
3395
+ $('#edit-modal-title').textContent = 'New coach session';
3396
+ const body = $('#edit-modal-body');
3397
+ body.innerHTML = '';
3398
+ const fields = {
3399
+ title: input('Title (optional)', '', 'e.g. Should I pivot?'),
3400
+ situation: textarea(
3401
+ 'Situation / decision to think through',
3402
+ '',
3403
+ 'Describe the decision and any constraints.',
3404
+ 4,
3405
+ ),
3406
+ };
3407
+ body.appendChild(fields.title.wrap);
3408
+ body.appendChild(fields.situation.wrap);
3409
+ $('#edit-modal-delete').hidden = true;
3410
+ $('#edit-modal').hidden = false;
3411
+ fields.situation.input.focus();
3412
+ editModalOnSave = async () => {
3413
+ const situation = fields.situation.input.value.trim();
3414
+ if (!situation) {
3415
+ toast('Situation is required.', 'err');
3416
+ throw new Error('Situation is required.');
3417
+ }
3418
+ const r = await fetchJSON('/api/coach/sessions', {
3419
+ method: 'POST',
3420
+ headers: { 'content-type': 'application/json' },
3421
+ body: JSON.stringify({ situation, title: fields.title.input.value.trim() || undefined }),
3422
+ });
3423
+ toast('Session started — coach is opening the conversation.', 'ok');
3424
+ coachState.currentSessionId = r.session.id;
3425
+ coachState.currentSession = r.session;
3426
+ coachState.thinking = true;
3427
+ await refreshCoachSessions();
3428
+ if (state.route === 'coach') renderCoachChat();
3429
+ };
3430
+ }
3431
+
3432
+ // WS bridge for coach events.
3433
+ window.addEventListener('aab-coach-event', (ev) => {
3434
+ const d = ev.detail || {};
3435
+ if (d.type === 'coach_thinking') {
3436
+ if (coachState.currentSessionId === d.sessionId) {
3437
+ coachState.thinking = true;
3438
+ if (state.route === 'coach') renderCoachChat();
3439
+ }
3440
+ } else if (d.type === 'coach_message') {
3441
+ if (coachState.currentSessionId === d.sessionId && d.session) {
3442
+ coachState.currentSession = d.session;
3443
+ coachState.thinking = false;
3444
+ if (state.route === 'coach') renderCoachChat();
3445
+ }
3446
+ refreshCoachSessions();
3447
+ } else if (d.type === 'coach_error') {
3448
+ if (coachState.currentSessionId === d.sessionId) {
3449
+ coachState.thinking = false;
3450
+ toast('Coach error: ' + d.message, 'err');
3451
+ if (state.route === 'coach') renderCoachChat();
3452
+ }
3453
+ } else if (d.type === 'coach_session_started' || d.type === 'coach_session_deleted' || d.type === 'coach_session_updated') {
3454
+ refreshCoachSessions();
3455
+ }
3456
+ });
3457
+
3458
+ // ==================================================================
3459
+ // Sparring (Phase 3) — 1:1 deep dive panel anchored to a response
3460
+ // ==================================================================
3461
+
3462
+ const sparringState = {
3463
+ open: false,
3464
+ session: null,
3465
+ member: null,
3466
+ discussion: null,
3467
+ thinking: false,
3468
+ activity: null,
3469
+ };
3470
+
3471
+ async function openSparringPanel({ discussion, memberId, memberName, anchorRoundNumber, anchorTurnNumber }) {
3472
+ try {
3473
+ const res = await fetch(`/api/discussions/${encodeURIComponent(discussion.id)}/sparring`, {
3474
+ method: 'POST',
3475
+ headers: { 'content-type': 'application/json' },
3476
+ body: JSON.stringify({ memberId, memberName, anchorRoundNumber, anchorTurnNumber }),
3477
+ });
3478
+ if (!res.ok) {
3479
+ const body = await res.json().catch(() => ({}));
3480
+ throw new Error(body.error || `Failed to open sparring (HTTP ${res.status})`);
3481
+ }
3482
+ const { session, reused } = await res.json();
3483
+ sparringState.open = true;
3484
+ sparringState.session = session;
3485
+ sparringState.member = state.members.find((m) => m.id === memberId) || { name: memberName };
3486
+ sparringState.discussion = discussion;
3487
+ sparringState.thinking = false;
3488
+ sparringState.activity = null;
3489
+ renderSparringModal();
3490
+ if (!reused) toast(`Sparring session opened with ${memberName}.`, 'ok');
3491
+ } catch (err) {
3492
+ toast(err.message || 'Could not open sparring', 'err');
3493
+ }
3494
+ }
3495
+
3496
+ function renderSparringModal() {
3497
+ let modal = document.getElementById('sparring-modal');
3498
+ if (!modal) {
3499
+ modal = h('div', {
3500
+ class: 'modal-backdrop',
3501
+ id: 'sparring-modal',
3502
+ 'data-testid': 'sparring-modal',
3503
+ });
3504
+ document.body.appendChild(modal);
3505
+ }
3506
+ modal.innerHTML = '';
3507
+ modal.hidden = !sparringState.open;
3508
+ if (!sparringState.open) return;
3509
+
3510
+ const inner = h('div', { class: 'modal modal-wide sparring-modal' });
3511
+
3512
+ const header = h('div', { class: 'modal-header' });
3513
+ const titleBlock = h('div', {}, [
3514
+ h('h2', { 'data-testid': 'sparring-title' }, `⚔ 1:1 with ${sparringState.member?.name || sparringState.session?.memberName}`),
3515
+ h(
3516
+ 'div',
3517
+ { class: 'message-meta' },
3518
+ `Anchor: round ${sparringState.session.anchorRoundNumber} · turn ${sparringState.session.anchorTurnNumber}`,
3519
+ ),
3520
+ ]);
3521
+ header.appendChild(titleBlock);
3522
+
3523
+ const headerActions = h('div', { class: 'spar-header-actions' });
3524
+ const injectBtn = h(
3525
+ 'button',
3526
+ {
3527
+ class: 'btn-secondary',
3528
+ type: 'button',
3529
+ 'data-testid': 'sparring-inject-btn',
3530
+ title: 'Write the latest reply back into the main discussion timeline',
3531
+ },
3532
+ '↩ Inject insight back',
3533
+ );
3534
+ injectBtn.addEventListener('click', openSparringInjectModal);
3535
+ headerActions.appendChild(injectBtn);
3536
+
3537
+ const closeBtn = h('button', { class: 'icon-btn', 'aria-label': 'Close', type: 'button' }, '×');
3538
+ closeBtn.addEventListener('click', closeSparringPanel);
3539
+ headerActions.appendChild(closeBtn);
3540
+ header.appendChild(headerActions);
3541
+ inner.appendChild(header);
3542
+
3543
+ const body = h('div', { class: 'modal-body sparring-body' });
3544
+
3545
+ // Sticky anchor banner
3546
+ const anchor = h('div', { class: 'sparring-anchor', 'data-testid': 'sparring-anchor' });
3547
+ anchor.appendChild(h('div', { class: 'sparring-anchor-label' }, 'Anchored response'));
3548
+ anchor.appendChild(h('div', { class: 'sparring-anchor-text' }, sparringState.session.anchorResponsePreview || ''));
3549
+ body.appendChild(anchor);
3550
+
3551
+ // Transcript
3552
+ const transcript = h('div', { class: 'sparring-transcript', 'data-testid': 'sparring-transcript' });
3553
+ const messages = sparringState.session.messages || [];
3554
+ if (messages.length === 0) {
3555
+ transcript.appendChild(
3556
+ h('div', { class: 'message-meta sparring-empty' }, 'No messages yet — type your first sharper question below.'),
3557
+ );
3558
+ }
3559
+ for (const m of messages) {
3560
+ transcript.appendChild(sparringBubble(m, sparringState.member?.name || sparringState.session.memberName));
3561
+ }
3562
+ if (sparringState.thinking) {
3563
+ transcript.appendChild(sparringThinkingBubble(sparringState.member?.name || sparringState.session.memberName, sparringState.activity));
3564
+ }
3565
+ body.appendChild(transcript);
3566
+
3567
+ // Composer
3568
+ const composer = h('div', { class: 'sparring-composer' });
3569
+ const textarea = h('textarea', {
3570
+ id: 'sparring-input',
3571
+ 'data-testid': 'sparring-input',
3572
+ rows: '3',
3573
+ placeholder: 'Push back, ask a sharper question, request a counter-example…',
3574
+ });
3575
+ composer.appendChild(textarea);
3576
+ const composerActions = h('div', { class: 'chat-actions sparring-composer-actions' });
3577
+ const sendBtn = h(
3578
+ 'button',
3579
+ { class: 'btn-primary', 'data-testid': 'sparring-send-btn', type: 'button' },
3580
+ '↳ Send',
3581
+ );
3582
+ sendBtn.addEventListener('click', () => sendSparringMessageFromUi(textarea));
3583
+ composerActions.appendChild(sendBtn);
3584
+ composer.appendChild(composerActions);
3585
+
3586
+ // Ctrl/Cmd+Enter shortcut
3587
+ textarea.addEventListener('keydown', (ev) => {
3588
+ if ((ev.metaKey || ev.ctrlKey) && ev.key === 'Enter') {
3589
+ ev.preventDefault();
3590
+ sendSparringMessageFromUi(textarea);
3591
+ }
3592
+ });
3593
+
3594
+ body.appendChild(composer);
3595
+
3596
+ inner.appendChild(body);
3597
+ modal.appendChild(inner);
3598
+
3599
+ // Auto-scroll transcript to bottom
3600
+ setTimeout(() => {
3601
+ transcript.scrollTop = transcript.scrollHeight;
3602
+ }, 0);
3603
+ }
3604
+
3605
+ function sparringBubble(message, memberName) {
3606
+ const isUser = message.role === 'user';
3607
+ const wrap = h('div', {
3608
+ class: 'message' + (isUser ? ' message-user' : ''),
3609
+ 'data-testid': isUser ? 'sparring-msg-user' : 'sparring-msg-assistant',
3610
+ });
3611
+ if (!isUser) {
3612
+ const member = state.members.find((m) => m.id === sparringState.session.memberId) || { name: memberName };
3613
+ const color = member.color || colorForMember(memberName);
3614
+ wrap.appendChild(h('div', { class: 'avatar', 'data-color': color }, initialsOf(memberName)));
3615
+ }
3616
+ const body = h('div', { class: 'message-body' + (isUser ? ' user-body' : '') });
3617
+ body.appendChild(h('div', { class: 'message-name' }, isUser ? 'You' : memberName));
3618
+ body.appendChild(h('div', { class: 'bubble' + (isUser ? ' user-bubble' : '') }, message.content));
3619
+ if (!isUser && Array.isArray(message.sources) && message.sources.length > 0) {
3620
+ const sources = h('div', { class: 'sparring-sources' });
3621
+ sources.appendChild(h('div', { class: 'struct-section-title' }, 'Sources'));
3622
+ const ul = h('ul');
3623
+ for (const s of message.sources) {
3624
+ const li = h('li', {});
3625
+ const link = h('a', { href: s.url, target: '_blank', rel: 'noreferrer noopener' }, s.title || s.url);
3626
+ li.appendChild(link);
3627
+ ul.appendChild(li);
3628
+ }
3629
+ sources.appendChild(ul);
3630
+ body.appendChild(sources);
3631
+ }
3632
+ wrap.appendChild(body);
3633
+ if (isUser) {
3634
+ wrap.appendChild(h('div', { class: 'avatar avatar-user', 'data-color': 'brand' }, '👤'));
3635
+ }
3636
+ return wrap;
3637
+ }
3638
+
3639
+ function sparringThinkingBubble(memberName, activity) {
3640
+ const member = state.members.find((m) => m.id === sparringState.session.memberId) || { name: memberName };
3641
+ const color = member.color || colorForMember(memberName);
3642
+ const wrap = h('div', { class: 'message', 'data-testid': 'sparring-typing' });
3643
+ wrap.appendChild(h('div', { class: 'avatar', 'data-color': color }, initialsOf(memberName)));
3644
+ const body = h('div', { class: 'message-body' });
3645
+ body.appendChild(h('div', { class: 'message-name' }, memberName));
3646
+ const bubble = h('div', { class: 'typing-bubble' });
3647
+ bubble.appendChild(h('span', { class: 'typing-activity' }, (activity || 'thinking').replace(/[.…]+$/, '')));
3648
+ const dots = h('div', { class: 'typing' });
3649
+ dots.appendChild(h('span'));
3650
+ dots.appendChild(h('span'));
3651
+ dots.appendChild(h('span'));
3652
+ bubble.appendChild(dots);
3653
+ body.appendChild(bubble);
3654
+ wrap.appendChild(body);
3655
+ return wrap;
3656
+ }
3657
+
3658
+ async function sendSparringMessageFromUi(textarea) {
3659
+ const content = (textarea.value || '').trim();
3660
+ if (!content) {
3661
+ toast('Type a message first.', 'err');
3662
+ return;
3663
+ }
3664
+ if (!sparringState.session) return;
3665
+ textarea.value = '';
3666
+ // Optimistic: append user message into local state, mark thinking.
3667
+ sparringState.session.messages.push({
3668
+ id: 'pending-' + Date.now(),
3669
+ sessionId: sparringState.session.id,
3670
+ role: 'user',
3671
+ content,
3672
+ sources: [],
3673
+ createdAt: new Date().toISOString(),
3674
+ });
3675
+ sparringState.thinking = true;
3676
+ sparringState.activity = null;
3677
+ renderSparringModal();
3678
+ try {
3679
+ const res = await fetch(`/api/sparring/${encodeURIComponent(sparringState.session.id)}/messages`, {
3680
+ method: 'POST',
3681
+ headers: { 'content-type': 'application/json' },
3682
+ body: JSON.stringify({ content }),
3683
+ });
3684
+ if (res.status !== 202) {
3685
+ const body = await res.json().catch(() => ({}));
3686
+ throw new Error(body.error || `HTTP ${res.status}`);
3687
+ }
3688
+ } catch (err) {
3689
+ sparringState.thinking = false;
3690
+ toast(err.message || 'Send failed', 'err');
3691
+ renderSparringModal();
3692
+ }
3693
+ }
3694
+
3695
+ function closeSparringPanel() {
3696
+ sparringState.open = false;
3697
+ sparringState.session = null;
3698
+ sparringState.member = null;
3699
+ sparringState.discussion = null;
3700
+ sparringState.thinking = false;
3701
+ sparringState.activity = null;
3702
+ renderSparringModal();
3703
+ }
3704
+
3705
+ function openSparringInjectModal() {
3706
+ if (!sparringState.session) return;
3707
+ const lastAssistant = [...sparringState.session.messages].reverse().find((m) => m.role === 'assistant');
3708
+ if (!lastAssistant) {
3709
+ toast('Send a message and get a reply first — there is nothing to inject.', 'err');
3710
+ return;
3711
+ }
3712
+ const insight = lastAssistant.content;
3713
+ let modal = document.getElementById('sparring-inject-modal');
3714
+ if (!modal) {
3715
+ modal = h('div', {
3716
+ class: 'modal-backdrop',
3717
+ id: 'sparring-inject-modal',
3718
+ 'data-testid': 'sparring-inject-modal',
3719
+ });
3720
+ document.body.appendChild(modal);
3721
+ }
3722
+ modal.innerHTML = '';
3723
+ modal.hidden = false;
3724
+ const inner = h('div', { class: 'modal modal-wide' });
3725
+ inner.appendChild(
3726
+ (() => {
3727
+ const head = h('div', { class: 'modal-header' });
3728
+ head.appendChild(h('h2', {}, '↩ Inject insight back to discussion'));
3729
+ const close = h('button', { class: 'icon-btn', 'aria-label': 'Close', type: 'button' }, '×');
3730
+ close.addEventListener('click', () => (modal.hidden = true));
3731
+ head.appendChild(close);
3732
+ return head;
3733
+ })(),
3734
+ );
3735
+ const body = h('div', { class: 'modal-body' });
3736
+ body.appendChild(
3737
+ h(
3738
+ 'div',
3739
+ { class: 'message-meta' },
3740
+ `Will land in discussion at round ${sparringState.session.anchorRoundNumber} as a sparring_injection user response.`,
3741
+ ),
3742
+ );
3743
+ body.appendChild(h('label', { class: 'field-label', for: 'sparring-inject-text' }, 'Insight text (editable)'));
3744
+ const ta = h(
3745
+ 'textarea',
3746
+ {
3747
+ id: 'sparring-inject-text',
3748
+ 'data-testid': 'sparring-inject-textarea',
3749
+ rows: '8',
3750
+ },
3751
+ insight,
3752
+ );
3753
+ body.appendChild(ta);
3754
+ inner.appendChild(body);
3755
+
3756
+ const footer = h('div', { class: 'modal-footer' });
3757
+ const cancel = h('button', { class: 'btn-secondary', type: 'button' }, 'Cancel');
3758
+ cancel.addEventListener('click', () => (modal.hidden = true));
3759
+ footer.appendChild(cancel);
3760
+ const confirm = h(
3761
+ 'button',
3762
+ { class: 'btn-primary', type: 'button', 'data-testid': 'sparring-inject-confirm' },
3763
+ '↩ Inject',
3764
+ );
3765
+ confirm.addEventListener('click', async () => {
3766
+ const text = (ta.value || '').trim();
3767
+ if (!text) {
3768
+ toast('Insight cannot be empty.', 'err');
3769
+ return;
3770
+ }
3771
+ try {
3772
+ const res = await fetch(`/api/sparring/${encodeURIComponent(sparringState.session.id)}/inject`, {
3773
+ method: 'POST',
3774
+ headers: { 'content-type': 'application/json' },
3775
+ body: JSON.stringify({ insight: text }),
3776
+ });
3777
+ if (!res.ok) {
3778
+ const errBody = await res.json().catch(() => ({}));
3779
+ throw new Error(errBody.error || `HTTP ${res.status}`);
3780
+ }
3781
+ const { discussion } = await res.json();
3782
+ modal.hidden = true;
3783
+ toast('Insight injected into the main discussion.', 'ok');
3784
+ // Refresh the underlying discussion if the user still has it open in the
3785
+ // background.
3786
+ if (state.currentDiscussion && state.currentDiscussion.id === discussion.id) {
3787
+ state.currentDiscussion = discussion;
3788
+ updateDiscussionList(discussion);
3789
+ if (state.route === 'discussions') {
3790
+ openChatView(discussion);
3791
+ }
3792
+ }
3793
+ } catch (err) {
3794
+ toast(err.message || 'Inject failed', 'err');
3795
+ }
3796
+ });
3797
+ footer.appendChild(confirm);
3798
+ inner.appendChild(footer);
3799
+
3800
+ modal.appendChild(inner);
3801
+ }
3802
+
3803
+ async function openSparringListModal(discussion) {
3804
+ let sessions = [];
3805
+ try {
3806
+ const res = await fetch(`/api/discussions/${encodeURIComponent(discussion.id)}/sparring`);
3807
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
3808
+ const body = await res.json();
3809
+ sessions = body.sessions || [];
3810
+ } catch (err) {
3811
+ toast('Could not load sparring sessions: ' + err.message, 'err');
3812
+ return;
3813
+ }
3814
+
3815
+ let modal = document.getElementById('sparring-list-modal');
3816
+ if (!modal) {
3817
+ modal = h('div', {
3818
+ class: 'modal-backdrop',
3819
+ id: 'sparring-list-modal',
3820
+ 'data-testid': 'sparring-list-modal',
3821
+ });
3822
+ document.body.appendChild(modal);
3823
+ }
3824
+ modal.innerHTML = '';
3825
+ modal.hidden = false;
3826
+
3827
+ const inner = h('div', { class: 'modal modal-wide' });
3828
+
3829
+ const header = h('div', { class: 'modal-header' });
3830
+ header.appendChild(h('h2', {}, `⚔ Sparring sessions · ${sessions.length}`));
3831
+ const close = h('button', { class: 'icon-btn', 'aria-label': 'Close', type: 'button' }, '×');
3832
+ close.addEventListener('click', () => (modal.hidden = true));
3833
+ header.appendChild(close);
3834
+ inner.appendChild(header);
3835
+
3836
+ const body = h('div', { class: 'modal-body' });
3837
+ if (sessions.length === 0) {
3838
+ body.appendChild(
3839
+ h(
3840
+ 'div',
3841
+ { class: 'message-meta' },
3842
+ 'No sparring sessions yet. Click ⚔ Spar on any response in the chat to start one.',
3843
+ ),
3844
+ );
3845
+ } else {
3846
+ const list = h('div', { class: 'sparring-session-list', 'data-testid': 'sparring-session-list' });
3847
+ for (const s of sessions) {
3848
+ const row = h('button', {
3849
+ class: 'sparring-session-row',
3850
+ type: 'button',
3851
+ 'data-testid': 'sparring-session-row',
3852
+ 'data-session-id': s.id,
3853
+ });
3854
+ row.appendChild(h('div', { class: 'spar-row-title' }, `${s.memberName} · round ${s.anchorRoundNumber} · turn ${s.anchorTurnNumber}`));
3855
+ row.appendChild(
3856
+ h(
3857
+ 'div',
3858
+ { class: 'spar-row-meta' },
3859
+ `${s.messages?.length || 0} message${(s.messages?.length || 0) === 1 ? '' : 's'} · ${formatRelative(s.updatedAt)}`,
3860
+ ),
3861
+ );
3862
+ if (s.title) row.appendChild(h('div', { class: 'spar-row-subtitle' }, s.title));
3863
+ row.addEventListener('click', () => {
3864
+ modal.hidden = true;
3865
+ sparringState.open = true;
3866
+ sparringState.session = s;
3867
+ sparringState.member = state.members.find((m) => m.id === s.memberId) || { name: s.memberName };
3868
+ sparringState.discussion = discussion;
3869
+ sparringState.thinking = false;
3870
+ sparringState.activity = null;
3871
+ renderSparringModal();
3872
+ });
3873
+ list.appendChild(row);
3874
+ }
3875
+ body.appendChild(list);
3876
+ }
3877
+ inner.appendChild(body);
3878
+
3879
+ modal.appendChild(inner);
3880
+ }
3881
+
3882
+ window.addEventListener('aab-sparring-event', (ev) => {
3883
+ const d = ev.detail;
3884
+ if (!d || !sparringState.session || d.sessionId !== sparringState.session.id) return;
3885
+ if (d.type === 'sparring_thinking') {
3886
+ sparringState.thinking = true;
3887
+ sparringState.activity = null;
3888
+ renderSparringModal();
3889
+ } else if (d.type === 'sparring_activity') {
3890
+ sparringState.thinking = true;
3891
+ sparringState.activity = d.activity;
3892
+ renderSparringModal();
3893
+ } else if (d.type === 'sparring_message') {
3894
+ sparringState.thinking = false;
3895
+ sparringState.activity = null;
3896
+ if (d.session) {
3897
+ sparringState.session = d.session;
3898
+ } else {
3899
+ // Append the message into the optimistic transcript.
3900
+ sparringState.session.messages = sparringState.session.messages.filter((m) => !m.id.startsWith('pending-'));
3901
+ sparringState.session.messages.push(d.message);
3902
+ }
3903
+ renderSparringModal();
3904
+ } else if (d.type === 'sparring_error') {
3905
+ sparringState.thinking = false;
3906
+ sparringState.activity = null;
3907
+ toast('Sparring error: ' + d.message, 'err');
3908
+ renderSparringModal();
3909
+ } else if (d.type === 'sparring_session_deleted') {
3910
+ closeSparringPanel();
3911
+ }
3912
+ });
3913
+
3914
+ // ------------------------------------------------------------------
3915
+ // Phase 5 — Skill Planner + skill-creator orchestration (client-side)
3916
+ // ------------------------------------------------------------------
3917
+
3918
+ const plannerState = {
3919
+ planId: null,
3920
+ runId: null,
3921
+ action: null,
3922
+ proposal: null, // SkillDesignProposal
3923
+ phases: { 'pc-scan': 'queued', 'wiki': 'queued', 'web': 'queued', 'reasoning': 'queued' },
3924
+ stream: [],
3925
+ };
3926
+
3927
+ function launchSkillPlan(action) {
3928
+ resetPlannerState(action);
3929
+ showPlannerProgress();
3930
+ fetchJSON(`/api/actions/${action.id}/plan`, {
3931
+ method: 'POST',
3932
+ headers: { 'content-type': 'application/json' },
3933
+ body: JSON.stringify({ plannerTier: 'maximalist' }),
3934
+ }).then((res) => {
3935
+ plannerState.planId = res.planId;
3936
+ }).catch((err) => {
3937
+ toast('Plan failed: ' + err.message, 'err');
3938
+ hidePlannerProgress();
3939
+ });
3940
+ }
3941
+
3942
+ function launchSkillSolve(action) {
3943
+ // Two-step UX: open Plan first; let the user accept; the Accept handler kicks off /solve.
3944
+ launchSkillPlan(action);
3945
+ }
3946
+
3947
+ function resetPlannerState(action) {
3948
+ plannerState.planId = null;
3949
+ plannerState.runId = null;
3950
+ plannerState.action = action;
3951
+ plannerState.proposal = null;
3952
+ plannerState.phases = { 'pc-scan': 'queued', 'wiki': 'queued', 'web': 'queued', 'reasoning': 'queued' };
3953
+ plannerState.stream = [];
3954
+ }
3955
+
3956
+ function showPlannerProgress() {
3957
+ const m = document.getElementById('planner-progress-modal');
3958
+ m.hidden = false;
3959
+ document.getElementById('planner-progress-title').textContent =
3960
+ 'Skill Planner — ' + (plannerState.action?.title ?? '');
3961
+ // Clear any stale error banner from a prior run.
3962
+ const banner = document.getElementById('planner-error-banner');
3963
+ if (banner) banner.remove();
3964
+ paintPlannerPhases();
3965
+ }
3966
+
3967
+ function hidePlannerProgress() {
3968
+ document.getElementById('planner-progress-modal').hidden = true;
3969
+ }
3970
+
3971
+ function paintPlannerPhases() {
3972
+ const phases = document.querySelectorAll('#planner-progress-body .planner-phase');
3973
+ const keys = ['pc-scan', 'wiki', 'web', 'reasoning'];
3974
+ phases.forEach((node, i) => {
3975
+ const key = keys[i];
3976
+ const status = plannerState.phases[key] ?? 'queued';
3977
+ node.dataset.status = status;
3978
+ const statusNode = node.querySelector('.planner-phase-status');
3979
+ if (statusNode) statusNode.textContent = status;
3980
+ });
3981
+ const stream = document.getElementById('planner-stream');
3982
+ if (stream) {
3983
+ stream.innerHTML = '';
3984
+ for (const line of plannerState.stream.slice(-20)) {
3985
+ const row = document.createElement('div');
3986
+ row.className = 'planner-stream-row';
3987
+ row.textContent = line;
3988
+ stream.appendChild(row);
3989
+ }
3990
+ }
3991
+ }
3992
+
3993
+ function showProposalModal(proposal) {
3994
+ plannerState.proposal = proposal;
3995
+ const m = document.getElementById('planner-proposal-modal');
3996
+ m.hidden = false;
3997
+ const title = m.querySelector('[data-testid="proposal-skill-name"]');
3998
+ if (title) title.textContent = 'Proposal: ' + proposal.skillName;
3999
+ const body = document.getElementById('planner-proposal-body');
4000
+ body.innerHTML = '';
4001
+ body.appendChild(renderProposalContent(proposal));
4002
+ }
4003
+
4004
+ function hideProposalModal() {
4005
+ document.getElementById('planner-proposal-modal').hidden = true;
4006
+ }
4007
+
4008
+ function renderProposalContent(proposal) {
4009
+ const wrap = document.createElement('div');
4010
+ wrap.className = 'planner-proposal';
4011
+
4012
+ const summary = document.createElement('p');
4013
+ summary.innerHTML = `<strong>${escapeHtml(proposal.skillSummary)}</strong>`;
4014
+ wrap.appendChild(summary);
4015
+
4016
+ // Tier radio
4017
+ const tierRow = document.createElement('div');
4018
+ tierRow.className = 'planner-tier-row';
4019
+ tierRow.dataset.testid = 'proposal-tier-radio';
4020
+ for (const t of ['minimal', 'standard', 'maximalist']) {
4021
+ const label = document.createElement('label');
4022
+ label.className = 'planner-tier-label';
4023
+ const radio = document.createElement('input');
4024
+ radio.type = 'radio';
4025
+ radio.name = 'planner-tier';
4026
+ radio.value = t;
4027
+ if (t === (proposal.recommendedTier === 'custom' ? 'maximalist' : proposal.recommendedTier)) radio.checked = true;
4028
+ label.appendChild(radio);
4029
+ label.appendChild(document.createTextNode(' ' + t));
4030
+ tierRow.appendChild(label);
4031
+ }
4032
+ wrap.appendChild(tierRow);
4033
+
4034
+ // Value rationale
4035
+ if (proposal.valueRationale) {
4036
+ const r = document.createElement('p');
4037
+ r.className = 'planner-rationale';
4038
+ r.textContent = proposal.valueRationale;
4039
+ wrap.appendChild(r);
4040
+ }
4041
+
4042
+ // Integrations table
4043
+ const intTitle = document.createElement('h3');
4044
+ intTitle.textContent = `Integrations (${proposal.integrations.length})`;
4045
+ wrap.appendChild(intTitle);
4046
+ for (const integration of proposal.integrations) {
4047
+ const row = document.createElement('div');
4048
+ row.className = 'planner-integration-row';
4049
+ row.dataset.testid = 'proposal-integration-row';
4050
+ row.dataset.integrationId = integration.id;
4051
+ const toggle = document.createElement('input');
4052
+ toggle.type = 'checkbox';
4053
+ toggle.checked = true;
4054
+ toggle.dataset.testid = 'proposal-integration-toggle';
4055
+ toggle.dataset.integrationId = integration.id;
4056
+ const label = document.createElement('span');
4057
+ label.innerHTML = `<strong>${escapeHtml(integration.name)}</strong> ` +
4058
+ `<span class="planner-kind">${escapeHtml(integration.invocationHint?.kind ?? '?')}</span>` +
4059
+ (integration.purpose ? ` — ${escapeHtml(integration.purpose)}` : '');
4060
+ row.appendChild(toggle);
4061
+ row.appendChild(label);
4062
+ wrap.appendChild(row);
4063
+ }
4064
+
4065
+ // Stakeholders
4066
+ const stakeholders = proposal.stakeholderTouchpoints ?? [];
4067
+ if (stakeholders.length > 0) {
4068
+ const sh = document.createElement('h3');
4069
+ sh.textContent = 'Stakeholders';
4070
+ wrap.appendChild(sh);
4071
+ for (const s of stakeholders) {
4072
+ const row = document.createElement('div');
4073
+ row.className = 'planner-stakeholder-row';
4074
+ row.dataset.testid = 'proposal-stakeholder-row';
4075
+ const toggle = document.createElement('input');
4076
+ toggle.type = 'checkbox';
4077
+ toggle.checked = true;
4078
+ toggle.dataset.stakeholderName = s.name;
4079
+ const label = document.createElement('span');
4080
+ label.innerHTML = `<strong>${escapeHtml(s.name)}</strong> (${escapeHtml(s.role ?? '?')}) — ${escapeHtml(s.touchpointKind ?? 'other')}, produces: ${escapeHtml(s.produces ?? '?')}`;
4081
+ row.appendChild(toggle);
4082
+ row.appendChild(label);
4083
+ wrap.appendChild(row);
4084
+ }
4085
+ }
4086
+
4087
+ // Narrative editor
4088
+ const ne = document.createElement('div');
4089
+ ne.style.marginTop = '12px';
4090
+ const neLabel = document.createElement('label');
4091
+ neLabel.className = 'field-label';
4092
+ neLabel.textContent = 'Narrative edits (optional)';
4093
+ ne.appendChild(neLabel);
4094
+ const neTextarea = document.createElement('textarea');
4095
+ neTextarea.dataset.testid = 'proposal-narrative-editor';
4096
+ neTextarea.id = 'proposal-narrative-editor';
4097
+ neTextarea.rows = 4;
4098
+ ne.appendChild(neTextarea);
4099
+ wrap.appendChild(ne);
4100
+
4101
+ // Cost line
4102
+ if (typeof proposal.estimatedCostUsd === 'number') {
4103
+ const cost = document.createElement('p');
4104
+ cost.className = 'planner-cost';
4105
+ cost.textContent = `Estimated cost: $${proposal.estimatedCostUsd.toFixed(2)} · ~${Math.round(proposal.estimatedDurationMinutes ?? 0)} min`;
4106
+ wrap.appendChild(cost);
4107
+ }
4108
+
4109
+ return wrap;
4110
+ }
4111
+
4112
+ function escapeHtml(s) {
4113
+ if (s == null) return '';
4114
+ return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
4115
+ }
4116
+
4117
+ // Wire planner modal buttons (once at boot).
4118
+ window.addEventListener('DOMContentLoaded', () => {
4119
+ const close = document.getElementById('planner-progress-close');
4120
+ if (close) close.addEventListener('click', hidePlannerProgress);
4121
+ const pclose = document.getElementById('planner-proposal-close');
4122
+ if (pclose) pclose.addEventListener('click', hideProposalModal);
4123
+ const reject = document.getElementById('proposal-reject-btn');
4124
+ if (reject) reject.addEventListener('click', hideProposalModal);
4125
+ const accept = document.getElementById('proposal-accept-btn');
4126
+ if (accept) accept.addEventListener('click', acceptProposalAndSolve);
4127
+ const exportBtn = document.getElementById('proposal-export-btn');
4128
+ if (exportBtn) exportBtn.addEventListener('click', exportProposalMarkdown);
4129
+ const replan = document.getElementById('proposal-replan-btn');
4130
+ if (replan) replan.addEventListener('click', () => {
4131
+ document.getElementById('replan-feedback-modal').hidden = false;
4132
+ });
4133
+ const replanCancel = document.getElementById('replan-feedback-cancel');
4134
+ if (replanCancel) replanCancel.addEventListener('click', () => {
4135
+ document.getElementById('replan-feedback-modal').hidden = true;
4136
+ });
4137
+ const replanClose = document.getElementById('replan-feedback-close');
4138
+ if (replanClose) replanClose.addEventListener('click', () => {
4139
+ document.getElementById('replan-feedback-modal').hidden = true;
4140
+ });
4141
+ const replanSubmit = document.getElementById('replan-feedback-submit');
4142
+ if (replanSubmit) replanSubmit.addEventListener('click', submitReplan);
4143
+ const runDetailClose = document.getElementById('run-detail-close');
4144
+ if (runDetailClose) runDetailClose.addEventListener('click', () => {
4145
+ document.getElementById('run-detail-modal').hidden = true;
4146
+ });
4147
+ });
4148
+
4149
+ function acceptProposalAndSolve() {
4150
+ if (!plannerState.action || !plannerState.planId || !plannerState.proposal) return;
4151
+ hideProposalModal();
4152
+ toast('Starting skill-creator…', 'ok');
4153
+ fetchJSON(`/api/actions/${plannerState.action.id}/solve`, {
4154
+ method: 'POST',
4155
+ headers: { 'content-type': 'application/json' },
4156
+ body: JSON.stringify({ planId: plannerState.planId }),
4157
+ }).then((res) => {
4158
+ plannerState.runId = res.runId;
4159
+ }).catch((err) => {
4160
+ toast('Solve failed: ' + err.message, 'err');
4161
+ });
4162
+ }
4163
+
4164
+ function exportProposalMarkdown() {
4165
+ if (!plannerState.planId) return;
4166
+ window.open(`/api/plans/${plannerState.planId}?as=md`, '_blank');
4167
+ }
4168
+
4169
+ function submitReplan() {
4170
+ const input = document.getElementById('replan-feedback-input');
4171
+ const feedback = (input.value || '').trim();
4172
+ if (feedback.length < 10) {
4173
+ toast('Feedback must be at least 10 characters.', 'err');
4174
+ return;
4175
+ }
4176
+ if (!plannerState.planId || !plannerState.action) return;
4177
+ document.getElementById('replan-feedback-modal').hidden = true;
4178
+ toast('Re-planning with feedback…', 'ok');
4179
+ fetchJSON(`/api/plans/${plannerState.planId}/replan`, {
4180
+ method: 'POST',
4181
+ headers: { 'content-type': 'application/json' },
4182
+ body: JSON.stringify({ feedback, actionId: plannerState.action.id }),
4183
+ }).then((res) => {
4184
+ plannerState.planId = res.planId;
4185
+ plannerState.phases.reasoning = 'running';
4186
+ paintPlannerPhases();
4187
+ showPlannerProgress();
4188
+ }).catch((err) => {
4189
+ toast('Re-plan failed: ' + err.message, 'err');
4190
+ });
4191
+ }
4192
+
4193
+ window.addEventListener('aab-planner-event', (ev) => {
4194
+ const d = ev.detail;
4195
+ if (d.type === 'planner_recon_progress') {
4196
+ const phase = d.phase ?? d.payload?.phase;
4197
+ if (phase === 'pc-scan') plannerState.phases['pc-scan'] = 'done';
4198
+ if (phase === 'wiki-recon') plannerState.phases['wiki'] = 'done';
4199
+ if (phase === 'web-research') plannerState.phases['web'] = 'done';
4200
+ if (d.summary) plannerState.stream.push(`${phase}: ${d.summary}`);
4201
+ paintPlannerPhases();
4202
+ } else if (d.type === 'planner_recon_done') {
4203
+ plannerState.phases['pc-scan'] = 'done';
4204
+ plannerState.phases['wiki'] = 'done';
4205
+ plannerState.phases['web'] = 'done';
4206
+ plannerState.phases['reasoning'] = 'running';
4207
+ paintPlannerPhases();
4208
+ } else if (d.type === 'planner_reasoning_started') {
4209
+ plannerState.phases['reasoning'] = 'running';
4210
+ paintPlannerPhases();
4211
+ } else if (d.type === 'planner_proposal_ready') {
4212
+ plannerState.phases['reasoning'] = 'done';
4213
+ paintPlannerPhases();
4214
+ if (d.proposal) {
4215
+ hidePlannerProgress();
4216
+ showProposalModal(d.proposal);
4217
+ } else {
4218
+ // Server fired proposal_ready without a payload — keep progress pane
4219
+ // open so the user sees the failure (and not just a vanished modal).
4220
+ showPlannerError('Planner emitted an empty proposal (server bug). Re-run or contact support.');
4221
+ }
4222
+ } else if (d.type === 'planner_failed') {
4223
+ // Persistent failure banner inside the still-open progress pane — toast
4224
+ // alone disappears in 4.5s and after a 10min Opus run the user has no
4225
+ // proof anything happened. Caught via the 2026-05-21 live MCP smoke.
4226
+ plannerState.phases['reasoning'] = 'failed';
4227
+ paintPlannerPhases();
4228
+ showPlannerError(d.errorMessage ?? d.reason ?? 'Planner failed (no detail)');
4229
+ toast('Planner failed: ' + (d.errorMessage ?? 'unknown'), 'err');
4230
+ } else if (d.type === 'skill_run_started') {
4231
+ toast('skill-creator authoring…', 'ok');
4232
+ } else if (d.type === 'skill_run_tool_call') {
4233
+ plannerState.stream.push(`tool: ${d.tool ?? '?'}`);
4234
+ paintPlannerPhases();
4235
+ } else if (d.type === 'skill_run_installed') {
4236
+ toast('Skill installed at ' + (d.installPath ?? '?'), 'ok');
4237
+ refreshState({ silent: true }).then(() => { if (state.route === 'actions') navigate('actions'); });
4238
+ } else if (d.type === 'skill_run_failed') {
4239
+ showPlannerError('skill-creator failed: ' + (d.errorMessage ?? 'unknown'));
4240
+ toast('skill-creator failed: ' + (d.errorMessage ?? 'unknown'), 'err');
4241
+ }
4242
+ });
4243
+
4244
+ function showPlannerError(message) {
4245
+ // Keep the progress pane visible with a sticky red banner so the user
4246
+ // doesn't lose context after a long-running failure.
4247
+ const m = document.getElementById('planner-progress-modal');
4248
+ if (m) m.hidden = false;
4249
+ const body = document.getElementById('planner-progress-body');
4250
+ if (!body) return;
4251
+ let banner = document.getElementById('planner-error-banner');
4252
+ if (!banner) {
4253
+ banner = document.createElement('div');
4254
+ banner.id = 'planner-error-banner';
4255
+ banner.className = 'planner-error-banner';
4256
+ banner.dataset.testid = 'planner-error-banner';
4257
+ body.appendChild(banner);
4258
+ }
4259
+ banner.textContent = '✗ ' + message;
4260
+ }
4261
+
4262
+ // ------------------------------------------------------------------
4263
+ // Phase 5 — Skills tab
4264
+ // ------------------------------------------------------------------
4265
+
4266
+ function renderSkillsView(main) {
4267
+ main.innerHTML = '';
4268
+ const header = h('div', { class: 'view-header' }, [
4269
+ h('h1', {}, 'Skills'),
4270
+ h('p', { class: 'view-sub' }, 'Installed Claude Code skills — project + user + plugin scope.'),
4271
+ ]);
4272
+ main.appendChild(header);
4273
+ const body = h('div', { class: 'skills-view', 'data-testid': 'skills-tab' });
4274
+ main.appendChild(body);
4275
+ fetchJSON('/api/skills').then((res) => {
4276
+ if (!res.skills || res.skills.length === 0) {
4277
+ body.innerHTML = '<p class="view-empty">No installed skills yet. Run <code>aab actions solve &lt;id&gt;</code> to ship one.</p>';
4278
+ return;
4279
+ }
4280
+ const list = h('div', { class: 'skills-list', 'data-testid': 'skills-list' });
4281
+ for (const s of res.skills) {
4282
+ const row = h('div', { class: 'skills-row', 'data-skill-name': s.name });
4283
+ row.appendChild(h('div', { class: 'skills-name' }, [
4284
+ h('strong', {}, s.name),
4285
+ h('span', { class: 'skills-scope' }, ` (${s.scope}${s.version ? '; v' + s.version : ''})`),
4286
+ ]));
4287
+ row.appendChild(h('div', { class: 'skills-dir' }, s.dir));
4288
+ const actions = h('div', { class: 'skills-actions' });
4289
+ const showBtn = h('button', { class: 'btn-secondary', 'data-testid': 'skill-show-btn' }, '👁 Show');
4290
+ showBtn.addEventListener('click', () => showSkillDetail(s.name));
4291
+ const testBtn = h('button', { class: 'btn-secondary', 'data-testid': 'skill-test-btn' }, '🧪 Test');
4292
+ testBtn.addEventListener('click', () => {
4293
+ const input = prompt(`Test ${s.name} — what prompt should we send?`, `Activate ${s.name}.`);
4294
+ if (input) testSkill(s.name, input);
4295
+ });
4296
+ actions.appendChild(showBtn);
4297
+ actions.appendChild(testBtn);
4298
+ row.appendChild(actions);
4299
+ list.appendChild(row);
4300
+ }
4301
+ body.appendChild(list);
4302
+ }).catch((err) => {
4303
+ body.innerHTML = '<p class="view-empty">Failed to load skills: ' + escapeHtml(err.message) + '</p>';
4304
+ });
4305
+ }
4306
+
4307
+ function showSkillDetail(name) {
4308
+ fetchJSON(`/api/skills/${encodeURIComponent(name)}`).then((res) => {
4309
+ const modal = document.getElementById('run-detail-modal');
4310
+ document.getElementById('run-detail-title').textContent = `Skill — ${name}`;
4311
+ const body = document.getElementById('run-detail-body');
4312
+ body.innerHTML = '';
4313
+ const pre = document.createElement('pre');
4314
+ pre.className = 'skill-detail-body';
4315
+ pre.textContent = res.body;
4316
+ body.appendChild(pre);
4317
+ modal.hidden = false;
4318
+ }).catch((err) => toast('Show failed: ' + err.message, 'err'));
4319
+ }
4320
+
4321
+ function testSkill(name, input) {
4322
+ toast(`Testing skill ${name} (this may take a minute)…`, 'ok');
4323
+ // We surface the test via a CLI call from the user's terminal — the GUI
4324
+ // shows a copy-able command for now (live in-browser execution is gated
4325
+ // behind a longer-running endpoint we'll add later).
4326
+ navigator.clipboard?.writeText(`aab skills test ${name} "${input.replace(/"/g, '\\"')}"`).then(
4327
+ () => toast('Copied `aab skills test` command to clipboard', 'ok'),
4328
+ () => toast('Run: aab skills test ' + name + ' "' + input + '"', 'ok'),
4329
+ );
4330
+ }
4331
+
4332
+ // ------------------------------------------------------------------
4333
+ // Usage dashboard (Phase 6.5)
4334
+ // ------------------------------------------------------------------
4335
+
4336
+ function renderUsageView(main) {
4337
+ const view = h('div', { class: 'view', 'data-testid': 'usage-view' });
4338
+ const header = h('div', { class: 'view-header' });
4339
+ header.appendChild(
4340
+ h('div', {}, [
4341
+ h('div', { class: 'view-title' }, 'Token usage & cost'),
4342
+ h('div', { class: 'view-subtitle' }, 'Live aggregation of token-usage logs for this workspace'),
4343
+ ]),
4344
+ );
4345
+ const rangeWrap = h('div', { class: 'usage-range' });
4346
+ ['7', '30', '90', 'all'].forEach((d) => {
4347
+ const btn = h('button', {
4348
+ class: 'btn-secondary usage-range-btn',
4349
+ 'data-days': d,
4350
+ 'data-testid': `usage-range-${d}`,
4351
+ }, d === 'all' ? 'All time' : `Last ${d} days`);
4352
+ btn.addEventListener('click', () => loadUsage(d));
4353
+ rangeWrap.appendChild(btn);
4354
+ });
4355
+ header.appendChild(rangeWrap);
4356
+ view.appendChild(header);
4357
+
4358
+ const body = h('div', { class: 'view-body usage-body', 'data-testid': 'usage-body' });
4359
+ body.appendChild(h('div', { class: 'usage-loading' }, 'Loading…'));
4360
+ view.appendChild(body);
4361
+ main.appendChild(view);
4362
+ loadUsage('30');
4363
+ }
4364
+
4365
+ async function loadUsage(daysSpec) {
4366
+ const body = $('[data-testid="usage-body"]');
4367
+ if (!body) return;
4368
+ $$('.usage-range-btn').forEach((b) => b.classList.toggle('active', b.dataset.days === daysSpec));
4369
+ body.innerHTML = '';
4370
+ body.appendChild(h('div', { class: 'usage-loading' }, 'Loading…'));
4371
+
4372
+ let qs = '';
4373
+ if (daysSpec !== 'all') {
4374
+ const days = Number(daysSpec);
4375
+ const d = new Date();
4376
+ d.setUTCDate(d.getUTCDate() - (days - 1));
4377
+ qs = `?since=${d.toISOString().slice(0, 10)}`;
4378
+ } else {
4379
+ qs = '?since=0000-01-01';
4380
+ }
4381
+ try {
4382
+ const data = await fetchJSON('/api/usage' + qs);
4383
+ body.innerHTML = '';
4384
+ body.appendChild(renderUsageContent(data));
4385
+ } catch (e) {
4386
+ body.innerHTML = '';
4387
+ body.appendChild(emptyState('⚠', 'Failed to load usage', e.message));
4388
+ }
4389
+ }
4390
+
4391
+ function renderUsageContent(data) {
4392
+ const wrap = h('div', { class: 'usage-content' });
4393
+ const { summary, totalLogs, since } = data;
4394
+ if (totalLogs === 0) {
4395
+ wrap.appendChild(emptyState('📊', 'No token usage logged yet', `No entries since ${since}. Start a discussion or run a skill plan to populate this view.`));
4396
+ return wrap;
4397
+ }
4398
+
4399
+ // Totals row
4400
+ const totals = summary.totals;
4401
+ const totalsRow = h('div', { class: 'usage-totals', 'data-testid': 'usage-totals' });
4402
+ totalsRow.appendChild(usageStat('Total cost', formatCost(totals.costUsd), 'usage-stat-cost'));
4403
+ totalsRow.appendChild(usageStat('Calls', formatNumber(totals.calls), 'usage-stat-calls'));
4404
+ totalsRow.appendChild(usageStat('Total tokens', formatNumber(totals.totalTokens), 'usage-stat-tokens'));
4405
+ totalsRow.appendChild(usageStat('Cached read', formatNumber(totals.cacheReadTokens), 'usage-stat-cache'));
4406
+ wrap.appendChild(totalsRow);
4407
+
4408
+ // Daily sparkline
4409
+ if (summary.byDay.length > 0) {
4410
+ wrap.appendChild(h('h3', { class: 'usage-section-title' }, 'Daily spend'));
4411
+ wrap.appendChild(renderUsageSparkline(summary.byDay));
4412
+ }
4413
+
4414
+ // By feature + by model side-by-side
4415
+ const split = h('div', { class: 'usage-split' });
4416
+ split.appendChild(renderUsageBucketTable('By feature', summary.byFeature, 'usage-by-feature'));
4417
+ split.appendChild(renderUsageBucketTable('By model', summary.byModel, 'usage-by-model'));
4418
+ wrap.appendChild(split);
4419
+
4420
+ return wrap;
4421
+ }
4422
+
4423
+ function usageStat(label, value, testidSuffix) {
4424
+ return h('div', { class: 'usage-stat', 'data-testid': testidSuffix }, [
4425
+ h('div', { class: 'usage-stat-label' }, label),
4426
+ h('div', { class: 'usage-stat-value' }, value),
4427
+ ]);
4428
+ }
4429
+
4430
+ function renderUsageSparkline(byDay) {
4431
+ const wrap = h('div', { class: 'usage-sparkline', 'data-testid': 'usage-sparkline' });
4432
+ const max = Math.max(0.0001, ...byDay.map((d) => d.costUsd));
4433
+ byDay.forEach((d) => {
4434
+ const pct = Math.max(2, Math.round((d.costUsd / max) * 100));
4435
+ const bar = h('div', { class: 'usage-sparkline-bar', title: `${d.key} · ${formatCost(d.costUsd)} · ${formatNumber(d.totalTokens)} tokens · ${d.calls} calls` });
4436
+ bar.style.height = pct + '%';
4437
+ bar.appendChild(h('span', { class: 'usage-sparkline-label' }, d.key.slice(5)));
4438
+ wrap.appendChild(bar);
4439
+ });
4440
+ return wrap;
4441
+ }
4442
+
4443
+ function renderUsageBucketTable(title, buckets, testid) {
4444
+ const wrap = h('div', { class: 'usage-table-wrap', 'data-testid': testid });
4445
+ wrap.appendChild(h('h3', { class: 'usage-section-title' }, title));
4446
+ if (buckets.length === 0) {
4447
+ wrap.appendChild(h('div', { class: 'usage-empty' }, 'No entries'));
4448
+ return wrap;
4449
+ }
4450
+ const table = h('table', { class: 'usage-table' });
4451
+ const head = h('tr', {}, [
4452
+ h('th', {}, title.replace(/^By /, '')),
4453
+ h('th', {}, 'Calls'),
4454
+ h('th', {}, 'Tokens'),
4455
+ h('th', {}, 'Cost'),
4456
+ ]);
4457
+ table.appendChild(h('thead', {}, head));
4458
+ const tbody = h('tbody', {});
4459
+ const maxCost = Math.max(0.0001, ...buckets.map((b) => b.costUsd));
4460
+ buckets.forEach((b) => {
4461
+ const pct = Math.round((b.costUsd / maxCost) * 100);
4462
+ const row = h('tr', {}, [
4463
+ h('td', { class: 'usage-table-key' }, b.key),
4464
+ h('td', {}, formatNumber(b.calls)),
4465
+ h('td', {}, formatNumber(b.totalTokens)),
4466
+ h('td', { class: 'usage-table-cost' }, [
4467
+ h('span', { class: 'usage-table-cost-value' }, formatCost(b.costUsd)),
4468
+ (() => {
4469
+ const bar = h('span', { class: 'usage-table-cost-bar' });
4470
+ bar.style.width = pct + '%';
4471
+ return bar;
4472
+ })(),
4473
+ ]),
4474
+ ]);
4475
+ tbody.appendChild(row);
4476
+ });
4477
+ table.appendChild(tbody);
4478
+ wrap.appendChild(table);
4479
+ return wrap;
4480
+ }
4481
+
4482
+ function formatCost(usd) {
4483
+ if (!usd || usd === 0) return '$0.00';
4484
+ if (usd < 0.01) return '<$0.01';
4485
+ return '$' + usd.toFixed(2);
4486
+ }
4487
+
4488
+ function formatNumber(n) {
4489
+ if (!Number.isFinite(n) || n === 0) return '0';
4490
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
4491
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
4492
+ return String(n);
4493
+ }
4494
+
4495
+ // ------------------------------------------------------------------
4496
+ // Theme toggle + mobile sidebar (Phase 6.5)
4497
+ // ------------------------------------------------------------------
4498
+
4499
+ const THEME_KEY = 'aab-theme';
4500
+
4501
+ function initTheme() {
4502
+ let saved = 'dark';
4503
+ try {
4504
+ saved = localStorage.getItem(THEME_KEY) || 'dark';
4505
+ } catch (_) { /* SSR / private mode */ }
4506
+ if (saved !== 'light' && saved !== 'dark') saved = 'dark';
4507
+ applyTheme(saved);
4508
+ const btn = $('#theme-toggle');
4509
+ if (btn) btn.addEventListener('click', toggleTheme);
4510
+ }
4511
+
4512
+ function applyTheme(theme) {
4513
+ document.documentElement.dataset.theme = theme;
4514
+ const icon = $('#theme-toggle-icon');
4515
+ const label = $('#theme-toggle-label');
4516
+ if (icon) icon.textContent = theme === 'light' ? '☀' : '🌙';
4517
+ if (label) label.textContent = theme === 'light' ? 'Light' : 'Dark';
4518
+ const btn = $('#theme-toggle');
4519
+ if (btn) btn.setAttribute('aria-label', `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`);
4520
+ }
4521
+
4522
+ function toggleTheme() {
4523
+ const current = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
4524
+ const next = current === 'light' ? 'dark' : 'light';
4525
+ applyTheme(next);
4526
+ try { localStorage.setItem(THEME_KEY, next); } catch (_) { /* swallow */ }
4527
+ }
4528
+
4529
+ function initSidebarToggle() {
4530
+ const toggle = $('#sidebar-toggle');
4531
+ const scrim = $('#sidebar-scrim');
4532
+ if (toggle) toggle.addEventListener('click', openSidebar);
4533
+ if (scrim) scrim.addEventListener('click', closeSidebar);
4534
+ }
4535
+
4536
+ function openSidebar() {
4537
+ document.body.classList.add('sidebar-open');
4538
+ const scrim = $('#sidebar-scrim');
4539
+ if (scrim) scrim.hidden = false;
4540
+ }
4541
+
4542
+ function closeSidebar() {
4543
+ if (!document.body.classList.contains('sidebar-open')) return;
4544
+ document.body.classList.remove('sidebar-open');
4545
+ const scrim = $('#sidebar-scrim');
4546
+ if (scrim) scrim.hidden = true;
4547
+ }
4548
+
4549
+ // ------------------------------------------------------------------
4550
+ // Go
4551
+ // ------------------------------------------------------------------
4552
+
4553
+ initTheme();
4554
+ initSidebarToggle();
4555
+ bootstrap();