@exotel-npm-dev/exotel-ai-assist 1.3.0 → 1.3.2

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,6 +499,14 @@ 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;
490
512
  if (this.isLeader) {
@@ -1685,6 +1707,7 @@ class ExotelAIAssistController extends EventEmitter {
1685
1707
  };
1686
1708
  try {
1687
1709
  this.transport.send(JSON.stringify(message));
1710
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
1688
1711
  return true;
1689
1712
  } catch (error) {
1690
1713
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -1728,6 +1751,13 @@ class ExotelAIAssistController extends EventEmitter {
1728
1751
  case "MESSAGE":
1729
1752
  if (msg.payload) this._handleServerPayload(msg.payload);
1730
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;
1731
1761
  }
1732
1762
  }
1733
1763
  _scheduleReconnect() {
@@ -1762,11 +1792,11 @@ class ExotelAIAssistController extends EventEmitter {
1762
1792
  this.emit("onReady", true);
1763
1793
  }
1764
1794
  _handleServerPayload(raw) {
1765
- var _a, _b;
1795
+ var _a, _b, _c, _d, _e, _f;
1766
1796
  let parsed;
1767
1797
  try {
1768
1798
  parsed = JSON.parse(raw);
1769
- } catch (_c) {
1799
+ } catch (_g) {
1770
1800
  this.emit("error", new Error(`Failed to parse server message: ${raw}`));
1771
1801
  return;
1772
1802
  }
@@ -1820,8 +1850,8 @@ class ExotelAIAssistController extends EventEmitter {
1820
1850
  value: event.value.text,
1821
1851
  timestamp: now,
1822
1852
  sequence: event.value.sequence,
1823
- feedbackType: null,
1824
- badFeedbackReason: null
1853
+ feedbackType: (_d = (_c = event.value) === null || _c === void 0 ? void 0 : _c.feedback_type) !== null && _d !== void 0 ? _d : null,
1854
+ badFeedbackReason: (_f = (_e = event.value) === null || _e === void 0 ? void 0 : _e.bad_feedback_reason) !== null && _f !== void 0 ? _f : null
1825
1855
  };
1826
1856
  this.emit("suggestion", suggestion);
1827
1857
  }
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,6 +19941,14 @@ 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;
19932
19954
  if (this.isLeader) {
@@ -21127,6 +21149,7 @@ class ExotelAIAssistController extends EventEmitter {
21127
21149
  };
21128
21150
  try {
21129
21151
  this.transport.send(JSON.stringify(message));
21152
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
21130
21153
  return true;
21131
21154
  } catch (error) {
21132
21155
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -21170,6 +21193,13 @@ class ExotelAIAssistController extends EventEmitter {
21170
21193
  case "MESSAGE":
21171
21194
  if (msg.payload) this._handleServerPayload(msg.payload);
21172
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;
21173
21203
  }
21174
21204
  }
21175
21205
  _scheduleReconnect() {
@@ -21204,11 +21234,11 @@ class ExotelAIAssistController extends EventEmitter {
21204
21234
  this.emit("onReady", true);
21205
21235
  }
21206
21236
  _handleServerPayload(raw) {
21207
- var _a, _b;
21237
+ var _a, _b, _c, _d, _e, _f;
21208
21238
  let parsed;
21209
21239
  try {
21210
21240
  parsed = JSON.parse(raw);
21211
- } catch (_c) {
21241
+ } catch (_g) {
21212
21242
  this.emit("error", new Error(`Failed to parse server message: ${raw}`));
21213
21243
  return;
21214
21244
  }
@@ -21262,8 +21292,8 @@ class ExotelAIAssistController extends EventEmitter {
21262
21292
  value: event.value.text,
21263
21293
  timestamp: now,
21264
21294
  sequence: event.value.sequence,
21265
- feedbackType: null,
21266
- badFeedbackReason: null
21295
+ feedbackType: (_d = (_c = event.value) === null || _c === void 0 ? void 0 : _c.feedback_type) !== null && _d !== void 0 ? _d : null,
21296
+ badFeedbackReason: (_f = (_e = event.value) === null || _e === void 0 ? void 0 : _e.bad_feedback_reason) !== null && _f !== void 0 ? _f : null
21267
21297
  };
21268
21298
  this.emit("suggestion", suggestion);
21269
21299
  }
@@ -21325,13 +21355,26 @@ function useExotelAIAssist(params) {
21325
21355
  });
21326
21356
  ctrl.on("streamState", setStreamState);
21327
21357
  ctrl.on("botConfig", setBotConfig);
21328
- 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
+ }));
21329
21362
  ctrl.on("sentiment", setSentiment);
21330
21363
  ctrl.on("transcript", lines => setTranscripts(prev => {
21331
21364
  const map = new Map(prev.map(l => [l.id, l]));
21332
21365
  for (const l of lines) map.set(l.id, l);
21333
21366
  return Array.from(map.values()).sort((a, b) => a.startTime - b.startTime);
21334
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
+ });
21335
21378
  ctrl.on("error", setLastError);
21336
21379
  ctrl.connect();
21337
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,6 +5227,14 @@ 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;
5218
5240
  if (this.isLeader) {
@@ -6413,6 +6435,7 @@ class ExotelAIAssistController extends EventEmitter {
6413
6435
  };
6414
6436
  try {
6415
6437
  this.transport.send(JSON.stringify(message));
6438
+ this.transport.broadcastFeedbackSync(sequence, feedbackType, badFeedbackReason);
6416
6439
  return true;
6417
6440
  } catch (error) {
6418
6441
  console.error("[ExotelAIAssist] Failed to send feedback:", error);
@@ -6456,6 +6479,13 @@ class ExotelAIAssistController extends EventEmitter {
6456
6479
  case "MESSAGE":
6457
6480
  if (msg.payload) this._handleServerPayload(msg.payload);
6458
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;
6459
6489
  }
6460
6490
  }
6461
6491
  _scheduleReconnect() {
@@ -6490,11 +6520,11 @@ class ExotelAIAssistController extends EventEmitter {
6490
6520
  this.emit("onReady", true);
6491
6521
  }
6492
6522
  _handleServerPayload(raw) {
6493
- var _a, _b;
6523
+ var _a, _b, _c, _d, _e, _f;
6494
6524
  let parsed;
6495
6525
  try {
6496
6526
  parsed = JSON.parse(raw);
6497
- } catch (_c) {
6527
+ } catch (_g) {
6498
6528
  this.emit("error", new Error(`Failed to parse server message: ${raw}`));
6499
6529
  return;
6500
6530
  }
@@ -6548,8 +6578,8 @@ class ExotelAIAssistController extends EventEmitter {
6548
6578
  value: event.value.text,
6549
6579
  timestamp: now,
6550
6580
  sequence: event.value.sequence,
6551
- feedbackType: null,
6552
- badFeedbackReason: null
6581
+ feedbackType: (_d = (_c = event.value) === null || _c === void 0 ? void 0 : _c.feedback_type) !== null && _d !== void 0 ? _d : null,
6582
+ badFeedbackReason: (_f = (_e = event.value) === null || _e === void 0 ? void 0 : _e.bad_feedback_reason) !== null && _f !== void 0 ? _f : null
6553
6583
  };
6554
6584
  this.emit("suggestion", suggestion);
6555
6585
  }
@@ -6611,13 +6641,26 @@ function useExotelAIAssist(params) {
6611
6641
  });
6612
6642
  ctrl.on("streamState", setStreamState);
6613
6643
  ctrl.on("botConfig", setBotConfig);
6614
- 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
+ }));
6615
6648
  ctrl.on("sentiment", setSentiment);
6616
6649
  ctrl.on("transcript", lines => setTranscripts(prev => {
6617
6650
  const map = new Map(prev.map(l => [l.id, l]));
6618
6651
  for (const l of lines) map.set(l.id, l);
6619
6652
  return Array.from(map.values()).sort((a, b) => a.startTime - b.startTime);
6620
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
+ });
6621
6664
  ctrl.on("error", setLastError);
6622
6665
  ctrl.connect();
6623
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;
@@ -67,6 +67,8 @@ interface TranscriptMessage {
67
67
  export interface SuggestionValue {
68
68
  text: string;
69
69
  sequence: number;
70
+ feedback_type?: "good" | "bad" | null;
71
+ bad_feedback_reason?: string | null;
70
72
  }
71
73
  export interface SuggestionFeedbackMessage {
72
74
  type: "suggestion_feedback";
@@ -117,6 +119,12 @@ export interface ControllerEvents {
117
119
  statusChange: (status: ConnectionStatus) => void;
118
120
  /** Fires whenever the server sends a `stream_status` message. */
119
121
  streamState: (state: StreamState) => void;
122
+ /** Fires when another tab updates suggestion feedback, for cross-tab UI sync. */
123
+ suggestionFeedbackSync: (data: {
124
+ sequence: number;
125
+ feedbackType: "good" | "bad" | null;
126
+ badFeedbackReason: string | null;
127
+ }) => void;
120
128
  error: (err: Error) => void;
121
129
  raw: (data: unknown) => void;
122
130
  }
@@ -133,5 +141,10 @@ export type WorkerInboundMessage = {
133
141
  } | {
134
142
  type: "ERROR";
135
143
  message: string;
144
+ } | {
145
+ type: "FEEDBACK_SYNC";
146
+ sequence: number;
147
+ feedbackType: "good" | "bad" | null;
148
+ badFeedbackReason: string | null;
136
149
  };
137
150
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exotel-npm-dev/exotel-ai-assist",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
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);
@@ -164,6 +165,14 @@ export class ExotelAIAssistController extends EventEmitter<ControllerEvents> {
164
165
  case "MESSAGE":
165
166
  if (msg.payload) this._handleServerPayload(msg.payload);
166
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;
167
176
  }
168
177
  }
169
178
 
@@ -270,8 +279,8 @@ export class ExotelAIAssistController extends EventEmitter<ControllerEvents> {
270
279
  value: event.value.text,
271
280
  timestamp: now,
272
281
  sequence: event.value.sequence,
273
- feedbackType: null,
274
- badFeedbackReason: null,
282
+ feedbackType: event.value?.feedback_type ?? null,
283
+ badFeedbackReason: event.value?.bad_feedback_reason ?? null,
275
284
  };
276
285
  this.emit("suggestion", suggestion);
277
286
  }
@@ -73,7 +73,12 @@ export function useExotelAIAssist(params: ExotelAIAssistParams): UseExotelAIAssi
73
73
  });
74
74
  ctrl.on("streamState", setStreamState);
75
75
  ctrl.on("botConfig", setBotConfig);
76
- 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
+ );
77
82
  ctrl.on("sentiment", setSentiment);
78
83
  ctrl.on("transcript", (lines) =>
79
84
  setTranscripts((prev) => {
@@ -82,6 +87,9 @@ export function useExotelAIAssist(params: ExotelAIAssistParams): UseExotelAIAssi
82
87
  return Array.from(map.values()).sort((a, b) => a.startTime - b.startTime);
83
88
  }),
84
89
  );
90
+ ctrl.on("suggestionFeedbackSync", ({ sequence, feedbackType, badFeedbackReason }) => {
91
+ setSuggestions((prev) => prev.map((s) => (s.sequence === sequence ? { ...s, feedbackType, badFeedbackReason } : s)));
92
+ });
85
93
  ctrl.on("error", setLastError);
86
94
 
87
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,6 +180,10 @@ 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 {
171
188
  if (this.isLeader) {
172
189
  this.channel.postMessage({ type: "DISCONNECTED", code: 1000 } satisfies WorkerInboundMessage);
@@ -80,6 +80,8 @@ interface TranscriptMessage {
80
80
  export interface SuggestionValue {
81
81
  text: string;
82
82
  sequence: number;
83
+ feedback_type?: "good" | "bad" | null;
84
+ bad_feedback_reason?: string | null;
83
85
  }
84
86
  export interface SuggestionFeedbackMessage {
85
87
  type: "suggestion_feedback";
@@ -137,6 +139,8 @@ export interface ControllerEvents {
137
139
  statusChange: (status: ConnectionStatus) => void;
138
140
  /** Fires whenever the server sends a `stream_status` message. */
139
141
  streamState: (state: StreamState) => void;
142
+ /** Fires when another tab updates suggestion feedback, for cross-tab UI sync. */
143
+ suggestionFeedbackSync: (data: { sequence: number; feedbackType: "good" | "bad" | null; badFeedbackReason: string | null }) => void;
140
144
  error: (err: Error) => void;
141
145
  raw: (data: unknown) => void;
142
146
  }
@@ -146,4 +150,5 @@ export type WorkerInboundMessage =
146
150
  | { type: "CONNECTED" }
147
151
  | { type: "ACKNOWLEDGED" }
148
152
  | { type: "DISCONNECTED"; code?: number }
149
- | { type: "ERROR"; message: string };
153
+ | { type: "ERROR"; message: string }
154
+ | { type: "FEEDBACK_SYNC"; sequence: number; feedbackType: "good" | "bad" | null; badFeedbackReason: string | null };