andito HF Staff commited on
Commit
019ba52
Β·
1 Parent(s): 1664dc5

Dismiss completed voice turn indicator

Browse files
Files changed (2) hide show
  1. main.js +1 -0
  2. ui/chat.js +38 -1
main.js CHANGED
@@ -1493,6 +1493,7 @@ async function doStart(audioContext = null) {
1493
  c.addEventListener("status", (e) => {
1494
  const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
1495
  onClientStatus(detail.status);
 
1496
  });
1497
  c.addEventListener("transcript", (e) => {
1498
  const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
 
1493
  c.addEventListener("status", (e) => {
1494
  const detail = /** @type {CustomEvent<{ status: string }>} */ (e).detail;
1495
  onClientStatus(detail.status);
1496
+ if (detail.status === "ai-speaking") chat.onAssistantActivity();
1497
  });
1498
  c.addEventListener("transcript", (e) => {
1499
  const d = /** @type {CustomEvent<{ role: "user" | "assistant"; text: string; partial: boolean; itemId?: string; responseId?: string }>} */ (e).detail;
ui/chat.js CHANGED
@@ -245,6 +245,25 @@ export class ChatView {
245
  }
246
  }
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  /**
249
  * (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
250
  * Calling it again resets the countdown β€” so a bubble that keeps updating
@@ -254,7 +273,20 @@ export class ChatView {
254
  */
255
  _bumpDismiss(el, delay = 4000) {
256
  this._bubbleExpiry.set(el, Date.now() + delay);
257
- if (!this._reaperHandle) this._reaperHandle = setTimeout(() => this._reapBubbles(), delay);
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  }
259
 
260
  // ── History ───────────────────────────────────────────────────────────────
@@ -403,6 +435,9 @@ export class ChatView {
403
  && this._activeUserBubble?.isConnected
404
  && !this._activeUserBubble.classList.contains("out");
405
  if (!reusable) {
 
 
 
406
  this._activeUserBubble = this._spawnVoiceBubble("listening");
407
  this._activeUserItemId = id;
408
  } else if (this._activeUserBubble.classList.contains("voice")) {
@@ -467,6 +502,7 @@ export class ChatView {
467
  this._bumpDismiss(this._activeUserBubble, 6000);
468
  this._markUnread();
469
  } else if (d.role === "assistant") {
 
470
  // Assistant transcript arrives once, as the full text, keyed by
471
  // response_id so a cancelled speculative response can be removed later. A
472
  // missing id gets a unique key so two id-less replies never collide.
@@ -557,6 +593,7 @@ export class ChatView {
557
  onResponseFinished(detail) {
558
  const { responseId, status, audible, transcript } = detail;
559
  if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
 
560
  // Without an id we can't target a specific response; the bubble will
561
  // auto-dismiss on its own timer regardless.
562
  if (!responseId) return;
 
245
  }
246
  }
247
 
248
+ /**
249
+ * Point the shared reaper at the current oldest bubble. This must run when an
250
+ * expiry moves earlier as well as later: a listening bubble starts with a
251
+ * long fail-safe, then gets a much shorter deadline once speech stops.
252
+ */
253
+ _scheduleBubbleReaper() {
254
+ if (this._reaperHandle) clearTimeout(this._reaperHandle);
255
+ this._reaperHandle = 0;
256
+ const oldest = /** @type {HTMLElement | null} */ (
257
+ this._bubbleStack.querySelector(".bubble:not(.out)")
258
+ );
259
+ if (!oldest) return;
260
+ const expiry = this._bubbleExpiry.get(oldest) ?? Date.now();
261
+ this._reaperHandle = setTimeout(
262
+ () => this._reapBubbles(),
263
+ Math.max(50, expiry - Date.now()),
264
+ );
265
+ }
266
+
267
  /**
268
  * (Re)arm a bubble's auto-dismiss by pushing its expiry out by `delay`.
269
  * Calling it again resets the countdown β€” so a bubble that keeps updating
 
273
  */
274
  _bumpDismiss(el, delay = 4000) {
275
  this._bubbleExpiry.set(el, Date.now() + delay);
276
+ this._scheduleBubbleReaper();
277
+ }
278
+
279
+ /**
280
+ * Remove a pending voice placeholder once the assistant has visibly started
281
+ * answering. A transcript can arrive before audible output, while direct
282
+ * audio models may only trigger the ai-speaking status, so both call here.
283
+ */
284
+ onAssistantActivity() {
285
+ const bubble = this._activeUserBubble;
286
+ if (!bubble?.isConnected || !bubble.classList.contains("voice")) return;
287
+ this._dismissBubble(bubble);
288
+ this._activeUserBubble = null;
289
+ this._scheduleBubbleReaper();
290
  }
291
 
292
  // ── History ───────────────────────────────────────────────────────────────
 
435
  && this._activeUserBubble?.isConnected
436
  && !this._activeUserBubble.classList.contains("out");
437
  if (!reusable) {
438
+ if (this._activeUserBubble?.classList.contains("voice")) {
439
+ this._dismissBubble(this._activeUserBubble);
440
+ }
441
  this._activeUserBubble = this._spawnVoiceBubble("listening");
442
  this._activeUserItemId = id;
443
  } else if (this._activeUserBubble.classList.contains("voice")) {
 
502
  this._bumpDismiss(this._activeUserBubble, 6000);
503
  this._markUnread();
504
  } else if (d.role === "assistant") {
505
+ this.onAssistantActivity();
506
  // Assistant transcript arrives once, as the full text, keyed by
507
  // response_id so a cancelled speculative response can be removed later. A
508
  // missing id gets a unique key so two id-less replies never collide.
 
593
  onResponseFinished(detail) {
594
  const { responseId, status, audible, transcript } = detail;
595
  if (DEBUG) console.debug(`[ui] response-finished resp=${responseId} status=${status} audible=${audible} known=${this._asstByResp.has(responseId)}`);
596
+ this.onAssistantActivity();
597
  // Without an id we can't target a specific response; the bubble will
598
  // auto-dismiss on its own timer regardless.
599
  if (!responseId) return;