@speechos/client 0.2.5 → 0.2.7

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.
@@ -26910,7 +26910,8 @@
26910
26910
  apiKey: "",
26911
26911
  userId: "",
26912
26912
  host: DEFAULT_HOST,
26913
- debug: false
26913
+ debug: false,
26914
+ webSocketFactory: void 0
26914
26915
  };
26915
26916
  /**
26916
26917
  * Validates and merges user config with defaults
@@ -26923,7 +26924,8 @@
26923
26924
  apiKey: userConfig.apiKey,
26924
26925
  userId: userConfig.userId ?? defaultConfig.userId,
26925
26926
  host: userConfig.host ?? defaultConfig.host,
26926
- debug: userConfig.debug ?? defaultConfig.debug
26927
+ debug: userConfig.debug ?? defaultConfig.debug,
26928
+ webSocketFactory: userConfig.webSocketFactory ?? defaultConfig.webSocketFactory
26927
26929
  };
26928
26930
  }
26929
26931
  /**
@@ -28284,6 +28286,8 @@
28284
28286
  const MESSAGE_TYPE_EXECUTE_COMMAND = "execute_command";
28285
28287
  const MESSAGE_TYPE_COMMAND_RESULT = "command_result";
28286
28288
  const MESSAGE_TYPE_ERROR = "error";
28289
+ const WS_OPEN = 1;
28290
+ const WS_CLOSED = 3;
28287
28291
  /**
28288
28292
  * Response timeout in milliseconds.
28289
28293
  */
@@ -28402,7 +28406,10 @@
28402
28406
  state.setMicEnabled(true);
28403
28407
  const wsUrl = this.getWebSocketUrl();
28404
28408
  if (config.debug) console.log("[SpeechOS] Connecting to WebSocket:", wsUrl);
28405
- this.ws = new WebSocket(wsUrl);
28409
+ this.pendingAuth = new Deferred$1();
28410
+ this.pendingAuth.setTimeout(RESPONSE_TIMEOUT_MS, "Connection timed out", "connection_timeout", "connection");
28411
+ const factory = config.webSocketFactory ?? ((url) => new WebSocket(url));
28412
+ this.ws = factory(wsUrl);
28406
28413
  this.ws.onopen = () => {
28407
28414
  if (config.debug) console.log("[SpeechOS] WebSocket connected, authenticating...");
28408
28415
  this.authenticate();
@@ -28411,19 +28418,21 @@
28411
28418
  this.handleMessage(event.data);
28412
28419
  };
28413
28420
  this.ws.onerror = (event) => {
28414
- console.error("[SpeechOS] WebSocket error:", event);
28421
+ const isConnectionBlocked = this.ws?.readyState === WS_CLOSED;
28422
+ const errorCode = isConnectionBlocked ? "connection_blocked" : "websocket_error";
28423
+ const errorMessage = isConnectionBlocked ? "This site's CSP blocks the extension. Try embedded mode instead." : "WebSocket connection error";
28424
+ console.error("[SpeechOS] WebSocket error:", event, { isConnectionBlocked });
28415
28425
  events.emit("error", {
28416
- code: "websocket_error",
28417
- message: "WebSocket connection error",
28426
+ code: errorCode,
28427
+ message: errorMessage,
28418
28428
  source: "connection"
28419
28429
  });
28430
+ if (this.pendingAuth) this.pendingAuth.reject(new Error(errorMessage));
28420
28431
  };
28421
28432
  this.ws.onclose = (event) => {
28422
28433
  if (config.debug) console.log("[SpeechOS] WebSocket closed:", event.code, event.reason);
28423
28434
  state.setConnected(false);
28424
28435
  };
28425
- this.pendingAuth = new Deferred$1();
28426
- this.pendingAuth.setTimeout(RESPONSE_TIMEOUT_MS, "Connection timed out", "connection_timeout", "connection");
28427
28436
  await this.pendingAuth.promise;
28428
28437
  this.pendingAuth = null;
28429
28438
  if (this.audioCapture) this.audioCapture.setReady();
@@ -28472,7 +28481,7 @@
28472
28481
  * Actually send the audio chunk (async operation).
28473
28482
  */
28474
28483
  async doSendAudioChunk(chunk) {
28475
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
28484
+ if (this.ws && this.ws.readyState === WS_OPEN) {
28476
28485
  const arrayBuffer = await chunk.arrayBuffer();
28477
28486
  this.ws.send(arrayBuffer);
28478
28487
  }
@@ -28518,6 +28527,10 @@
28518
28527
  }
28519
28528
  handleIntermediateTranscription(message) {
28520
28529
  const config = getConfig();
28530
+ events.emit("transcription:interim", {
28531
+ transcript: message.transcript,
28532
+ isFinal: message.is_final
28533
+ });
28521
28534
  if (config.debug) console.log("[SpeechOS] Intermediate transcription:", message.transcript, "final:", message.is_final);
28522
28535
  }
28523
28536
  handleFinalTranscript(message) {
@@ -28654,7 +28667,7 @@
28654
28667
  * the transcript. Uses the same pattern as LiveKit's ReadableStream approach.
28655
28668
  */
28656
28669
  async waitForBufferDrain() {
28657
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
28670
+ if (!this.ws || this.ws.readyState !== WS_OPEN) return;
28658
28671
  const config = getConfig();
28659
28672
  const startTime = Date.now();
28660
28673
  while (this.ws.bufferedAmount > 0) {
@@ -28670,7 +28683,7 @@
28670
28683
  * Send a JSON message over the WebSocket.
28671
28684
  */
28672
28685
  sendMessage(message) {
28673
- if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
28686
+ if (this.ws && this.ws.readyState === WS_OPEN) this.ws.send(JSON.stringify(message));
28674
28687
  }
28675
28688
  /**
28676
28689
  * Disconnect from the WebSocket.
@@ -28712,7 +28725,7 @@
28712
28725
  * Check if connected to WebSocket.
28713
28726
  */
28714
28727
  isConnected() {
28715
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
28728
+ return this.ws !== null && this.ws.readyState === WS_OPEN;
28716
28729
  }
28717
28730
  /**
28718
28731
  * Get the last input text from a command result.
@@ -28893,6 +28906,10 @@
28893
28906
  this.focusHandler = (event) => {
28894
28907
  const target = event.target;
28895
28908
  if (isFormField(target)) {
28909
+ console.log("[SpeechOS] FormDetector: focus on form field", {
28910
+ element: target,
28911
+ tagName: target?.tagName,
28912
+ });
28896
28913
  state.setFocusedElement(target);
28897
28914
  state.show();
28898
28915
  events.emit("form:focus", { element: target });
@@ -30148,6 +30165,71 @@
30148
30165
  deleteTranscript: deleteTranscript,
30149
30166
  };
30150
30167
 
30168
+ function isNativeField(field) {
30169
+ return field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement;
30170
+ }
30171
+ /** Call a function after focusing a field and then restore the previous focus afterwards if necessary */
30172
+ function withFocus(field, callback) {
30173
+ const document = field.ownerDocument;
30174
+ const initialFocus = document.activeElement;
30175
+ if (initialFocus === field) {
30176
+ return callback();
30177
+ }
30178
+ try {
30179
+ field.focus();
30180
+ return callback();
30181
+ }
30182
+ finally {
30183
+ field.blur(); // Supports `intialFocus === body`
30184
+ if (initialFocus instanceof HTMLElement) {
30185
+ initialFocus.focus();
30186
+ }
30187
+ }
30188
+ }
30189
+ // This will insert into the focused field. It shouild always be called inside withFocus.
30190
+ // Use this one locally if there are multiple `insertTextIntoField` or `document.execCommand` calls
30191
+ function insertTextWhereverTheFocusIs(document, text) {
30192
+ if (text === '') {
30193
+ // https://github.com/fregante/text-field-edit/issues/16
30194
+ document.execCommand('delete');
30195
+ }
30196
+ else {
30197
+ document.execCommand('insertText', false, text);
30198
+ }
30199
+ }
30200
+ /** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
30201
+ function insertTextIntoField(field, text) {
30202
+ withFocus(field, () => {
30203
+ insertTextWhereverTheFocusIs(field.ownerDocument, text);
30204
+ });
30205
+ }
30206
+ /** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
30207
+ function setFieldText(field, text) {
30208
+ if (isNativeField(field)) {
30209
+ field.select();
30210
+ insertTextIntoField(field, text);
30211
+ }
30212
+ else {
30213
+ const document = field.ownerDocument;
30214
+ withFocus(field, () => {
30215
+ document.execCommand('selectAll', false, text);
30216
+ insertTextWhereverTheFocusIs(document, text);
30217
+ });
30218
+ }
30219
+ }
30220
+ /** Get the selected text in a field or an empty string if nothing is selected. */
30221
+ function getFieldSelection(field) {
30222
+ if (isNativeField(field)) {
30223
+ return field.value.slice(field.selectionStart, field.selectionEnd);
30224
+ }
30225
+ const selection = field.ownerDocument.getSelection();
30226
+ if (selection && field.contains(selection.anchorNode)) {
30227
+ // The selection is inside the field
30228
+ return selection.toString();
30229
+ }
30230
+ return '';
30231
+ }
30232
+
30151
30233
  /**
30152
30234
  * @license
30153
30235
  * Copyright 2017 Google LLC
@@ -30508,7 +30590,9 @@
30508
30590
  this.activeAction = null;
30509
30591
  this.editPreviewText = "";
30510
30592
  this.errorMessage = null;
30511
- this.commandFeedback = null;
30593
+ this.showRetryButton = true;
30594
+ this.actionFeedback = null;
30595
+ this.showNoAudioWarning = false;
30512
30596
  }
30513
30597
  static { this.styles = [
30514
30598
  themeStyles,
@@ -31098,8 +31182,9 @@
31098
31182
  background-position: center;
31099
31183
  }
31100
31184
 
31101
- /* Command feedback badge - no match state (neutral gray) */
31102
- .status-label.command-none {
31185
+ /* Command/edit feedback badge - no match/empty state (neutral gray) */
31186
+ .status-label.command-none,
31187
+ .status-label.edit-empty {
31103
31188
  background: #4b5563;
31104
31189
  box-shadow: 0 4px 12px rgba(75, 85, 99, 0.3);
31105
31190
  animation: command-feedback-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)
@@ -31215,10 +31300,14 @@
31215
31300
  bottom: 72px; /* Above button */
31216
31301
  left: 50%;
31217
31302
  transform: translateX(-50%) translateY(8px);
31303
+ min-width: 200px;
31218
31304
  max-width: 280px;
31305
+ width: max-content;
31219
31306
  font-size: 13px;
31220
31307
  color: white;
31221
31308
  white-space: normal;
31309
+ word-wrap: break-word;
31310
+ overflow-wrap: break-word;
31222
31311
  text-align: center;
31223
31312
  padding: 12px 16px;
31224
31313
  border-radius: 12px;
@@ -31256,6 +31345,60 @@
31256
31345
  border-color: rgba(255, 255, 255, 0.5);
31257
31346
  }
31258
31347
 
31348
+ /* No audio warning banner */
31349
+ .no-audio-warning {
31350
+ position: absolute;
31351
+ bottom: 120px; /* Above button and waveform visualizer */
31352
+ left: 50%;
31353
+ transform: translateX(-50%) translateY(8px);
31354
+ display: flex;
31355
+ align-items: center;
31356
+ gap: 8px;
31357
+ padding: 10px 14px;
31358
+ border-radius: 12px;
31359
+ background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
31360
+ box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3);
31361
+ transition: all 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
31362
+ pointer-events: none;
31363
+ opacity: 0;
31364
+ white-space: nowrap;
31365
+ }
31366
+
31367
+ .no-audio-warning.visible {
31368
+ opacity: 1;
31369
+ transform: translateX(-50%) translateY(0);
31370
+ pointer-events: auto;
31371
+ }
31372
+
31373
+ .no-audio-warning .warning-icon {
31374
+ flex-shrink: 0;
31375
+ color: white;
31376
+ }
31377
+
31378
+ .no-audio-warning .warning-text {
31379
+ font-size: 13px;
31380
+ font-weight: 500;
31381
+ color: white;
31382
+ }
31383
+
31384
+ .no-audio-warning .settings-link {
31385
+ background: rgba(255, 255, 255, 0.2);
31386
+ border: 1px solid rgba(255, 255, 255, 0.3);
31387
+ border-radius: 6px;
31388
+ padding: 4px 10px;
31389
+ font-size: 12px;
31390
+ font-weight: 600;
31391
+ color: white;
31392
+ cursor: pointer;
31393
+ transition: all 0.15s;
31394
+ white-space: nowrap;
31395
+ }
31396
+
31397
+ .no-audio-warning .settings-link:hover {
31398
+ background: rgba(255, 255, 255, 0.3);
31399
+ border-color: rgba(255, 255, 255, 0.5);
31400
+ }
31401
+
31259
31402
  /* Mobile styles - 30% larger */
31260
31403
  @media (max-width: 768px) and (hover: none) {
31261
31404
  .mic-button {
@@ -31377,6 +31520,7 @@
31377
31520
  .error-message {
31378
31521
  font-size: 15px;
31379
31522
  padding: 14px 18px;
31523
+ min-width: 220px;
31380
31524
  max-width: 300px;
31381
31525
  bottom: 94px;
31382
31526
  }
@@ -31385,6 +31529,21 @@
31385
31529
  padding: 8px 14px;
31386
31530
  font-size: 14px;
31387
31531
  }
31532
+
31533
+ .no-audio-warning {
31534
+ padding: 12px 16px;
31535
+ gap: 10px;
31536
+ bottom: 145px; /* Above button and waveform on mobile */
31537
+ }
31538
+
31539
+ .no-audio-warning .warning-text {
31540
+ font-size: 15px;
31541
+ }
31542
+
31543
+ .no-audio-warning .settings-link {
31544
+ padding: 6px 12px;
31545
+ font-size: 14px;
31546
+ }
31388
31547
  }
31389
31548
  `,
31390
31549
  ]; }
@@ -31442,6 +31601,14 @@
31442
31601
  composed: true,
31443
31602
  }));
31444
31603
  }
31604
+ handleOpenSettings(e) {
31605
+ e.stopPropagation();
31606
+ e.preventDefault();
31607
+ this.dispatchEvent(new CustomEvent("open-settings", {
31608
+ bubbles: true,
31609
+ composed: true,
31610
+ }));
31611
+ }
31445
31612
  getButtonClass() {
31446
31613
  const classes = ["mic-button"];
31447
31614
  if (this.expanded && this.recordingState === "idle") {
@@ -31526,13 +31693,16 @@
31526
31693
  }
31527
31694
  return this.recordingState;
31528
31695
  }
31529
- getCommandFeedbackLabel() {
31530
- if (this.commandFeedback === "success") {
31696
+ getActionFeedbackLabel() {
31697
+ if (this.actionFeedback === "command-success") {
31531
31698
  return "Got it!";
31532
31699
  }
31533
- if (this.commandFeedback === "none") {
31700
+ if (this.actionFeedback === "command-none") {
31534
31701
  return "No command matched";
31535
31702
  }
31703
+ if (this.actionFeedback === "edit-empty") {
31704
+ return "Couldn't understand edit";
31705
+ }
31536
31706
  return "";
31537
31707
  }
31538
31708
  render() {
@@ -31542,9 +31712,9 @@
31542
31712
  const showSiriEdit = this.recordingState === "processing" && this.activeAction === "edit";
31543
31713
  const statusLabel = this.getStatusLabel();
31544
31714
  const showVisualizer = this.shouldShowVisualizer();
31545
- // Show status label during recording (either visualizer or edit text) OR command feedback
31546
- const showCommandFeedback = this.recordingState === "idle" && this.commandFeedback !== null;
31547
- const showStatus = this.recordingState === "recording" || showCommandFeedback;
31715
+ // Show status label during recording (either visualizer or edit text) OR action feedback
31716
+ const showActionFeedback = this.recordingState === "idle" && this.actionFeedback !== null;
31717
+ const showStatus = this.recordingState === "recording" || showActionFeedback;
31548
31718
  const showCancel = this.recordingState === "connecting" ||
31549
31719
  this.recordingState === "recording" ||
31550
31720
  this.recordingState === "processing";
@@ -31576,13 +31746,46 @@
31576
31746
  ? b `
31577
31747
  <div class="error-message ${showError ? "visible" : ""}">
31578
31748
  ${this.errorMessage}
31579
- <button class="retry-button" @click="${this.handleRetry}">
31580
- Retry Connection
31581
- </button>
31749
+ ${this.showRetryButton
31750
+ ? b `
31751
+ <button class="retry-button" @click="${this.handleRetry}">
31752
+ Retry Connection
31753
+ </button>
31754
+ `
31755
+ : ""}
31582
31756
  </div>
31583
31757
  `
31584
31758
  : ""}
31585
31759
 
31760
+ <div
31761
+ class="no-audio-warning ${this.showNoAudioWarning &&
31762
+ this.recordingState === "recording"
31763
+ ? "visible"
31764
+ : ""}"
31765
+ >
31766
+ <svg
31767
+ class="warning-icon"
31768
+ width="16"
31769
+ height="16"
31770
+ viewBox="0 0 24 24"
31771
+ fill="none"
31772
+ stroke="currentColor"
31773
+ stroke-width="2"
31774
+ stroke-linecap="round"
31775
+ stroke-linejoin="round"
31776
+ >
31777
+ <path
31778
+ d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
31779
+ />
31780
+ <line x1="12" y1="9" x2="12" y2="13" />
31781
+ <line x1="12" y1="17" x2="12.01" y2="17" />
31782
+ </svg>
31783
+ <span class="warning-text">We're not hearing anything</span>
31784
+ <button class="settings-link" @click="${this.handleOpenSettings}">
31785
+ Check Settings
31786
+ </button>
31787
+ </div>
31788
+
31586
31789
  <button
31587
31790
  class="${this.getButtonClass()}"
31588
31791
  @click="${this.handleClick}"
@@ -31595,14 +31798,14 @@
31595
31798
  </button>
31596
31799
 
31597
31800
  <span
31598
- class="status-label ${showStatus ? "visible" : ""} ${showCommandFeedback
31599
- ? `command-${this.commandFeedback}`
31801
+ class="status-label ${showStatus ? "visible" : ""} ${showActionFeedback
31802
+ ? this.actionFeedback
31600
31803
  : showVisualizer
31601
31804
  ? "visualizer"
31602
31805
  : this.getStatusClass()}"
31603
31806
  >
31604
- ${showCommandFeedback
31605
- ? this.getCommandFeedbackLabel()
31807
+ ${showActionFeedback
31808
+ ? this.getActionFeedbackLabel()
31606
31809
  : showVisualizer
31607
31810
  ? b `<speechos-audio-visualizer
31608
31811
  ?active="${showVisualizer}"
@@ -31648,9 +31851,15 @@
31648
31851
  __decorate([
31649
31852
  n({ type: String })
31650
31853
  ], SpeechOSMicButton.prototype, "errorMessage", void 0);
31854
+ __decorate([
31855
+ n({ type: Boolean })
31856
+ ], SpeechOSMicButton.prototype, "showRetryButton", void 0);
31651
31857
  __decorate([
31652
31858
  n({ type: String })
31653
- ], SpeechOSMicButton.prototype, "commandFeedback", void 0);
31859
+ ], SpeechOSMicButton.prototype, "actionFeedback", void 0);
31860
+ __decorate([
31861
+ n({ type: Boolean })
31862
+ ], SpeechOSMicButton.prototype, "showNoAudioWarning", void 0);
31654
31863
  SpeechOSMicButton = __decorate([
31655
31864
  t$1("speechos-mic-button")
31656
31865
  ], SpeechOSMicButton);
@@ -34680,6 +34889,7 @@
34680
34889
  super(...arguments);
34681
34890
  this.open = false;
34682
34891
  this.text = "";
34892
+ this.mode = "dictation";
34683
34893
  this.copied = false;
34684
34894
  this.copyTimeout = null;
34685
34895
  }
@@ -34759,6 +34969,41 @@
34759
34969
  color: #10b981;
34760
34970
  flex-shrink: 0;
34761
34971
  }
34972
+
34973
+ /* Edit mode styles */
34974
+ :host([mode="edit"]) .logo-icon {
34975
+ background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
34976
+ }
34977
+
34978
+ :host([mode="edit"]) .modal-title {
34979
+ background: linear-gradient(135deg, #a78bfa 0%, #818cf8 100%);
34980
+ -webkit-background-clip: text;
34981
+ -webkit-text-fill-color: transparent;
34982
+ background-clip: text;
34983
+ }
34984
+
34985
+ :host([mode="edit"]) .hint {
34986
+ background: rgba(139, 92, 246, 0.08);
34987
+ }
34988
+
34989
+ :host([mode="edit"]) .hint-icon {
34990
+ color: #8b5cf6;
34991
+ }
34992
+
34993
+ :host([mode="edit"]) .btn-primary {
34994
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
34995
+ box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
34996
+ }
34997
+
34998
+ :host([mode="edit"]) .btn-primary:hover {
34999
+ background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
35000
+ box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
35001
+ }
35002
+
35003
+ :host([mode="edit"]) .btn-success {
35004
+ background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
35005
+ box-shadow: 0 4px 12px rgba(167, 139, 250, 0.3);
35006
+ }
34762
35007
  `,
34763
35008
  ]; }
34764
35009
  disconnectedCallback() {
@@ -34811,6 +35056,17 @@
34811
35056
  console.error("[SpeechOS] Failed to copy text:", err);
34812
35057
  }
34813
35058
  }
35059
+ get modalTitle() {
35060
+ return this.mode === "edit" ? "Edit Complete" : "Dictation Complete";
35061
+ }
35062
+ get modalIcon() {
35063
+ return this.mode === "edit" ? editIcon(18) : micIcon(18);
35064
+ }
35065
+ get hintText() {
35066
+ return this.mode === "edit"
35067
+ ? "Tip: The editor didn't accept the edit. Copy and paste manually."
35068
+ : "Tip: Focus a text field first to auto-insert next time";
35069
+ }
34814
35070
  render() {
34815
35071
  return b `
34816
35072
  <div
@@ -34820,8 +35076,8 @@
34820
35076
  <div class="modal-card">
34821
35077
  <div class="modal-header">
34822
35078
  <div class="header-content">
34823
- <div class="logo-icon">${micIcon(18)}</div>
34824
- <h2 class="modal-title">Dictation Complete</h2>
35079
+ <div class="logo-icon">${this.modalIcon}</div>
35080
+ <h2 class="modal-title">${this.modalTitle}</h2>
34825
35081
  </div>
34826
35082
  <button
34827
35083
  class="close-button"
@@ -34838,7 +35094,7 @@
34838
35094
  <svg class="hint-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
34839
35095
  <circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>
34840
35096
  </svg>
34841
- <span>Tip: Focus a text field first to auto-insert next time</span>
35097
+ <span>${this.hintText}</span>
34842
35098
  </div>
34843
35099
  </div>
34844
35100
 
@@ -34865,6 +35121,9 @@
34865
35121
  __decorate([
34866
35122
  n({ type: String })
34867
35123
  ], SpeechOSDictationOutputModal.prototype, "text", void 0);
35124
+ __decorate([
35125
+ n({ type: String, reflect: true })
35126
+ ], SpeechOSDictationOutputModal.prototype, "mode", void 0);
34868
35127
  __decorate([
34869
35128
  r()
34870
35129
  ], SpeechOSDictationOutputModal.prototype, "copied", void 0);
@@ -35024,15 +35283,29 @@
35024
35283
  * duration so users can see the visual feedback before transitioning to recording.
35025
35284
  */
35026
35285
  const MIN_CONNECTING_ANIMATION_MS = 200;
35286
+ /**
35287
+ * Time to wait for a transcription event before showing the "no audio" warning (in milliseconds).
35288
+ * If no transcription:interim event is received within this time during recording,
35289
+ * it indicates the server isn't receiving/processing audio.
35290
+ */
35291
+ const NO_AUDIO_WARNING_TIMEOUT_MS = 5000;
35292
+ /**
35293
+ * Number of consecutive actions with empty results before showing warning on next action.
35294
+ */
35295
+ const CONSECUTIVE_NO_AUDIO_THRESHOLD = 2;
35027
35296
  let SpeechOSWidget = class SpeechOSWidget extends i$1 {
35028
35297
  constructor() {
35029
35298
  super(...arguments);
35030
35299
  this.widgetState = state.getState();
35031
35300
  this.settingsOpen = false;
35301
+ this.settingsOpenFromWarning = false;
35032
35302
  this.dictationModalOpen = false;
35033
35303
  this.dictationModalText = "";
35304
+ this.dictationModalMode = "dictation";
35034
35305
  this.editHelpModalOpen = false;
35035
- this.commandFeedback = null;
35306
+ this.actionFeedback = null;
35307
+ this.showNoAudioWarning = false;
35308
+ this.isErrorRetryable = true;
35036
35309
  this.dictationTargetElement = null;
35037
35310
  this.editTargetElement = null;
35038
35311
  this.dictationCursorStart = null;
@@ -35044,7 +35317,7 @@
35044
35317
  this.modalElement = null;
35045
35318
  this.dictationModalElement = null;
35046
35319
  this.editHelpModalElement = null;
35047
- this.commandFeedbackTimeout = null;
35320
+ this.actionFeedbackTimeout = null;
35048
35321
  this.customPosition = null;
35049
35322
  this.isDragging = false;
35050
35323
  this.dragStartPos = null;
@@ -35054,6 +35327,11 @@
35054
35327
  this.suppressNextClick = false;
35055
35328
  this.boundViewportResizeHandler = null;
35056
35329
  this.boundScrollHandler = null;
35330
+ // No-audio warning state tracking
35331
+ this.consecutiveNoAudioActions = 0;
35332
+ this.transcriptionReceived = false;
35333
+ this.noAudioWarningTimeout = null;
35334
+ this.transcriptionInterimUnsubscribe = null;
35057
35335
  }
35058
35336
  static { SpeechOSWidget_1 = this; }
35059
35337
  static { this.styles = [
@@ -35136,6 +35414,7 @@
35136
35414
  this.modalElement = document.createElement("speechos-settings-modal");
35137
35415
  this.modalElement.addEventListener("modal-close", () => {
35138
35416
  this.settingsOpen = false;
35417
+ this.settingsOpenFromWarning = false;
35139
35418
  });
35140
35419
  document.body.appendChild(this.modalElement);
35141
35420
  // Mount dictation output modal
@@ -35151,7 +35430,17 @@
35151
35430
  });
35152
35431
  document.body.appendChild(this.editHelpModalElement);
35153
35432
  this.stateUnsubscribe = state.subscribe((newState) => {
35154
- if (!newState.isVisible || !newState.isExpanded) {
35433
+ if (!newState.isVisible) {
35434
+ if (getConfig().debug && this.settingsOpen) {
35435
+ console.log("[SpeechOS] Closing settings modal: widget hidden");
35436
+ }
35437
+ this.settingsOpen = false;
35438
+ this.settingsOpenFromWarning = false;
35439
+ }
35440
+ else if (!newState.isExpanded && !this.settingsOpenFromWarning) {
35441
+ if (getConfig().debug && this.settingsOpen) {
35442
+ console.log("[SpeechOS] Closing settings modal: widget collapsed");
35443
+ }
35155
35444
  this.settingsOpen = false;
35156
35445
  }
35157
35446
  // Clear custom position when focused element changes (re-anchor to new element)
@@ -35165,6 +35454,8 @@
35165
35454
  this.errorEventUnsubscribe = events.on("error", (payload) => {
35166
35455
  if (this.widgetState.recordingState !== "idle" &&
35167
35456
  this.widgetState.recordingState !== "error") {
35457
+ // Check if this is a non-retryable error (e.g., CSP blocked connection)
35458
+ this.isErrorRetryable = payload.code !== "connection_blocked";
35168
35459
  state.setError(payload.message);
35169
35460
  getBackend().disconnect().catch(() => { });
35170
35461
  }
@@ -35197,9 +35488,9 @@
35197
35488
  this.editHelpModalElement.remove();
35198
35489
  this.editHelpModalElement = null;
35199
35490
  }
35200
- if (this.commandFeedbackTimeout) {
35201
- clearTimeout(this.commandFeedbackTimeout);
35202
- this.commandFeedbackTimeout = null;
35491
+ if (this.actionFeedbackTimeout) {
35492
+ clearTimeout(this.actionFeedbackTimeout);
35493
+ this.actionFeedbackTimeout = null;
35203
35494
  }
35204
35495
  if (this.stateUnsubscribe) {
35205
35496
  this.stateUnsubscribe();
@@ -35227,6 +35518,7 @@
35227
35518
  window.removeEventListener("scroll", this.boundScrollHandler);
35228
35519
  this.boundScrollHandler = null;
35229
35520
  }
35521
+ this.cleanupNoAudioWarningTracking();
35230
35522
  }
35231
35523
  updated(changedProperties) {
35232
35524
  if (changedProperties.has("settingsOpen") && this.modalElement) {
@@ -35238,6 +35530,9 @@
35238
35530
  if (changedProperties.has("dictationModalText") && this.dictationModalElement) {
35239
35531
  this.dictationModalElement.text = this.dictationModalText;
35240
35532
  }
35533
+ if (changedProperties.has("dictationModalMode") && this.dictationModalElement) {
35534
+ this.dictationModalElement.mode = this.dictationModalMode;
35535
+ }
35241
35536
  if (changedProperties.has("editHelpModalOpen") && this.editHelpModalElement) {
35242
35537
  this.editHelpModalElement.open = this.editHelpModalOpen;
35243
35538
  }
@@ -35403,7 +35698,7 @@
35403
35698
  }
35404
35699
  if (this.widgetState.recordingState === "idle") {
35405
35700
  // Clear command feedback on any mic click
35406
- this.clearCommandFeedback();
35701
+ this.clearActionFeedback();
35407
35702
  // If we're expanding, prefetch the token to reduce latency when user selects an action
35408
35703
  if (!this.widgetState.isExpanded) {
35409
35704
  // Fire and forget - we don't need to wait for this (LiveKit only)
@@ -35420,6 +35715,8 @@
35420
35715
  }
35421
35716
  }
35422
35717
  async handleStopRecording() {
35718
+ // Clean up no-audio warning tracking
35719
+ this.cleanupNoAudioWarningTracking();
35423
35720
  if (this.widgetState.activeAction === "edit") {
35424
35721
  await this.handleStopEdit();
35425
35722
  }
@@ -35431,14 +35728,27 @@
35431
35728
  const backend = getBackend();
35432
35729
  try {
35433
35730
  const transcription = await this.withMinDisplayTime(backend.stopVoiceSession(), 300);
35731
+ // Track result for consecutive failure detection
35732
+ this.trackActionResult(!!transcription);
35434
35733
  if (transcription) {
35734
+ if (getConfig().debug) {
35735
+ console.log("[SpeechOS] Transcription received:", {
35736
+ transcription,
35737
+ dictationTargetElement: this.dictationTargetElement,
35738
+ tagName: this.dictationTargetElement?.tagName,
35739
+ });
35740
+ }
35435
35741
  // Check if we have a target element to insert into
35436
35742
  if (this.dictationTargetElement) {
35437
35743
  this.insertTranscription(transcription);
35438
35744
  }
35439
35745
  else {
35440
35746
  // No target element - show dictation output modal
35747
+ if (getConfig().debug) {
35748
+ console.log("[SpeechOS] No target element, showing dictation modal");
35749
+ }
35441
35750
  this.dictationModalText = transcription;
35751
+ this.dictationModalMode = "dictation";
35442
35752
  this.dictationModalOpen = true;
35443
35753
  }
35444
35754
  transcriptStore.saveTranscript(transcription, "dictate");
@@ -35450,6 +35760,8 @@
35450
35760
  backend.startAutoRefresh?.();
35451
35761
  }
35452
35762
  catch (error) {
35763
+ // Track as failed result
35764
+ this.trackActionResult(false);
35453
35765
  const errorMessage = error instanceof Error ? error.message : "Failed to transcribe audio";
35454
35766
  if (errorMessage !== "Disconnected") {
35455
35767
  state.setError(errorMessage);
@@ -35459,6 +35771,8 @@
35459
35771
  }
35460
35772
  }
35461
35773
  async handleCancelOperation() {
35774
+ // Clean up no-audio warning tracking
35775
+ this.cleanupNoAudioWarningTracking();
35462
35776
  await getBackend().disconnect();
35463
35777
  if (this.widgetState.recordingState === "error") {
35464
35778
  state.clearError();
@@ -35488,7 +35802,7 @@
35488
35802
  }
35489
35803
  }
35490
35804
  handleCloseWidget() {
35491
- this.clearCommandFeedback();
35805
+ this.clearActionFeedback();
35492
35806
  state.hide();
35493
35807
  }
35494
35808
  handleSettingsClick() {
@@ -35578,45 +35892,70 @@
35578
35892
  return;
35579
35893
  }
35580
35894
  const tagName = target.tagName.toLowerCase();
35895
+ const originalContent = this.getElementContent(target) || "";
35581
35896
  if (tagName === "input" || tagName === "textarea") {
35582
35897
  const inputEl = target;
35898
+ // Restore cursor position before inserting
35583
35899
  const start = this.dictationCursorStart ?? inputEl.value.length;
35584
35900
  const end = this.dictationCursorEnd ?? inputEl.value.length;
35585
- const before = inputEl.value.substring(0, start);
35586
- const after = inputEl.value.substring(end);
35587
- inputEl.value = before + text + after;
35588
- if (this.supportsSelection(inputEl)) {
35589
- const newCursorPos = start + text.length;
35590
- inputEl.setSelectionRange(newCursorPos, newCursorPos);
35591
- }
35592
- inputEl.dispatchEvent(new Event("input", { bubbles: true }));
35593
- inputEl.focus();
35901
+ inputEl.setSelectionRange(start, end);
35902
+ // Use text-field-edit to insert text (handles undo, events, etc.)
35903
+ insertTextIntoField(inputEl, text);
35594
35904
  state.setFocusedElement(inputEl);
35595
35905
  }
35596
35906
  else if (target.isContentEditable) {
35597
35907
  target.focus();
35598
35908
  state.setFocusedElement(target);
35599
- const textNode = document.createTextNode(text);
35600
- target.appendChild(textNode);
35601
- const selection = window.getSelection();
35602
- if (selection) {
35603
- const range = document.createRange();
35604
- range.selectNodeContents(textNode);
35605
- range.collapse(false);
35606
- selection.removeAllRanges();
35607
- selection.addRange(range);
35608
- }
35609
- target.dispatchEvent(new Event("input", { bubbles: true }));
35909
+ // Use text-field-edit for contentEditable elements
35910
+ insertTextIntoField(target, text);
35610
35911
  }
35611
35912
  events.emit("transcription:inserted", { text, element: target });
35913
+ // Verify insertion was applied after DOM updates
35914
+ this.verifyInsertionApplied(target, text, originalContent);
35612
35915
  this.dictationTargetElement = null;
35613
35916
  this.dictationCursorStart = null;
35614
35917
  this.dictationCursorEnd = null;
35615
35918
  }
35919
+ /**
35920
+ * Verify that a dictation insertion was actually applied to the target element.
35921
+ * Some custom editors (CodeMirror, Monaco, Slate, etc.) don't respond to
35922
+ * standard DOM editing methods. If the insertion fails, show a fallback modal.
35923
+ */
35924
+ verifyInsertionApplied(target, insertedText, originalContent) {
35925
+ // Use requestAnimationFrame to check after DOM updates
35926
+ requestAnimationFrame(() => {
35927
+ const tagName = target.tagName.toLowerCase();
35928
+ let currentContent = "";
35929
+ if (tagName === "input" || tagName === "textarea") {
35930
+ currentContent = target.value;
35931
+ }
35932
+ else if (target.isContentEditable) {
35933
+ currentContent = target.textContent || "";
35934
+ }
35935
+ // Check if the insertion was applied:
35936
+ // - Content should contain the inserted text
35937
+ // - Or content should be different from original (for empty fields)
35938
+ const insertionApplied = currentContent.includes(insertedText) ||
35939
+ (originalContent === "" && currentContent !== "");
35940
+ if (!insertionApplied) {
35941
+ if (getConfig().debug) {
35942
+ console.log("[SpeechOS] Dictation failed to insert, showing fallback modal", {
35943
+ insertedText,
35944
+ currentContent,
35945
+ originalContent,
35946
+ });
35947
+ }
35948
+ // Show fallback modal with dictation mode styling
35949
+ this.dictationModalText = insertedText;
35950
+ this.dictationModalMode = "dictation";
35951
+ this.dictationModalOpen = true;
35952
+ }
35953
+ });
35954
+ }
35616
35955
  handleActionSelect(event) {
35617
35956
  const { action } = event.detail;
35618
35957
  // Clear any existing command feedback when a new action is selected
35619
- this.clearCommandFeedback();
35958
+ this.clearActionFeedback();
35620
35959
  state.setActiveAction(action);
35621
35960
  if (action === "dictate") {
35622
35961
  this.startDictation();
@@ -35651,6 +35990,13 @@
35651
35990
  this.dictationTargetElement = this.widgetState.focusedElement;
35652
35991
  this.dictationCursorStart = null;
35653
35992
  this.dictationCursorEnd = null;
35993
+ if (getConfig().debug) {
35994
+ console.log("[SpeechOS] startDictation:", {
35995
+ focusedElement: this.widgetState.focusedElement,
35996
+ dictationTargetElement: this.dictationTargetElement,
35997
+ tagName: this.dictationTargetElement?.tagName,
35998
+ });
35999
+ }
35654
36000
  if (this.dictationTargetElement) {
35655
36001
  const tagName = this.dictationTargetElement.tagName.toLowerCase();
35656
36002
  if (tagName === "input" || tagName === "textarea") {
@@ -35676,13 +36022,18 @@
35676
36022
  // Ensure minimum animation duration before transitioning to recording
35677
36023
  const elapsed = Date.now() - connectingStartTime;
35678
36024
  const remainingDelay = MIN_CONNECTING_ANIMATION_MS - elapsed;
36025
+ const startRecording = () => {
36026
+ if (state.getState().recordingState === "error") {
36027
+ return;
36028
+ }
36029
+ state.setRecordingState("recording");
36030
+ this.startNoAudioWarningTracking();
36031
+ };
35679
36032
  if (remainingDelay > 0) {
35680
- setTimeout(() => {
35681
- state.setRecordingState("recording");
35682
- }, remainingDelay);
36033
+ setTimeout(startRecording, remainingDelay);
35683
36034
  }
35684
36035
  else {
35685
- state.setRecordingState("recording");
36036
+ startRecording();
35686
36037
  }
35687
36038
  },
35688
36039
  });
@@ -35690,7 +36041,10 @@
35690
36041
  catch (error) {
35691
36042
  const errorMessage = error instanceof Error ? error.message : "Connection failed";
35692
36043
  if (errorMessage !== "Disconnected") {
35693
- state.setError(`Failed to connect: ${errorMessage}`);
36044
+ // Only set error if not already in error state (error event may have already set it)
36045
+ if (this.widgetState.recordingState !== "error") {
36046
+ state.setError(`Failed to connect: ${errorMessage}`);
36047
+ }
35694
36048
  await backend.disconnect();
35695
36049
  }
35696
36050
  }
@@ -35700,6 +36054,13 @@
35700
36054
  this.editSelectionStart = null;
35701
36055
  this.editSelectionEnd = null;
35702
36056
  this.editSelectedText = "";
36057
+ if (getConfig().debug) {
36058
+ console.log("[SpeechOS] startEdit:", {
36059
+ focusedElement: this.widgetState.focusedElement,
36060
+ editTargetElement: this.editTargetElement,
36061
+ tagName: this.editTargetElement?.tagName,
36062
+ });
36063
+ }
35703
36064
  if (this.editTargetElement) {
35704
36065
  const tagName = this.editTargetElement.tagName.toLowerCase();
35705
36066
  if (tagName === "input" || tagName === "textarea") {
@@ -35710,7 +36071,8 @@
35710
36071
  const start = this.editSelectionStart ?? 0;
35711
36072
  const end = this.editSelectionEnd ?? 0;
35712
36073
  if (start !== end) {
35713
- this.editSelectedText = inputEl.value.substring(start, end);
36074
+ // Use getFieldSelection from text-field-edit
36075
+ this.editSelectedText = getFieldSelection(inputEl);
35714
36076
  }
35715
36077
  }
35716
36078
  else {
@@ -35719,13 +36081,11 @@
35719
36081
  }
35720
36082
  }
35721
36083
  else if (this.editTargetElement.isContentEditable) {
35722
- const selection = window.getSelection();
35723
- if (selection && selection.rangeCount > 0) {
35724
- const selectedText = selection.toString();
35725
- this.editSelectionStart = 0;
35726
- this.editSelectionEnd = selectedText.length;
35727
- this.editSelectedText = selectedText;
35728
- }
36084
+ // Use getFieldSelection from text-field-edit for contentEditable too
36085
+ const selectedText = getFieldSelection(this.editTargetElement);
36086
+ this.editSelectionStart = 0;
36087
+ this.editSelectionEnd = selectedText.length;
36088
+ this.editSelectedText = selectedText;
35729
36089
  }
35730
36090
  }
35731
36091
  // Capture the content to edit at start time (sent with auth message)
@@ -35742,13 +36102,18 @@
35742
36102
  // Ensure minimum animation duration before transitioning to recording
35743
36103
  const elapsed = Date.now() - connectingStartTime;
35744
36104
  const remainingDelay = MIN_CONNECTING_ANIMATION_MS - elapsed;
36105
+ const startRecording = () => {
36106
+ if (state.getState().recordingState === "error") {
36107
+ return;
36108
+ }
36109
+ state.setRecordingState("recording");
36110
+ this.startNoAudioWarningTracking();
36111
+ };
35745
36112
  if (remainingDelay > 0) {
35746
- setTimeout(() => {
35747
- state.setRecordingState("recording");
35748
- }, remainingDelay);
36113
+ setTimeout(startRecording, remainingDelay);
35749
36114
  }
35750
36115
  else {
35751
- state.setRecordingState("recording");
36116
+ startRecording();
35752
36117
  }
35753
36118
  },
35754
36119
  });
@@ -35756,7 +36121,10 @@
35756
36121
  catch (error) {
35757
36122
  const errorMessage = error instanceof Error ? error.message : "Connection failed";
35758
36123
  if (errorMessage !== "Disconnected") {
35759
- state.setError(`Failed to connect: ${errorMessage}`);
36124
+ // Only set error if not already in error state (error event may have already set it)
36125
+ if (this.widgetState.recordingState !== "error") {
36126
+ state.setError(`Failed to connect: ${errorMessage}`);
36127
+ }
35760
36128
  await backend.disconnect();
35761
36129
  }
35762
36130
  }
@@ -35767,12 +36135,30 @@
35767
36135
  const backend = getBackend();
35768
36136
  try {
35769
36137
  const editedText = await this.withMinDisplayTime(backend.requestEditText(originalContent), 300);
36138
+ // Check if server returned no change (couldn't understand edit)
36139
+ const noChange = editedText.trim() === originalContent.trim();
36140
+ if (noChange) {
36141
+ this.trackActionResult(false);
36142
+ this.showActionFeedback("edit-empty");
36143
+ state.completeRecording();
36144
+ this.editTargetElement = null;
36145
+ this.editSelectionStart = null;
36146
+ this.editSelectionEnd = null;
36147
+ this.editSelectedText = "";
36148
+ backend.disconnect().catch(() => { });
36149
+ backend.startAutoRefresh?.();
36150
+ return;
36151
+ }
36152
+ // Track result - got a meaningful change
36153
+ this.trackActionResult(true);
35770
36154
  this.applyEdit(editedText);
35771
36155
  backend.disconnect().catch(() => { });
35772
36156
  // Start auto-refresh to keep token fresh for subsequent commands (LiveKit only)
35773
36157
  backend.startAutoRefresh?.();
35774
36158
  }
35775
36159
  catch (error) {
36160
+ // Track as failed result
36161
+ this.trackActionResult(false);
35776
36162
  const errorMessage = error instanceof Error ? error.message : "Failed to apply edit";
35777
36163
  if (errorMessage !== "Disconnected") {
35778
36164
  state.setError(errorMessage);
@@ -35795,13 +36181,18 @@
35795
36181
  // Ensure minimum animation duration before transitioning to recording
35796
36182
  const elapsed = Date.now() - connectingStartTime;
35797
36183
  const remainingDelay = MIN_CONNECTING_ANIMATION_MS - elapsed;
36184
+ const startRecording = () => {
36185
+ if (state.getState().recordingState === "error") {
36186
+ return;
36187
+ }
36188
+ state.setRecordingState("recording");
36189
+ this.startNoAudioWarningTracking();
36190
+ };
35798
36191
  if (remainingDelay > 0) {
35799
- setTimeout(() => {
35800
- state.setRecordingState("recording");
35801
- }, remainingDelay);
36192
+ setTimeout(startRecording, remainingDelay);
35802
36193
  }
35803
36194
  else {
35804
- state.setRecordingState("recording");
36195
+ startRecording();
35805
36196
  }
35806
36197
  },
35807
36198
  });
@@ -35809,7 +36200,10 @@
35809
36200
  catch (error) {
35810
36201
  const errorMessage = error instanceof Error ? error.message : "Connection failed";
35811
36202
  if (errorMessage !== "Disconnected") {
35812
- state.setError(`Failed to connect: ${errorMessage}`);
36203
+ // Only set error if not already in error state (error event may have already set it)
36204
+ if (this.widgetState.recordingState !== "error") {
36205
+ state.setError(`Failed to connect: ${errorMessage}`);
36206
+ }
35813
36207
  await backend.disconnect();
35814
36208
  }
35815
36209
  }
@@ -35821,6 +36215,8 @@
35821
36215
  const backend = getBackend();
35822
36216
  try {
35823
36217
  const result = await this.withMinDisplayTime(backend.requestCommand(commands), 300);
36218
+ // Track result - null result means no command matched (possibly no audio)
36219
+ this.trackActionResult(result !== null);
35824
36220
  // Get input text from the backend if available
35825
36221
  const inputText = backend.getLastInputText?.();
35826
36222
  // Save to transcript store
@@ -35838,12 +36234,14 @@
35838
36234
  // Keep widget visible but collapsed (just mic button, no action bubbles)
35839
36235
  state.setState({ isExpanded: false });
35840
36236
  // Show command feedback
35841
- this.showCommandFeedback(result ? "success" : "none");
36237
+ this.showActionFeedback(result ? "command-success" : "command-none");
35842
36238
  backend.disconnect().catch(() => { });
35843
36239
  // Start auto-refresh to keep token fresh for subsequent commands (LiveKit only)
35844
36240
  backend.startAutoRefresh?.();
35845
36241
  }
35846
36242
  catch (error) {
36243
+ // Track as failed result
36244
+ this.trackActionResult(false);
35847
36245
  const errorMessage = error instanceof Error ? error.message : "Failed to process command";
35848
36246
  if (errorMessage !== "Disconnected") {
35849
36247
  state.setError(errorMessage);
@@ -35851,24 +36249,110 @@
35851
36249
  }
35852
36250
  }
35853
36251
  }
35854
- showCommandFeedback(feedback) {
35855
- this.commandFeedback = feedback;
36252
+ showActionFeedback(feedback) {
36253
+ this.actionFeedback = feedback;
35856
36254
  // Clear any existing timeout
35857
- if (this.commandFeedbackTimeout) {
35858
- clearTimeout(this.commandFeedbackTimeout);
36255
+ if (this.actionFeedbackTimeout) {
36256
+ clearTimeout(this.actionFeedbackTimeout);
35859
36257
  }
35860
36258
  // Auto-dismiss after 4 seconds
35861
- this.commandFeedbackTimeout = window.setTimeout(() => {
35862
- this.commandFeedback = null;
35863
- this.commandFeedbackTimeout = null;
36259
+ this.actionFeedbackTimeout = window.setTimeout(() => {
36260
+ this.actionFeedback = null;
36261
+ this.actionFeedbackTimeout = null;
35864
36262
  }, 4000);
35865
36263
  }
35866
- clearCommandFeedback() {
35867
- if (this.commandFeedbackTimeout) {
35868
- clearTimeout(this.commandFeedbackTimeout);
35869
- this.commandFeedbackTimeout = null;
36264
+ clearActionFeedback() {
36265
+ if (this.actionFeedbackTimeout) {
36266
+ clearTimeout(this.actionFeedbackTimeout);
36267
+ this.actionFeedbackTimeout = null;
35870
36268
  }
35871
- this.commandFeedback = null;
36269
+ this.actionFeedback = null;
36270
+ }
36271
+ /**
36272
+ * Start tracking for no-audio warning when recording begins.
36273
+ */
36274
+ startNoAudioWarningTracking() {
36275
+ this.transcriptionReceived = false;
36276
+ this.showNoAudioWarning = false;
36277
+ // If we had consecutive failures, show warning immediately
36278
+ if (this.consecutiveNoAudioActions >= CONSECUTIVE_NO_AUDIO_THRESHOLD) {
36279
+ this.showNoAudioWarning = true;
36280
+ }
36281
+ // Start timeout - if no transcription within 5s, show warning
36282
+ this.noAudioWarningTimeout = window.setTimeout(() => {
36283
+ if (!this.transcriptionReceived &&
36284
+ this.widgetState.recordingState === "recording") {
36285
+ this.showNoAudioWarning = true;
36286
+ }
36287
+ }, NO_AUDIO_WARNING_TIMEOUT_MS);
36288
+ // Subscribe to transcription:interim events
36289
+ this.transcriptionInterimUnsubscribe = events.on("transcription:interim", () => {
36290
+ this.transcriptionReceived = true;
36291
+ if (this.showNoAudioWarning) {
36292
+ this.showNoAudioWarning = false;
36293
+ }
36294
+ });
36295
+ }
36296
+ /**
36297
+ * Clean up no-audio warning tracking when recording stops.
36298
+ */
36299
+ cleanupNoAudioWarningTracking() {
36300
+ if (this.noAudioWarningTimeout !== null) {
36301
+ clearTimeout(this.noAudioWarningTimeout);
36302
+ this.noAudioWarningTimeout = null;
36303
+ }
36304
+ if (this.transcriptionInterimUnsubscribe) {
36305
+ this.transcriptionInterimUnsubscribe();
36306
+ this.transcriptionInterimUnsubscribe = null;
36307
+ }
36308
+ this.showNoAudioWarning = false;
36309
+ }
36310
+ /**
36311
+ * Track the result of an action for consecutive failure detection.
36312
+ */
36313
+ trackActionResult(hasContent) {
36314
+ if (hasContent) {
36315
+ this.consecutiveNoAudioActions = 0;
36316
+ }
36317
+ else {
36318
+ this.consecutiveNoAudioActions++;
36319
+ }
36320
+ }
36321
+ /**
36322
+ * Handle opening settings from the no-audio warning.
36323
+ * Stops the current dictation session immediately, then opens settings.
36324
+ */
36325
+ async handleOpenSettingsFromWarning() {
36326
+ if (getConfig().debug) {
36327
+ console.log("[SpeechOS] No-audio settings link clicked");
36328
+ }
36329
+ // Clean up no-audio warning tracking first
36330
+ this.cleanupNoAudioWarningTracking();
36331
+ // Keep settings open even if widget collapses
36332
+ this.settingsOpenFromWarning = true;
36333
+ // Stop audio capture and disconnect immediately (don't wait for transcription)
36334
+ // Kick this off before opening settings so audio stops fast, but don't block UI.
36335
+ const disconnectPromise = getBackend().disconnect().catch((error) => {
36336
+ if (getConfig().debug) {
36337
+ console.log("[SpeechOS] Disconnect failed while opening settings", error);
36338
+ }
36339
+ });
36340
+ // Update UI state to idle
36341
+ state.cancelRecording();
36342
+ // Clear target elements
36343
+ this.dictationTargetElement = null;
36344
+ this.editTargetElement = null;
36345
+ this.dictationCursorStart = null;
36346
+ this.dictationCursorEnd = null;
36347
+ this.editSelectionStart = null;
36348
+ this.editSelectionEnd = null;
36349
+ this.editSelectedText = "";
36350
+ // Open settings modal
36351
+ this.settingsOpen = true;
36352
+ if (getConfig().debug) {
36353
+ console.log("[SpeechOS] Settings modal opened from no-audio warning");
36354
+ }
36355
+ await disconnectPromise;
35872
36356
  }
35873
36357
  supportsSelection(element) {
35874
36358
  if (element.tagName.toLowerCase() === "textarea") {
@@ -35884,21 +36368,14 @@
35884
36368
  const tagName = element.tagName.toLowerCase();
35885
36369
  if (tagName === "input" || tagName === "textarea") {
35886
36370
  const inputEl = element;
35887
- const fullContent = inputEl.value;
35888
- const start = this.editSelectionStart ?? 0;
35889
- const end = this.editSelectionEnd ?? fullContent.length;
35890
- const hasSelection = start !== end;
35891
- if (hasSelection) {
35892
- return fullContent.substring(start, end);
35893
- }
35894
- return fullContent;
36371
+ const selectedText = getFieldSelection(inputEl);
36372
+ // If there's selected text, return it; otherwise return full content
36373
+ return selectedText || inputEl.value;
35895
36374
  }
35896
36375
  else if (element.isContentEditable) {
35897
- const selection = window.getSelection();
35898
- if (selection && selection.toString().length > 0) {
35899
- return selection.toString();
35900
- }
35901
- return element.textContent || "";
36376
+ const selectedText = getFieldSelection(element);
36377
+ // If there's selected text, return it; otherwise return full content
36378
+ return selectedText || element.textContent || "";
35902
36379
  }
35903
36380
  return "";
35904
36381
  }
@@ -35913,40 +36390,44 @@
35913
36390
  if (tagName === "input" || tagName === "textarea") {
35914
36391
  const inputEl = target;
35915
36392
  originalContent = inputEl.value;
35916
- inputEl.focus();
35917
- if (this.supportsSelection(inputEl)) {
35918
- const selectionStart = this.editSelectionStart ?? 0;
35919
- const selectionEnd = this.editSelectionEnd ?? inputEl.value.length;
35920
- const hasSelection = selectionStart !== selectionEnd;
35921
- if (hasSelection) {
35922
- inputEl.setSelectionRange(selectionStart, selectionEnd);
35923
- }
35924
- else {
35925
- inputEl.setSelectionRange(0, inputEl.value.length);
35926
- }
35927
- document.execCommand("insertText", false, editedText);
36393
+ // Restore the original selection/cursor position
36394
+ const selectionStart = this.editSelectionStart ?? 0;
36395
+ const selectionEnd = this.editSelectionEnd ?? inputEl.value.length;
36396
+ const hasSelection = selectionStart !== selectionEnd;
36397
+ if (hasSelection) {
36398
+ // Restore selection, then use insertTextIntoField() to replace it
36399
+ inputEl.setSelectionRange(selectionStart, selectionEnd);
36400
+ insertTextIntoField(inputEl, editedText);
35928
36401
  }
35929
36402
  else {
35930
- inputEl.value = editedText;
35931
- inputEl.dispatchEvent(new Event("input", { bubbles: true }));
36403
+ // No selection - replace entire content using setFieldText()
36404
+ setFieldText(inputEl, editedText);
35932
36405
  }
35933
36406
  state.setFocusedElement(inputEl);
35934
36407
  }
35935
36408
  else if (target.isContentEditable) {
35936
36409
  originalContent = target.textContent || "";
35937
- target.focus();
35938
- state.setFocusedElement(target);
35939
36410
  const hasSelection = this.editSelectionStart !== null &&
35940
36411
  this.editSelectionEnd !== null &&
35941
36412
  this.editSelectionStart !== this.editSelectionEnd;
35942
- if (!hasSelection) {
36413
+ if (hasSelection) {
36414
+ // Selection exists - focus and insert (assumes selection is still active or we restore it)
36415
+ target.focus();
36416
+ insertTextIntoField(target, editedText);
36417
+ }
36418
+ else {
36419
+ // No selection - select all content first, then replace with insertTextIntoField()
36420
+ target.focus();
35943
36421
  const selection = window.getSelection();
35944
- const range = document.createRange();
35945
- range.selectNodeContents(target);
35946
- selection?.removeAllRanges();
35947
- selection?.addRange(range);
36422
+ if (selection) {
36423
+ const range = document.createRange();
36424
+ range.selectNodeContents(target);
36425
+ selection.removeAllRanges();
36426
+ selection.addRange(range);
36427
+ }
36428
+ insertTextIntoField(target, editedText);
35948
36429
  }
35949
- document.execCommand("insertText", false, editedText);
36430
+ state.setFocusedElement(target);
35950
36431
  }
35951
36432
  transcriptStore.saveTranscript(editedText, "edit", originalContent);
35952
36433
  events.emit("edit:applied", {
@@ -35955,11 +36436,54 @@
35955
36436
  element: target,
35956
36437
  });
35957
36438
  state.completeRecording();
36439
+ // Verify edit was applied after DOM updates
36440
+ this.verifyEditApplied(target, editedText, originalContent);
35958
36441
  this.editTargetElement = null;
35959
36442
  this.editSelectionStart = null;
35960
36443
  this.editSelectionEnd = null;
35961
36444
  this.editSelectedText = "";
35962
36445
  }
36446
+ /**
36447
+ * Verify that an edit was actually applied to the target element.
36448
+ * Some custom editors (CodeMirror, Monaco, Slate, etc.) don't respond to
36449
+ * standard DOM editing methods. If the edit fails, show a fallback modal.
36450
+ */
36451
+ verifyEditApplied(target, editedText, originalContent) {
36452
+ // Use requestAnimationFrame to check after DOM updates
36453
+ requestAnimationFrame(() => {
36454
+ const tagName = target.tagName.toLowerCase();
36455
+ let currentContent = "";
36456
+ if (tagName === "input" || tagName === "textarea") {
36457
+ currentContent = target.value;
36458
+ }
36459
+ else if (target.isContentEditable) {
36460
+ currentContent = target.textContent || "";
36461
+ }
36462
+ // Normalize whitespace for comparison
36463
+ const normalizedCurrent = currentContent.trim();
36464
+ const normalizedEdited = editedText.trim();
36465
+ const normalizedOriginal = originalContent.trim();
36466
+ // Check if the edit was applied:
36467
+ // - Content should be different from original (unless edit was no-op)
36468
+ // - Content should contain or match the edited text
36469
+ const editApplied = normalizedCurrent !== normalizedOriginal ||
36470
+ normalizedCurrent === normalizedEdited ||
36471
+ normalizedCurrent.includes(normalizedEdited);
36472
+ if (!editApplied) {
36473
+ if (getConfig().debug) {
36474
+ console.log("[SpeechOS] Edit failed to apply, showing fallback modal", {
36475
+ expected: editedText,
36476
+ actual: currentContent,
36477
+ original: originalContent,
36478
+ });
36479
+ }
36480
+ // Show fallback modal with edit mode styling
36481
+ this.dictationModalText = editedText;
36482
+ this.dictationModalMode = "edit";
36483
+ this.dictationModalOpen = true;
36484
+ }
36485
+ });
36486
+ }
35963
36487
  render() {
35964
36488
  if (!this.widgetState.isVisible) {
35965
36489
  this.setAttribute("hidden", "");
@@ -35987,12 +36511,15 @@
35987
36511
  activeAction="${this.widgetState.activeAction || ""}"
35988
36512
  editPreviewText="${this.editSelectedText}"
35989
36513
  errorMessage="${this.widgetState.errorMessage || ""}"
35990
- .commandFeedback="${this.commandFeedback}"
36514
+ ?showRetryButton="${this.isErrorRetryable}"
36515
+ .actionFeedback="${this.actionFeedback}"
36516
+ ?showNoAudioWarning="${this.showNoAudioWarning}"
35991
36517
  @mic-click="${this.handleMicClick}"
35992
36518
  @stop-recording="${this.handleStopRecording}"
35993
36519
  @cancel-operation="${this.handleCancelOperation}"
35994
36520
  @retry-connection="${this.handleRetryConnection}"
35995
36521
  @close-widget="${this.handleCloseWidget}"
36522
+ @open-settings="${this.handleOpenSettingsFromWarning}"
35996
36523
  ></speechos-mic-button>
35997
36524
  </div>
35998
36525
  </div>
@@ -36011,12 +36538,21 @@
36011
36538
  __decorate([
36012
36539
  r()
36013
36540
  ], SpeechOSWidget.prototype, "dictationModalText", void 0);
36541
+ __decorate([
36542
+ r()
36543
+ ], SpeechOSWidget.prototype, "dictationModalMode", void 0);
36014
36544
  __decorate([
36015
36545
  r()
36016
36546
  ], SpeechOSWidget.prototype, "editHelpModalOpen", void 0);
36017
36547
  __decorate([
36018
36548
  r()
36019
- ], SpeechOSWidget.prototype, "commandFeedback", void 0);
36549
+ ], SpeechOSWidget.prototype, "actionFeedback", void 0);
36550
+ __decorate([
36551
+ r()
36552
+ ], SpeechOSWidget.prototype, "showNoAudioWarning", void 0);
36553
+ __decorate([
36554
+ r()
36555
+ ], SpeechOSWidget.prototype, "isErrorRetryable", void 0);
36020
36556
  SpeechOSWidget = SpeechOSWidget_1 = __decorate([
36021
36557
  t$1("speechos-widget")
36022
36558
  ], SpeechOSWidget);