@harborgroup/my-team 0.1.0 → 0.3.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/dist/web/app.js CHANGED
@@ -6,13 +6,70 @@ const el = {
6
6
  cliMissing: document.getElementById('onboarding-cli-missing'),
7
7
  notAuthenticated: document.getElementById('onboarding-not-authenticated'),
8
8
  checkAgain: document.getElementById('check-again'),
9
- dashboard: document.getElementById('dashboard'),
10
- accountLine: document.getElementById('account-line'),
9
+ appShell: document.getElementById('app-shell'),
10
+
11
+ updateBanner: document.getElementById('update-banner'),
12
+ updateBannerText: document.getElementById('update-banner-text'),
13
+ updateBannerButton: document.getElementById('update-banner-button'),
14
+ updateBannerDismiss: document.getElementById('update-banner-dismiss'),
15
+
16
+ railHome: document.getElementById('rail-home'),
17
+ railMessages: document.getElementById('rail-messages'),
18
+ railProfile: document.getElementById('rail-profile'),
19
+ railSettings: document.getElementById('rail-settings'),
20
+
21
+ pageHome: document.getElementById('page-home'),
22
+ pageMessages: document.getElementById('page-messages'),
23
+ pageProfile: document.getElementById('page-profile'),
24
+ pageSettings: document.getElementById('page-settings'),
25
+
26
+ homeAvatar: document.getElementById('home-avatar'),
27
+ homeGreeting: document.getElementById('home-greeting'),
28
+ homeSub: document.getElementById('home-sub'),
29
+ homeGoMessages: document.getElementById('home-go-messages'),
30
+
31
+ workspaceName: document.getElementById('workspace-name'),
32
+ navGeneral: document.getElementById('nav-general'),
33
+ navCeo: document.getElementById('nav-ceo'),
34
+ ceoAvatar: document.getElementById('ceo-avatar'),
35
+ ceoNavName: document.getElementById('ceo-nav-name'),
36
+ ceoStatusDot: document.getElementById('ceo-status-dot'),
37
+ channelTitle: document.getElementById('channel-title'),
38
+ channelSubtitle: document.getElementById('channel-subtitle'),
39
+ channelGeneral: document.getElementById('channel-general'),
40
+ channelCeo: document.getElementById('channel-ceo'),
11
41
  messages: document.getElementById('messages'),
42
+ typingIndicator: document.getElementById('typing-indicator'),
43
+ typingAvatar: document.getElementById('typing-avatar'),
12
44
  form: document.getElementById('chat-form'),
13
45
  input: document.getElementById('chat-input'),
46
+ sendButton: document.getElementById('chat-send'),
47
+
48
+ profileForm: document.getElementById('profile-form'),
49
+ profileCompany: document.getElementById('profile-company'),
50
+ profileMission: document.getElementById('profile-mission'),
51
+ profileCeoName: document.getElementById('profile-ceo-name'),
52
+ profileCeoPersonality: document.getElementById('profile-ceo-personality'),
53
+ profileDefaultModel: document.getElementById('profile-default-model'),
54
+ profileDefaultEffort: document.getElementById('profile-default-effort'),
55
+ profileSavedHint: document.getElementById('profile-saved-hint'),
56
+
57
+ chatModelSelect: document.getElementById('chat-model-select'),
58
+ chatEffortSelect: document.getElementById('chat-effort-select'),
59
+
60
+ settingsAccount: document.getElementById('settings-account'),
61
+ settingsLogPath: document.getElementById('settings-log-path'),
14
62
  };
15
63
 
64
+ let lastProfile = null;
65
+ let lastAccountInfo = null;
66
+ let lastModels = [];
67
+ let onboardingKickedOff = false;
68
+ let currentPage = 'messages';
69
+ let currentChannel = 'ceo';
70
+ let statusState = 'offline';
71
+ let metaCache = null;
72
+
16
73
  function api(path, options = {}) {
17
74
  return fetch(path, {
18
75
  ...options,
@@ -20,55 +77,616 @@ function api(path, options = {}) {
20
77
  });
21
78
  }
22
79
 
23
- function show(section) {
24
- el.loading.hidden = true;
25
- el.onboarding.hidden = section !== 'onboarding';
26
- el.dashboard.hidden = section !== 'dashboard';
80
+ function getCeoInitial() {
81
+ const name = lastProfile?.ceoName;
82
+ return name && name !== 'your AI CEO' ? name.trim()[0].toUpperCase() : '?';
83
+ }
84
+
85
+ function renderProfileUI(profile) {
86
+ el.workspaceName.textContent = profile?.companyName || 'my-team';
87
+ const ceoName = profile?.ceoName || 'your AI CEO';
88
+ el.ceoNavName.textContent = ceoName;
89
+ el.ceoAvatar.textContent = getCeoInitial();
90
+ if (currentPage === 'messages' && currentChannel === 'ceo') el.channelTitle.textContent = ceoName;
91
+ if (currentPage === 'home') renderHomePage();
92
+ }
93
+
94
+ function setStatus(state) {
95
+ statusState = state;
96
+ el.ceoStatusDot.classList.remove('online', 'typing');
97
+ if (state === 'online') el.ceoStatusDot.classList.add('online');
98
+ else if (state === 'typing') el.ceoStatusDot.classList.add('typing');
99
+ if (currentPage === 'messages' && currentChannel === 'ceo') {
100
+ el.channelSubtitle.textContent = state === 'typing' ? 'Typing…' : state === 'online' ? 'Active' : 'Offline';
101
+ }
102
+ }
103
+
104
+ function selectChannel(channel) {
105
+ currentChannel = channel;
106
+ el.navGeneral.classList.toggle('active', channel === 'general');
107
+ el.navCeo.classList.toggle('active', channel === 'ceo');
108
+ el.channelGeneral.hidden = channel !== 'general';
109
+ el.channelCeo.hidden = channel !== 'ceo';
110
+ if (channel === 'general') {
111
+ el.channelTitle.textContent = '# general';
112
+ el.channelSubtitle.textContent = '';
113
+ } else {
114
+ el.channelTitle.textContent = lastProfile?.ceoName || 'your AI CEO';
115
+ setStatus(statusState);
116
+ }
117
+ }
118
+
119
+ el.navGeneral.addEventListener('click', () => selectChannel('general'));
120
+ el.navCeo.addEventListener('click', () => selectChannel('ceo'));
121
+
122
+ function modelDisplayName(value) {
123
+ return lastModels.find((m) => m.value === value)?.displayName ?? value;
124
+ }
125
+
126
+ // No synthetic "use the default" placeholder here — the SDK's own
127
+ // supportedModels() list already includes a real "default" entry
128
+ // (displayName "Default (recommended)") that itself supports effort
129
+ // levels. A separate blank option would be a confusing near-duplicate and
130
+ // would incorrectly read as "no model supports effort" to the effort
131
+ // selector below, since '' never matches a real model.
132
+ function populateModelSelect(selectEl) {
133
+ selectEl.innerHTML = '';
134
+ for (const model of lastModels) {
135
+ const opt = document.createElement('option');
136
+ opt.value = model.value;
137
+ opt.textContent = model.displayName;
138
+ selectEl.appendChild(opt);
139
+ }
140
+ }
141
+
142
+ // Effort options are constrained to what the selected model actually
143
+ // supports (confirmed via the SDK's supportedModels() data — e.g. Haiku
144
+ // supports no effort levels at all) so a silent downgrade shouldn't occur
145
+ // in practice; the UI simply never offers an invalid combination.
146
+ function populateEffortSelect(selectEl, modelValue, blankLabel) {
147
+ const info = lastModels.find((m) => m.value === modelValue);
148
+ selectEl.innerHTML = '';
149
+ if (!info?.supportsEffort) {
150
+ selectEl.disabled = true;
151
+ const opt = document.createElement('option');
152
+ opt.value = '';
153
+ opt.textContent = 'N/A';
154
+ selectEl.appendChild(opt);
155
+ return;
156
+ }
157
+ selectEl.disabled = false;
158
+ const blank = document.createElement('option');
159
+ blank.value = '';
160
+ blank.textContent = blankLabel;
161
+ selectEl.appendChild(blank);
162
+ for (const level of info.supportedEffortLevels ?? []) {
163
+ const opt = document.createElement('option');
164
+ opt.value = level;
165
+ opt.textContent = level;
166
+ selectEl.appendChild(opt);
167
+ }
168
+ }
169
+
170
+ function setupModelSelectors() {
171
+ populateModelSelect(el.chatModelSelect);
172
+ populateModelSelect(el.profileDefaultModel);
173
+
174
+ // Assigning select.value a string that matches no <option> clears the
175
+ // selection entirely (selectedIndex -1) rather than falling back to the
176
+ // first option — only assign when there's a real saved default to apply.
177
+ if (lastProfile?.defaultModel) el.chatModelSelect.value = lastProfile.defaultModel;
178
+ populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
179
+ if (lastProfile?.defaultEffort) el.chatEffortSelect.value = lastProfile.defaultEffort;
180
+ }
181
+
182
+ el.chatModelSelect.addEventListener('change', () => {
183
+ populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
184
+ });
185
+
186
+ el.profileDefaultModel.addEventListener('change', () => {
187
+ populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
188
+ });
189
+
190
+ function populateProfileForm(profile) {
191
+ el.profileCompany.value = profile?.companyName ?? '';
192
+ el.profileMission.value = profile?.mission ?? '';
193
+ el.profileCeoName.value = profile?.ceoName ?? '';
194
+ el.profileCeoPersonality.value = profile?.ceoPersonality ?? '';
195
+ if (profile?.defaultModel) el.profileDefaultModel.value = profile.defaultModel;
196
+ populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
197
+ if (profile?.defaultEffort) el.profileDefaultEffort.value = profile.defaultEffort;
198
+ }
199
+
200
+ function renderHomePage() {
201
+ el.homeAvatar.textContent = getCeoInitial();
202
+ const ceoName = lastProfile?.ceoName || 'your AI CEO';
203
+ const companyName = lastProfile?.companyName;
204
+ el.homeGreeting.textContent = companyName ? `Welcome to ${companyName}` : 'Welcome to my-team';
205
+ el.homeSub.textContent = lastProfile?.onboardingComplete
206
+ ? `${ceoName} is ready and waiting in Messages.`
207
+ : `Head to Messages to finish setting up ${ceoName}.`;
208
+ }
209
+
210
+ async function renderSettingsPage() {
211
+ el.settingsAccount.textContent = lastAccountInfo
212
+ ? `${lastAccountInfo.email ?? 'unknown'}${lastAccountInfo.subscriptionType ? ` (${lastAccountInfo.subscriptionType})` : ''}`
213
+ : 'unknown';
214
+ if (!metaCache) {
215
+ const res = await api('/api/meta');
216
+ metaCache = await res.json();
217
+ }
218
+ el.settingsLogPath.textContent = metaCache.logFilePath;
219
+ }
220
+
221
+ async function checkForUpdateBanner(retriesLeft) {
222
+ const res = await api('/api/meta');
223
+ metaCache = await res.json();
224
+
225
+ if (metaCache.updateInfo?.updateAvailable) {
226
+ el.updateBannerText.textContent = `A new version of my-team is available (v${metaCache.updateInfo.latestVersion}).`;
227
+ el.updateBanner.hidden = false;
228
+ } else if (!metaCache.updateInfo && retriesLeft > 0) {
229
+ // The registry check runs in the background at server startup and may
230
+ // not have resolved yet — try once more shortly rather than never
231
+ // showing the banner this session.
232
+ setTimeout(() => checkForUpdateBanner(retriesLeft - 1), 3000);
233
+ }
234
+ }
235
+
236
+ el.updateBannerButton.addEventListener('click', async () => {
237
+ el.updateBannerButton.disabled = true;
238
+ el.updateBannerButton.textContent = 'Updating…';
239
+ const res = await api('/api/update', { method: 'POST' });
240
+ const result = await res.json();
241
+ if (result.ok) {
242
+ el.updateBannerText.textContent = 'Updated — restart (stop and run "npm run team" again) to use the new version.';
243
+ el.updateBannerButton.hidden = true;
244
+ } else {
245
+ el.updateBannerButton.disabled = false;
246
+ el.updateBannerButton.textContent = 'Update';
247
+ el.updateBannerText.textContent = 'Update failed — check the terminal for details.';
248
+ }
249
+ });
250
+
251
+ el.updateBannerDismiss.addEventListener('click', () => {
252
+ el.updateBanner.hidden = true;
253
+ });
254
+
255
+ function selectPage(page) {
256
+ currentPage = page;
257
+ el.railHome.classList.toggle('active', page === 'home');
258
+ el.railMessages.classList.toggle('active', page === 'messages');
259
+ el.railProfile.classList.toggle('active', page === 'profile');
260
+ el.railSettings.classList.toggle('active', page === 'settings');
261
+ el.pageHome.hidden = page !== 'home';
262
+ el.pageMessages.hidden = page !== 'messages';
263
+ el.pageProfile.hidden = page !== 'profile';
264
+ el.pageSettings.hidden = page !== 'settings';
265
+
266
+ if (page === 'home') renderHomePage();
267
+ else if (page === 'profile') populateProfileForm(lastProfile);
268
+ else if (page === 'settings') renderSettingsPage();
269
+ else if (page === 'messages') selectChannel(currentChannel);
27
270
  }
28
271
 
272
+ el.railHome.addEventListener('click', () => selectPage('home'));
273
+ el.railMessages.addEventListener('click', () => selectPage('messages'));
274
+ el.railProfile.addEventListener('click', () => selectPage('profile'));
275
+ el.railSettings.addEventListener('click', () => selectPage('settings'));
276
+ el.homeGoMessages.addEventListener('click', () => selectPage('messages'));
277
+
29
278
  async function checkStatus() {
30
279
  el.loading.hidden = false;
31
280
  el.onboarding.hidden = true;
32
- el.dashboard.hidden = true;
281
+ el.appShell.hidden = true;
282
+
33
283
  const res = await api('/api/status');
34
284
  const result = await res.json();
285
+ el.loading.hidden = true;
35
286
 
36
287
  if (result.ok) {
37
- const info = result.accountInfo;
38
- el.accountLine.textContent = `Logged in as ${info.email ?? 'unknown'}${info.subscriptionType ? ` (${info.subscriptionType})` : ''}`;
39
- show('dashboard');
288
+ lastAccountInfo = result.accountInfo;
289
+ lastProfile = result.profile;
290
+ lastModels = result.models ?? [];
291
+ renderProfileUI(lastProfile);
292
+ setupModelSelectors();
293
+ el.appShell.hidden = false;
294
+ setStatus('online');
295
+ selectPage('messages');
296
+ checkForUpdateBanner(1);
297
+
298
+ if (!lastProfile?.onboardingComplete && !onboardingKickedOff) {
299
+ onboardingKickedOff = true;
300
+ kickoffOnboarding();
301
+ }
40
302
  return;
41
303
  }
42
304
 
43
305
  el.cliMissing.hidden = result.reason !== 'cli-missing';
44
306
  el.notAuthenticated.hidden = result.reason !== 'not-authenticated';
45
- show('onboarding');
307
+ el.onboarding.hidden = false;
46
308
  }
47
309
 
48
310
  el.checkAgain.addEventListener('click', checkStatus);
49
311
 
50
- function appendMessage(className, text) {
51
- const div = document.createElement('div');
52
- div.className = `msg ${className}`;
53
- div.textContent = text;
54
- el.messages.appendChild(div);
312
+ async function refreshProfileIfNeeded() {
313
+ if (lastProfile?.onboardingComplete) return;
314
+ const res = await api('/api/profile');
315
+ const { profile } = await res.json();
316
+ if (profile) {
317
+ lastProfile = profile;
318
+ renderProfileUI(profile);
319
+ }
320
+ }
321
+
322
+ el.profileForm.addEventListener('submit', async (e) => {
323
+ e.preventDefault();
324
+ const companyName = el.profileCompany.value.trim();
325
+ const mission = el.profileMission.value.trim();
326
+ if (!companyName || !mission) return;
327
+
328
+ const res = await api('/api/profile', {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({
332
+ companyName,
333
+ mission,
334
+ ceoName: el.profileCeoName.value.trim(),
335
+ ceoPersonality: el.profileCeoPersonality.value.trim(),
336
+ defaultModel: el.profileDefaultModel.value,
337
+ defaultEffort: el.profileDefaultEffort.value,
338
+ }),
339
+ });
340
+ if (!res.ok) return;
341
+
342
+ const profileRes = await api('/api/profile');
343
+ const { profile } = await profileRes.json();
344
+ lastProfile = profile;
345
+ renderProfileUI(lastProfile);
346
+
347
+ el.profileSavedHint.hidden = false;
348
+ setTimeout(() => {
349
+ el.profileSavedHint.hidden = true;
350
+ }, 2000);
351
+ });
352
+
353
+ function escapeHtml(str) {
354
+ return str.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
355
+ }
356
+
357
+ // Minimal, safe markdown rendering: escape first, then convert a handful of
358
+ // common patterns. Applied to the full accumulated text on every streamed
359
+ // delta, so an unclosed marker mid-stream self-corrects once the closing
360
+ // marker arrives a moment later.
361
+ function renderMarkdown(text) {
362
+ let html = escapeHtml(text);
363
+ html = html.replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`);
364
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
365
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
366
+ html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
367
+ return html;
368
+ }
369
+
370
+ function appendMessageRow(kind, name, avatarText, text) {
371
+ const row = document.createElement('div');
372
+ row.className = `msg-row ${kind}`;
373
+
374
+ const avatar = document.createElement('span');
375
+ avatar.className = 'avatar';
376
+ avatar.textContent = avatarText;
377
+
378
+ const content = document.createElement('div');
379
+ content.className = 'msg-content';
380
+
381
+ const meta = document.createElement('div');
382
+ meta.className = 'msg-meta';
383
+ meta.textContent = name;
384
+ const time = document.createElement('span');
385
+ time.className = 'msg-time';
386
+ time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
387
+ meta.appendChild(time);
388
+
389
+ const textEl = document.createElement('div');
390
+ textEl.className = 'msg-text';
391
+ textEl.innerHTML = renderMarkdown(text);
392
+
393
+ content.appendChild(meta);
394
+ content.appendChild(textEl);
395
+ row.appendChild(avatar);
396
+ row.appendChild(content);
397
+ el.messages.appendChild(row);
398
+ el.messages.scrollTop = el.messages.scrollHeight;
399
+ return { row, textEl };
400
+ }
401
+
402
+ function appendUserMessage(text) {
403
+ appendMessageRow('user', 'You', 'Y', text);
404
+ }
405
+
406
+ let pendingWidgetCount = 0;
407
+
408
+ function setPendingWidget(delta) {
409
+ pendingWidgetCount = Math.max(0, pendingWidgetCount + delta);
410
+ el.input.disabled = pendingWidgetCount > 0;
411
+ el.sendButton.disabled = pendingWidgetCount > 0;
412
+ if (pendingWidgetCount === 0) el.input.focus();
413
+ }
414
+
415
+ const WIDGET_OPEN_FENCE = '```question-widget';
416
+
417
+ // Incremental parser: processes streamed text chunks as they arrive rather
418
+ // than re-parsing the full accumulated text on every delta. This matters for
419
+ // two reasons — (1) a widget block mid-stream would otherwise flash raw JSON
420
+ // before its closing fence arrives, and (2) naively re-rendering the whole
421
+ // message on every delta would wipe out a widget the user already answered
422
+ // if more text streams in afterward.
423
+ class AssistantContentRenderer {
424
+ constructor(containerEl) {
425
+ this.container = containerEl;
426
+ this.state = 'text';
427
+ this.buffer = '';
428
+ this.textSegmentEl = null;
429
+ this.textRaw = '';
430
+ this.widgetPlaceholderEl = null;
431
+ this.widgetRaw = '';
432
+ }
433
+
434
+ push(deltaText) {
435
+ this.buffer += deltaText;
436
+ for (;;) {
437
+ if (this.state === 'text') {
438
+ const fenceIdx = this.buffer.indexOf(WIDGET_OPEN_FENCE);
439
+ if (fenceIdx === -1) {
440
+ // Hold back a tail as long as the marker minus one character —
441
+ // deltas arrive token-by-token, so the marker itself can easily
442
+ // be split across chunks (e.g. one delta ending "``" and the next
443
+ // starting "`question-widget"). Flushing eagerly would render the
444
+ // first half as plain text before the second half ever confirms
445
+ // it was a fence, and the marker would never be recognized.
446
+ const safeLen = Math.max(0, this.buffer.length - (WIDGET_OPEN_FENCE.length - 1));
447
+ const toFlush = this.buffer.slice(0, safeLen);
448
+ this.buffer = this.buffer.slice(safeLen);
449
+ if (toFlush) {
450
+ if (!this.textSegmentEl) {
451
+ this.textSegmentEl = document.createElement('div');
452
+ this.textSegmentEl.className = 'msg-text-segment';
453
+ this.container.appendChild(this.textSegmentEl);
454
+ }
455
+ this.textRaw += toFlush;
456
+ this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
457
+ }
458
+ return;
459
+ }
460
+ if (fenceIdx > 0 || this.textRaw) {
461
+ if (!this.textSegmentEl) {
462
+ this.textSegmentEl = document.createElement('div');
463
+ this.textSegmentEl.className = 'msg-text-segment';
464
+ this.container.appendChild(this.textSegmentEl);
465
+ }
466
+ this.textRaw += this.buffer.slice(0, fenceIdx);
467
+ this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
468
+ }
469
+ this.textSegmentEl = null;
470
+ this.textRaw = '';
471
+ this.buffer = this.buffer.slice(fenceIdx + WIDGET_OPEN_FENCE.length);
472
+ this.state = 'in-widget';
473
+ this.widgetRaw = '';
474
+ this.widgetPlaceholderEl = document.createElement('div');
475
+ this.widgetPlaceholderEl.className = 'question-widget-placeholder';
476
+ this.widgetPlaceholderEl.textContent = 'Preparing a question…';
477
+ this.container.appendChild(this.widgetPlaceholderEl);
478
+ } else {
479
+ const closeIdx = this.buffer.indexOf('```');
480
+ if (closeIdx === -1) {
481
+ // Same tail-holding logic for the closing fence (3 chars).
482
+ const safeLen = Math.max(0, this.buffer.length - 2);
483
+ this.widgetRaw += this.buffer.slice(0, safeLen);
484
+ this.buffer = this.buffer.slice(safeLen);
485
+ return;
486
+ }
487
+ this.widgetRaw += this.buffer.slice(0, closeIdx);
488
+ this.buffer = this.buffer.slice(closeIdx + 3);
489
+ this.state = 'text';
490
+ this.renderWidget(this.widgetRaw);
491
+ }
492
+ }
493
+ }
494
+
495
+ renderWidget(rawJson) {
496
+ let data;
497
+ try {
498
+ data = JSON.parse(rawJson);
499
+ } catch {
500
+ // Malformed JSON from the model — show the raw text rather than
501
+ // silently dropping it, so a parsing failure is at least visible.
502
+ this.widgetPlaceholderEl.textContent = rawJson.trim();
503
+ this.widgetPlaceholderEl.className = 'msg-text-segment';
504
+ this.widgetPlaceholderEl = null;
505
+ return;
506
+ }
507
+ setPendingWidget(1);
508
+ let answered = false;
509
+ const widgetEl = createQuestionWidget(data, (messageText) => {
510
+ if (answered) return;
511
+ answered = true;
512
+ setPendingWidget(-1);
513
+ streamChat(messageText, { showUserBubble: false });
514
+ });
515
+ this.widgetPlaceholderEl.replaceWith(widgetEl);
516
+ this.widgetPlaceholderEl = null;
517
+ }
518
+
519
+ finish() {
520
+ // No more text is coming, so anything still held back as a possible
521
+ // partial fence match is definitely not one — flush it as plain text.
522
+ if (this.state === 'text' && this.buffer) {
523
+ if (!this.textSegmentEl) {
524
+ this.textSegmentEl = document.createElement('div');
525
+ this.textSegmentEl.className = 'msg-text-segment';
526
+ this.container.appendChild(this.textSegmentEl);
527
+ }
528
+ this.textRaw += this.buffer;
529
+ this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
530
+ this.buffer = '';
531
+ }
532
+ // A block left unterminated when the turn ends (shouldn't normally
533
+ // happen) — show what was received rather than leaving a permanent
534
+ // "Preparing a question…" placeholder with no way to proceed.
535
+ if (this.state === 'in-widget' && this.widgetPlaceholderEl) {
536
+ this.widgetPlaceholderEl.textContent = `${WIDGET_OPEN_FENCE}\n${this.widgetRaw}${this.buffer}`;
537
+ this.widgetPlaceholderEl.className = 'msg-text-segment';
538
+ }
539
+ }
540
+ }
541
+
542
+ function createQuestionWidget(data, onAnswer) {
543
+ const card = document.createElement('div');
544
+ card.className = 'question-widget';
545
+
546
+ const questionEl = document.createElement('div');
547
+ questionEl.className = 'question-widget-question';
548
+ questionEl.textContent = data.question ?? 'Question';
549
+ card.appendChild(questionEl);
550
+
551
+ const body = document.createElement('div');
552
+ body.className = 'question-widget-body';
553
+ card.appendChild(body);
554
+
555
+ function submit(displayText, messageText) {
556
+ card.classList.add('answered');
557
+ body.innerHTML = '';
558
+ const answerEl = document.createElement('div');
559
+ answerEl.className = 'question-widget-answer';
560
+ answerEl.textContent = displayText;
561
+ body.appendChild(answerEl);
562
+ onAnswer(messageText ?? displayText);
563
+ }
564
+
565
+ function buildOtherRow(placeholder) {
566
+ const row = document.createElement('div');
567
+ row.className = 'question-widget-row question-widget-other';
568
+ const input = document.createElement('input');
569
+ input.type = 'text';
570
+ input.placeholder = placeholder;
571
+ const btn = document.createElement('button');
572
+ btn.type = 'button';
573
+ btn.textContent = 'Send';
574
+ row.appendChild(input);
575
+ row.appendChild(btn);
576
+ const trigger = () => {
577
+ const val = input.value.trim();
578
+ if (val) submit(val, val);
579
+ };
580
+ btn.addEventListener('click', trigger);
581
+ input.addEventListener('keydown', (e) => {
582
+ if (e.key === 'Enter') trigger();
583
+ });
584
+ return row;
585
+ }
586
+
587
+ const type = data.type;
588
+ const options = Array.isArray(data.options) ? data.options : [];
589
+
590
+ if (type === 'single_select') {
591
+ for (const opt of options) {
592
+ const btn = document.createElement('button');
593
+ btn.type = 'button';
594
+ btn.className = 'question-widget-option';
595
+ btn.textContent = opt;
596
+ btn.addEventListener('click', () => submit(opt, opt));
597
+ body.appendChild(btn);
598
+ }
599
+ body.appendChild(buildOtherRow('Other…'));
600
+ } else if (type === 'multi_select') {
601
+ const checkboxes = [];
602
+ for (const opt of options) {
603
+ const label = document.createElement('label');
604
+ label.className = 'question-widget-checkbox-label';
605
+ const cb = document.createElement('input');
606
+ cb.type = 'checkbox';
607
+ cb.value = opt;
608
+ checkboxes.push(cb);
609
+ label.appendChild(cb);
610
+ label.appendChild(document.createTextNode(opt));
611
+ body.appendChild(label);
612
+ }
613
+ const otherInput = document.createElement('input');
614
+ otherInput.type = 'text';
615
+ otherInput.placeholder = 'Other (optional)…';
616
+ otherInput.className = 'question-widget-other-inline';
617
+ body.appendChild(otherInput);
618
+
619
+ const submitBtn = document.createElement('button');
620
+ submitBtn.type = 'button';
621
+ submitBtn.className = 'question-widget-submit';
622
+ submitBtn.textContent = 'Submit';
623
+ submitBtn.addEventListener('click', () => {
624
+ const selected = checkboxes.filter((c) => c.checked).map((c) => c.value);
625
+ const other = otherInput.value.trim();
626
+ if (other) selected.push(other);
627
+ if (!selected.length) return;
628
+ const display = selected.join(', ');
629
+ submit(display, display);
630
+ });
631
+ body.appendChild(submitBtn);
632
+ } else {
633
+ // 'text', or an unrecognized type — a free-text prompt never silently drops the question.
634
+ body.appendChild(buildOtherRow('Type your answer…'));
635
+ }
636
+
637
+ return card;
638
+ }
639
+
640
+ function appendAssistantRow(turnMeta) {
641
+ const result = appendMessageRow('assistant', lastProfile?.ceoName || 'your AI CEO', getCeoInitial(), '');
642
+ if (turnMeta?.model) {
643
+ const badge = document.createElement('span');
644
+ badge.className = 'msg-model-badge';
645
+ const modelLabel = modelDisplayName(turnMeta.model);
646
+ badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
647
+ result.row.querySelector('.msg-meta').appendChild(badge);
648
+ }
649
+ result.renderer = new AssistantContentRenderer(result.textEl);
650
+ return result;
651
+ }
652
+
653
+ function appendToolMessage(name) {
654
+ appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`);
655
+ }
656
+
657
+ function appendErrorMessage(message) {
658
+ appendMessageRow('error', 'Error', '!', message);
659
+ }
660
+
661
+ function showTypingIndicator() {
662
+ el.typingAvatar.textContent = getCeoInitial();
663
+ el.typingIndicator.hidden = false;
55
664
  el.messages.scrollTop = el.messages.scrollHeight;
56
- return div;
57
665
  }
58
666
 
59
- async function sendMessage(message) {
60
- appendMessage('user', message);
61
- const assistantEl = appendMessage('assistant', '');
62
- let assistantText = '';
667
+ function hideTypingIndicator() {
668
+ el.typingIndicator.hidden = true;
669
+ }
670
+
671
+ async function streamChat(message, { showUserBubble }) {
672
+ if (showUserBubble) appendUserMessage(message);
673
+ showTypingIndicator();
674
+ setStatus('typing');
675
+
676
+ let assistantRenderer = null;
677
+ let sawContent = false;
678
+ let turnMeta = null;
63
679
 
64
680
  const res = await api('/api/chat', {
65
681
  method: 'POST',
66
682
  headers: { 'Content-Type': 'application/json' },
67
- body: JSON.stringify({ message }),
683
+ body: JSON.stringify({ message, model: el.chatModelSelect.value, effort: el.chatEffortSelect.value }),
68
684
  });
69
685
 
70
686
  if (!res.ok || !res.body) {
71
- appendMessage('error', `Request failed (${res.status})`);
687
+ hideTypingIndicator();
688
+ appendErrorMessage(`Request failed (${res.status})`);
689
+ setStatus('offline');
72
690
  return;
73
691
  }
74
692
 
@@ -89,16 +707,43 @@ async function sendMessage(message) {
89
707
  if (!line) continue;
90
708
  const event = JSON.parse(line.slice('data: '.length));
91
709
 
92
- if (event.type === 'text') {
93
- assistantText += event.text;
94
- assistantEl.textContent = assistantText;
710
+ if (event.type === 'meta') {
711
+ turnMeta = { model: event.model, effort: event.effort };
712
+ } else if (event.type === 'text-delta') {
713
+ if (!sawContent) {
714
+ hideTypingIndicator();
715
+ sawContent = true;
716
+ }
717
+ if (!assistantRenderer) {
718
+ assistantRenderer = appendAssistantRow(turnMeta).renderer;
719
+ }
720
+ assistantRenderer.push(event.text);
721
+ el.messages.scrollTop = el.messages.scrollHeight;
95
722
  } else if (event.type === 'tool-use') {
96
- appendMessage('tool', `\u{1F527} using tool: ${event.name}`);
723
+ if (!sawContent) {
724
+ hideTypingIndicator();
725
+ sawContent = true;
726
+ }
727
+ appendToolMessage(event.name);
97
728
  } else if (event.type === 'error') {
98
- appendMessage('error', event.message);
729
+ if (!sawContent) {
730
+ hideTypingIndicator();
731
+ sawContent = true;
732
+ }
733
+ appendErrorMessage(event.message);
734
+ setStatus('offline');
99
735
  }
100
736
  }
101
737
  }
738
+
739
+ assistantRenderer?.finish();
740
+ hideTypingIndicator();
741
+ if (statusState !== 'offline') setStatus('online');
742
+ await refreshProfileIfNeeded();
743
+ }
744
+
745
+ function kickoffOnboarding() {
746
+ return streamChat('Please begin the onboarding conversation.', { showUserBubble: false });
102
747
  }
103
748
 
104
749
  el.form.addEventListener('submit', async (e) => {
@@ -107,11 +752,18 @@ el.form.addEventListener('submit', async (e) => {
107
752
  if (!message) return;
108
753
  el.input.value = '';
109
754
  el.input.disabled = true;
755
+ el.sendButton.disabled = true;
110
756
  try {
111
- await sendMessage(message);
757
+ await streamChat(message, { showUserBubble: true });
112
758
  } finally {
113
- el.input.disabled = false;
114
- el.input.focus();
759
+ // A widget the model asked as part of this same turn may still be
760
+ // unanswered — setPendingWidget already owns disabling in that case,
761
+ // so only re-enable here if nothing is pending.
762
+ if (pendingWidgetCount === 0) {
763
+ el.input.disabled = false;
764
+ el.sendButton.disabled = false;
765
+ el.input.focus();
766
+ }
115
767
  }
116
768
  });
117
769