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