@harborgroup/my-team 0.2.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/README.md +5 -1
- package/dist/server/profile.js +25 -2
- package/dist/web/app.js +250 -8
- package/dist/web/index.html +1 -1
- package/dist/web/style.css +125 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -46,6 +46,10 @@ 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
|
+
|
|
49
53
|
## Status
|
|
50
54
|
|
|
51
|
-
v1.
|
|
55
|
+
v1.5: 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. No persisted chat history across restarts, no multi-user support, no `#general` backend (sidebar placeholder only).
|
package/dist/server/profile.js
CHANGED
|
@@ -100,6 +100,21 @@ 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.`;
|
|
103
118
|
/**
|
|
104
119
|
* Strong, first-person identity framing — deliberately not "you're also
|
|
105
120
|
* helping company X" phrasing. Weaker framing was tested and Claude treated
|
|
@@ -109,7 +124,9 @@ export async function writeProfile(cwd, data) {
|
|
|
109
124
|
* external/injected content, fixed it in testing.
|
|
110
125
|
*/
|
|
111
126
|
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
|
|
127
|
+
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.
|
|
128
|
+
|
|
129
|
+
${QUESTION_WIDGET_INSTRUCTIONS}`;
|
|
113
130
|
}
|
|
114
131
|
/**
|
|
115
132
|
* Used instead of formatProfileForSystemPrompt while onboardingComplete is
|
|
@@ -121,5 +138,11 @@ export function formatProfileForSystemPrompt(profile) {
|
|
|
121
138
|
*/
|
|
122
139
|
export function getOnboardingSystemPrompt(cwd) {
|
|
123
140
|
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
|
|
141
|
+
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.
|
|
142
|
+
|
|
143
|
+
${QUESTION_WIDGET_INSTRUCTIONS}
|
|
144
|
+
|
|
145
|
+
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.
|
|
146
|
+
|
|
147
|
+
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.`;
|
|
125
148
|
}
|
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'),
|
|
@@ -402,6 +403,240 @@ function appendUserMessage(text) {
|
|
|
402
403
|
appendMessageRow('user', 'You', 'Y', text);
|
|
403
404
|
}
|
|
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
|
+
|
|
405
640
|
function appendAssistantRow(turnMeta) {
|
|
406
641
|
const result = appendMessageRow('assistant', lastProfile?.ceoName || 'your AI CEO', getCeoInitial(), '');
|
|
407
642
|
if (turnMeta?.model) {
|
|
@@ -411,6 +646,7 @@ function appendAssistantRow(turnMeta) {
|
|
|
411
646
|
badge.textContent = turnMeta.effort ? `${modelLabel} · ${turnMeta.effort}` : modelLabel;
|
|
412
647
|
result.row.querySelector('.msg-meta').appendChild(badge);
|
|
413
648
|
}
|
|
649
|
+
result.renderer = new AssistantContentRenderer(result.textEl);
|
|
414
650
|
return result;
|
|
415
651
|
}
|
|
416
652
|
|
|
@@ -437,8 +673,7 @@ async function streamChat(message, { showUserBubble }) {
|
|
|
437
673
|
showTypingIndicator();
|
|
438
674
|
setStatus('typing');
|
|
439
675
|
|
|
440
|
-
let
|
|
441
|
-
let assistantText = '';
|
|
676
|
+
let assistantRenderer = null;
|
|
442
677
|
let sawContent = false;
|
|
443
678
|
let turnMeta = null;
|
|
444
679
|
|
|
@@ -479,11 +714,10 @@ async function streamChat(message, { showUserBubble }) {
|
|
|
479
714
|
hideTypingIndicator();
|
|
480
715
|
sawContent = true;
|
|
481
716
|
}
|
|
482
|
-
if (!
|
|
483
|
-
|
|
717
|
+
if (!assistantRenderer) {
|
|
718
|
+
assistantRenderer = appendAssistantRow(turnMeta).renderer;
|
|
484
719
|
}
|
|
485
|
-
|
|
486
|
-
assistantTextEl.innerHTML = renderMarkdown(assistantText);
|
|
720
|
+
assistantRenderer.push(event.text);
|
|
487
721
|
el.messages.scrollTop = el.messages.scrollHeight;
|
|
488
722
|
} else if (event.type === 'tool-use') {
|
|
489
723
|
if (!sawContent) {
|
|
@@ -502,6 +736,7 @@ async function streamChat(message, { showUserBubble }) {
|
|
|
502
736
|
}
|
|
503
737
|
}
|
|
504
738
|
|
|
739
|
+
assistantRenderer?.finish();
|
|
505
740
|
hideTypingIndicator();
|
|
506
741
|
if (statusState !== 'offline') setStatus('online');
|
|
507
742
|
await refreshProfileIfNeeded();
|
|
@@ -517,11 +752,18 @@ el.form.addEventListener('submit', async (e) => {
|
|
|
517
752
|
if (!message) return;
|
|
518
753
|
el.input.value = '';
|
|
519
754
|
el.input.disabled = true;
|
|
755
|
+
el.sendButton.disabled = true;
|
|
520
756
|
try {
|
|
521
757
|
await streamChat(message, { showUserBubble: true });
|
|
522
758
|
} finally {
|
|
523
|
-
|
|
524
|
-
|
|
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
|
+
}
|
|
525
767
|
}
|
|
526
768
|
});
|
|
527
769
|
|
package/dist/web/index.html
CHANGED
|
@@ -134,7 +134,7 @@
|
|
|
134
134
|
</div>
|
|
135
135
|
<form id="chat-form">
|
|
136
136
|
<input id="chat-input" type="text" placeholder="Message your CEO…" autocomplete="off" />
|
|
137
|
-
<button type="submit">Send</button>
|
|
137
|
+
<button type="submit" id="chat-send">Send</button>
|
|
138
138
|
</form>
|
|
139
139
|
</section>
|
|
140
140
|
</main>
|
package/dist/web/style.css
CHANGED
|
@@ -502,6 +502,131 @@ body {
|
|
|
502
502
|
color: #e5484d;
|
|
503
503
|
}
|
|
504
504
|
|
|
505
|
+
.msg-text-segment:empty {
|
|
506
|
+
display: none;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.question-widget-placeholder {
|
|
510
|
+
display: inline-flex;
|
|
511
|
+
align-items: center;
|
|
512
|
+
gap: 0.4rem;
|
|
513
|
+
color: var(--muted);
|
|
514
|
+
font-style: italic;
|
|
515
|
+
font-size: 0.88rem;
|
|
516
|
+
padding: 0.3rem 0;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.question-widget {
|
|
520
|
+
border: 1px solid var(--border);
|
|
521
|
+
border-radius: 10px;
|
|
522
|
+
padding: 0.9rem 1rem;
|
|
523
|
+
margin: 0.5rem 0;
|
|
524
|
+
max-width: 420px;
|
|
525
|
+
background: var(--bg-subtle);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.question-widget-question {
|
|
529
|
+
font-weight: 600;
|
|
530
|
+
margin-bottom: 0.7rem;
|
|
531
|
+
line-height: 1.4;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.question-widget-body {
|
|
535
|
+
display: flex;
|
|
536
|
+
flex-direction: column;
|
|
537
|
+
gap: 0.45rem;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
.question-widget-option {
|
|
541
|
+
background: var(--bg);
|
|
542
|
+
color: var(--fg);
|
|
543
|
+
border: 1px solid var(--border);
|
|
544
|
+
text-align: left;
|
|
545
|
+
font-weight: 500;
|
|
546
|
+
padding: 0.55rem 0.8rem;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
.question-widget-option:hover {
|
|
550
|
+
border-color: var(--accent);
|
|
551
|
+
background: color-mix(in srgb, var(--accent) 8%, var(--bg));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.question-widget-checkbox-label {
|
|
555
|
+
display: flex;
|
|
556
|
+
align-items: center;
|
|
557
|
+
gap: 0.5rem;
|
|
558
|
+
font-size: 0.92rem;
|
|
559
|
+
cursor: pointer;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.question-widget-checkbox-label input {
|
|
563
|
+
width: 16px;
|
|
564
|
+
height: 16px;
|
|
565
|
+
accent-color: var(--accent);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.question-widget-submit {
|
|
569
|
+
align-self: flex-start;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
.question-widget-row {
|
|
573
|
+
display: flex;
|
|
574
|
+
gap: 0.5rem;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.question-widget-row input {
|
|
578
|
+
flex: 1;
|
|
579
|
+
padding: 0.5rem 0.7rem;
|
|
580
|
+
border-radius: 7px;
|
|
581
|
+
border: 1px solid var(--border);
|
|
582
|
+
background: var(--bg);
|
|
583
|
+
color: var(--fg);
|
|
584
|
+
font-size: 0.9rem;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.question-widget-row input:focus {
|
|
588
|
+
outline: none;
|
|
589
|
+
border-color: var(--accent);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
.question-widget-row button {
|
|
593
|
+
flex-shrink: 0;
|
|
594
|
+
padding: 0.5rem 0.9rem;
|
|
595
|
+
font-size: 0.85rem;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.question-widget-other {
|
|
599
|
+
margin-top: 0.15rem;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
.question-widget-other-inline {
|
|
603
|
+
padding: 0.5rem 0.7rem;
|
|
604
|
+
border-radius: 7px;
|
|
605
|
+
border: 1px solid var(--border);
|
|
606
|
+
background: var(--bg);
|
|
607
|
+
color: var(--fg);
|
|
608
|
+
font-size: 0.9rem;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
.question-widget-other-inline:focus {
|
|
612
|
+
outline: none;
|
|
613
|
+
border-color: var(--accent);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
.question-widget.answered {
|
|
617
|
+
background: transparent;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.question-widget-answer {
|
|
621
|
+
color: var(--accent);
|
|
622
|
+
font-weight: 600;
|
|
623
|
+
font-size: 0.92rem;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
.question-widget-answer::before {
|
|
627
|
+
content: '✓ ';
|
|
628
|
+
}
|
|
629
|
+
|
|
505
630
|
#typing-indicator {
|
|
506
631
|
display: flex;
|
|
507
632
|
align-items: center;
|