@exotel-npm-dev/exotel-ai-assist 1.2.9 → 1.3.1

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.
@@ -349,7 +349,7 @@ class BroadcastChannelTransport {
349
349
  const lockName = `exotel-ai-assist-leader:${sessionHash}`;
350
350
  this.channel = new BroadcastChannel(channelName);
351
351
  this.channel.onmessage = ev => {
352
- var _a;
352
+ var _a, _b, _c;
353
353
  const data = ev.data;
354
354
  if (this.isLeader) {
355
355
  if (data.type === "JOIN" && this.socketConnected && this.ackPayload) {
@@ -361,11 +361,18 @@ class BroadcastChannelTransport {
361
361
  payload: this.ackPayload
362
362
  });
363
363
  }
364
+ if (data.type === "SEND" && ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
365
+ this.socket.send(data.payload);
366
+ }
367
+ if (data.type === "FEEDBACK_SYNC") {
368
+ (_b = this.handler) === null || _b === void 0 ? void 0 : _b.call(this, data);
369
+ }
364
370
  return;
365
371
  }
366
- // Follower: forward every message except JOIN to the controller handler.
367
- if (data.type !== "JOIN") {
368
- (_a = this.handler) === null || _a === void 0 ? void 0 : _a.call(this, data);
372
+ // Follower: forward server messages to the controller handler.
373
+ // JOIN and SEND are internal transport messages, not controller messages.
374
+ if (data.type !== "JOIN" && data.type !== "SEND") {
375
+ (_c = this.handler) === null || _c === void 0 ? void 0 : _c.call(this, data);
369
376
  }
370
377
  };
371
378
  this._electLeader(lockName);
@@ -475,8 +482,15 @@ class BroadcastChannelTransport {
475
482
  }
476
483
  send(payload) {
477
484
  var _a;
478
- if (this.isLeader && ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
479
- this.socket.send(payload);
485
+ if (this.isLeader) {
486
+ if (((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
487
+ this.socket.send(payload);
488
+ }
489
+ } else {
490
+ this.channel.postMessage({
491
+ type: "SEND",
492
+ payload
493
+ });
480
494
  }
481
495
  }
482
496
  onMessage(handler) {
@@ -485,8 +499,22 @@ class BroadcastChannelTransport {
485
499
  markAcknowledged(rawAckPayload) {
486
500
  this.ackPayload = rawAckPayload;
487
501
  }
502
+ broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason) {
503
+ this.channel.postMessage({
504
+ type: "FEEDBACK_SYNC",
505
+ sequence,
506
+ feedbackType,
507
+ badFeedbackReason
508
+ });
509
+ }
488
510
  destroy() {
489
511
  var _a;
512
+ if (this.isLeader) {
513
+ this.channel.postMessage({
514
+ type: "DISCONNECTED",
515
+ code: 1000
516
+ });
517
+ }
490
518
  this._destroyed = true;
491
519
  this.disconnect();
492
520
  this.channel.close();
@@ -1679,6 +1707,7 @@ class ExotelAIAssistController extends EventEmitter {
1679
1707
  };
1680
1708
  try {
1681
1709
  this.transport.send(JSON.stringify(message));
1710
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
1682
1711
  return true;
1683
1712
  } catch (error) {
1684
1713
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -1705,12 +1734,13 @@ class ExotelAIAssistController extends EventEmitter {
1705
1734
  if (this.connectionEstablished) {
1706
1735
  const wasThrottled = this._streamState === "throttled";
1707
1736
  if (this.readyFired && !wasThrottled) {
1708
- this.readyFired = false;
1709
1737
  this.emit("onReady", false);
1710
1738
  }
1739
+ this.connectionEstablished = false;
1740
+ this.readyFired = false;
1741
+ this._streamState = null;
1711
1742
  this._setStatus("disconnected");
1712
1743
  this.emit("onCallEnd");
1713
- this.destroy();
1714
1744
  } else {
1715
1745
  this._scheduleReconnect();
1716
1746
  }
@@ -1721,6 +1751,13 @@ class ExotelAIAssistController extends EventEmitter {
1721
1751
  case "MESSAGE":
1722
1752
  if (msg.payload) this._handleServerPayload(msg.payload);
1723
1753
  break;
1754
+ case "FEEDBACK_SYNC":
1755
+ this.emit("suggestionFeedbackSync", {
1756
+ sequence: msg.sequence,
1757
+ feedbackType: msg.feedbackType,
1758
+ badFeedbackReason: msg.badFeedbackReason
1759
+ });
1760
+ break;
1724
1761
  }
1725
1762
  }
1726
1763
  _scheduleReconnect() {
package/dist/index.js CHANGED
@@ -19791,7 +19791,7 @@ class BroadcastChannelTransport {
19791
19791
  const lockName = `exotel-ai-assist-leader:${sessionHash}`;
19792
19792
  this.channel = new BroadcastChannel(channelName);
19793
19793
  this.channel.onmessage = ev => {
19794
- var _a;
19794
+ var _a, _b, _c;
19795
19795
  const data = ev.data;
19796
19796
  if (this.isLeader) {
19797
19797
  if (data.type === "JOIN" && this.socketConnected && this.ackPayload) {
@@ -19803,11 +19803,18 @@ class BroadcastChannelTransport {
19803
19803
  payload: this.ackPayload
19804
19804
  });
19805
19805
  }
19806
+ if (data.type === "SEND" && ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
19807
+ this.socket.send(data.payload);
19808
+ }
19809
+ if (data.type === "FEEDBACK_SYNC") {
19810
+ (_b = this.handler) === null || _b === void 0 ? void 0 : _b.call(this, data);
19811
+ }
19806
19812
  return;
19807
19813
  }
19808
- // Follower: forward every message except JOIN to the controller handler.
19809
- if (data.type !== "JOIN") {
19810
- (_a = this.handler) === null || _a === void 0 ? void 0 : _a.call(this, data);
19814
+ // Follower: forward server messages to the controller handler.
19815
+ // JOIN and SEND are internal transport messages, not controller messages.
19816
+ if (data.type !== "JOIN" && data.type !== "SEND") {
19817
+ (_c = this.handler) === null || _c === void 0 ? void 0 : _c.call(this, data);
19811
19818
  }
19812
19819
  };
19813
19820
  this._electLeader(lockName);
@@ -19917,8 +19924,15 @@ class BroadcastChannelTransport {
19917
19924
  }
19918
19925
  send(payload) {
19919
19926
  var _a;
19920
- if (this.isLeader && ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
19921
- this.socket.send(payload);
19927
+ if (this.isLeader) {
19928
+ if (((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
19929
+ this.socket.send(payload);
19930
+ }
19931
+ } else {
19932
+ this.channel.postMessage({
19933
+ type: "SEND",
19934
+ payload
19935
+ });
19922
19936
  }
19923
19937
  }
19924
19938
  onMessage(handler) {
@@ -19927,8 +19941,22 @@ class BroadcastChannelTransport {
19927
19941
  markAcknowledged(rawAckPayload) {
19928
19942
  this.ackPayload = rawAckPayload;
19929
19943
  }
19944
+ broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason) {
19945
+ this.channel.postMessage({
19946
+ type: "FEEDBACK_SYNC",
19947
+ sequence,
19948
+ feedbackType,
19949
+ badFeedbackReason
19950
+ });
19951
+ }
19930
19952
  destroy() {
19931
19953
  var _a;
19954
+ if (this.isLeader) {
19955
+ this.channel.postMessage({
19956
+ type: "DISCONNECTED",
19957
+ code: 1000
19958
+ });
19959
+ }
19932
19960
  this._destroyed = true;
19933
19961
  this.disconnect();
19934
19962
  this.channel.close();
@@ -21121,6 +21149,7 @@ class ExotelAIAssistController extends EventEmitter {
21121
21149
  };
21122
21150
  try {
21123
21151
  this.transport.send(JSON.stringify(message));
21152
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
21124
21153
  return true;
21125
21154
  } catch (error) {
21126
21155
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -21147,12 +21176,13 @@ class ExotelAIAssistController extends EventEmitter {
21147
21176
  if (this.connectionEstablished) {
21148
21177
  const wasThrottled = this._streamState === "throttled";
21149
21178
  if (this.readyFired && !wasThrottled) {
21150
- this.readyFired = false;
21151
21179
  this.emit("onReady", false);
21152
21180
  }
21181
+ this.connectionEstablished = false;
21182
+ this.readyFired = false;
21183
+ this._streamState = null;
21153
21184
  this._setStatus("disconnected");
21154
21185
  this.emit("onCallEnd");
21155
- this.destroy();
21156
21186
  } else {
21157
21187
  this._scheduleReconnect();
21158
21188
  }
@@ -21163,6 +21193,13 @@ class ExotelAIAssistController extends EventEmitter {
21163
21193
  case "MESSAGE":
21164
21194
  if (msg.payload) this._handleServerPayload(msg.payload);
21165
21195
  break;
21196
+ case "FEEDBACK_SYNC":
21197
+ this.emit("suggestionFeedbackSync", {
21198
+ sequence: msg.sequence,
21199
+ feedbackType: msg.feedbackType,
21200
+ badFeedbackReason: msg.badFeedbackReason
21201
+ });
21202
+ break;
21166
21203
  }
21167
21204
  }
21168
21205
  _scheduleReconnect() {
@@ -21308,16 +21345,36 @@ function useExotelAIAssist(params) {
21308
21345
  setLastError(null);
21309
21346
  const ctrl = new ExotelAIAssistController(params);
21310
21347
  controllerRef.current = ctrl;
21311
- ctrl.on("onReady", setIsReady);
21348
+ ctrl.on("onReady", ready => {
21349
+ setIsReady(ready);
21350
+ if (!ready) {
21351
+ setSuggestions([]);
21352
+ setTranscripts([]);
21353
+ setSentiment(null);
21354
+ }
21355
+ });
21312
21356
  ctrl.on("streamState", setStreamState);
21313
21357
  ctrl.on("botConfig", setBotConfig);
21314
- ctrl.on("suggestion", s => setSuggestions(prev => [...prev, s].slice(-50)));
21358
+ ctrl.on("suggestion", s => setSuggestions(prev => {
21359
+ if (prev.some(existing => existing.sequence === s.sequence)) return prev;
21360
+ return [...prev, s].slice(-50);
21361
+ }));
21315
21362
  ctrl.on("sentiment", setSentiment);
21316
21363
  ctrl.on("transcript", lines => setTranscripts(prev => {
21317
21364
  const map = new Map(prev.map(l => [l.id, l]));
21318
21365
  for (const l of lines) map.set(l.id, l);
21319
21366
  return Array.from(map.values()).sort((a, b) => a.startTime - b.startTime);
21320
21367
  }));
21368
+ ctrl.on("suggestionFeedbackSync", ({
21369
+ sequence,
21370
+ feedbackType,
21371
+ badFeedbackReason
21372
+ }) => {
21373
+ setSuggestions(prev => prev.map(s => s.sequence === sequence ? Object.assign(Object.assign({}, s), {
21374
+ feedbackType,
21375
+ badFeedbackReason
21376
+ }) : s));
21377
+ });
21321
21378
  ctrl.on("error", setLastError);
21322
21379
  ctrl.connect();
21323
21380
  return () => {
@@ -5077,7 +5077,7 @@ class BroadcastChannelTransport {
5077
5077
  const lockName = `exotel-ai-assist-leader:${sessionHash}`;
5078
5078
  this.channel = new BroadcastChannel(channelName);
5079
5079
  this.channel.onmessage = ev => {
5080
- var _a;
5080
+ var _a, _b, _c;
5081
5081
  const data = ev.data;
5082
5082
  if (this.isLeader) {
5083
5083
  if (data.type === "JOIN" && this.socketConnected && this.ackPayload) {
@@ -5089,11 +5089,18 @@ class BroadcastChannelTransport {
5089
5089
  payload: this.ackPayload
5090
5090
  });
5091
5091
  }
5092
+ if (data.type === "SEND" && ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
5093
+ this.socket.send(data.payload);
5094
+ }
5095
+ if (data.type === "FEEDBACK_SYNC") {
5096
+ (_b = this.handler) === null || _b === void 0 ? void 0 : _b.call(this, data);
5097
+ }
5092
5098
  return;
5093
5099
  }
5094
- // Follower: forward every message except JOIN to the controller handler.
5095
- if (data.type !== "JOIN") {
5096
- (_a = this.handler) === null || _a === void 0 ? void 0 : _a.call(this, data);
5100
+ // Follower: forward server messages to the controller handler.
5101
+ // JOIN and SEND are internal transport messages, not controller messages.
5102
+ if (data.type !== "JOIN" && data.type !== "SEND") {
5103
+ (_c = this.handler) === null || _c === void 0 ? void 0 : _c.call(this, data);
5097
5104
  }
5098
5105
  };
5099
5106
  this._electLeader(lockName);
@@ -5203,8 +5210,15 @@ class BroadcastChannelTransport {
5203
5210
  }
5204
5211
  send(payload) {
5205
5212
  var _a;
5206
- if (this.isLeader && ((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
5207
- this.socket.send(payload);
5213
+ if (this.isLeader) {
5214
+ if (((_a = this.socket) === null || _a === void 0 ? void 0 : _a.readyState) === WebSocket.OPEN) {
5215
+ this.socket.send(payload);
5216
+ }
5217
+ } else {
5218
+ this.channel.postMessage({
5219
+ type: "SEND",
5220
+ payload
5221
+ });
5208
5222
  }
5209
5223
  }
5210
5224
  onMessage(handler) {
@@ -5213,8 +5227,22 @@ class BroadcastChannelTransport {
5213
5227
  markAcknowledged(rawAckPayload) {
5214
5228
  this.ackPayload = rawAckPayload;
5215
5229
  }
5230
+ broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason) {
5231
+ this.channel.postMessage({
5232
+ type: "FEEDBACK_SYNC",
5233
+ sequence,
5234
+ feedbackType,
5235
+ badFeedbackReason
5236
+ });
5237
+ }
5216
5238
  destroy() {
5217
5239
  var _a;
5240
+ if (this.isLeader) {
5241
+ this.channel.postMessage({
5242
+ type: "DISCONNECTED",
5243
+ code: 1000
5244
+ });
5245
+ }
5218
5246
  this._destroyed = true;
5219
5247
  this.disconnect();
5220
5248
  this.channel.close();
@@ -6407,6 +6435,7 @@ class ExotelAIAssistController extends EventEmitter {
6407
6435
  };
6408
6436
  try {
6409
6437
  this.transport.send(JSON.stringify(message));
6438
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
6410
6439
  return true;
6411
6440
  } catch (error) {
6412
6441
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -6433,12 +6462,13 @@ class ExotelAIAssistController extends EventEmitter {
6433
6462
  if (this.connectionEstablished) {
6434
6463
  const wasThrottled = this._streamState === "throttled";
6435
6464
  if (this.readyFired && !wasThrottled) {
6436
- this.readyFired = false;
6437
6465
  this.emit("onReady", false);
6438
6466
  }
6467
+ this.connectionEstablished = false;
6468
+ this.readyFired = false;
6469
+ this._streamState = null;
6439
6470
  this._setStatus("disconnected");
6440
6471
  this.emit("onCallEnd");
6441
- this.destroy();
6442
6472
  } else {
6443
6473
  this._scheduleReconnect();
6444
6474
  }
@@ -6449,6 +6479,13 @@ class ExotelAIAssistController extends EventEmitter {
6449
6479
  case "MESSAGE":
6450
6480
  if (msg.payload) this._handleServerPayload(msg.payload);
6451
6481
  break;
6482
+ case "FEEDBACK_SYNC":
6483
+ this.emit("suggestionFeedbackSync", {
6484
+ sequence: msg.sequence,
6485
+ feedbackType: msg.feedbackType,
6486
+ badFeedbackReason: msg.badFeedbackReason
6487
+ });
6488
+ break;
6452
6489
  }
6453
6490
  }
6454
6491
  _scheduleReconnect() {
@@ -6594,16 +6631,36 @@ function useExotelAIAssist(params) {
6594
6631
  setLastError(null);
6595
6632
  const ctrl = new ExotelAIAssistController(params);
6596
6633
  controllerRef.current = ctrl;
6597
- ctrl.on("onReady", setIsReady);
6634
+ ctrl.on("onReady", ready => {
6635
+ setIsReady(ready);
6636
+ if (!ready) {
6637
+ setSuggestions([]);
6638
+ setTranscripts([]);
6639
+ setSentiment(null);
6640
+ }
6641
+ });
6598
6642
  ctrl.on("streamState", setStreamState);
6599
6643
  ctrl.on("botConfig", setBotConfig);
6600
- ctrl.on("suggestion", s => setSuggestions(prev => [...prev, s].slice(-50)));
6644
+ ctrl.on("suggestion", s => setSuggestions(prev => {
6645
+ if (prev.some(existing => existing.sequence === s.sequence)) return prev;
6646
+ return [...prev, s].slice(-50);
6647
+ }));
6601
6648
  ctrl.on("sentiment", setSentiment);
6602
6649
  ctrl.on("transcript", lines => setTranscripts(prev => {
6603
6650
  const map = new Map(prev.map(l => [l.id, l]));
6604
6651
  for (const l of lines) map.set(l.id, l);
6605
6652
  return Array.from(map.values()).sort((a, b) => a.startTime - b.startTime);
6606
6653
  }));
6654
+ ctrl.on("suggestionFeedbackSync", ({
6655
+ sequence,
6656
+ feedbackType,
6657
+ badFeedbackReason
6658
+ }) => {
6659
+ setSuggestions(prev => prev.map(s => s.sequence === sequence ? Object.assign(Object.assign({}, s), {
6660
+ feedbackType,
6661
+ badFeedbackReason
6662
+ }) : s));
6663
+ });
6607
6664
  ctrl.on("error", setLastError);
6608
6665
  ctrl.connect();
6609
6666
  return () => {
@@ -8,6 +8,8 @@ export interface ITransport {
8
8
  /** Store the raw ack payload so follower tabs joining later receive it as a
9
9
  * MESSAGE replay instead of a bare ACKNOWLEDGED signal. */
10
10
  markAcknowledged(rawAckPayload: string): void;
11
+ /** Broadcast a feedback state change to other tabs for UI sync. */
12
+ broadcastFeedbackSync(sequence: number, feedbackType: "good" | "bad" | null, badFeedbackReason: string | null): void;
11
13
  destroy(): void;
12
14
  }
13
15
  export declare class BroadcastChannelTransport implements ITransport {
@@ -35,6 +37,7 @@ export declare class BroadcastChannelTransport implements ITransport {
35
37
  send(payload: string): void;
36
38
  onMessage(handler: TransportMessageHandler): void;
37
39
  markAcknowledged(rawAckPayload: string): void;
40
+ broadcastFeedbackSync(sequence: number, feedbackType: "good" | "bad" | null, badFeedbackReason: string | null): void;
38
41
  destroy(): void;
39
42
  }
40
43
  export declare function createTransport(sessionHash: string): ITransport;
@@ -117,6 +117,12 @@ export interface ControllerEvents {
117
117
  statusChange: (status: ConnectionStatus) => void;
118
118
  /** Fires whenever the server sends a `stream_status` message. */
119
119
  streamState: (state: StreamState) => void;
120
+ /** Fires when another tab updates suggestion feedback, for cross-tab UI sync. */
121
+ suggestionFeedbackSync: (data: {
122
+ sequence: number;
123
+ feedbackType: "good" | "bad" | null;
124
+ badFeedbackReason: string | null;
125
+ }) => void;
120
126
  error: (err: Error) => void;
121
127
  raw: (data: unknown) => void;
122
128
  }
@@ -133,5 +139,10 @@ export type WorkerInboundMessage = {
133
139
  } | {
134
140
  type: "ERROR";
135
141
  message: string;
142
+ } | {
143
+ type: "FEEDBACK_SYNC";
144
+ sequence: number;
145
+ feedbackType: "good" | "bad" | null;
146
+ badFeedbackReason: string | null;
136
147
  };
137
148
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exotel-npm-dev/exotel-ai-assist",
3
- "version": "1.2.9",
3
+ "version": "1.3.1",
4
4
  "description": "AI-powered real-time suggestions, transcript, and sentiment for agent calls",
5
5
  "license": "Apache-2.0",
6
6
  "main": "dist/index.js",
@@ -116,6 +116,7 @@ export class ExotelAIAssistController extends EventEmitter<ControllerEvents> {
116
116
 
117
117
  try {
118
118
  this.transport.send(JSON.stringify(message));
119
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
119
120
  return true;
120
121
  } catch (error) {
121
122
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -145,12 +146,13 @@ export class ExotelAIAssistController extends EventEmitter<ControllerEvents> {
145
146
  if (this.connectionEstablished) {
146
147
  const wasThrottled = this._streamState === "throttled";
147
148
  if (this.readyFired && !wasThrottled) {
148
- this.readyFired = false;
149
149
  this.emit("onReady", false);
150
150
  }
151
+ this.connectionEstablished = false;
152
+ this.readyFired = false;
153
+ this._streamState = null;
151
154
  this._setStatus("disconnected");
152
155
  this.emit("onCallEnd");
153
- this.destroy();
154
156
  } else {
155
157
  this._scheduleReconnect();
156
158
  }
@@ -163,6 +165,14 @@ export class ExotelAIAssistController extends EventEmitter<ControllerEvents> {
163
165
  case "MESSAGE":
164
166
  if (msg.payload) this._handleServerPayload(msg.payload);
165
167
  break;
168
+
169
+ case "FEEDBACK_SYNC":
170
+ this.emit("suggestionFeedbackSync", {
171
+ sequence: msg.sequence,
172
+ feedbackType: msg.feedbackType,
173
+ badFeedbackReason: msg.badFeedbackReason,
174
+ });
175
+ break;
166
176
  }
167
177
  }
168
178
 
@@ -63,10 +63,22 @@ export function useExotelAIAssist(params: ExotelAIAssistParams): UseExotelAIAssi
63
63
  const ctrl = new ExotelAIAssistController(params);
64
64
  controllerRef.current = ctrl;
65
65
 
66
- ctrl.on("onReady", setIsReady);
66
+ ctrl.on("onReady", (ready) => {
67
+ setIsReady(ready);
68
+ if (!ready) {
69
+ setSuggestions([]);
70
+ setTranscripts([]);
71
+ setSentiment(null);
72
+ }
73
+ });
67
74
  ctrl.on("streamState", setStreamState);
68
75
  ctrl.on("botConfig", setBotConfig);
69
- ctrl.on("suggestion", (s) => setSuggestions((prev) => [...prev, s].slice(-MAX_SUGGESTIONS)));
76
+ ctrl.on("suggestion", (s) =>
77
+ setSuggestions((prev) => {
78
+ if (prev.some((existing) => existing.sequence === s.sequence)) return prev;
79
+ return [...prev, s].slice(-MAX_SUGGESTIONS);
80
+ }),
81
+ );
70
82
  ctrl.on("sentiment", setSentiment);
71
83
  ctrl.on("transcript", (lines) =>
72
84
  setTranscripts((prev) => {
@@ -75,6 +87,9 @@ export function useExotelAIAssist(params: ExotelAIAssistParams): UseExotelAIAssi
75
87
  return Array.from(map.values()).sort((a, b) => a.startTime - b.startTime);
76
88
  }),
77
89
  );
90
+ ctrl.on("suggestionFeedbackSync", ({ sequence, feedbackType, badFeedbackReason }) => {
91
+ setSuggestions((prev) => prev.map((s) => (s.sequence === sequence ? { ...s, feedbackType, badFeedbackReason } : s)));
92
+ });
78
93
  ctrl.on("error", setLastError);
79
94
 
80
95
  ctrl.connect();
@@ -4,7 +4,7 @@ type TransportMessageHandler = (msg: WorkerInboundMessage) => void;
4
4
 
5
5
  // Internal-only message used for the leader-sync handshake.
6
6
  // Never forwarded to the controller.
7
- type InternalMessage = WorkerInboundMessage | { type: "JOIN" };
7
+ type InternalMessage = WorkerInboundMessage | { type: "JOIN" } | { type: "SEND"; payload: string };
8
8
 
9
9
  export interface ITransport {
10
10
  connect(url: string, authToken: string): void;
@@ -14,6 +14,8 @@ export interface ITransport {
14
14
  /** Store the raw ack payload so follower tabs joining later receive it as a
15
15
  * MESSAGE replay instead of a bare ACKNOWLEDGED signal. */
16
16
  markAcknowledged(rawAckPayload: string): void;
17
+ /** Broadcast a feedback state change to other tabs for UI sync. */
18
+ broadcastFeedbackSync(sequence: number, feedbackType: "good" | "bad" | null, badFeedbackReason: string | null): void;
17
19
  destroy(): void;
18
20
  }
19
21
 
@@ -48,11 +50,18 @@ export class BroadcastChannelTransport implements ITransport {
48
50
  this.channel.postMessage({ type: "CONNECTED" } satisfies WorkerInboundMessage);
49
51
  this.channel.postMessage({ type: "MESSAGE", payload: this.ackPayload } satisfies WorkerInboundMessage);
50
52
  }
53
+ if (data.type === "SEND" && this.socket?.readyState === WebSocket.OPEN) {
54
+ this.socket.send((data as { type: "SEND"; payload: string }).payload);
55
+ }
56
+ if (data.type === "FEEDBACK_SYNC") {
57
+ this.handler?.(data as WorkerInboundMessage);
58
+ }
51
59
  return;
52
60
  }
53
61
 
54
- // Follower: forward every message except JOIN to the controller handler.
55
- if (data.type !== "JOIN") {
62
+ // Follower: forward server messages to the controller handler.
63
+ // JOIN and SEND are internal transport messages, not controller messages.
64
+ if (data.type !== "JOIN" && data.type !== "SEND") {
56
65
  this.handler?.(data as WorkerInboundMessage);
57
66
  }
58
67
  };
@@ -154,8 +163,12 @@ export class BroadcastChannelTransport implements ITransport {
154
163
  }
155
164
 
156
165
  send(payload: string): void {
157
- if (this.isLeader && this.socket?.readyState === WebSocket.OPEN) {
158
- this.socket.send(payload);
166
+ if (this.isLeader) {
167
+ if (this.socket?.readyState === WebSocket.OPEN) {
168
+ this.socket.send(payload);
169
+ }
170
+ } else {
171
+ this.channel.postMessage({ type: "SEND", payload } satisfies InternalMessage);
159
172
  }
160
173
  }
161
174
 
@@ -167,7 +180,14 @@ export class BroadcastChannelTransport implements ITransport {
167
180
  this.ackPayload = rawAckPayload;
168
181
  }
169
182
 
183
+ broadcastFeedbackSync(sequence: number, feedbackType: "good" | "bad" | null, badFeedbackReason: string | null): void {
184
+ this.channel.postMessage({ type: "FEEDBACK_SYNC", sequence, feedbackType, badFeedbackReason } satisfies WorkerInboundMessage);
185
+ }
186
+
170
187
  destroy(): void {
188
+ if (this.isLeader) {
189
+ this.channel.postMessage({ type: "DISCONNECTED", code: 1000 } satisfies WorkerInboundMessage);
190
+ }
171
191
  this._destroyed = true;
172
192
  this.disconnect();
173
193
  this.channel.close();
@@ -137,6 +137,8 @@ export interface ControllerEvents {
137
137
  statusChange: (status: ConnectionStatus) => void;
138
138
  /** Fires whenever the server sends a `stream_status` message. */
139
139
  streamState: (state: StreamState) => void;
140
+ /** Fires when another tab updates suggestion feedback, for cross-tab UI sync. */
141
+ suggestionFeedbackSync: (data: { sequence: number; feedbackType: "good" | "bad" | null; badFeedbackReason: string | null }) => void;
140
142
  error: (err: Error) => void;
141
143
  raw: (data: unknown) => void;
142
144
  }
@@ -146,4 +148,5 @@ export type WorkerInboundMessage =
146
148
  | { type: "CONNECTED" }
147
149
  | { type: "ACKNOWLEDGED" }
148
150
  | { type: "DISCONNECTED"; code?: number }
149
- | { type: "ERROR"; message: string };
151
+ | { type: "ERROR"; message: string }
152
+ | { type: "FEEDBACK_SYNC"; sequence: number; feedbackType: "good" | "bad" | null; badFeedbackReason: string | null };