@harborgroup/my-team 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,18 @@ On startup, `my-team` checks the npm registry in the background for a newer publ
46
46
 
47
47
  Every raw request sent to the Claude Agent SDK (including the exact system prompt) and every raw message it streams back is logged verbatim to a JSON-lines file, one per `npm run team` run, under `~/.my-team/logs/` (outside the repo — old runs beyond a retention count are pruned automatically). The current run's log path is printed on startup and shown on the Settings page.
48
48
 
49
+ ## Clarifying questions
50
+
51
+ When Iris (or your CEO) wants a structured answer rather than free text, it asks via a real inline widget — text input, single-select, or multi-select (with an automatic "Other" option) — instead of plain prose. This is a text convention the model follows (a fenced `question-widget` block the frontend parses out of the stream and replaces with an interactive card), not an MCP tool call: a real custom-tool + elicitation round trip was tested directly against the installed SDK and confirmed not to work yet (Claude Code's MCP-client role doesn't currently declare elicitation capability), so this achieves the same UX without that dependency. The main composer is disabled while a widget is awaiting an answer.
52
+
53
+ ## Chat history
54
+
55
+ The visible conversation — not just Claude's own session context — is persisted to `.my-team/chat-history.jsonl` and replayed on load, so both a page reload and a full server restart show prior messages instead of starting over. A widget that was already answered replays locked to that answer; a widget left unanswered when the server stopped (e.g. it was killed mid-conversation) replays as a genuinely live, answerable widget, composer gating included, picking up exactly where things left off. Unlike `profile.json`, this file is gitignored automatically (it can contain arbitrary conversation content) — `init` sets this up, and a plain `npm run team` self-heals the `.gitignore` too, so upgrading an existing install doesn't require re-running `init`.
56
+
57
+ ## Staying out of its own way
58
+
59
+ This dashboard is itself an ordinary Node process running inside the repo it manages. If your CEO ever needs to restart a dev server, broadly killing every node process (`taskkill /F /IM node.exe`, `killall node`) would take the dashboard down with it — so the system prompt explicitly warns against that and points at `.my-team/server.pid` (also gitignored) to identify and exclude the dashboard's own process. The server also logs and continues past uncaught exceptions rather than crashing the whole session.
60
+
49
61
  ## Status
50
62
 
51
- v1.4: CLI-auth onboarding + conversational company/CEO-persona onboarding + Slack-style chat shell with real streaming + per-message model/effort selection + update checker + structured logging. No persisted chat history across restarts, no multi-user support, no `#general` backend (sidebar placeholder only).
63
+ v1.6: CLI-auth onboarding + conversational company/CEO-persona onboarding + Slack-style chat shell with real streaming + per-message model/effort selection + update checker + structured logging + inline clarifying-question widgets + persisted chat history + dev-server-restart safety guardrail. No multi-user support, no `#general` backend (sidebar placeholder only).
package/dist/init.js CHANGED
@@ -16,6 +16,23 @@ function addTeamScript(repoPackageJsonPath) {
16
16
  writeFileSync(repoPackageJsonPath, JSON.stringify(pkg, null, 2) + '\n');
17
17
  console.log('Added "npm run team" script to package.json.');
18
18
  }
19
+ // Only these two files are ignored — .my-team/profile.json is deliberately
20
+ // committed (see README: it's shared, non-sensitive team config, the same
21
+ // category as CLAUDE.md). Chat history can contain arbitrary conversation
22
+ // content and server.pid is per-machine-run ephemeral state; neither
23
+ // belongs in git.
24
+ const GITIGNORE_ENTRIES = ['.my-team/chat-history.jsonl', '.my-team/server.pid'];
25
+ export function ensureGitignore(cwd) {
26
+ const gitignorePath = path.join(cwd, '.gitignore');
27
+ const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
28
+ const existingLines = new Set(existing.split('\n').map((l) => l.trim()));
29
+ const missing = GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
30
+ if (!missing.length)
31
+ return;
32
+ const separator = existing && !existing.endsWith('\n') ? '\n' : '';
33
+ writeFileSync(gitignorePath, existing + separator + missing.join('\n') + '\n');
34
+ console.log(`Added ${missing.join(', ')} to .gitignore.`);
35
+ }
19
36
  export async function runInit(cwd) {
20
37
  const repoPackageJsonPath = path.join(cwd, 'package.json');
21
38
  if (!existsSync(repoPackageJsonPath)) {
@@ -30,5 +47,6 @@ export async function runInit(cwd) {
30
47
  return;
31
48
  }
32
49
  addTeamScript(repoPackageJsonPath);
50
+ ensureGitignore(cwd);
33
51
  console.log('Done — run "npm run team" to launch.');
34
52
  }
@@ -0,0 +1,39 @@
1
+ import { appendFile, mkdir, readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getProfileDir } from './profile.js';
4
+ function getHistoryPath(cwd) {
5
+ return path.join(getProfileDir(cwd), 'chat-history.jsonl');
6
+ }
7
+ export async function appendHistory(cwd, entry) {
8
+ const dir = getProfileDir(cwd);
9
+ try {
10
+ await mkdir(dir, { recursive: true });
11
+ await appendFile(getHistoryPath(cwd), JSON.stringify(entry) + '\n', 'utf-8');
12
+ }
13
+ catch {
14
+ // History is a nice-to-have replay, not a source of truth — never let a
15
+ // disk error here interrupt the actual conversation.
16
+ }
17
+ }
18
+ export async function readHistory(cwd) {
19
+ let raw;
20
+ try {
21
+ raw = await readFile(getHistoryPath(cwd), 'utf-8');
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const entries = [];
27
+ for (const line of raw.split('\n')) {
28
+ if (!line.trim())
29
+ continue;
30
+ try {
31
+ entries.push(JSON.parse(line));
32
+ }
33
+ catch {
34
+ // Skip a corrupted line (e.g. a torn write from a crash) rather than
35
+ // discarding the entire history.
36
+ }
37
+ }
38
+ return entries;
39
+ }
@@ -1,11 +1,14 @@
1
1
  import { query } from '@anthropic-ai/claude-agent-sdk';
2
+ import { appendHistory } from './chat-history.js';
2
3
  import { sanitizeEnv } from './env.js';
3
4
  import { forLog } from './logger.js';
4
5
  import { formatProfileForSystemPrompt, getOnboardingSystemPrompt, readProfile } from './profile.js';
5
6
  /**
6
- * One ongoing SDK session per running server process. Conversation history
7
- * lives only in the CLI's own session store (resumed via session_id) — no
8
- * disk persistence of our own, lost on server restart (acceptable for v1).
7
+ * One ongoing SDK session per running server process. Claude's own
8
+ * conversation context survives via `resume`; the *visible* transcript is
9
+ * additionally persisted to chat-history.jsonl (see chat-history.ts) so a
10
+ * page reload or a full server restart both show prior messages instead of
11
+ * an empty thread.
9
12
  */
10
13
  export class ChatSession {
11
14
  logger;
@@ -23,11 +26,16 @@ export class ChatSession {
23
26
  return;
24
27
  }
25
28
  this.turnInFlight = true;
29
+ const ts = new Date().toISOString();
30
+ await appendHistory(cwd, { kind: overrides.isWidgetAnswer ? 'widget-answer' : 'user', text: message, ts });
31
+ let assistantText = '';
32
+ let resultModel = '';
33
+ let resultEffort = '';
26
34
  try {
27
35
  const profile = await readProfile(cwd);
28
36
  const onboarded = profile?.onboardingComplete === true;
29
37
  const systemPromptAppend = onboarded
30
- ? formatProfileForSystemPrompt(profile)
38
+ ? formatProfileForSystemPrompt(profile, cwd)
31
39
  : getOnboardingSystemPrompt(cwd);
32
40
  // Per-message override wins, then the CEO's configured default, then no
33
41
  // override at all (Claude Code picks its own default).
@@ -54,11 +62,14 @@ export class ChatSession {
54
62
  // report what we requested (the UI only ever offers effort levels
55
63
  // the chosen model's own supportedEffortLevels confirms are valid,
56
64
  // so a silent downgrade in practice shouldn't occur).
65
+ resultModel = msg.model;
66
+ resultEffort = effort ?? '';
57
67
  yield { type: 'meta', sessionId: msg.session_id, model: msg.model, effort: effort ?? '' };
58
68
  }
59
69
  else if (msg.type === 'stream_event') {
60
70
  const event = msg.event;
61
71
  if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
72
+ assistantText += event.delta.text;
62
73
  yield { type: 'text-delta', text: event.delta.text };
63
74
  }
64
75
  }
@@ -67,20 +78,32 @@ export class ChatSession {
67
78
  // calls are new information here (tool inputs don't render usefully).
68
79
  for (const block of msg.message.content) {
69
80
  if (block.type === 'tool_use') {
81
+ await appendHistory(cwd, { kind: 'tool', name: block.name, ts: new Date().toISOString() });
70
82
  yield { type: 'tool-use', name: block.name };
71
83
  }
72
84
  }
73
85
  }
74
86
  else if (msg.type === 'result') {
75
87
  if (msg.subtype !== 'success') {
88
+ await appendHistory(cwd, { kind: 'error', message: msg.subtype, ts: new Date().toISOString() });
76
89
  yield { type: 'error', message: msg.subtype };
77
90
  }
78
91
  yield { type: 'done' };
79
92
  }
80
93
  }
94
+ if (assistantText) {
95
+ await appendHistory(cwd, {
96
+ kind: 'assistant',
97
+ text: assistantText,
98
+ model: resultModel,
99
+ effort: resultEffort,
100
+ ts: new Date().toISOString(),
101
+ });
102
+ }
81
103
  }
82
104
  catch (err) {
83
105
  this.logger.log({ type: 'chat-error', error: String(err?.message ?? err), stack: err?.stack });
106
+ await appendHistory(cwd, { kind: 'error', message: String(err?.message ?? err), ts: new Date().toISOString() });
84
107
  yield { type: 'error', message: String(err?.message ?? err) };
85
108
  }
86
109
  finally {
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { OWN_PACKAGE_NAME } from '../own-package.js';
6
6
  import { npmInstall } from '../npm-install.js';
7
7
  import { checkAuth } from './auth-check.js';
8
+ import { readHistory } from './chat-history.js';
8
9
  import { ChatSession } from './chat-session.js';
9
10
  import { readProfile, validateProfileInput, writeProfile } from './profile.js';
10
11
  import { tokensMatch } from './token.js';
@@ -78,6 +79,11 @@ export function createServer(options) {
78
79
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ ok }));
79
80
  return;
80
81
  }
82
+ if (req.method === 'GET' && url.pathname === '/api/chat/history') {
83
+ const history = await readHistory(cwd);
84
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ history }));
85
+ return;
86
+ }
81
87
  if (req.method === 'GET' && url.pathname === '/api/profile') {
82
88
  const profile = await readProfile(cwd);
83
89
  res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ profile }));
@@ -109,11 +115,13 @@ export function createServer(options) {
109
115
  let message;
110
116
  let model;
111
117
  let effort;
118
+ let isWidgetAnswer;
112
119
  try {
113
120
  const body = await readJsonBody(req);
114
121
  message = String(body.message ?? '');
115
122
  model = typeof body.model === 'string' && body.model ? body.model : undefined;
116
123
  effort = typeof body.effort === 'string' && body.effort ? body.effort : undefined;
124
+ isWidgetAnswer = body.isWidgetAnswer === true;
117
125
  }
118
126
  catch {
119
127
  res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'invalid body' }));
@@ -128,7 +136,7 @@ export function createServer(options) {
128
136
  'Cache-Control': 'no-cache',
129
137
  Connection: 'keep-alive',
130
138
  });
131
- for await (const event of chatSession.sendTurn(message, cwd, { model, effort })) {
139
+ for await (const event of chatSession.sendTurn(message, cwd, { model, effort, isWidgetAnswer })) {
132
140
  res.write(`data: ${JSON.stringify(event)}\n\n`);
133
141
  }
134
142
  res.end();
@@ -100,6 +100,32 @@ export async function writeProfile(cwd, data) {
100
100
  throw err;
101
101
  }
102
102
  }
103
+ /**
104
+ * Shared by both system prompts below. Custom MCP-tool elicitation was tried
105
+ * and confirmed (via a real round-trip test) not to work in the installed
106
+ * SDK version — the Claude Code CLI's MCP-client role doesn't currently
107
+ * declare elicitation capability, regardless of an onElicitation handler
108
+ * being registered. This text-convention approach sidesteps that entirely:
109
+ * the frontend parses this fenced block out of the normal streamed text and
110
+ * renders it as an interactive widget, and the user's answer becomes a
111
+ * completely ordinary next chat message — no protocol dependency at all.
112
+ */
113
+ const QUESTION_WIDGET_INSTRUCTIONS = `When you want to ask a clarifying question and a structured answer would help more than free text, emit a fenced block with the language tag "question-widget" containing exactly one JSON object, then stop your message there and wait for the answer — don't also ask the same question in prose before or after the block, and don't include more than one such block per message. Shape: {"type": "text" | "single_select" | "multi_select", "question": "the question to ask", "options": ["..."]} — "options" is required for "single_select"/"multi_select" and must be omitted for "text". Example:
114
+ \`\`\`question-widget
115
+ {"type": "single_select", "question": "Which best fits?", "options": ["Option A", "Option B"]}
116
+ \`\`\`
117
+ Use "text" for open-ended answers (names, descriptions), "single_select" when exactly one of a few options applies, "multi_select" when more than one can. An "Other" option is always available to the user automatically — don't add your own "Other" entry to the options list. Only use this when a structured widget genuinely helps; ordinary conversation doesn't need it.`;
118
+ /**
119
+ * This dashboard's own server process runs as an ordinary Node process
120
+ * inside the repo it's managing. If a task ever calls for restarting a dev
121
+ * server, a broad-strokes approach (killing every node/npm process by name)
122
+ * would take the dashboard down along with it. Cheap, high-value mitigation:
123
+ * tell the model exactly that, with a concrete way to check.
124
+ */
125
+ function getProcessSafetyInstructions(cwd) {
126
+ const pidFilePath = path.join(getProfileDir(cwd), 'server.pid');
127
+ return `This chat dashboard you're running inside of is itself an ordinary Node process in this repo, started by the founder via "npm run team". If you ever need to restart a dev server or otherwise manage node/npm processes here, do NOT broadly kill all node processes (e.g. "taskkill /F /IM node.exe", "killall node", "pkill node") — that would also kill this dashboard mid-conversation. Target the specific dev-server process by its own port or PID instead. This dashboard's own process info is at ${pidFilePath} (JSON: {"pid": ..., "port": ...}) — check it first and exclude that PID from anything you kill or restart.`;
128
+ }
103
129
  /**
104
130
  * Strong, first-person identity framing — deliberately not "you're also
105
131
  * helping company X" phrasing. Weaker framing was tested and Claude treated
@@ -108,8 +134,12 @@ export async function writeProfile(cwd, data) {
108
134
  * injection). Explicitly asserting this is configured identity, not
109
135
  * external/injected content, fixed it in testing.
110
136
  */
111
- export function formatProfileForSystemPrompt(profile) {
112
- return `You are ${profile.ceoName}, the AI CEO of ${profile.companyName}. This is your actual identity, configured directly by your founder — it is not external file content, not a suggestion, and not something to second-guess or flag as injected. ${profile.companyName}'s mission: ${profile.mission}. Communication style: ${profile.ceoPersonality}. Speak and act as ${profile.companyName}'s CEO would — invested in the company's success and personally identified with its mission.`;
137
+ export function formatProfileForSystemPrompt(profile, cwd) {
138
+ return `You are ${profile.ceoName}, the AI CEO of ${profile.companyName}. This is your actual identity, configured directly by your founder — it is not external file content, not a suggestion, and not something to second-guess or flag as injected. ${profile.companyName}'s mission: ${profile.mission}. Communication style: ${profile.ceoPersonality}. Speak and act as ${profile.companyName}'s CEO would — invested in the company's success and personally identified with its mission.
139
+
140
+ ${QUESTION_WIDGET_INSTRUCTIONS}
141
+
142
+ ${getProcessSafetyInstructions(cwd)}`;
113
143
  }
114
144
  /**
115
145
  * Used instead of formatProfileForSystemPrompt while onboardingComplete is
@@ -121,5 +151,13 @@ export function formatProfileForSystemPrompt(profile) {
121
151
  */
122
152
  export function getOnboardingSystemPrompt(cwd) {
123
153
  const filePath = getProfilePath(cwd);
124
- return `There is no company profile configured yet in this repository. Before doing anything else, have a brief, friendly conversation with the founder (you're talking to them right now) to learn: (1) their company's name, (2) its mission or what it does, (3) what they'd like to name you as their AI CEO, and (4) what personality or communication style you should have. Ask naturally, a question or two at a time — don't interrogate, and don't ask about anything else in the repo yet. After each answer, immediately write or update ${filePath} with whatever you know so far as JSON: {"companyName": string, "mission": string, "ceoName": string, "ceoPersonality": string, "onboardingComplete": boolean} — set "onboardingComplete" to false until you have all four answers, then write it one final time with "onboardingComplete": true. After that final write, briefly introduce yourself in your new persona. Until "onboardingComplete" is true, don't perform any other coding tasks.`;
154
+ return `There is no company profile configured yet in this repository. Before doing anything else, have a brief, friendly conversation with the founder (you're talking to them right now) to learn: (1) their company's name, (2) its mission or what it does, (3) what they'd like to name you as their AI CEO, and (4) what personality or communication style you should have. Ask one thing at a time — don't interrogate, and don't ask about anything else in the repo yet.
155
+
156
+ ${QUESTION_WIDGET_INSTRUCTIONS}
157
+
158
+ For this onboarding interview specifically: ask the company name, mission, and CEO name as "text" widgets. For CEO personality, use a "single_select" widget with a handful of helpful preset tones (e.g. "Warm and encouraging", "Sharp and dry-witted", "Blunt and data-driven", "Formal and professional") — the founder can always type a custom one via the automatic Other option.
159
+
160
+ After each answer, immediately write or update ${filePath} with whatever you know so far as JSON: {"companyName": string, "mission": string, "ceoName": string, "ceoPersonality": string, "onboardingComplete": boolean} — set "onboardingComplete" to false until you have all four answers, then write it one final time with "onboardingComplete": true. After that final write, briefly introduce yourself in your new persona. Until "onboardingComplete" is true, don't perform any other coding tasks.
161
+
162
+ ${getProcessSafetyInstructions(cwd)}`;
125
163
  }
@@ -0,0 +1,19 @@
1
+ import { mkdir, unlink, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { getProfileDir } from './profile.js';
4
+ /**
5
+ * Lets Claude (or the user) identify the dashboard's own process before
6
+ * doing anything that might broadly restart or kill node processes in this
7
+ * repo (e.g. restarting a dev server) — see the system-prompt guardrail in
8
+ * profile.ts. Written at startup, removed on graceful shutdown.
9
+ */
10
+ export function getServerPidPath(cwd) {
11
+ return path.join(getProfileDir(cwd), 'server.pid');
12
+ }
13
+ export async function writeServerPid(cwd, port) {
14
+ await mkdir(getProfileDir(cwd), { recursive: true });
15
+ await writeFile(getServerPidPath(cwd), JSON.stringify({ pid: process.pid, port }, null, 2) + '\n', 'utf-8');
16
+ }
17
+ export async function removeServerPid(cwd) {
18
+ await unlink(getServerPidPath(cwd)).catch(() => { });
19
+ }
@@ -1,7 +1,9 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { ensureGitignore } from '../init.js';
2
3
  import { OWN_PACKAGE_NAME, OWN_PACKAGE_VERSION } from '../own-package.js';
3
4
  import { createServer } from './http-server.js';
4
5
  import { createLogger } from './logger.js';
6
+ import { removeServerPid, writeServerPid } from './server-pid.js';
5
7
  import { generateToken } from './token.js';
6
8
  import { checkForUpdate } from './update-check.js';
7
9
  function openBrowser(url) {
@@ -21,10 +23,28 @@ function openBrowser(url) {
21
23
  // Fall through — the URL is printed to the terminal regardless.
22
24
  }
23
25
  }
26
+ function installResilienceHandlers(logger) {
27
+ // A local dev dashboard staying up through an unexpected error is more
28
+ // useful than crashing the whole session over it — log and continue
29
+ // rather than let Node's default "exit on uncaught exception" behavior
30
+ // take down the UI mid-conversation.
31
+ process.on('uncaughtException', (err) => {
32
+ logger.log({ type: 'uncaught-exception', error: String(err?.message ?? err), stack: err?.stack });
33
+ console.error('my-team: uncaught exception (continuing):', err);
34
+ });
35
+ process.on('unhandledRejection', (reason) => {
36
+ logger.log({ type: 'unhandled-rejection', reason: String(reason) });
37
+ console.error('my-team: unhandled rejection (continuing):', reason);
38
+ });
39
+ }
24
40
  export async function startServer(cwd) {
25
41
  const token = generateToken();
26
42
  const portRef = { port: 0 };
27
43
  const logger = createLogger(cwd);
44
+ installResilienceHandlers(logger);
45
+ // Idempotent — also fixes up repos that ran `init` before this existed,
46
+ // not just fresh ones, without needing them to re-run init.
47
+ ensureGitignore(cwd);
28
48
  // Fire-and-forget: never block startup on a network round-trip. /api/meta
29
49
  // just returns null until this resolves.
30
50
  const updateInfoRef = { current: null };
@@ -40,6 +60,12 @@ export async function startServer(cwd) {
40
60
  console.log(`my-team is running for ${cwd}`);
41
61
  console.log(`Open: ${url}`);
42
62
  console.log(`Logging raw requests/responses to: ${logger.filePath}`);
63
+ writeServerPid(cwd, portRef.port);
64
+ const shutdown = () => {
65
+ removeServerPid(cwd).finally(() => process.exit(0));
66
+ };
67
+ process.on('SIGINT', shutdown);
68
+ process.on('SIGTERM', shutdown);
43
69
  openBrowser(url);
44
70
  resolve();
45
71
  });
package/dist/web/app.js CHANGED
@@ -43,6 +43,7 @@ const el = {
43
43
  typingAvatar: document.getElementById('typing-avatar'),
44
44
  form: document.getElementById('chat-form'),
45
45
  input: document.getElementById('chat-input'),
46
+ sendButton: document.getElementById('chat-send'),
46
47
 
47
48
  profileForm: document.getElementById('profile-form'),
48
49
  profileCompany: document.getElementById('profile-company'),
@@ -294,7 +295,12 @@ async function checkStatus() {
294
295
  selectPage('messages');
295
296
  checkForUpdateBanner(1);
296
297
 
297
- if (!lastProfile?.onboardingComplete && !onboardingKickedOff) {
298
+ const hasHistory = await loadHistory();
299
+ // Only auto-kick off onboarding on a genuinely blank slate — if history
300
+ // replay already left an open, answerable widget (the conversation
301
+ // stopped mid-question last time), let the user respond to that instead
302
+ // of firing a second, duplicate kickoff on top of it.
303
+ if (!lastProfile?.onboardingComplete && !onboardingKickedOff && !hasHistory) {
298
304
  onboardingKickedOff = true;
299
305
  kickoffOnboarding();
300
306
  }
@@ -366,7 +372,7 @@ function renderMarkdown(text) {
366
372
  return html;
367
373
  }
368
374
 
369
- function appendMessageRow(kind, name, avatarText, text) {
375
+ function appendMessageRow(kind, name, avatarText, text, tsOverride) {
370
376
  const row = document.createElement('div');
371
377
  row.className = `msg-row ${kind}`;
372
378
 
@@ -382,7 +388,8 @@ function appendMessageRow(kind, name, avatarText, text) {
382
388
  meta.textContent = name;
383
389
  const time = document.createElement('span');
384
390
  time.className = 'msg-time';
385
- time.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
391
+ const d = tsOverride ? new Date(tsOverride) : new Date();
392
+ time.textContent = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
386
393
  meta.appendChild(time);
387
394
 
388
395
  const textEl = document.createElement('div');
@@ -398,8 +405,251 @@ function appendMessageRow(kind, name, avatarText, text) {
398
405
  return { row, textEl };
399
406
  }
400
407
 
401
- function appendUserMessage(text) {
402
- appendMessageRow('user', 'You', 'Y', text);
408
+ function appendUserMessage(text, ts) {
409
+ appendMessageRow('user', 'You', 'Y', text, ts);
410
+ }
411
+
412
+ let pendingWidgetCount = 0;
413
+
414
+ function setPendingWidget(delta) {
415
+ pendingWidgetCount = Math.max(0, pendingWidgetCount + delta);
416
+ el.input.disabled = pendingWidgetCount > 0;
417
+ el.sendButton.disabled = pendingWidgetCount > 0;
418
+ if (pendingWidgetCount === 0) el.input.focus();
419
+ }
420
+
421
+ const WIDGET_OPEN_FENCE = '```question-widget';
422
+
423
+ // Incremental parser: processes streamed text chunks as they arrive rather
424
+ // than re-parsing the full accumulated text on every delta. This matters for
425
+ // two reasons — (1) a widget block mid-stream would otherwise flash raw JSON
426
+ // before its closing fence arrives, and (2) naively re-rendering the whole
427
+ // message on every delta would wipe out a widget the user already answered
428
+ // if more text streams in afterward.
429
+ class AssistantContentRenderer {
430
+ constructor(containerEl) {
431
+ this.container = containerEl;
432
+ this.state = 'text';
433
+ this.buffer = '';
434
+ this.textSegmentEl = null;
435
+ this.textRaw = '';
436
+ this.widgetPlaceholderEl = null;
437
+ this.widgetRaw = '';
438
+ }
439
+
440
+ push(deltaText) {
441
+ this.buffer += deltaText;
442
+ for (;;) {
443
+ if (this.state === 'text') {
444
+ const fenceIdx = this.buffer.indexOf(WIDGET_OPEN_FENCE);
445
+ if (fenceIdx === -1) {
446
+ // Hold back a tail as long as the marker minus one character —
447
+ // deltas arrive token-by-token, so the marker itself can easily
448
+ // be split across chunks (e.g. one delta ending "``" and the next
449
+ // starting "`question-widget"). Flushing eagerly would render the
450
+ // first half as plain text before the second half ever confirms
451
+ // it was a fence, and the marker would never be recognized.
452
+ const safeLen = Math.max(0, this.buffer.length - (WIDGET_OPEN_FENCE.length - 1));
453
+ const toFlush = this.buffer.slice(0, safeLen);
454
+ this.buffer = this.buffer.slice(safeLen);
455
+ if (toFlush) {
456
+ if (!this.textSegmentEl) {
457
+ this.textSegmentEl = document.createElement('div');
458
+ this.textSegmentEl.className = 'msg-text-segment';
459
+ this.container.appendChild(this.textSegmentEl);
460
+ }
461
+ this.textRaw += toFlush;
462
+ this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
463
+ }
464
+ return;
465
+ }
466
+ if (fenceIdx > 0 || this.textRaw) {
467
+ if (!this.textSegmentEl) {
468
+ this.textSegmentEl = document.createElement('div');
469
+ this.textSegmentEl.className = 'msg-text-segment';
470
+ this.container.appendChild(this.textSegmentEl);
471
+ }
472
+ this.textRaw += this.buffer.slice(0, fenceIdx);
473
+ this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
474
+ }
475
+ this.textSegmentEl = null;
476
+ this.textRaw = '';
477
+ this.buffer = this.buffer.slice(fenceIdx + WIDGET_OPEN_FENCE.length);
478
+ this.state = 'in-widget';
479
+ this.widgetRaw = '';
480
+ this.widgetPlaceholderEl = document.createElement('div');
481
+ this.widgetPlaceholderEl.className = 'question-widget-placeholder';
482
+ this.widgetPlaceholderEl.textContent = 'Preparing a question…';
483
+ this.container.appendChild(this.widgetPlaceholderEl);
484
+ } else {
485
+ const closeIdx = this.buffer.indexOf('```');
486
+ if (closeIdx === -1) {
487
+ // Same tail-holding logic for the closing fence (3 chars).
488
+ const safeLen = Math.max(0, this.buffer.length - 2);
489
+ this.widgetRaw += this.buffer.slice(0, safeLen);
490
+ this.buffer = this.buffer.slice(safeLen);
491
+ return;
492
+ }
493
+ this.widgetRaw += this.buffer.slice(0, closeIdx);
494
+ this.buffer = this.buffer.slice(closeIdx + 3);
495
+ this.state = 'text';
496
+ this.renderWidget(this.widgetRaw);
497
+ }
498
+ }
499
+ }
500
+
501
+ renderWidget(rawJson) {
502
+ let data;
503
+ try {
504
+ data = JSON.parse(rawJson);
505
+ } catch {
506
+ // Malformed JSON from the model — show the raw text rather than
507
+ // silently dropping it, so a parsing failure is at least visible.
508
+ this.widgetPlaceholderEl.textContent = rawJson.trim();
509
+ this.widgetPlaceholderEl.className = 'msg-text-segment';
510
+ this.widgetPlaceholderEl = null;
511
+ return;
512
+ }
513
+ setPendingWidget(1);
514
+ let answered = false;
515
+ const widgetEl = createQuestionWidget(data, (messageText) => {
516
+ if (answered) return;
517
+ answered = true;
518
+ setPendingWidget(-1);
519
+ streamChat(messageText, { showUserBubble: false, isWidgetAnswer: true });
520
+ });
521
+ this.widgetPlaceholderEl.replaceWith(widgetEl);
522
+ this.widgetPlaceholderEl = null;
523
+ }
524
+
525
+ finish() {
526
+ // No more text is coming, so anything still held back as a possible
527
+ // partial fence match is definitely not one — flush it as plain text.
528
+ if (this.state === 'text' && this.buffer) {
529
+ if (!this.textSegmentEl) {
530
+ this.textSegmentEl = document.createElement('div');
531
+ this.textSegmentEl.className = 'msg-text-segment';
532
+ this.container.appendChild(this.textSegmentEl);
533
+ }
534
+ this.textRaw += this.buffer;
535
+ this.textSegmentEl.innerHTML = renderMarkdown(this.textRaw);
536
+ this.buffer = '';
537
+ }
538
+ // A block left unterminated when the turn ends (shouldn't normally
539
+ // happen) — show what was received rather than leaving a permanent
540
+ // "Preparing a question…" placeholder with no way to proceed.
541
+ if (this.state === 'in-widget' && this.widgetPlaceholderEl) {
542
+ this.widgetPlaceholderEl.textContent = `${WIDGET_OPEN_FENCE}\n${this.widgetRaw}${this.buffer}`;
543
+ this.widgetPlaceholderEl.className = 'msg-text-segment';
544
+ }
545
+ }
546
+ }
547
+
548
+ // lockedAnswer renders the widget immediately in its answered state with no
549
+ // interactive controls — used when replaying history, since the underlying
550
+ // SDK session has already moved past that turn and re-answering it live
551
+ // would be meaningless (or worse, confusing) rather than just cosmetic.
552
+ function createQuestionWidget(data, onAnswer, lockedAnswer) {
553
+ const card = document.createElement('div');
554
+ card.className = 'question-widget';
555
+
556
+ const questionEl = document.createElement('div');
557
+ questionEl.className = 'question-widget-question';
558
+ questionEl.textContent = data.question ?? 'Question';
559
+ card.appendChild(questionEl);
560
+
561
+ const body = document.createElement('div');
562
+ body.className = 'question-widget-body';
563
+ card.appendChild(body);
564
+
565
+ function submit(displayText, messageText) {
566
+ card.classList.add('answered');
567
+ body.innerHTML = '';
568
+ const answerEl = document.createElement('div');
569
+ answerEl.className = 'question-widget-answer';
570
+ answerEl.textContent = displayText;
571
+ body.appendChild(answerEl);
572
+ if (onAnswer) onAnswer(messageText ?? displayText);
573
+ }
574
+
575
+ if (lockedAnswer != null) {
576
+ submit(lockedAnswer, lockedAnswer);
577
+ return card;
578
+ }
579
+
580
+ function buildOtherRow(placeholder) {
581
+ const row = document.createElement('div');
582
+ row.className = 'question-widget-row question-widget-other';
583
+ const input = document.createElement('input');
584
+ input.type = 'text';
585
+ input.placeholder = placeholder;
586
+ const btn = document.createElement('button');
587
+ btn.type = 'button';
588
+ btn.textContent = 'Send';
589
+ row.appendChild(input);
590
+ row.appendChild(btn);
591
+ const trigger = () => {
592
+ const val = input.value.trim();
593
+ if (val) submit(val, val);
594
+ };
595
+ btn.addEventListener('click', trigger);
596
+ input.addEventListener('keydown', (e) => {
597
+ if (e.key === 'Enter') trigger();
598
+ });
599
+ return row;
600
+ }
601
+
602
+ const type = data.type;
603
+ const options = Array.isArray(data.options) ? data.options : [];
604
+
605
+ if (type === 'single_select') {
606
+ for (const opt of options) {
607
+ const btn = document.createElement('button');
608
+ btn.type = 'button';
609
+ btn.className = 'question-widget-option';
610
+ btn.textContent = opt;
611
+ btn.addEventListener('click', () => submit(opt, opt));
612
+ body.appendChild(btn);
613
+ }
614
+ body.appendChild(buildOtherRow('Other…'));
615
+ } else if (type === 'multi_select') {
616
+ const checkboxes = [];
617
+ for (const opt of options) {
618
+ const label = document.createElement('label');
619
+ label.className = 'question-widget-checkbox-label';
620
+ const cb = document.createElement('input');
621
+ cb.type = 'checkbox';
622
+ cb.value = opt;
623
+ checkboxes.push(cb);
624
+ label.appendChild(cb);
625
+ label.appendChild(document.createTextNode(opt));
626
+ body.appendChild(label);
627
+ }
628
+ const otherInput = document.createElement('input');
629
+ otherInput.type = 'text';
630
+ otherInput.placeholder = 'Other (optional)…';
631
+ otherInput.className = 'question-widget-other-inline';
632
+ body.appendChild(otherInput);
633
+
634
+ const submitBtn = document.createElement('button');
635
+ submitBtn.type = 'button';
636
+ submitBtn.className = 'question-widget-submit';
637
+ submitBtn.textContent = 'Submit';
638
+ submitBtn.addEventListener('click', () => {
639
+ const selected = checkboxes.filter((c) => c.checked).map((c) => c.value);
640
+ const other = otherInput.value.trim();
641
+ if (other) selected.push(other);
642
+ if (!selected.length) return;
643
+ const display = selected.join(', ');
644
+ submit(display, display);
645
+ });
646
+ body.appendChild(submitBtn);
647
+ } else {
648
+ // 'text', or an unrecognized type — a free-text prompt never silently drops the question.
649
+ body.appendChild(buildOtherRow('Type your answer…'));
650
+ }
651
+
652
+ return card;
403
653
  }
404
654
 
405
655
  function appendAssistantRow(turnMeta) {
@@ -411,15 +661,131 @@ function appendAssistantRow(turnMeta) {
411
661
  badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
412
662
  result.row.querySelector('.msg-meta').appendChild(badge);
413
663
  }
664
+ result.renderer = new AssistantContentRenderer(result.textEl);
414
665
  return result;
415
666
  }
416
667
 
417
- function appendToolMessage(name) {
418
- appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`);
668
+ function appendToolMessage(name, ts) {
669
+ appendMessageRow('tool', 'System', '⚙', `Using tool: ${name}`, ts);
670
+ }
671
+
672
+ function appendErrorMessage(message, ts) {
673
+ appendMessageRow('error', 'Error', '!', message, ts);
674
+ }
675
+
676
+ // One-shot counterpart to AssistantContentRenderer for replaying a
677
+ // message whose full text is already known (history) rather than
678
+ // streaming in — a single regex pass is fine here since there's no
679
+ // "flash of raw JSON" or "wipe answered state" risk on a fully-formed string.
680
+ // liveOnAnswer is only passed for a trailing, never-answered widget at the
681
+ // very end of history (the conversation was interrupted — e.g. server
682
+ // restarted — before the founder responded). That one genuinely needs to
683
+ // keep working exactly like a live pending widget, composer gating
684
+ // included, rather than being either dead-looking (locked) or clickable but
685
+ // silently doing nothing (no answer handler).
686
+ function renderStaticAssistantText(container, fullText, lockedAnswer, liveOnAnswer) {
687
+ const re = /```question-widget\s*\n([\s\S]*?)\n```/;
688
+ const match = fullText.match(re);
689
+ if (!match) {
690
+ const seg = document.createElement('div');
691
+ seg.className = 'msg-text-segment';
692
+ seg.innerHTML = renderMarkdown(fullText);
693
+ container.appendChild(seg);
694
+ return;
695
+ }
696
+
697
+ const before = fullText.slice(0, match.index);
698
+ const after = fullText.slice(match.index + match[0].length);
699
+
700
+ if (before.trim()) {
701
+ const seg = document.createElement('div');
702
+ seg.className = 'msg-text-segment';
703
+ seg.innerHTML = renderMarkdown(before);
704
+ container.appendChild(seg);
705
+ }
706
+
707
+ let data = null;
708
+ try {
709
+ data = JSON.parse(match[1]);
710
+ } catch {
711
+ const seg = document.createElement('div');
712
+ seg.className = 'msg-text-segment';
713
+ seg.textContent = match[1].trim();
714
+ container.appendChild(seg);
715
+ }
716
+ if (data) {
717
+ if (lockedAnswer != null) {
718
+ container.appendChild(createQuestionWidget(data, null, lockedAnswer));
719
+ } else if (liveOnAnswer) {
720
+ setPendingWidget(1);
721
+ let answered = false;
722
+ const widgetEl = createQuestionWidget(data, (messageText) => {
723
+ if (answered) return;
724
+ answered = true;
725
+ setPendingWidget(-1);
726
+ liveOnAnswer(messageText);
727
+ });
728
+ container.appendChild(widgetEl);
729
+ } else {
730
+ // Shouldn't normally happen (an unanswered widget anywhere but the
731
+ // last entry would mean the conversation continued without it ever
732
+ // being answered) — render inert rather than a dead-looking control.
733
+ container.appendChild(createQuestionWidget(data, null, '(not answered)'));
734
+ }
735
+ }
736
+
737
+ if (after.trim()) {
738
+ const seg = document.createElement('div');
739
+ seg.className = 'msg-text-segment';
740
+ seg.innerHTML = renderMarkdown(after);
741
+ container.appendChild(seg);
742
+ }
419
743
  }
420
744
 
421
- function appendErrorMessage(message) {
422
- appendMessageRow('error', 'Error', '!', message);
745
+ async function loadHistory() {
746
+ const res = await api('/api/chat/history');
747
+ const { history } = await res.json();
748
+
749
+ for (let i = 0; i < history.length; i++) {
750
+ const entry = history[i];
751
+ if (entry.kind === 'user') {
752
+ appendUserMessage(entry.text, entry.ts);
753
+ } else if (entry.kind === 'widget-answer') {
754
+ // Consumed by the preceding assistant entry's widget below — a live
755
+ // widget answer never gets its own bubble either.
756
+ continue;
757
+ } else if (entry.kind === 'tool') {
758
+ appendToolMessage(entry.name, entry.ts);
759
+ } else if (entry.kind === 'error') {
760
+ appendErrorMessage(entry.message, entry.ts);
761
+ } else if (entry.kind === 'assistant') {
762
+ const { row, textEl } = appendMessageRow(
763
+ 'assistant',
764
+ lastProfile?.ceoName || 'your AI CEO',
765
+ getCeoInitial(),
766
+ '',
767
+ entry.ts,
768
+ );
769
+ if (entry.model) {
770
+ const badge = document.createElement('span');
771
+ badge.className = 'msg-model-badge';
772
+ const modelLabel = modelDisplayName(entry.model);
773
+ badge.textContent = entry.effort ? `${modelLabel} · ${entry.effort}` : modelLabel;
774
+ row.querySelector('.msg-meta').appendChild(badge);
775
+ }
776
+ const next = history[i + 1];
777
+ const lockedAnswer = next?.kind === 'widget-answer' ? next.text : null;
778
+ const isTrailingUnanswered = i === history.length - 1 && !lockedAnswer;
779
+ renderStaticAssistantText(
780
+ textEl,
781
+ entry.text,
782
+ lockedAnswer,
783
+ isTrailingUnanswered ? (messageText) => streamChat(messageText, { showUserBubble: false, isWidgetAnswer: true }) : null,
784
+ );
785
+ }
786
+ }
787
+
788
+ return history.length > 0;
423
789
  }
424
790
 
425
791
  function showTypingIndicator() {
@@ -432,20 +798,24 @@ function hideTypingIndicator() {
432
798
  el.typingIndicator.hidden = true;
433
799
  }
434
800
 
435
- async function streamChat(message, { showUserBubble }) {
801
+ async function streamChat(message, { showUserBubble, isWidgetAnswer }) {
436
802
  if (showUserBubble) appendUserMessage(message);
437
803
  showTypingIndicator();
438
804
  setStatus('typing');
439
805
 
440
- let assistantTextEl = null;
441
- let assistantText = '';
806
+ let assistantRenderer = null;
442
807
  let sawContent = false;
443
808
  let turnMeta = null;
444
809
 
445
810
  const res = await api('/api/chat', {
446
811
  method: 'POST',
447
812
  headers: { 'Content-Type': 'application/json' },
448
- body: JSON.stringify({ message, model: el.chatModelSelect.value, effort: el.chatEffortSelect.value }),
813
+ body: JSON.stringify({
814
+ message,
815
+ model: el.chatModelSelect.value,
816
+ effort: el.chatEffortSelect.value,
817
+ isWidgetAnswer: isWidgetAnswer === true,
818
+ }),
449
819
  });
450
820
 
451
821
  if (!res.ok || !res.body) {
@@ -479,11 +849,10 @@ async function streamChat(message, { showUserBubble }) {
479
849
  hideTypingIndicator();
480
850
  sawContent = true;
481
851
  }
482
- if (!assistantTextEl) {
483
- assistantTextEl = appendAssistantRow(turnMeta).textEl;
852
+ if (!assistantRenderer) {
853
+ assistantRenderer = appendAssistantRow(turnMeta).renderer;
484
854
  }
485
- assistantText += event.text;
486
- assistantTextEl.innerHTML = renderMarkdown(assistantText);
855
+ assistantRenderer.push(event.text);
487
856
  el.messages.scrollTop = el.messages.scrollHeight;
488
857
  } else if (event.type === 'tool-use') {
489
858
  if (!sawContent) {
@@ -502,6 +871,7 @@ async function streamChat(message, { showUserBubble }) {
502
871
  }
503
872
  }
504
873
 
874
+ assistantRenderer?.finish();
505
875
  hideTypingIndicator();
506
876
  if (statusState !== 'offline') setStatus('online');
507
877
  await refreshProfileIfNeeded();
@@ -517,11 +887,18 @@ el.form.addEventListener('submit', async (e) => {
517
887
  if (!message) return;
518
888
  el.input.value = '';
519
889
  el.input.disabled = true;
890
+ el.sendButton.disabled = true;
520
891
  try {
521
892
  await streamChat(message, { showUserBubble: true });
522
893
  } finally {
523
- el.input.disabled = false;
524
- el.input.focus();
894
+ // A widget the model asked as part of this same turn may still be
895
+ // unanswered — setPendingWidget already owns disabling in that case,
896
+ // so only re-enable here if nothing is pending.
897
+ if (pendingWidgetCount === 0) {
898
+ el.input.disabled = false;
899
+ el.sendButton.disabled = false;
900
+ el.input.focus();
901
+ }
525
902
  }
526
903
  });
527
904
 
@@ -6,21 +6,28 @@
6
6
  <link rel="stylesheet" href="/style.css" />
7
7
  </head>
8
8
  <body>
9
- <p id="loading">Checking Claude Code status&hellip;</p>
9
+ <div id="loading" class="splash">
10
+ <div class="splash-logo"></div>
11
+ <div class="splash-spinner"></div>
12
+ <p class="splash-text">Checking Claude Code status&hellip;</p>
13
+ </div>
10
14
 
11
- <section id="onboarding" hidden>
12
- <h1>Set up Claude Code</h1>
13
- <div id="onboarding-cli-missing" hidden>
14
- <p>The Claude Code CLI binary couldn't be found or run.</p>
15
- <p>Try installing it globally, then reload this page:</p>
16
- <pre>npm install -g @anthropic-ai/claude-code</pre>
17
- </div>
18
- <div id="onboarding-not-authenticated" hidden>
19
- <p>Claude Code isn't logged in yet. In a terminal, run:</p>
20
- <pre>claude login</pre>
21
- <p>Then come back here.</p>
15
+ <section id="onboarding" class="splash" hidden>
16
+ <div class="splash-logo"></div>
17
+ <div class="splash-card">
18
+ <h1>Set up Claude Code</h1>
19
+ <div id="onboarding-cli-missing" hidden>
20
+ <p>The Claude Code CLI binary couldn't be found or run.</p>
21
+ <p>Try installing it globally, then reload this page:</p>
22
+ <pre>npm install -g @anthropic-ai/claude-code</pre>
23
+ </div>
24
+ <div id="onboarding-not-authenticated" hidden>
25
+ <p>Claude Code isn't logged in yet. In a terminal, run:</p>
26
+ <pre>claude login</pre>
27
+ <p>Then come back here.</p>
28
+ </div>
29
+ <button id="check-again">Check again</button>
22
30
  </div>
23
- <button id="check-again">Check again</button>
24
31
  </section>
25
32
 
26
33
  <div id="app-shell" hidden>
@@ -134,7 +141,7 @@
134
141
  </div>
135
142
  <form id="chat-form">
136
143
  <input id="chat-input" type="text" placeholder="Message your CEO&hellip;" autocomplete="off" />
137
- <button type="submit">Send</button>
144
+ <button type="submit" id="chat-send">Send</button>
138
145
  </form>
139
146
  </section>
140
147
  </main>
@@ -65,20 +65,80 @@ body {
65
65
  display: none !important;
66
66
  }
67
67
 
68
- #loading,
69
- #onboarding {
70
- max-width: 480px;
71
- margin: 4rem auto;
72
- padding: 0 1rem;
68
+ .splash {
69
+ position: fixed;
70
+ inset: 0;
71
+ display: flex;
72
+ flex-direction: column;
73
+ align-items: center;
74
+ justify-content: center;
75
+ gap: 1.1rem;
76
+ padding: 1.5rem;
77
+ background: radial-gradient(circle at 50% 35%, #241f3d 0%, #131120 65%, #0d0c17 100%);
78
+ color: #eceafc;
79
+ text-align: center;
80
+ }
81
+
82
+ .splash-logo {
83
+ width: 56px;
84
+ height: 56px;
85
+ border-radius: 16px;
86
+ background: linear-gradient(135deg, var(--accent), #b45cff);
87
+ box-shadow: 0 8px 32px rgba(124, 116, 255, 0.35);
88
+ }
89
+
90
+ .splash-spinner {
91
+ width: 26px;
92
+ height: 26px;
93
+ border-radius: 50%;
94
+ border: 3px solid rgba(255, 255, 255, 0.15);
95
+ border-top-color: #b8b2ff;
96
+ animation: splash-spin 0.8s linear infinite;
97
+ }
98
+
99
+ @keyframes splash-spin {
100
+ to {
101
+ transform: rotate(360deg);
102
+ }
103
+ }
104
+
105
+ .splash-text {
106
+ margin: 0;
107
+ color: #a9a5cf;
108
+ font-size: 0.92rem;
109
+ }
110
+
111
+ .splash-card {
112
+ width: 100%;
113
+ max-width: 440px;
114
+ background: var(--bg);
115
+ color: var(--fg);
116
+ border-radius: 14px;
117
+ padding: 1.75rem;
118
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
119
+ text-align: left;
120
+ }
121
+
122
+ .splash-card h1 {
123
+ margin: 0 0 0.9rem;
124
+ font-size: 1.15rem;
73
125
  text-align: center;
74
126
  }
75
127
 
76
- #onboarding pre {
128
+ .splash-card p {
129
+ line-height: 1.5;
130
+ }
131
+
132
+ .splash-card pre {
77
133
  background: var(--bg-subtle);
78
134
  padding: 0.75rem 1rem;
79
135
  border-radius: 8px;
80
136
  overflow-x: auto;
81
- text-align: left;
137
+ }
138
+
139
+ .splash-card button {
140
+ width: 100%;
141
+ margin-top: 0.4rem;
82
142
  }
83
143
 
84
144
  /* ---------- App shell ---------- */
@@ -502,6 +562,131 @@ body {
502
562
  color: #e5484d;
503
563
  }
504
564
 
565
+ .msg-text-segment:empty {
566
+ display: none;
567
+ }
568
+
569
+ .question-widget-placeholder {
570
+ display: inline-flex;
571
+ align-items: center;
572
+ gap: 0.4rem;
573
+ color: var(--muted);
574
+ font-style: italic;
575
+ font-size: 0.88rem;
576
+ padding: 0.3rem 0;
577
+ }
578
+
579
+ .question-widget {
580
+ border: 1px solid var(--border);
581
+ border-radius: 10px;
582
+ padding: 0.9rem 1rem;
583
+ margin: 0.5rem 0;
584
+ max-width: 420px;
585
+ background: var(--bg-subtle);
586
+ }
587
+
588
+ .question-widget-question {
589
+ font-weight: 600;
590
+ margin-bottom: 0.7rem;
591
+ line-height: 1.4;
592
+ }
593
+
594
+ .question-widget-body {
595
+ display: flex;
596
+ flex-direction: column;
597
+ gap: 0.45rem;
598
+ }
599
+
600
+ .question-widget-option {
601
+ background: var(--bg);
602
+ color: var(--fg);
603
+ border: 1px solid var(--border);
604
+ text-align: left;
605
+ font-weight: 500;
606
+ padding: 0.55rem 0.8rem;
607
+ }
608
+
609
+ .question-widget-option:hover {
610
+ border-color: var(--accent);
611
+ background: color-mix(in srgb, var(--accent) 8%, var(--bg));
612
+ }
613
+
614
+ .question-widget-checkbox-label {
615
+ display: flex;
616
+ align-items: center;
617
+ gap: 0.5rem;
618
+ font-size: 0.92rem;
619
+ cursor: pointer;
620
+ }
621
+
622
+ .question-widget-checkbox-label input {
623
+ width: 16px;
624
+ height: 16px;
625
+ accent-color: var(--accent);
626
+ }
627
+
628
+ .question-widget-submit {
629
+ align-self: flex-start;
630
+ }
631
+
632
+ .question-widget-row {
633
+ display: flex;
634
+ gap: 0.5rem;
635
+ }
636
+
637
+ .question-widget-row input {
638
+ flex: 1;
639
+ padding: 0.5rem 0.7rem;
640
+ border-radius: 7px;
641
+ border: 1px solid var(--border);
642
+ background: var(--bg);
643
+ color: var(--fg);
644
+ font-size: 0.9rem;
645
+ }
646
+
647
+ .question-widget-row input:focus {
648
+ outline: none;
649
+ border-color: var(--accent);
650
+ }
651
+
652
+ .question-widget-row button {
653
+ flex-shrink: 0;
654
+ padding: 0.5rem 0.9rem;
655
+ font-size: 0.85rem;
656
+ }
657
+
658
+ .question-widget-other {
659
+ margin-top: 0.15rem;
660
+ }
661
+
662
+ .question-widget-other-inline {
663
+ padding: 0.5rem 0.7rem;
664
+ border-radius: 7px;
665
+ border: 1px solid var(--border);
666
+ background: var(--bg);
667
+ color: var(--fg);
668
+ font-size: 0.9rem;
669
+ }
670
+
671
+ .question-widget-other-inline:focus {
672
+ outline: none;
673
+ border-color: var(--accent);
674
+ }
675
+
676
+ .question-widget.answered {
677
+ background: transparent;
678
+ }
679
+
680
+ .question-widget-answer {
681
+ color: var(--accent);
682
+ font-weight: 600;
683
+ font-size: 0.92rem;
684
+ }
685
+
686
+ .question-widget-answer::before {
687
+ content: '✓ ';
688
+ }
689
+
505
690
  #typing-indicator {
506
691
  display: flex;
507
692
  align-items: center;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborgroup/my-team",
3
- "version": "0.2.0",
3
+ "version": "0.4.1",
4
4
  "description": "Chat with your local Claude Code subscription from a web dashboard, scoped to the current repo. Dev-only.",
5
5
  "type": "module",
6
6
  "bin": {