@kodaris/krubble-app-components 1.0.100 → 1.0.102

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.
@@ -5626,6 +5626,24 @@ let KRChatbot = class KRChatbot extends i$3 {
5626
5626
  * in place; call requestUpdate() after changing it to re-render.
5627
5627
  */
5628
5628
  this._globalSkillPending = new Set();
5629
+ /**
5630
+ * The live Web Speech recognizer while dictating, else null. Not reactive —
5631
+ * `_isDictating` drives the UI; this is just the handle used to stop it.
5632
+ */
5633
+ this._speechRecognition = null;
5634
+ /**
5635
+ * The composer text as it stood when dictation started. Each `result` event
5636
+ * re-reports the whole utterance (final + interim), so the value is rebuilt as
5637
+ * this base + the transcript rather than appended to — appending would repeat
5638
+ * the interim text on every event.
5639
+ */
5640
+ this._speechBaseValue = '';
5641
+ /**
5642
+ * Transcript of utterances the recognizer has marked final this session.
5643
+ * Held separately because `results` is pruned in continuous mode on some
5644
+ * browsers; without this, earlier sentences would vanish mid-dictation.
5645
+ */
5646
+ this._speechFinalTranscript = '';
5629
5647
  // --- @property (public API) ---
5630
5648
  /**
5631
5649
  * Callback function for sending messages. Receives the user's message and
@@ -5775,6 +5793,33 @@ let KRChatbot = class KRChatbot extends i$3 {
5775
5793
  this._skillBrowseSource = 'org';
5776
5794
  /** Search text in the browse dialog, routed to the active tab's search. */
5777
5795
  this._skillBrowseQuery = '';
5796
+ /** True while the mic is live and speech is being transcribed into the composer. */
5797
+ this._isDictating = false;
5798
+ /**
5799
+ * Close the `/` skill menu when the click lands outside it.
5800
+ *
5801
+ * Bound at the document because a click anywhere — including outside this
5802
+ * component entirely — should dismiss the menu. `composedPath()` rather than
5803
+ * `e.target` since the menu lives in this component's shadow root, where
5804
+ * `target` on a document listener is retargeted to the host element and every
5805
+ * inside click would look like an outside one.
5806
+ */
5807
+ this._handleDocumentClick = (e) => {
5808
+ if (!this._skillMenuOpened) {
5809
+ return;
5810
+ }
5811
+ // Excluded: the textarea, since clicking it keeps editing the same `/` command
5812
+ // and dismissing there would fight the user still typing it; and the plus
5813
+ // button, which owns the menu's open/close itself. The plus exclusion matters
5814
+ // in both directions — this listener runs after the button's own handler, so
5815
+ // without it a click that just opened the menu would immediately close it
5816
+ // again, and a click meant to toggle it shut would be double-handled.
5817
+ const path = e.composedPath();
5818
+ if (path.some((el) => el instanceof HTMLElement && (el.classList.contains('skill-menu') || el.classList.contains('input') || el.classList.contains('input-plus')))) {
5819
+ return;
5820
+ }
5821
+ this._skillMenuOpened = false;
5822
+ };
5778
5823
  this._handleMouseMove = (e) => {
5779
5824
  if (this._isDragging) {
5780
5825
  let newLeft = this._dragStartLeft + (e.clientX - this._dragStartX);
@@ -5918,6 +5963,7 @@ let KRChatbot = class KRChatbot extends i$3 {
5918
5963
  this.opened = true;
5919
5964
  }
5920
5965
  this._restoreState();
5966
+ document.addEventListener('click', this._handleDocumentClick);
5921
5967
  }
5922
5968
  disconnectedCallback() {
5923
5969
  super.disconnectedCallback();
@@ -5927,6 +5973,15 @@ let KRChatbot = class KRChatbot extends i$3 {
5927
5973
  }
5928
5974
  document.removeEventListener('mousemove', this._handleMouseMove);
5929
5975
  document.removeEventListener('mouseup', this._handleMouseUp);
5976
+ document.removeEventListener('click', this._handleDocumentClick);
5977
+ // Release the mic if the chatbot is torn down mid-dictation — otherwise the
5978
+ // recognizer keeps the browser's recording indicator lit with nothing to
5979
+ // transcribe into. abort() (not stop()) since the discarded text has nowhere to go.
5980
+ if (this._speechRecognition) {
5981
+ this._speechRecognition.abort();
5982
+ this._speechRecognition = null;
5983
+ this._isDictating = false;
5984
+ }
5930
5985
  // Release any object URLs still held by staged attachments.
5931
5986
  this._pendingConversationFiles.forEach((p) => {
5932
5987
  if (p.previewUrl) {
@@ -6140,7 +6195,12 @@ let KRChatbot = class KRChatbot extends i$3 {
6140
6195
  }
6141
6196
  const state = JSON.parse(raw);
6142
6197
  // Draft text and the debug drawer are persisted for all variants.
6143
- if (typeof state.message === 'string') {
6198
+ // A lone `/` is dropped — it's the in-progress half of a menu interaction,
6199
+ // not a draft, and restoring it would revive a stale menu the user never
6200
+ // used. Anything with an actual command after the slash (`/code`, `/code do
6201
+ // the thing`) is a real draft and restores normally. Trimmed before testing
6202
+ // so a stray space around the slash doesn't defeat the check.
6203
+ if (typeof state.message === 'string' && state.message.trim() !== '/') {
6144
6204
  this._inputValue = state.message;
6145
6205
  }
6146
6206
  this._debugPanelOpen = state.debugPanelOpen === 'true';
@@ -6533,6 +6593,92 @@ let KRChatbot = class KRChatbot extends i$3 {
6533
6593
  this._skillMenuOpened = true;
6534
6594
  this._searchInvocableSkills('');
6535
6595
  }
6596
+ /**
6597
+ * The mic button toggles dictation. Speech is transcribed straight into the
6598
+ * composer as editable text — the user still reviews and presses send, so a
6599
+ * misheard word is fixable and nothing is sent by voice alone.
6600
+ *
6601
+ * Recognition runs entirely in the browser's Web Speech API (Chrome/Edge send
6602
+ * audio to Google's service, Safari to Apple's). No audio reaches Kodaris, and
6603
+ * there's no backend call or cost on our side.
6604
+ */
6605
+ _handleMicClick() {
6606
+ // Second click stops. `stop()` flushes the pending utterance as a final
6607
+ // result, so the last words aren't lost; onend clears the state.
6608
+ if (this._speechRecognition) {
6609
+ this._speechRecognition.stop();
6610
+ return;
6611
+ }
6612
+ // Prefixed in Chrome/Edge/Safari; the unprefixed name is checked first for
6613
+ // whenever they drop the prefix. The mic button isn't rendered without one of
6614
+ // these, so reaching here unsupported means a mid-session change — bail quietly.
6615
+ const SpeechRecognitionCtor = window.SpeechRecognition || window.webkitSpeechRecognition;
6616
+ if (!SpeechRecognitionCtor) {
6617
+ return;
6618
+ }
6619
+ const recognition = new SpeechRecognitionCtor();
6620
+ // Keep listening through natural pauses — a chat message is often several
6621
+ // sentences, and the default stops at the first one.
6622
+ recognition.continuous = true;
6623
+ // Show words as they're recognized rather than only at end of utterance.
6624
+ recognition.interimResults = true;
6625
+ // `lang` is deliberately left unset so the recognizer follows the page/OS
6626
+ // language, which is correct for every consumer of this component.
6627
+ // Dictation ADDS to whatever is already typed rather than replacing it, so
6628
+ // the user can type half a message and speak the rest. The separating space
6629
+ // is part of the base so the transcript never runs into the existing word.
6630
+ this._speechBaseValue = this._inputValue;
6631
+ if (this._speechBaseValue && !this._speechBaseValue.endsWith(' ')) {
6632
+ this._speechBaseValue += ' ';
6633
+ }
6634
+ this._speechFinalTranscript = '';
6635
+ recognition.onresult = (e) => {
6636
+ // Everything from resultIndex on is new since the last event: finalized
6637
+ // pieces move to _speechFinalTranscript permanently, while non-final ones
6638
+ // are rebuilt each event because the browser revises its guess as it hears
6639
+ // more. Rebuilding the whole value (base + final + interim) is what keeps
6640
+ // those revisions from stacking up as duplicated text.
6641
+ let interim = '';
6642
+ for (let i = e.resultIndex; i < e.results.length; i++) {
6643
+ if (e.results[i].isFinal) {
6644
+ this._speechFinalTranscript += e.results[i][0].transcript;
6645
+ }
6646
+ else {
6647
+ interim += e.results[i][0].transcript;
6648
+ }
6649
+ }
6650
+ this._inputValue = this._speechBaseValue + this._speechFinalTranscript + interim;
6651
+ this._saveState();
6652
+ // Grow the textarea as dictated text wraps — the same clamp the typing path
6653
+ // uses, since @input doesn't fire for programmatic value changes.
6654
+ if (this._inputEl) {
6655
+ this._inputEl.style.height = 'auto';
6656
+ this._inputEl.style.height = Math.min(this._inputEl.scrollHeight, 120) + 'px';
6657
+ }
6658
+ };
6659
+ recognition.onerror = (e) => {
6660
+ // Denied mic permission is the one error worth explaining — the user has to
6661
+ // fix it in browser settings, and the button would otherwise look broken.
6662
+ if (e.error === 'not-allowed' || e.error === 'service-not-allowed') {
6663
+ this._error = 'Microphone access is blocked. Allow it in your browser settings to use voice input.';
6664
+ return;
6665
+ }
6666
+ // `no-speech` (silence) and `aborted` (user stopped) are normal; anything
6667
+ // else is a transient service hiccup. onend still runs, so the mic resets.
6668
+ };
6669
+ // Fires on stop(), on error, and when the browser ends recognition on its own
6670
+ // (e.g. a long silence) — the single place the live state is torn down.
6671
+ recognition.onend = () => {
6672
+ this._speechRecognition = null;
6673
+ this._isDictating = false;
6674
+ };
6675
+ // start() is what triggers the browser's mic permission prompt.
6676
+ this._speechRecognition = recognition;
6677
+ this._isDictating = true;
6678
+ this._error = '';
6679
+ recognition.start();
6680
+ this._inputEl?.focus();
6681
+ }
6536
6682
  /** Insert `/code ` into the composer and focus it, ready for the message. */
6537
6683
  _handleSkillSelect(skill) {
6538
6684
  // A global skill must be installed before it can be chosen — inserting /code
@@ -6602,6 +6748,15 @@ let KRChatbot = class KRChatbot extends i$3 {
6602
6748
  // the menu interaction, and the picker is a separate window so the menu would
6603
6749
  // otherwise linger open behind it (and after a file is chosen).
6604
6750
  this._skillMenuOpened = false;
6751
+ // A lone `/` was only the means of reaching this action, so it shouldn't be
6752
+ // left behind in the composer once the picker opens. Nothing else is touched:
6753
+ // a real command (`/code`) stays because `/code` plus an attachment is a valid
6754
+ // invocation, and the plus button reaches this same method with an arbitrary
6755
+ // draft in the composer, which must survive.
6756
+ if (this._inputValue.trim() === '/') {
6757
+ this._inputValue = '';
6758
+ this._saveState();
6759
+ }
6605
6760
  this._pendingAttachAnalyze = aiAnalyze;
6606
6761
  this._fileInputEl?.click();
6607
6762
  }
@@ -6803,6 +6958,13 @@ let KRChatbot = class KRChatbot extends i$3 {
6803
6958
  previewUrl: p.previewUrl,
6804
6959
  }));
6805
6960
  }
6961
+ // The message is on its way, so stop the mic if it's still live — otherwise
6962
+ // it would keep transcribing into the now-cleared composer. abort() rather
6963
+ // than stop() because a trailing pending utterance belongs to the message
6964
+ // just sent; flushing it would drop stray words into the next one.
6965
+ if (this._speechRecognition) {
6966
+ this._speechRecognition.abort();
6967
+ }
6806
6968
  this.messages = [...this.messages, userMessage];
6807
6969
  this._inputValue = '';
6808
6970
  this._saveState();
@@ -7156,6 +7318,122 @@ let KRChatbot = class KRChatbot extends i$3 {
7156
7318
  }
7157
7319
  }
7158
7320
  // --- Render ---
7321
+ /**
7322
+ * The composer's controls: attach, the textarea, the mic, and send/stop.
7323
+ *
7324
+ * While dictating, only the mic swaps — to a stop button — so the textarea
7325
+ * stays visible with the transcript streaming into it and send stays reachable.
7326
+ */
7327
+ _renderComposerControls() {
7328
+ return b `
7329
+ <button
7330
+ class="input-plus"
7331
+ @click=${this._handlePlusClick}
7332
+ >+</button>
7333
+ <textarea
7334
+ class="input"
7335
+ .value=${this._inputValue}
7336
+ placeholder=${this.placeholder}
7337
+ rows="1"
7338
+ @input=${this._handleInputChange}
7339
+ @keydown=${this._handleKeyDown}
7340
+ @paste=${this._handlePaste}
7341
+ ></textarea>
7342
+ ${window.SpeechRecognition || window.webkitSpeechRecognition ? b `
7343
+ ${this._isDictating ? b `
7344
+ <button
7345
+ class="mic-stop"
7346
+ title="Stop dictation"
7347
+ aria-label="Stop dictation"
7348
+ @click=${this._handleMicClick}
7349
+ >
7350
+ <svg
7351
+ xmlns="http://www.w3.org/2000/svg"
7352
+ fill="currentColor"
7353
+ viewBox="0 0 24 24"
7354
+ class="mic-stop-icon"
7355
+ aria-hidden="true"
7356
+ >
7357
+ <rect
7358
+ x="6"
7359
+ y="6"
7360
+ width="12"
7361
+ height="12"
7362
+ rx="2"
7363
+ />
7364
+ </svg>
7365
+ </button>
7366
+ ` : b `
7367
+ <button
7368
+ class="mic"
7369
+ title="Dictate a message"
7370
+ aria-label="Dictate a message"
7371
+ @click=${this._handleMicClick}
7372
+ >
7373
+ <svg
7374
+ xmlns="http://www.w3.org/2000/svg"
7375
+ viewBox="0 0 24 24"
7376
+ fill="none"
7377
+ stroke="currentColor"
7378
+ stroke-width="1.75"
7379
+ stroke-linecap="round"
7380
+ stroke-linejoin="round"
7381
+ class="mic-icon"
7382
+ aria-hidden="true"
7383
+ >
7384
+ <path d="M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3Z"/>
7385
+ <path d="M19 11a7 7 0 0 1-14 0"/>
7386
+ <path d="M12 18v3"/>
7387
+ </svg>
7388
+ </button>
7389
+ `}
7390
+ ` : A}
7391
+ ${this._isStreaming ? b `
7392
+ <button
7393
+ class="stop"
7394
+ @click=${this._handleStop}
7395
+ title="Stop generating"
7396
+ >
7397
+ <svg
7398
+ xmlns="http://www.w3.org/2000/svg"
7399
+ fill="currentColor"
7400
+ viewBox="0 0 24 24"
7401
+ class="stop-icon"
7402
+ >
7403
+ <rect
7404
+ x="6"
7405
+ y="6"
7406
+ width="12"
7407
+ height="12"
7408
+ rx="2"
7409
+ />
7410
+ </svg>
7411
+ </button>
7412
+ ` : b `
7413
+ <button
7414
+ class=${e({
7415
+ 'send': true,
7416
+ 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
7417
+ })}
7418
+ @click=${this._handleSubmit}
7419
+ title="Send message"
7420
+ >
7421
+ <svg
7422
+ width="16"
7423
+ height="16"
7424
+ viewBox="0 0 24 24"
7425
+ fill="none"
7426
+ stroke="currentColor"
7427
+ stroke-width="2.5"
7428
+ stroke-linecap="round"
7429
+ class="send-icon"
7430
+ >
7431
+ <path d="M12 19V5M5 12l7-7 7 7"/>
7432
+ </svg>
7433
+ </button>
7434
+ `}
7435
+ `;
7436
+ }
7159
7437
  /** The `/` skill menu shown above the composer while typing a slash command. */
7160
7438
  _renderSkillMenu() {
7161
7439
  const skills = this._invocableSkills;
@@ -7862,63 +8140,7 @@ let KRChatbot = class KRChatbot extends i$3 {
7862
8140
  />
7863
8141
  ${this._skillMenuOpened ? this._renderSkillMenu() : A}
7864
8142
  <div class="input-wrapper">
7865
- <button
7866
- class="input-plus"
7867
- @click=${this._handlePlusClick}
7868
- >+</button>
7869
- <textarea
7870
- class="input"
7871
- .value=${this._inputValue}
7872
- placeholder=${this.placeholder}
7873
- rows="1"
7874
- @input=${this._handleInputChange}
7875
- @keydown=${this._handleKeyDown}
7876
- @paste=${this._handlePaste}
7877
- ></textarea>
7878
- ${this._isStreaming ? b `
7879
- <button
7880
- class="stop"
7881
- @click=${this._handleStop}
7882
- title="Stop generating"
7883
- >
7884
- <svg
7885
- xmlns="http://www.w3.org/2000/svg"
7886
- fill="currentColor"
7887
- viewBox="0 0 24 24"
7888
- class="stop-icon"
7889
- >
7890
- <rect
7891
- x="6"
7892
- y="6"
7893
- width="12"
7894
- height="12"
7895
- rx="2"
7896
- />
7897
- </svg>
7898
- </button>
7899
- ` : b `
7900
- <button
7901
- class=${e({
7902
- 'send': true,
7903
- 'send--disabled': !this._inputValue.trim() && !this._pendingConversationFiles.length,
7904
- })}
7905
- @click=${this._handleSubmit}
7906
- title="Send message"
7907
- >
7908
- <svg
7909
- width="16"
7910
- height="16"
7911
- viewBox="0 0 24 24"
7912
- fill="none"
7913
- stroke="currentColor"
7914
- stroke-width="2.5"
7915
- stroke-linecap="round"
7916
- class="send-icon"
7917
- >
7918
- <path d="M12 19V5M5 12l7-7 7 7"/>
7919
- </svg>
7920
- </button>
7921
- `}
8143
+ ${this._renderComposerControls()}
7922
8144
  </div>
7923
8145
  </div>
7924
8146
  ${this._skillBrowseOpened ? this._renderSkillBrowse() : A}
@@ -8718,6 +8940,71 @@ KRChatbot.styles = i$6 `
8718
8940
  color: #b0b0b0;
8719
8941
  }
8720
8942
 
8943
+ /* margin-right rather than a wider .input-wrapper gap, which would also push
8944
+ the plus button and textarea apart. Applied to both mic states so the
8945
+ spacing doesn't shift when dictation swaps one for the other. */
8946
+ .mic,
8947
+ .mic-stop {
8948
+ margin-right: 4px;
8949
+ }
8950
+
8951
+ .mic {
8952
+ width: 34px;
8953
+ height: 34px;
8954
+ border: none;
8955
+ background: transparent;
8956
+ color: var(--kr-chatbot-text);
8957
+ border-radius: 50%;
8958
+ cursor: pointer;
8959
+ display: flex;
8960
+ align-items: center;
8961
+ justify-content: center;
8962
+ flex-shrink: 0;
8963
+ transition: background 0.15s, color 0.15s;
8964
+ }
8965
+
8966
+ .mic:hover {
8967
+ background: #f8f9fc;
8968
+ }
8969
+
8970
+ .mic-icon {
8971
+ width: 21px;
8972
+ height: 21px;
8973
+ }
8974
+
8975
+ /* Dictation swaps the mic for this stop button, sitting in the mic's slot so
8976
+ the composer's layout is unchanged and the textarea stays visible with the
8977
+ transcript streaming into it. Structurally identical to .stop; only the
8978
+ colors differ — dictation isn't an error state, so it takes a neutral grey
8979
+ instead of the error red, and that also keeps it distinct from the solid
8980
+ send button sitting right beside it. The icon is dark rather than white
8981
+ because white on this grey wouldn't meet contrast. */
8982
+ .mic-stop {
8983
+ width: 34px;
8984
+ height: 34px;
8985
+ border-radius: 50%;
8986
+ background: #eae7e7;
8987
+ color: #1f1f1f;
8988
+ border: none;
8989
+ cursor: pointer;
8990
+ display: flex;
8991
+ align-items: center;
8992
+ justify-content: center;
8993
+ flex-shrink: 0;
8994
+ transition: all 0.15s;
8995
+ }
8996
+
8997
+ .mic-stop:hover {
8998
+ background: #ded9d9;
8999
+ }
9000
+
9001
+ /* Larger than .stop-icon's 14px: the rect fills only half its 24-unit
9002
+ viewBox, so the drawn square is half this value. */
9003
+ .mic-stop-icon {
9004
+ width: 20px;
9005
+ height: 20px;
9006
+ }
9007
+
8721
9008
  .send {
8722
9009
  width: 34px;
8723
9010
  height: 34px;
@@ -9345,6 +9632,9 @@ __decorate([
9345
9632
  __decorate([
9346
9633
  r()
9347
9634
  ], KRChatbot.prototype, "_skillBrowseQuery", void 0);
9635
+ __decorate([
9636
+ r()
9637
+ ], KRChatbot.prototype, "_isDictating", void 0);
9348
9638
  __decorate([
9349
9639
  e$3('.panel')
9350
9640
  ], KRChatbot.prototype, "_panelEl", void 0);