@speechos/client 0.2.6 → 0.2.8

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
  /**
@@ -27212,14 +27214,16 @@
27212
27214
  });
27213
27215
  }
27214
27216
  /**
27215
- * Complete the recording flow and return to idle
27217
+ * Complete the recording flow and return to idle.
27218
+ * Keeps widget visible but collapsed (just mic button, no action bubbles).
27216
27219
  */
27217
27220
  completeRecording() {
27218
27221
  this.setState({
27219
27222
  recordingState: "idle",
27220
27223
  activeAction: null,
27221
27224
  isConnected: false,
27222
- isMicEnabled: false
27225
+ isMicEnabled: false,
27226
+ isExpanded: false
27223
27227
  });
27224
27228
  }
27225
27229
  /**
@@ -28284,6 +28288,8 @@
28284
28288
  const MESSAGE_TYPE_EXECUTE_COMMAND = "execute_command";
28285
28289
  const MESSAGE_TYPE_COMMAND_RESULT = "command_result";
28286
28290
  const MESSAGE_TYPE_ERROR = "error";
28291
+ const WS_OPEN = 1;
28292
+ const WS_CLOSED = 3;
28287
28293
  /**
28288
28294
  * Response timeout in milliseconds.
28289
28295
  */
@@ -28402,7 +28408,10 @@
28402
28408
  state.setMicEnabled(true);
28403
28409
  const wsUrl = this.getWebSocketUrl();
28404
28410
  if (config.debug) console.log("[SpeechOS] Connecting to WebSocket:", wsUrl);
28405
- this.ws = new WebSocket(wsUrl);
28411
+ this.pendingAuth = new Deferred$1();
28412
+ this.pendingAuth.setTimeout(RESPONSE_TIMEOUT_MS, "Connection timed out", "connection_timeout", "connection");
28413
+ const factory = config.webSocketFactory ?? ((url) => new WebSocket(url));
28414
+ this.ws = factory(wsUrl);
28406
28415
  this.ws.onopen = () => {
28407
28416
  if (config.debug) console.log("[SpeechOS] WebSocket connected, authenticating...");
28408
28417
  this.authenticate();
@@ -28411,19 +28420,21 @@
28411
28420
  this.handleMessage(event.data);
28412
28421
  };
28413
28422
  this.ws.onerror = (event) => {
28414
- console.error("[SpeechOS] WebSocket error:", event);
28423
+ const isConnectionBlocked = this.ws?.readyState === WS_CLOSED;
28424
+ const errorCode = isConnectionBlocked ? "connection_blocked" : "websocket_error";
28425
+ const errorMessage = isConnectionBlocked ? "This site's CSP blocks the extension. Try embedded mode instead." : "WebSocket connection error";
28426
+ console.error("[SpeechOS] WebSocket error:", event, { isConnectionBlocked });
28415
28427
  events.emit("error", {
28416
- code: "websocket_error",
28417
- message: "WebSocket connection error",
28428
+ code: errorCode,
28429
+ message: errorMessage,
28418
28430
  source: "connection"
28419
28431
  });
28432
+ if (this.pendingAuth) this.pendingAuth.reject(new Error(errorMessage));
28420
28433
  };
28421
28434
  this.ws.onclose = (event) => {
28422
28435
  if (config.debug) console.log("[SpeechOS] WebSocket closed:", event.code, event.reason);
28423
28436
  state.setConnected(false);
28424
28437
  };
28425
- this.pendingAuth = new Deferred$1();
28426
- this.pendingAuth.setTimeout(RESPONSE_TIMEOUT_MS, "Connection timed out", "connection_timeout", "connection");
28427
28438
  await this.pendingAuth.promise;
28428
28439
  this.pendingAuth = null;
28429
28440
  if (this.audioCapture) this.audioCapture.setReady();
@@ -28472,7 +28483,7 @@
28472
28483
  * Actually send the audio chunk (async operation).
28473
28484
  */
28474
28485
  async doSendAudioChunk(chunk) {
28475
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
28486
+ if (this.ws && this.ws.readyState === WS_OPEN) {
28476
28487
  const arrayBuffer = await chunk.arrayBuffer();
28477
28488
  this.ws.send(arrayBuffer);
28478
28489
  }
@@ -28658,7 +28669,7 @@
28658
28669
  * the transcript. Uses the same pattern as LiveKit's ReadableStream approach.
28659
28670
  */
28660
28671
  async waitForBufferDrain() {
28661
- if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
28672
+ if (!this.ws || this.ws.readyState !== WS_OPEN) return;
28662
28673
  const config = getConfig();
28663
28674
  const startTime = Date.now();
28664
28675
  while (this.ws.bufferedAmount > 0) {
@@ -28674,7 +28685,7 @@
28674
28685
  * Send a JSON message over the WebSocket.
28675
28686
  */
28676
28687
  sendMessage(message) {
28677
- if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message));
28688
+ if (this.ws && this.ws.readyState === WS_OPEN) this.ws.send(JSON.stringify(message));
28678
28689
  }
28679
28690
  /**
28680
28691
  * Disconnect from the WebSocket.
@@ -28716,7 +28727,7 @@
28716
28727
  * Check if connected to WebSocket.
28717
28728
  */
28718
28729
  isConnected() {
28719
- return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
28730
+ return this.ws !== null && this.ws.readyState === WS_OPEN;
28720
28731
  }
28721
28732
  /**
28722
28733
  * Get the last input text from a command result.
@@ -28897,6 +28908,10 @@
28897
28908
  this.focusHandler = (event) => {
28898
28909
  const target = event.target;
28899
28910
  if (isFormField(target)) {
28911
+ console.log("[SpeechOS] FormDetector: focus on form field", {
28912
+ element: target,
28913
+ tagName: target?.tagName,
28914
+ });
28900
28915
  state.setFocusedElement(target);
28901
28916
  state.show();
28902
28917
  events.emit("form:focus", { element: target });
@@ -30152,6 +30167,71 @@
30152
30167
  deleteTranscript: deleteTranscript,
30153
30168
  };
30154
30169
 
30170
+ function isNativeField(field) {
30171
+ return field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement;
30172
+ }
30173
+ /** Call a function after focusing a field and then restore the previous focus afterwards if necessary */
30174
+ function withFocus(field, callback) {
30175
+ const document = field.ownerDocument;
30176
+ const initialFocus = document.activeElement;
30177
+ if (initialFocus === field) {
30178
+ return callback();
30179
+ }
30180
+ try {
30181
+ field.focus();
30182
+ return callback();
30183
+ }
30184
+ finally {
30185
+ field.blur(); // Supports `intialFocus === body`
30186
+ if (initialFocus instanceof HTMLElement) {
30187
+ initialFocus.focus();
30188
+ }
30189
+ }
30190
+ }
30191
+ // This will insert into the focused field. It shouild always be called inside withFocus.
30192
+ // Use this one locally if there are multiple `insertTextIntoField` or `document.execCommand` calls
30193
+ function insertTextWhereverTheFocusIs(document, text) {
30194
+ if (text === '') {
30195
+ // https://github.com/fregante/text-field-edit/issues/16
30196
+ document.execCommand('delete');
30197
+ }
30198
+ else {
30199
+ document.execCommand('insertText', false, text);
30200
+ }
30201
+ }
30202
+ /** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
30203
+ function insertTextIntoField(field, text) {
30204
+ withFocus(field, () => {
30205
+ insertTextWhereverTheFocusIs(field.ownerDocument, text);
30206
+ });
30207
+ }
30208
+ /** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
30209
+ function setFieldText(field, text) {
30210
+ if (isNativeField(field)) {
30211
+ field.select();
30212
+ insertTextIntoField(field, text);
30213
+ }
30214
+ else {
30215
+ const document = field.ownerDocument;
30216
+ withFocus(field, () => {
30217
+ document.execCommand('selectAll', false, text);
30218
+ insertTextWhereverTheFocusIs(document, text);
30219
+ });
30220
+ }
30221
+ }
30222
+ /** Get the selected text in a field or an empty string if nothing is selected. */
30223
+ function getFieldSelection(field) {
30224
+ if (isNativeField(field)) {
30225
+ return field.value.slice(field.selectionStart, field.selectionEnd);
30226
+ }
30227
+ const selection = field.ownerDocument.getSelection();
30228
+ if (selection && field.contains(selection.anchorNode)) {
30229
+ // The selection is inside the field
30230
+ return selection.toString();
30231
+ }
30232
+ return '';
30233
+ }
30234
+
30155
30235
  /**
30156
30236
  * @license
30157
30237
  * Copyright 2017 Google LLC
@@ -30512,6 +30592,7 @@
30512
30592
  this.activeAction = null;
30513
30593
  this.editPreviewText = "";
30514
30594
  this.errorMessage = null;
30595
+ this.showRetryButton = true;
30515
30596
  this.actionFeedback = null;
30516
30597
  this.showNoAudioWarning = false;
30517
30598
  }
@@ -31221,10 +31302,14 @@
31221
31302
  bottom: 72px; /* Above button */
31222
31303
  left: 50%;
31223
31304
  transform: translateX(-50%) translateY(8px);
31305
+ min-width: 200px;
31224
31306
  max-width: 280px;
31307
+ width: max-content;
31225
31308
  font-size: 13px;
31226
31309
  color: white;
31227
31310
  white-space: normal;
31311
+ word-wrap: break-word;
31312
+ overflow-wrap: break-word;
31228
31313
  text-align: center;
31229
31314
  padding: 12px 16px;
31230
31315
  border-radius: 12px;
@@ -31437,6 +31522,7 @@
31437
31522
  .error-message {
31438
31523
  font-size: 15px;
31439
31524
  padding: 14px 18px;
31525
+ min-width: 220px;
31440
31526
  max-width: 300px;
31441
31527
  bottom: 94px;
31442
31528
  }
@@ -31662,9 +31748,13 @@
31662
31748
  ? b `
31663
31749
  <div class="error-message ${showError ? "visible" : ""}">
31664
31750
  ${this.errorMessage}
31665
- <button class="retry-button" @click="${this.handleRetry}">
31666
- Retry Connection
31667
- </button>
31751
+ ${this.showRetryButton
31752
+ ? b `
31753
+ <button class="retry-button" @click="${this.handleRetry}">
31754
+ Retry Connection
31755
+ </button>
31756
+ `
31757
+ : ""}
31668
31758
  </div>
31669
31759
  `
31670
31760
  : ""}
@@ -31763,6 +31853,9 @@
31763
31853
  __decorate([
31764
31854
  n({ type: String })
31765
31855
  ], SpeechOSMicButton.prototype, "errorMessage", void 0);
31856
+ __decorate([
31857
+ n({ type: Boolean })
31858
+ ], SpeechOSMicButton.prototype, "showRetryButton", void 0);
31766
31859
  __decorate([
31767
31860
  n({ type: String })
31768
31861
  ], SpeechOSMicButton.prototype, "actionFeedback", void 0);
@@ -34798,6 +34891,7 @@
34798
34891
  super(...arguments);
34799
34892
  this.open = false;
34800
34893
  this.text = "";
34894
+ this.mode = "dictation";
34801
34895
  this.copied = false;
34802
34896
  this.copyTimeout = null;
34803
34897
  }
@@ -34877,6 +34971,41 @@
34877
34971
  color: #10b981;
34878
34972
  flex-shrink: 0;
34879
34973
  }
34974
+
34975
+ /* Edit mode styles */
34976
+ :host([mode="edit"]) .logo-icon {
34977
+ background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
34978
+ }
34979
+
34980
+ :host([mode="edit"]) .modal-title {
34981
+ background: linear-gradient(135deg, #a78bfa 0%, #818cf8 100%);
34982
+ -webkit-background-clip: text;
34983
+ -webkit-text-fill-color: transparent;
34984
+ background-clip: text;
34985
+ }
34986
+
34987
+ :host([mode="edit"]) .hint {
34988
+ background: rgba(139, 92, 246, 0.08);
34989
+ }
34990
+
34991
+ :host([mode="edit"]) .hint-icon {
34992
+ color: #8b5cf6;
34993
+ }
34994
+
34995
+ :host([mode="edit"]) .btn-primary {
34996
+ background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
34997
+ box-shadow: 0 4px 12px rgba(139, 92, 246, 0.3);
34998
+ }
34999
+
35000
+ :host([mode="edit"]) .btn-primary:hover {
35001
+ background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
35002
+ box-shadow: 0 6px 16px rgba(139, 92, 246, 0.4);
35003
+ }
35004
+
35005
+ :host([mode="edit"]) .btn-success {
35006
+ background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%);
35007
+ box-shadow: 0 4px 12px rgba(167, 139, 250, 0.3);
35008
+ }
34880
35009
  `,
34881
35010
  ]; }
34882
35011
  disconnectedCallback() {
@@ -34929,6 +35058,17 @@
34929
35058
  console.error("[SpeechOS] Failed to copy text:", err);
34930
35059
  }
34931
35060
  }
35061
+ get modalTitle() {
35062
+ return this.mode === "edit" ? "Edit Complete" : "Dictation Complete";
35063
+ }
35064
+ get modalIcon() {
35065
+ return this.mode === "edit" ? editIcon(18) : micIcon(18);
35066
+ }
35067
+ get hintText() {
35068
+ return this.mode === "edit"
35069
+ ? "Tip: The editor didn't accept the edit. Copy and paste manually."
35070
+ : "Tip: Focus a text field first to auto-insert next time";
35071
+ }
34932
35072
  render() {
34933
35073
  return b `
34934
35074
  <div
@@ -34938,8 +35078,8 @@
34938
35078
  <div class="modal-card">
34939
35079
  <div class="modal-header">
34940
35080
  <div class="header-content">
34941
- <div class="logo-icon">${micIcon(18)}</div>
34942
- <h2 class="modal-title">Dictation Complete</h2>
35081
+ <div class="logo-icon">${this.modalIcon}</div>
35082
+ <h2 class="modal-title">${this.modalTitle}</h2>
34943
35083
  </div>
34944
35084
  <button
34945
35085
  class="close-button"
@@ -34956,7 +35096,7 @@
34956
35096
  <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">
34957
35097
  <circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/>
34958
35098
  </svg>
34959
- <span>Tip: Focus a text field first to auto-insert next time</span>
35099
+ <span>${this.hintText}</span>
34960
35100
  </div>
34961
35101
  </div>
34962
35102
 
@@ -34983,6 +35123,9 @@
34983
35123
  __decorate([
34984
35124
  n({ type: String })
34985
35125
  ], SpeechOSDictationOutputModal.prototype, "text", void 0);
35126
+ __decorate([
35127
+ n({ type: String, reflect: true })
35128
+ ], SpeechOSDictationOutputModal.prototype, "mode", void 0);
34986
35129
  __decorate([
34987
35130
  r()
34988
35131
  ], SpeechOSDictationOutputModal.prototype, "copied", void 0);
@@ -35160,9 +35303,11 @@
35160
35303
  this.settingsOpenFromWarning = false;
35161
35304
  this.dictationModalOpen = false;
35162
35305
  this.dictationModalText = "";
35306
+ this.dictationModalMode = "dictation";
35163
35307
  this.editHelpModalOpen = false;
35164
35308
  this.actionFeedback = null;
35165
35309
  this.showNoAudioWarning = false;
35310
+ this.isErrorRetryable = true;
35166
35311
  this.dictationTargetElement = null;
35167
35312
  this.editTargetElement = null;
35168
35313
  this.dictationCursorStart = null;
@@ -35311,6 +35456,8 @@
35311
35456
  this.errorEventUnsubscribe = events.on("error", (payload) => {
35312
35457
  if (this.widgetState.recordingState !== "idle" &&
35313
35458
  this.widgetState.recordingState !== "error") {
35459
+ // Check if this is a non-retryable error (e.g., CSP blocked connection)
35460
+ this.isErrorRetryable = payload.code !== "connection_blocked";
35314
35461
  state.setError(payload.message);
35315
35462
  getBackend().disconnect().catch(() => { });
35316
35463
  }
@@ -35385,6 +35532,9 @@
35385
35532
  if (changedProperties.has("dictationModalText") && this.dictationModalElement) {
35386
35533
  this.dictationModalElement.text = this.dictationModalText;
35387
35534
  }
35535
+ if (changedProperties.has("dictationModalMode") && this.dictationModalElement) {
35536
+ this.dictationModalElement.mode = this.dictationModalMode;
35537
+ }
35388
35538
  if (changedProperties.has("editHelpModalOpen") && this.editHelpModalElement) {
35389
35539
  this.editHelpModalElement.open = this.editHelpModalOpen;
35390
35540
  }
@@ -35583,13 +35733,24 @@
35583
35733
  // Track result for consecutive failure detection
35584
35734
  this.trackActionResult(!!transcription);
35585
35735
  if (transcription) {
35736
+ if (getConfig().debug) {
35737
+ console.log("[SpeechOS] Transcription received:", {
35738
+ transcription,
35739
+ dictationTargetElement: this.dictationTargetElement,
35740
+ tagName: this.dictationTargetElement?.tagName,
35741
+ });
35742
+ }
35586
35743
  // Check if we have a target element to insert into
35587
35744
  if (this.dictationTargetElement) {
35588
35745
  this.insertTranscription(transcription);
35589
35746
  }
35590
35747
  else {
35591
35748
  // No target element - show dictation output modal
35749
+ if (getConfig().debug) {
35750
+ console.log("[SpeechOS] No target element, showing dictation modal");
35751
+ }
35592
35752
  this.dictationModalText = transcription;
35753
+ this.dictationModalMode = "dictation";
35593
35754
  this.dictationModalOpen = true;
35594
35755
  }
35595
35756
  transcriptStore.saveTranscript(transcription, "dictate");
@@ -35733,41 +35894,68 @@
35733
35894
  return;
35734
35895
  }
35735
35896
  const tagName = target.tagName.toLowerCase();
35897
+ const originalContent = this.getElementContent(target) || "";
35736
35898
  if (tagName === "input" || tagName === "textarea") {
35737
35899
  const inputEl = target;
35900
+ // Ensure DOM focus is on the input before inserting
35901
+ inputEl.focus();
35902
+ // Restore cursor position before inserting
35738
35903
  const start = this.dictationCursorStart ?? inputEl.value.length;
35739
35904
  const end = this.dictationCursorEnd ?? inputEl.value.length;
35740
- const before = inputEl.value.substring(0, start);
35741
- const after = inputEl.value.substring(end);
35742
- inputEl.value = before + text + after;
35743
- if (this.supportsSelection(inputEl)) {
35744
- const newCursorPos = start + text.length;
35745
- inputEl.setSelectionRange(newCursorPos, newCursorPos);
35746
- }
35747
- inputEl.dispatchEvent(new Event("input", { bubbles: true }));
35748
- inputEl.focus();
35905
+ inputEl.setSelectionRange(start, end);
35906
+ // Use text-field-edit to insert text (handles undo, events, etc.)
35907
+ insertTextIntoField(inputEl, text);
35749
35908
  state.setFocusedElement(inputEl);
35750
35909
  }
35751
35910
  else if (target.isContentEditable) {
35752
35911
  target.focus();
35753
35912
  state.setFocusedElement(target);
35754
- const textNode = document.createTextNode(text);
35755
- target.appendChild(textNode);
35756
- const selection = window.getSelection();
35757
- if (selection) {
35758
- const range = document.createRange();
35759
- range.selectNodeContents(textNode);
35760
- range.collapse(false);
35761
- selection.removeAllRanges();
35762
- selection.addRange(range);
35763
- }
35764
- target.dispatchEvent(new Event("input", { bubbles: true }));
35913
+ // Use text-field-edit for contentEditable elements
35914
+ insertTextIntoField(target, text);
35765
35915
  }
35766
35916
  events.emit("transcription:inserted", { text, element: target });
35917
+ // Verify insertion was applied after DOM updates
35918
+ this.verifyInsertionApplied(target, text, originalContent);
35767
35919
  this.dictationTargetElement = null;
35768
35920
  this.dictationCursorStart = null;
35769
35921
  this.dictationCursorEnd = null;
35770
35922
  }
35923
+ /**
35924
+ * Verify that a dictation insertion was actually applied to the target element.
35925
+ * Some custom editors (CodeMirror, Monaco, Slate, etc.) don't respond to
35926
+ * standard DOM editing methods. If the insertion fails, show a fallback modal.
35927
+ */
35928
+ verifyInsertionApplied(target, insertedText, originalContent) {
35929
+ // Use requestAnimationFrame to check after DOM updates
35930
+ requestAnimationFrame(() => {
35931
+ const tagName = target.tagName.toLowerCase();
35932
+ let currentContent = "";
35933
+ if (tagName === "input" || tagName === "textarea") {
35934
+ currentContent = target.value;
35935
+ }
35936
+ else if (target.isContentEditable) {
35937
+ currentContent = target.textContent || "";
35938
+ }
35939
+ // Check if the insertion was applied:
35940
+ // - Content should contain the inserted text
35941
+ // - Or content should be different from original (for empty fields)
35942
+ const insertionApplied = currentContent.includes(insertedText) ||
35943
+ (originalContent === "" && currentContent !== "");
35944
+ if (!insertionApplied) {
35945
+ if (getConfig().debug) {
35946
+ console.log("[SpeechOS] Dictation failed to insert, showing fallback modal", {
35947
+ insertedText,
35948
+ currentContent,
35949
+ originalContent,
35950
+ });
35951
+ }
35952
+ // Show fallback modal with dictation mode styling
35953
+ this.dictationModalText = insertedText;
35954
+ this.dictationModalMode = "dictation";
35955
+ this.dictationModalOpen = true;
35956
+ }
35957
+ });
35958
+ }
35771
35959
  handleActionSelect(event) {
35772
35960
  const { action } = event.detail;
35773
35961
  // Clear any existing command feedback when a new action is selected
@@ -35806,6 +35994,13 @@
35806
35994
  this.dictationTargetElement = this.widgetState.focusedElement;
35807
35995
  this.dictationCursorStart = null;
35808
35996
  this.dictationCursorEnd = null;
35997
+ if (getConfig().debug) {
35998
+ console.log("[SpeechOS] startDictation:", {
35999
+ focusedElement: this.widgetState.focusedElement,
36000
+ dictationTargetElement: this.dictationTargetElement,
36001
+ tagName: this.dictationTargetElement?.tagName,
36002
+ });
36003
+ }
35809
36004
  if (this.dictationTargetElement) {
35810
36005
  const tagName = this.dictationTargetElement.tagName.toLowerCase();
35811
36006
  if (tagName === "input" || tagName === "textarea") {
@@ -35831,15 +36026,18 @@
35831
36026
  // Ensure minimum animation duration before transitioning to recording
35832
36027
  const elapsed = Date.now() - connectingStartTime;
35833
36028
  const remainingDelay = MIN_CONNECTING_ANIMATION_MS - elapsed;
36029
+ const startRecording = () => {
36030
+ if (state.getState().recordingState === "error") {
36031
+ return;
36032
+ }
36033
+ state.setRecordingState("recording");
36034
+ this.startNoAudioWarningTracking();
36035
+ };
35834
36036
  if (remainingDelay > 0) {
35835
- setTimeout(() => {
35836
- state.setRecordingState("recording");
35837
- this.startNoAudioWarningTracking();
35838
- }, remainingDelay);
36037
+ setTimeout(startRecording, remainingDelay);
35839
36038
  }
35840
36039
  else {
35841
- state.setRecordingState("recording");
35842
- this.startNoAudioWarningTracking();
36040
+ startRecording();
35843
36041
  }
35844
36042
  },
35845
36043
  });
@@ -35860,6 +36058,13 @@
35860
36058
  this.editSelectionStart = null;
35861
36059
  this.editSelectionEnd = null;
35862
36060
  this.editSelectedText = "";
36061
+ if (getConfig().debug) {
36062
+ console.log("[SpeechOS] startEdit:", {
36063
+ focusedElement: this.widgetState.focusedElement,
36064
+ editTargetElement: this.editTargetElement,
36065
+ tagName: this.editTargetElement?.tagName,
36066
+ });
36067
+ }
35863
36068
  if (this.editTargetElement) {
35864
36069
  const tagName = this.editTargetElement.tagName.toLowerCase();
35865
36070
  if (tagName === "input" || tagName === "textarea") {
@@ -35870,7 +36075,8 @@
35870
36075
  const start = this.editSelectionStart ?? 0;
35871
36076
  const end = this.editSelectionEnd ?? 0;
35872
36077
  if (start !== end) {
35873
- this.editSelectedText = inputEl.value.substring(start, end);
36078
+ // Use getFieldSelection from text-field-edit
36079
+ this.editSelectedText = getFieldSelection(inputEl);
35874
36080
  }
35875
36081
  }
35876
36082
  else {
@@ -35879,13 +36085,11 @@
35879
36085
  }
35880
36086
  }
35881
36087
  else if (this.editTargetElement.isContentEditable) {
35882
- const selection = window.getSelection();
35883
- if (selection && selection.rangeCount > 0) {
35884
- const selectedText = selection.toString();
35885
- this.editSelectionStart = 0;
35886
- this.editSelectionEnd = selectedText.length;
35887
- this.editSelectedText = selectedText;
35888
- }
36088
+ // Use getFieldSelection from text-field-edit for contentEditable too
36089
+ const selectedText = getFieldSelection(this.editTargetElement);
36090
+ this.editSelectionStart = 0;
36091
+ this.editSelectionEnd = selectedText.length;
36092
+ this.editSelectedText = selectedText;
35889
36093
  }
35890
36094
  }
35891
36095
  // Capture the content to edit at start time (sent with auth message)
@@ -35902,15 +36106,18 @@
35902
36106
  // Ensure minimum animation duration before transitioning to recording
35903
36107
  const elapsed = Date.now() - connectingStartTime;
35904
36108
  const remainingDelay = MIN_CONNECTING_ANIMATION_MS - elapsed;
36109
+ const startRecording = () => {
36110
+ if (state.getState().recordingState === "error") {
36111
+ return;
36112
+ }
36113
+ state.setRecordingState("recording");
36114
+ this.startNoAudioWarningTracking();
36115
+ };
35905
36116
  if (remainingDelay > 0) {
35906
- setTimeout(() => {
35907
- state.setRecordingState("recording");
35908
- this.startNoAudioWarningTracking();
35909
- }, remainingDelay);
36117
+ setTimeout(startRecording, remainingDelay);
35910
36118
  }
35911
36119
  else {
35912
- state.setRecordingState("recording");
35913
- this.startNoAudioWarningTracking();
36120
+ startRecording();
35914
36121
  }
35915
36122
  },
35916
36123
  });
@@ -35978,15 +36185,18 @@
35978
36185
  // Ensure minimum animation duration before transitioning to recording
35979
36186
  const elapsed = Date.now() - connectingStartTime;
35980
36187
  const remainingDelay = MIN_CONNECTING_ANIMATION_MS - elapsed;
36188
+ const startRecording = () => {
36189
+ if (state.getState().recordingState === "error") {
36190
+ return;
36191
+ }
36192
+ state.setRecordingState("recording");
36193
+ this.startNoAudioWarningTracking();
36194
+ };
35981
36195
  if (remainingDelay > 0) {
35982
- setTimeout(() => {
35983
- state.setRecordingState("recording");
35984
- this.startNoAudioWarningTracking();
35985
- }, remainingDelay);
36196
+ setTimeout(startRecording, remainingDelay);
35986
36197
  }
35987
36198
  else {
35988
- state.setRecordingState("recording");
35989
- this.startNoAudioWarningTracking();
36199
+ startRecording();
35990
36200
  }
35991
36201
  },
35992
36202
  });
@@ -36025,8 +36235,6 @@
36025
36235
  // Note: command:complete event is already emitted by the backend
36026
36236
  // when the command_result message is received, so we don't emit here
36027
36237
  state.completeRecording();
36028
- // Keep widget visible but collapsed (just mic button, no action bubbles)
36029
- state.setState({ isExpanded: false });
36030
36238
  // Show command feedback
36031
36239
  this.showActionFeedback(result ? "command-success" : "command-none");
36032
36240
  backend.disconnect().catch(() => { });
@@ -36162,21 +36370,14 @@
36162
36370
  const tagName = element.tagName.toLowerCase();
36163
36371
  if (tagName === "input" || tagName === "textarea") {
36164
36372
  const inputEl = element;
36165
- const fullContent = inputEl.value;
36166
- const start = this.editSelectionStart ?? 0;
36167
- const end = this.editSelectionEnd ?? fullContent.length;
36168
- const hasSelection = start !== end;
36169
- if (hasSelection) {
36170
- return fullContent.substring(start, end);
36171
- }
36172
- return fullContent;
36373
+ const selectedText = getFieldSelection(inputEl);
36374
+ // If there's selected text, return it; otherwise return full content
36375
+ return selectedText || inputEl.value;
36173
36376
  }
36174
36377
  else if (element.isContentEditable) {
36175
- const selection = window.getSelection();
36176
- if (selection && selection.toString().length > 0) {
36177
- return selection.toString();
36178
- }
36179
- return element.textContent || "";
36378
+ const selectedText = getFieldSelection(element);
36379
+ // If there's selected text, return it; otherwise return full content
36380
+ return selectedText || element.textContent || "";
36180
36381
  }
36181
36382
  return "";
36182
36383
  }
@@ -36191,40 +36392,46 @@
36191
36392
  if (tagName === "input" || tagName === "textarea") {
36192
36393
  const inputEl = target;
36193
36394
  originalContent = inputEl.value;
36395
+ // Ensure DOM focus is on the input before editing
36194
36396
  inputEl.focus();
36195
- if (this.supportsSelection(inputEl)) {
36196
- const selectionStart = this.editSelectionStart ?? 0;
36197
- const selectionEnd = this.editSelectionEnd ?? inputEl.value.length;
36198
- const hasSelection = selectionStart !== selectionEnd;
36199
- if (hasSelection) {
36200
- inputEl.setSelectionRange(selectionStart, selectionEnd);
36201
- }
36202
- else {
36203
- inputEl.setSelectionRange(0, inputEl.value.length);
36204
- }
36205
- document.execCommand("insertText", false, editedText);
36397
+ // Restore the original selection/cursor position
36398
+ const selectionStart = this.editSelectionStart ?? 0;
36399
+ const selectionEnd = this.editSelectionEnd ?? inputEl.value.length;
36400
+ const hasSelection = selectionStart !== selectionEnd;
36401
+ if (hasSelection) {
36402
+ // Restore selection, then use insertTextIntoField() to replace it
36403
+ inputEl.setSelectionRange(selectionStart, selectionEnd);
36404
+ insertTextIntoField(inputEl, editedText);
36206
36405
  }
36207
36406
  else {
36208
- inputEl.value = editedText;
36209
- inputEl.dispatchEvent(new Event("input", { bubbles: true }));
36407
+ // No selection - replace entire content using setFieldText()
36408
+ setFieldText(inputEl, editedText);
36210
36409
  }
36211
36410
  state.setFocusedElement(inputEl);
36212
36411
  }
36213
36412
  else if (target.isContentEditable) {
36214
36413
  originalContent = target.textContent || "";
36215
- target.focus();
36216
- state.setFocusedElement(target);
36217
36414
  const hasSelection = this.editSelectionStart !== null &&
36218
36415
  this.editSelectionEnd !== null &&
36219
36416
  this.editSelectionStart !== this.editSelectionEnd;
36220
- if (!hasSelection) {
36417
+ if (hasSelection) {
36418
+ // Selection exists - focus and insert (assumes selection is still active or we restore it)
36419
+ target.focus();
36420
+ insertTextIntoField(target, editedText);
36421
+ }
36422
+ else {
36423
+ // No selection - select all content first, then replace with insertTextIntoField()
36424
+ target.focus();
36221
36425
  const selection = window.getSelection();
36222
- const range = document.createRange();
36223
- range.selectNodeContents(target);
36224
- selection?.removeAllRanges();
36225
- selection?.addRange(range);
36426
+ if (selection) {
36427
+ const range = document.createRange();
36428
+ range.selectNodeContents(target);
36429
+ selection.removeAllRanges();
36430
+ selection.addRange(range);
36431
+ }
36432
+ insertTextIntoField(target, editedText);
36226
36433
  }
36227
- document.execCommand("insertText", false, editedText);
36434
+ state.setFocusedElement(target);
36228
36435
  }
36229
36436
  transcriptStore.saveTranscript(editedText, "edit", originalContent);
36230
36437
  events.emit("edit:applied", {
@@ -36233,11 +36440,54 @@
36233
36440
  element: target,
36234
36441
  });
36235
36442
  state.completeRecording();
36443
+ // Verify edit was applied after DOM updates
36444
+ this.verifyEditApplied(target, editedText, originalContent);
36236
36445
  this.editTargetElement = null;
36237
36446
  this.editSelectionStart = null;
36238
36447
  this.editSelectionEnd = null;
36239
36448
  this.editSelectedText = "";
36240
36449
  }
36450
+ /**
36451
+ * Verify that an edit was actually applied to the target element.
36452
+ * Some custom editors (CodeMirror, Monaco, Slate, etc.) don't respond to
36453
+ * standard DOM editing methods. If the edit fails, show a fallback modal.
36454
+ */
36455
+ verifyEditApplied(target, editedText, originalContent) {
36456
+ // Use requestAnimationFrame to check after DOM updates
36457
+ requestAnimationFrame(() => {
36458
+ const tagName = target.tagName.toLowerCase();
36459
+ let currentContent = "";
36460
+ if (tagName === "input" || tagName === "textarea") {
36461
+ currentContent = target.value;
36462
+ }
36463
+ else if (target.isContentEditable) {
36464
+ currentContent = target.textContent || "";
36465
+ }
36466
+ // Normalize whitespace for comparison
36467
+ const normalizedCurrent = currentContent.trim();
36468
+ const normalizedEdited = editedText.trim();
36469
+ const normalizedOriginal = originalContent.trim();
36470
+ // Check if the edit was applied:
36471
+ // - Content should be different from original (unless edit was no-op)
36472
+ // - Content should contain or match the edited text
36473
+ const editApplied = normalizedCurrent !== normalizedOriginal ||
36474
+ normalizedCurrent === normalizedEdited ||
36475
+ normalizedCurrent.includes(normalizedEdited);
36476
+ if (!editApplied) {
36477
+ if (getConfig().debug) {
36478
+ console.log("[SpeechOS] Edit failed to apply, showing fallback modal", {
36479
+ expected: editedText,
36480
+ actual: currentContent,
36481
+ original: originalContent,
36482
+ });
36483
+ }
36484
+ // Show fallback modal with edit mode styling
36485
+ this.dictationModalText = editedText;
36486
+ this.dictationModalMode = "edit";
36487
+ this.dictationModalOpen = true;
36488
+ }
36489
+ });
36490
+ }
36241
36491
  render() {
36242
36492
  if (!this.widgetState.isVisible) {
36243
36493
  this.setAttribute("hidden", "");
@@ -36265,6 +36515,7 @@
36265
36515
  activeAction="${this.widgetState.activeAction || ""}"
36266
36516
  editPreviewText="${this.editSelectedText}"
36267
36517
  errorMessage="${this.widgetState.errorMessage || ""}"
36518
+ ?showRetryButton="${this.isErrorRetryable}"
36268
36519
  .actionFeedback="${this.actionFeedback}"
36269
36520
  ?showNoAudioWarning="${this.showNoAudioWarning}"
36270
36521
  @mic-click="${this.handleMicClick}"
@@ -36291,6 +36542,9 @@
36291
36542
  __decorate([
36292
36543
  r()
36293
36544
  ], SpeechOSWidget.prototype, "dictationModalText", void 0);
36545
+ __decorate([
36546
+ r()
36547
+ ], SpeechOSWidget.prototype, "dictationModalMode", void 0);
36294
36548
  __decorate([
36295
36549
  r()
36296
36550
  ], SpeechOSWidget.prototype, "editHelpModalOpen", void 0);
@@ -36300,6 +36554,9 @@
36300
36554
  __decorate([
36301
36555
  r()
36302
36556
  ], SpeechOSWidget.prototype, "showNoAudioWarning", void 0);
36557
+ __decorate([
36558
+ r()
36559
+ ], SpeechOSWidget.prototype, "isErrorRetryable", void 0);
36303
36560
  SpeechOSWidget = SpeechOSWidget_1 = __decorate([
36304
36561
  t$1("speechos-widget")
36305
36562
  ], SpeechOSWidget);