@harborgroup/my-team 0.1.0 → 0.2.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,69 @@ 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
+
47
+ profileForm: document.getElementById('profile-form'),
48
+ profileCompany: document.getElementById('profile-company'),
49
+ profileMission: document.getElementById('profile-mission'),
50
+ profileCeoName: document.getElementById('profile-ceo-name'),
51
+ profileCeoPersonality: document.getElementById('profile-ceo-personality'),
52
+ profileDefaultModel: document.getElementById('profile-default-model'),
53
+ profileDefaultEffort: document.getElementById('profile-default-effort'),
54
+ profileSavedHint: document.getElementById('profile-saved-hint'),
55
+
56
+ chatModelSelect: document.getElementById('chat-model-select'),
57
+ chatEffortSelect: document.getElementById('chat-effort-select'),
58
+
59
+ settingsAccount: document.getElementById('settings-account'),
60
+ settingsLogPath: document.getElementById('settings-log-path'),
14
61
  };
15
62
 
63
+ let lastProfile = null;
64
+ let lastAccountInfo = null;
65
+ let lastModels = [];
66
+ let onboardingKickedOff = false;
67
+ let currentPage = 'messages';
68
+ let currentChannel = 'ceo';
69
+ let statusState = 'offline';
70
+ let metaCache = null;
71
+
16
72
  function api(path, options = {}) {
17
73
  return fetch(path, {
18
74
  ...options,
@@ -20,55 +76,382 @@ function api(path, options = {}) {
20
76
  });
21
77
  }
22
78
 
23
- function show(section) {
24
- el.loading.hidden = true;
25
- el.onboarding.hidden = section !== 'onboarding';
26
- el.dashboard.hidden = section !== 'dashboard';
79
+ function getCeoInitial() {
80
+ const name = lastProfile?.ceoName;
81
+ return name && name !== 'your AI CEO' ? name.trim()[0].toUpperCase() : '?';
82
+ }
83
+
84
+ function renderProfileUI(profile) {
85
+ el.workspaceName.textContent = profile?.companyName || 'my-team';
86
+ const ceoName = profile?.ceoName || 'your AI CEO';
87
+ el.ceoNavName.textContent = ceoName;
88
+ el.ceoAvatar.textContent = getCeoInitial();
89
+ if (currentPage === 'messages' && currentChannel === 'ceo') el.channelTitle.textContent = ceoName;
90
+ if (currentPage === 'home') renderHomePage();
91
+ }
92
+
93
+ function setStatus(state) {
94
+ statusState = state;
95
+ el.ceoStatusDot.classList.remove('online', 'typing');
96
+ if (state === 'online') el.ceoStatusDot.classList.add('online');
97
+ else if (state === 'typing') el.ceoStatusDot.classList.add('typing');
98
+ if (currentPage === 'messages' && currentChannel === 'ceo') {
99
+ el.channelSubtitle.textContent = state === 'typing' ? 'Typing…' : state === 'online' ? 'Active' : 'Offline';
100
+ }
27
101
  }
28
102
 
103
+ function selectChannel(channel) {
104
+ currentChannel = channel;
105
+ el.navGeneral.classList.toggle('active', channel === 'general');
106
+ el.navCeo.classList.toggle('active', channel === 'ceo');
107
+ el.channelGeneral.hidden = channel !== 'general';
108
+ el.channelCeo.hidden = channel !== 'ceo';
109
+ if (channel === 'general') {
110
+ el.channelTitle.textContent = '# general';
111
+ el.channelSubtitle.textContent = '';
112
+ } else {
113
+ el.channelTitle.textContent = lastProfile?.ceoName || 'your AI CEO';
114
+ setStatus(statusState);
115
+ }
116
+ }
117
+
118
+ el.navGeneral.addEventListener('click', () => selectChannel('general'));
119
+ el.navCeo.addEventListener('click', () => selectChannel('ceo'));
120
+
121
+ function modelDisplayName(value) {
122
+ return lastModels.find((m) => m.value === value)?.displayName ?? value;
123
+ }
124
+
125
+ // No synthetic "use the default" placeholder here — the SDK's own
126
+ // supportedModels() list already includes a real "default" entry
127
+ // (displayName "Default (recommended)") that itself supports effort
128
+ // levels. A separate blank option would be a confusing near-duplicate and
129
+ // would incorrectly read as "no model supports effort" to the effort
130
+ // selector below, since '' never matches a real model.
131
+ function populateModelSelect(selectEl) {
132
+ selectEl.innerHTML = '';
133
+ for (const model of lastModels) {
134
+ const opt = document.createElement('option');
135
+ opt.value = model.value;
136
+ opt.textContent = model.displayName;
137
+ selectEl.appendChild(opt);
138
+ }
139
+ }
140
+
141
+ // Effort options are constrained to what the selected model actually
142
+ // supports (confirmed via the SDK's supportedModels() data — e.g. Haiku
143
+ // supports no effort levels at all) so a silent downgrade shouldn't occur
144
+ // in practice; the UI simply never offers an invalid combination.
145
+ function populateEffortSelect(selectEl, modelValue, blankLabel) {
146
+ const info = lastModels.find((m) => m.value === modelValue);
147
+ selectEl.innerHTML = '';
148
+ if (!info?.supportsEffort) {
149
+ selectEl.disabled = true;
150
+ const opt = document.createElement('option');
151
+ opt.value = '';
152
+ opt.textContent = 'N/A';
153
+ selectEl.appendChild(opt);
154
+ return;
155
+ }
156
+ selectEl.disabled = false;
157
+ const blank = document.createElement('option');
158
+ blank.value = '';
159
+ blank.textContent = blankLabel;
160
+ selectEl.appendChild(blank);
161
+ for (const level of info.supportedEffortLevels ?? []) {
162
+ const opt = document.createElement('option');
163
+ opt.value = level;
164
+ opt.textContent = level;
165
+ selectEl.appendChild(opt);
166
+ }
167
+ }
168
+
169
+ function setupModelSelectors() {
170
+ populateModelSelect(el.chatModelSelect);
171
+ populateModelSelect(el.profileDefaultModel);
172
+
173
+ // Assigning select.value a string that matches no <option> clears the
174
+ // selection entirely (selectedIndex -1) rather than falling back to the
175
+ // first option — only assign when there's a real saved default to apply.
176
+ if (lastProfile?.defaultModel) el.chatModelSelect.value = lastProfile.defaultModel;
177
+ populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
178
+ if (lastProfile?.defaultEffort) el.chatEffortSelect.value = lastProfile.defaultEffort;
179
+ }
180
+
181
+ el.chatModelSelect.addEventListener('change', () => {
182
+ populateEffortSelect(el.chatEffortSelect, el.chatModelSelect.value, 'Default effort');
183
+ });
184
+
185
+ el.profileDefaultModel.addEventListener('change', () => {
186
+ populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
187
+ });
188
+
189
+ function populateProfileForm(profile) {
190
+ el.profileCompany.value = profile?.companyName ?? '';
191
+ el.profileMission.value = profile?.mission ?? '';
192
+ el.profileCeoName.value = profile?.ceoName ?? '';
193
+ el.profileCeoPersonality.value = profile?.ceoPersonality ?? '';
194
+ if (profile?.defaultModel) el.profileDefaultModel.value = profile.defaultModel;
195
+ populateEffortSelect(el.profileDefaultEffort, el.profileDefaultModel.value, 'Default');
196
+ if (profile?.defaultEffort) el.profileDefaultEffort.value = profile.defaultEffort;
197
+ }
198
+
199
+ function renderHomePage() {
200
+ el.homeAvatar.textContent = getCeoInitial();
201
+ const ceoName = lastProfile?.ceoName || 'your AI CEO';
202
+ const companyName = lastProfile?.companyName;
203
+ el.homeGreeting.textContent = companyName ? `Welcome to ${companyName}` : 'Welcome to my-team';
204
+ el.homeSub.textContent = lastProfile?.onboardingComplete
205
+ ? `${ceoName} is ready and waiting in Messages.`
206
+ : `Head to Messages to finish setting up ${ceoName}.`;
207
+ }
208
+
209
+ async function renderSettingsPage() {
210
+ el.settingsAccount.textContent = lastAccountInfo
211
+ ? `${lastAccountInfo.email ?? 'unknown'}${lastAccountInfo.subscriptionType ? ` (${lastAccountInfo.subscriptionType})` : ''}`
212
+ : 'unknown';
213
+ if (!metaCache) {
214
+ const res = await api('/api/meta');
215
+ metaCache = await res.json();
216
+ }
217
+ el.settingsLogPath.textContent = metaCache.logFilePath;
218
+ }
219
+
220
+ async function checkForUpdateBanner(retriesLeft) {
221
+ const res = await api('/api/meta');
222
+ metaCache = await res.json();
223
+
224
+ if (metaCache.updateInfo?.updateAvailable) {
225
+ el.updateBannerText.textContent = `A new version of my-team is available (v${metaCache.updateInfo.latestVersion}).`;
226
+ el.updateBanner.hidden = false;
227
+ } else if (!metaCache.updateInfo && retriesLeft > 0) {
228
+ // The registry check runs in the background at server startup and may
229
+ // not have resolved yet — try once more shortly rather than never
230
+ // showing the banner this session.
231
+ setTimeout(() => checkForUpdateBanner(retriesLeft - 1), 3000);
232
+ }
233
+ }
234
+
235
+ el.updateBannerButton.addEventListener('click', async () => {
236
+ el.updateBannerButton.disabled = true;
237
+ el.updateBannerButton.textContent = 'Updating…';
238
+ const res = await api('/api/update', { method: 'POST' });
239
+ const result = await res.json();
240
+ if (result.ok) {
241
+ el.updateBannerText.textContent = 'Updated — restart (stop and run "npm run team" again) to use the new version.';
242
+ el.updateBannerButton.hidden = true;
243
+ } else {
244
+ el.updateBannerButton.disabled = false;
245
+ el.updateBannerButton.textContent = 'Update';
246
+ el.updateBannerText.textContent = 'Update failed — check the terminal for details.';
247
+ }
248
+ });
249
+
250
+ el.updateBannerDismiss.addEventListener('click', () => {
251
+ el.updateBanner.hidden = true;
252
+ });
253
+
254
+ function selectPage(page) {
255
+ currentPage = page;
256
+ el.railHome.classList.toggle('active', page === 'home');
257
+ el.railMessages.classList.toggle('active', page === 'messages');
258
+ el.railProfile.classList.toggle('active', page === 'profile');
259
+ el.railSettings.classList.toggle('active', page === 'settings');
260
+ el.pageHome.hidden = page !== 'home';
261
+ el.pageMessages.hidden = page !== 'messages';
262
+ el.pageProfile.hidden = page !== 'profile';
263
+ el.pageSettings.hidden = page !== 'settings';
264
+
265
+ if (page === 'home') renderHomePage();
266
+ else if (page === 'profile') populateProfileForm(lastProfile);
267
+ else if (page === 'settings') renderSettingsPage();
268
+ else if (page === 'messages') selectChannel(currentChannel);
269
+ }
270
+
271
+ el.railHome.addEventListener('click', () => selectPage('home'));
272
+ el.railMessages.addEventListener('click', () => selectPage('messages'));
273
+ el.railProfile.addEventListener('click', () => selectPage('profile'));
274
+ el.railSettings.addEventListener('click', () => selectPage('settings'));
275
+ el.homeGoMessages.addEventListener('click', () => selectPage('messages'));
276
+
29
277
  async function checkStatus() {
30
278
  el.loading.hidden = false;
31
279
  el.onboarding.hidden = true;
32
- el.dashboard.hidden = true;
280
+ el.appShell.hidden = true;
281
+
33
282
  const res = await api('/api/status');
34
283
  const result = await res.json();
284
+ el.loading.hidden = true;
35
285
 
36
286
  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');
287
+ lastAccountInfo = result.accountInfo;
288
+ lastProfile = result.profile;
289
+ lastModels = result.models ?? [];
290
+ renderProfileUI(lastProfile);
291
+ setupModelSelectors();
292
+ el.appShell.hidden = false;
293
+ setStatus('online');
294
+ selectPage('messages');
295
+ checkForUpdateBanner(1);
296
+
297
+ if (!lastProfile?.onboardingComplete && !onboardingKickedOff) {
298
+ onboardingKickedOff = true;
299
+ kickoffOnboarding();
300
+ }
40
301
  return;
41
302
  }
42
303
 
43
304
  el.cliMissing.hidden = result.reason !== 'cli-missing';
44
305
  el.notAuthenticated.hidden = result.reason !== 'not-authenticated';
45
- show('onboarding');
306
+ el.onboarding.hidden = false;
46
307
  }
47
308
 
48
309
  el.checkAgain.addEventListener('click', checkStatus);
49
310
 
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);
311
+ async function refreshProfileIfNeeded() {
312
+ if (lastProfile?.onboardingComplete) return;
313
+ const res = await api('/api/profile');
314
+ const { profile } = await res.json();
315
+ if (profile) {
316
+ lastProfile = profile;
317
+ renderProfileUI(profile);
318
+ }
319
+ }
320
+
321
+ el.profileForm.addEventListener('submit', async (e) => {
322
+ e.preventDefault();
323
+ const companyName = el.profileCompany.value.trim();
324
+ const mission = el.profileMission.value.trim();
325
+ if (!companyName || !mission) return;
326
+
327
+ const res = await api('/api/profile', {
328
+ method: 'POST',
329
+ headers: { 'Content-Type': 'application/json' },
330
+ body: JSON.stringify({
331
+ companyName,
332
+ mission,
333
+ ceoName: el.profileCeoName.value.trim(),
334
+ ceoPersonality: el.profileCeoPersonality.value.trim(),
335
+ defaultModel: el.profileDefaultModel.value,
336
+ defaultEffort: el.profileDefaultEffort.value,
337
+ }),
338
+ });
339
+ if (!res.ok) return;
340
+
341
+ const profileRes = await api('/api/profile');
342
+ const { profile } = await profileRes.json();
343
+ lastProfile = profile;
344
+ renderProfileUI(lastProfile);
345
+
346
+ el.profileSavedHint.hidden = false;
347
+ setTimeout(() => {
348
+ el.profileSavedHint.hidden = true;
349
+ }, 2000);
350
+ });
351
+
352
+ function escapeHtml(str) {
353
+ return str.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
354
+ }
355
+
356
+ // Minimal, safe markdown rendering: escape first, then convert a handful of
357
+ // common patterns. Applied to the full accumulated text on every streamed
358
+ // delta, so an unclosed marker mid-stream self-corrects once the closing
359
+ // marker arrives a moment later.
360
+ function renderMarkdown(text) {
361
+ let html = escapeHtml(text);
362
+ html = html.replace(/```([\s\S]*?)```/g, (_, code) => `<pre><code>${code}</code></pre>`);
363
+ html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
364
+ html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
365
+ html = html.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
366
+ return html;
367
+ }
368
+
369
+ function appendMessageRow(kind, name, avatarText, text) {
370
+ const row = document.createElement('div');
371
+ row.className = `msg-row ${kind}`;
372
+
373
+ const avatar = document.createElement('span');
374
+ avatar.className = 'avatar';
375
+ avatar.textContent = avatarText;
376
+
377
+ const content = document.createElement('div');
378
+ content.className = 'msg-content';
379
+
380
+ const meta = document.createElement('div');
381
+ meta.className = 'msg-meta';
382
+ meta.textContent = name;
383
+ const time = document.createElement('span');
384
+ time.className = 'msg-time';
385
+ time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
386
+ meta.appendChild(time);
387
+
388
+ const textEl = document.createElement('div');
389
+ textEl.className = 'msg-text';
390
+ textEl.innerHTML = renderMarkdown(text);
391
+
392
+ content.appendChild(meta);
393
+ content.appendChild(textEl);
394
+ row.appendChild(avatar);
395
+ row.appendChild(content);
396
+ el.messages.appendChild(row);
55
397
  el.messages.scrollTop = el.messages.scrollHeight;
56
- return div;
398
+ return { row, textEl };
57
399
  }
58
400
 
59
- async function sendMessage(message) {
60
- appendMessage('user', message);
61
- const assistantEl = appendMessage('assistant', '');
401
+ function appendUserMessage(text) {
402
+ appendMessageRow('user', 'You', 'Y', text);
403
+ }
404
+
405
+ function appendAssistantRow(turnMeta) {
406
+ const result = appendMessageRow('assistant', lastProfile?.ceoName || 'your AI CEO', getCeoInitial(), '');
407
+ if (turnMeta?.model) {
408
+ const badge = document.createElement('span');
409
+ badge.className = 'msg-model-badge';
410
+ const modelLabel = modelDisplayName(turnMeta.model);
411
+ badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
412
+ result.row.querySelector('.msg-meta').appendChild(badge);
413
+ }
414
+ return result;
415
+ }
416
+
417
+ function appendToolMessage(name) {
418
+ appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`);
419
+ }
420
+
421
+ function appendErrorMessage(message) {
422
+ appendMessageRow('error', 'Error', '!', message);
423
+ }
424
+
425
+ function showTypingIndicator() {
426
+ el.typingAvatar.textContent = getCeoInitial();
427
+ el.typingIndicator.hidden = false;
428
+ el.messages.scrollTop = el.messages.scrollHeight;
429
+ }
430
+
431
+ function hideTypingIndicator() {
432
+ el.typingIndicator.hidden = true;
433
+ }
434
+
435
+ async function streamChat(message, { showUserBubble }) {
436
+ if (showUserBubble) appendUserMessage(message);
437
+ showTypingIndicator();
438
+ setStatus('typing');
439
+
440
+ let assistantTextEl = null;
62
441
  let assistantText = '';
442
+ let sawContent = false;
443
+ let turnMeta = null;
63
444
 
64
445
  const res = await api('/api/chat', {
65
446
  method: 'POST',
66
447
  headers: { 'Content-Type': 'application/json' },
67
- body: JSON.stringify({ message }),
448
+ body: JSON.stringify({ message, model: el.chatModelSelect.value, effort: el.chatEffortSelect.value }),
68
449
  });
69
450
 
70
451
  if (!res.ok || !res.body) {
71
- appendMessage('error', `Request failed (${res.status})`);
452
+ hideTypingIndicator();
453
+ appendErrorMessage(`Request failed (${res.status})`);
454
+ setStatus('offline');
72
455
  return;
73
456
  }
74
457
 
@@ -89,16 +472,43 @@ async function sendMessage(message) {
89
472
  if (!line) continue;
90
473
  const event = JSON.parse(line.slice('data: '.length));
91
474
 
92
- if (event.type === 'text') {
475
+ if (event.type === 'meta') {
476
+ turnMeta = { model: event.model, effort: event.effort };
477
+ } else if (event.type === 'text-delta') {
478
+ if (!sawContent) {
479
+ hideTypingIndicator();
480
+ sawContent = true;
481
+ }
482
+ if (!assistantTextEl) {
483
+ assistantTextEl = appendAssistantRow(turnMeta).textEl;
484
+ }
93
485
  assistantText += event.text;
94
- assistantEl.textContent = assistantText;
486
+ assistantTextEl.innerHTML = renderMarkdown(assistantText);
487
+ el.messages.scrollTop = el.messages.scrollHeight;
95
488
  } else if (event.type === 'tool-use') {
96
- appendMessage('tool', `\u{1F527} using tool: ${event.name}`);
489
+ if (!sawContent) {
490
+ hideTypingIndicator();
491
+ sawContent = true;
492
+ }
493
+ appendToolMessage(event.name);
97
494
  } else if (event.type === 'error') {
98
- appendMessage('error', event.message);
495
+ if (!sawContent) {
496
+ hideTypingIndicator();
497
+ sawContent = true;
498
+ }
499
+ appendErrorMessage(event.message);
500
+ setStatus('offline');
99
501
  }
100
502
  }
101
503
  }
504
+
505
+ hideTypingIndicator();
506
+ if (statusState !== 'offline') setStatus('online');
507
+ await refreshProfileIfNeeded();
508
+ }
509
+
510
+ function kickoffOnboarding() {
511
+ return streamChat('Please begin the onboarding conversation.', { showUserBubble: false });
102
512
  }
103
513
 
104
514
  el.form.addEventListener('submit', async (e) => {
@@ -108,7 +518,7 @@ el.form.addEventListener('submit', async (e) => {
108
518
  el.input.value = '';
109
519
  el.input.disabled = true;
110
520
  try {
111
- await sendMessage(message);
521
+ await streamChat(message, { showUserBubble: true });
112
522
  } finally {
113
523
  el.input.disabled = false;
114
524
  el.input.focus();