@xapp/chat-widget 1.90.0 → 1.91.0

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.
package/dist/index.es.js CHANGED
@@ -2992,7 +2992,9 @@ function setSessionId(sessionId) {
2992
2992
 
2993
2993
  // Endpoint path constants
2994
2994
  var HOMEOWNER_ENDPOINT_PATH = "/chat/stream";
2995
+ var HOMEOWNER_CLOSE_ENDPOINT_PATH = "/chat/close";
2995
2996
  var BUSINESS_OWNER_ENDPOINT_PATH = "/api/chat/stream";
2997
+ var BUSINESS_OWNER_CLOSE_ENDPOINT_PATH = "/api/chat/close";
2996
2998
  // Default retry configuration for MCP connections
2997
2999
  var DEFAULT_MCP_RETRY_CONFIG = [
2998
3000
  { retry: 0, delay: 0, text: "" }, // Initial attempt (silent)
@@ -3146,6 +3148,22 @@ function clearStoredLastSeq(sessionId) {
3146
3148
  // sessionStorage may be unavailable
3147
3149
  }
3148
3150
  }
3151
+ /**
3152
+ * Returns the current local time as an ISO 8601 string with the local timezone offset.
3153
+ * Unlike toISOString(), this preserves the user's local time zone (e.g. "2024-01-15T14:30:00-04:00")
3154
+ * so the agent server can correctly determine time-of-day context (morning greetings, etc.).
3155
+ */
3156
+ function getLocalISOString(date) {
3157
+ if (date === void 0) { date = new Date(); }
3158
+ var offsetMinutes = -date.getTimezoneOffset();
3159
+ var sign = offsetMinutes >= 0 ? "+" : "-";
3160
+ var pad = function (n) { return String(Math.abs(n)).padStart(2, "0"); };
3161
+ var offsetHours = Math.floor(Math.abs(offsetMinutes) / 60);
3162
+ var offsetMins = Math.abs(offsetMinutes) % 60;
3163
+ return ("".concat(date.getFullYear(), "-").concat(pad(date.getMonth() + 1), "-").concat(pad(date.getDate())) +
3164
+ "T".concat(pad(date.getHours()), ":").concat(pad(date.getMinutes()), ":").concat(pad(date.getSeconds())) +
3165
+ "".concat(sign).concat(pad(offsetHours), ":").concat(pad(offsetMins)));
3166
+ }
3149
3167
  /**
3150
3168
  * Gets the current page context from browser globals.
3151
3169
  * If an override is provided, uses that instead (useful for testing).
@@ -3154,7 +3172,7 @@ function clearStoredLastSeq(sessionId) {
3154
3172
  * may contain sensitive data (tokens, PII). Only origin + pathname are sent.
3155
3173
  */
3156
3174
  function getPageContext(override) {
3157
- var localTime = new Date().toISOString();
3175
+ var localTime = getLocalISOString();
3158
3176
  // If override provided, merge with defaults (override wins)
3159
3177
  // Overrides are typically for testing and may include full URLs
3160
3178
  if (override && Object.keys(override).length > 0) {
@@ -3206,12 +3224,15 @@ var MCPChat = /** @class */ (function () {
3206
3224
  this.isValidatingSession = false;
3207
3225
  // Tool call tracking for debug mode
3208
3226
  this.currentToolCalls = [];
3209
- // TODO: isReplaying and lastSeq are tracked for future replay support.
3210
- // Wire up replay requests in wakeup() once the server endpoint supports it.
3211
3227
  this.isReplaying = false;
3228
+ // Sleep/wakeup state for tab backgrounding recovery
3229
+ this.isSleeping = false;
3230
+ this.isRequestingReplay = false;
3231
+ this.needsReplayOnWakeup = false;
3212
3232
  // True when userId was assigned by the server (via connected SSE event),
3213
3233
  // meaning it should not be overwritten by the default client fingerprint.
3214
3234
  this.userIdIsServerAssigned = false;
3235
+ this.sentMessageCount = 0;
3215
3236
  this.config = config;
3216
3237
  this.options = options;
3217
3238
  this.configurableMessages = options === null || options === void 0 ? void 0 : options.configurableMessages;
@@ -3247,6 +3268,7 @@ var MCPChat = /** @class */ (function () {
3247
3268
  }
3248
3269
  MCPChat.prototype.init = function (dispatch) {
3249
3270
  this.dispatch = dispatch;
3271
+ this.sessionStartTime = Date.now();
3250
3272
  var mode = this.config.mode || "homeowner";
3251
3273
  log("MCPChat initialized with mode: ".concat(mode));
3252
3274
  // Warn if not using HTTPS with authentication
@@ -3327,7 +3349,9 @@ var MCPChat = /** @class */ (function () {
3327
3349
  // Page context
3328
3350
  pageContext: getPageContext(this.config.pageContextOverride),
3329
3351
  // Client timestamp for latency measurement and clock drift detection
3330
- clientTime: new Date().toISOString() }, additionalData);
3352
+ clientTime: new Date().toISOString(),
3353
+ // Last sequence number for replay on reconnect
3354
+ lastSeq: this.lastSeq }, additionalData);
3331
3355
  };
3332
3356
  /**
3333
3357
  * Initializes a new session by calling the endpoint with { newSession: true }.
@@ -3400,6 +3424,14 @@ var MCPChat = /** @class */ (function () {
3400
3424
  // homeowner mode - serverUrl should already include appId
3401
3425
  return "".concat(serverUrl).concat(HOMEOWNER_ENDPOINT_PATH);
3402
3426
  };
3427
+ MCPChat.prototype.getCloseEndpoint = function () {
3428
+ var serverUrl = this.config.serverUrl.replace(/\/+$/, "");
3429
+ var mode = this.config.mode || "homeowner";
3430
+ if (mode === "business_owner") {
3431
+ return "".concat(serverUrl).concat(BUSINESS_OWNER_CLOSE_ENDPOINT_PATH);
3432
+ }
3433
+ return "".concat(serverUrl).concat(HOMEOWNER_CLOSE_ENDPOINT_PATH);
3434
+ };
3403
3435
  MCPChat.prototype.sendNewMessage = function (msg, user) {
3404
3436
  log("SERVER: new message: ".concat(JSON.stringify(msg)));
3405
3437
  this.dispatch({
@@ -3550,6 +3582,30 @@ var MCPChat = /** @class */ (function () {
3550
3582
  },
3551
3583
  });
3552
3584
  };
3585
+ MCPChat.prototype.dispatchStreamAbort = function (messageId) {
3586
+ log("[dispatchStreamAbort] Stream aborted: ".concat(messageId));
3587
+ this.dispatch({
3588
+ type: "chat",
3589
+ detail: {
3590
+ type: "chat.stream.abort",
3591
+ user: this.getBot(undefined),
3592
+ messageId: messageId,
3593
+ timestamp: Date.now(),
3594
+ },
3595
+ });
3596
+ };
3597
+ MCPChat.prototype.dispatchStreamResume = function (messageId) {
3598
+ log("[dispatchStreamResume] Stream resumed: ".concat(messageId));
3599
+ this.dispatch({
3600
+ type: "chat",
3601
+ detail: {
3602
+ type: "chat.stream.resume",
3603
+ user: this.getBot(undefined),
3604
+ messageId: messageId,
3605
+ timestamp: Date.now(),
3606
+ },
3607
+ });
3608
+ };
3553
3609
  MCPChat.prototype.dispatchToolStart = function (params) {
3554
3610
  this.dispatch({
3555
3611
  type: "chat",
@@ -3863,7 +3919,10 @@ var MCPChat = /** @class */ (function () {
3863
3919
  attempt++;
3864
3920
  return [3 /*break*/, 3];
3865
3921
  case 14:
3866
- if (!success) {
3922
+ if (success) {
3923
+ this.sentMessageCount++;
3924
+ }
3925
+ else {
3867
3926
  log("All MCP retry attempts exhausted");
3868
3927
  }
3869
3928
  return [2 /*return*/];
@@ -3915,6 +3974,10 @@ var MCPChat = /** @class */ (function () {
3915
3974
  : {})),
3916
3975
  body: JSON.stringify(body),
3917
3976
  signal: _this.abortController.signal,
3977
+ // Keep the connection open when the tab is hidden. Without this,
3978
+ // fetchEventSource closes and re-POSTs on visibility change, which
3979
+ // creates duplicate streaming messages and out-of-order responses.
3980
+ openWhenHidden: true,
3918
3981
  onopen: function (response) { return __awaiter$1(_this, void 0, void 0, function () {
3919
3982
  var error;
3920
3983
  return __generator$1(this, function (_a) {
@@ -3931,6 +3994,11 @@ var MCPChat = /** @class */ (function () {
3931
3994
  });
3932
3995
  }); },
3933
3996
  onmessage: function (event) {
3997
+ // Guard: ignore events from stale/aborted requests
3998
+ if (_this.currentMessageId !== messageId) {
3999
+ log("[SSE] Ignoring stale event for messageId: ".concat(messageId, ", current: ").concat(_this.currentMessageId));
4000
+ return;
4001
+ }
3934
4002
  log("SSE event: ".concat(event.event, ", data: ").concat(event.data));
3935
4003
  var eventType = event.event || "message";
3936
4004
  var data = {};
@@ -3970,11 +4038,17 @@ var MCPChat = /** @class */ (function () {
3970
4038
  storeIdentity(data.userId, data.sessionId);
3971
4039
  log("[SSE] Identity from connected - userId: ".concat(data.userId, ", sessionId: ").concat(data.sessionId));
3972
4040
  }
3973
- // Always start streaming - server sends greeting for both new and returning users
3974
- // (resumeSession requests always get a personalized greeting)
3975
4041
  _this.currentToolCalls = [];
3976
4042
  _this.stopTyping();
3977
- _this.dispatchStreamStart(messageId);
4043
+ if (data.isReplay) {
4044
+ // Replay connection — resume into the existing partial
4045
+ // message instead of creating a duplicate placeholder
4046
+ _this.dispatchStreamResume(messageId);
4047
+ }
4048
+ else {
4049
+ // Normal connection — create new streaming placeholder
4050
+ _this.dispatchStreamStart(messageId);
4051
+ }
3978
4052
  _this.startStreamThrottle(messageId);
3979
4053
  break;
3980
4054
  case "session_expired":
@@ -3997,9 +4071,9 @@ var MCPChat = /** @class */ (function () {
3997
4071
  if (data.text) {
3998
4072
  log("[SSE] Text chunk received (".concat(data.text.length, " chars), total chunks: ").concat(textChunks.length + 1, ", isReplay: ").concat(data.isReplay));
3999
4073
  textChunks.push(data.text);
4000
- if (data.isReplay) {
4001
- // Replayed text — dispatch immediately (no throttle)
4002
- // so the user sees caught-up content instantly
4074
+ if (data.isReplay || _this.isSleeping) {
4075
+ // Replayed text or tab hidden — dispatch immediately
4076
+ // (no throttle timer running in either case)
4003
4077
  _this.dispatchStreamChunk(messageId, data.text);
4004
4078
  }
4005
4079
  else {
@@ -4134,7 +4208,20 @@ var MCPChat = /** @class */ (function () {
4134
4208
  if (_this.currentMessageId === messageId && !hasError) {
4135
4209
  hasError = true;
4136
4210
  _this.isReplaying = false;
4137
- _this.stopStreamThrottle();
4211
+ if (_this.isSleeping) {
4212
+ // Connection dropped while tab is hidden — don't treat as
4213
+ // normal error. Finalize partial message and request replay
4214
+ // when the user returns.
4215
+ log("[MCPChat] SSE error during sleep - will request replay on wakeup");
4216
+ _this.needsReplayOnWakeup = true;
4217
+ if (_this.streamingMessageIdForThrottle) {
4218
+ _this.dispatchStreamAbort(messageId);
4219
+ }
4220
+ _this.stopStreamThrottle();
4221
+ }
4222
+ else {
4223
+ _this.stopStreamThrottle();
4224
+ }
4138
4225
  sseError = error instanceof Error ? error : new Error(String(error));
4139
4226
  // Get status code if available
4140
4227
  var statusCode = (error === null || error === void 0 ? void 0 : error.statusCode) || httpStatusCode;
@@ -4203,23 +4290,138 @@ var MCPChat = /** @class */ (function () {
4203
4290
  }
4204
4291
  };
4205
4292
  MCPChat.prototype.sleep = function () {
4206
- this.stopStreamThrottle();
4207
- this.isReplaying = false;
4208
- if (this.abortController) {
4209
- this.abortController.abort();
4210
- this.abortController = undefined;
4293
+ this.isSleeping = true;
4294
+ if (this.streamingMessageIdForThrottle) {
4295
+ // Active stream in progress — keep SSE connection alive so the
4296
+ // response completes naturally while the tab is hidden.
4297
+ this.needsReplayOnWakeup = false;
4298
+ log("[MCPChat] sleep() - stream in progress, keeping SSE connection alive");
4299
+ // Flush any buffered text to Redux immediately
4300
+ if (this.streamBuffer.length > 0) {
4301
+ this.dispatchStreamChunk(this.streamingMessageIdForThrottle, this.streamBuffer);
4302
+ this.streamBuffer = "";
4303
+ }
4304
+ // Stop the visual throttle timer (browsers throttle timers anyway)
4305
+ // but keep streamingMessageIdForThrottle so incoming chunks can be dispatched
4306
+ if (this.streamThrottleTimer) {
4307
+ clearInterval(this.streamThrottleTimer);
4308
+ this.streamThrottleTimer = undefined;
4309
+ }
4310
+ if (this.visibilityHandler) {
4311
+ document.removeEventListener("visibilitychange", this.visibilityHandler);
4312
+ this.visibilityHandler = undefined;
4313
+ }
4314
+ }
4315
+ else {
4316
+ // No active stream — safe to abort the idle connection
4317
+ this.stopStreamThrottle();
4318
+ this.isReplaying = false;
4319
+ if (this.abortController) {
4320
+ this.abortController.abort();
4321
+ this.abortController = undefined;
4322
+ }
4211
4323
  }
4212
4324
  };
4213
4325
  MCPChat.prototype.wakeup = function () {
4214
- // When tab is foregrounded, just restore connection status.
4215
- // Don't validate/replay yet — the session will be validated
4216
- // on the next user message. This avoids creating duplicate
4217
- // greetings if validation fails transiently.
4326
+ this.isSleeping = false;
4327
+ if (this.streamingMessageIdForThrottle && this.abortController) {
4328
+ // Stream is still in progress — restart the visual throttle
4329
+ log("[MCPChat] wakeup() - stream still in progress, restarting throttle");
4330
+ // Flush accumulated buffer before startStreamThrottle (which resets it)
4331
+ if (this.streamBuffer.length > 0) {
4332
+ this.dispatchStreamChunk(this.streamingMessageIdForThrottle, this.streamBuffer);
4333
+ this.streamBuffer = "";
4334
+ }
4335
+ this.startStreamThrottle(this.streamingMessageIdForThrottle);
4336
+ }
4337
+ else if (this.needsReplayOnWakeup) {
4338
+ this.needsReplayOnWakeup = false;
4339
+ if (this.lastSeq !== undefined && this._sessionId) {
4340
+ // Connection dropped during sleep — request replay of missed events.
4341
+ // Don't set "online" yet — requestReplay is fire-and-forget and the
4342
+ // connection status will be updated by the SSE onopen handler.
4343
+ log("[MCPChat] wakeup() - connection lost during sleep, requesting replay");
4344
+ this.requestReplay();
4345
+ return;
4346
+ }
4347
+ // Cannot replay — missing lastSeq or sessionId
4348
+ err("[MCPChat] wakeup() - cannot replay, missing lastSeq (".concat(this.lastSeq, ") or sessionId (").concat(this._sessionId, ")"));
4349
+ }
4218
4350
  if (this._sessionId) {
4219
4351
  log("[MCPChat] Wakeup - session exists, lastSeq: ".concat(this.lastSeq));
4220
4352
  this.setConnectionStatus("online");
4221
4353
  }
4222
4354
  };
4355
+ MCPChat.prototype.requestReplay = function () {
4356
+ return __awaiter$1(this, void 0, void 0, function () {
4357
+ var error_5;
4358
+ return __generator$1(this, function (_a) {
4359
+ switch (_a.label) {
4360
+ case 0:
4361
+ if (this.isRequestingReplay) {
4362
+ log("[MCPChat] Replay already in progress, skipping");
4363
+ return [2 /*return*/];
4364
+ }
4365
+ this.isRequestingReplay = true;
4366
+ this.typing();
4367
+ _a.label = 1;
4368
+ case 1:
4369
+ _a.trys.push([1, 3, 4, 5]);
4370
+ // lastSeq is already included by buildRequestPayload
4371
+ return [4 /*yield*/, this.performSSERequest(this.buildRequestPayload())];
4372
+ case 2:
4373
+ // lastSeq is already included by buildRequestPayload
4374
+ _a.sent();
4375
+ log("[MCPChat] Replay request completed");
4376
+ return [3 /*break*/, 5];
4377
+ case 3:
4378
+ error_5 = _a.sent();
4379
+ err("[MCPChat] Replay request failed: ".concat(error_5));
4380
+ this.setConnectionStatus("offline");
4381
+ return [3 /*break*/, 5];
4382
+ case 4:
4383
+ this.stopTyping();
4384
+ this.isRequestingReplay = false;
4385
+ return [7 /*endfinally*/];
4386
+ case 5: return [2 /*return*/];
4387
+ }
4388
+ });
4389
+ });
4390
+ };
4391
+ /**
4392
+ * Sends a best-effort close notification to the agent server.
4393
+ * Uses navigator.sendBeacon() so the request survives page unload.
4394
+ * Called from the beforeunload event handler and the widget close button.
4395
+ */
4396
+ MCPChat.prototype.notifyDisconnecting = function (reason) {
4397
+ if (reason === void 0) { reason = "close_tab"; }
4398
+ if (!this._sessionId) {
4399
+ return;
4400
+ }
4401
+ var endpoint = this.getCloseEndpoint();
4402
+ var payload = JSON.stringify({
4403
+ sessionId: this._sessionId,
4404
+ userId: this._userId || undefined,
4405
+ reason: reason,
4406
+ messageCount: this.sentMessageCount,
4407
+ durationMs: this.sessionStartTime !== undefined ? Date.now() - this.sessionStartTime : 0,
4408
+ });
4409
+ log("[MCPChat] notifyDisconnecting: reason=".concat(reason, ", endpoint=").concat(endpoint));
4410
+ // For widget_close, the page is not unloading so fetch() can be used — it supports
4411
+ // auth headers (needed for business_owner mode) unlike sendBeacon.
4412
+ if (reason === "widget_close" && typeof fetch !== "undefined") {
4413
+ var headers = { "Content-Type": "application/json" };
4414
+ if (this.config.authToken) {
4415
+ headers["Authorization"] = "Bearer ".concat(this.config.authToken);
4416
+ }
4417
+ fetch(endpoint, { method: "POST", headers: headers, body: payload, keepalive: true });
4418
+ return;
4419
+ }
4420
+ if (typeof navigator !== "undefined" && navigator.sendBeacon) {
4421
+ var blob = new Blob([payload], { type: "application/json" });
4422
+ navigator.sendBeacon(endpoint, blob);
4423
+ }
4424
+ };
4223
4425
  MCPChat.prototype.startSession = function (sessionId) {
4224
4426
  // userId priority:
4225
4427
  // 1. Existing server-assigned userId (from localStorage or previous connected event)
@@ -35567,7 +35769,7 @@ var ChatWidgetUI = function (props) {
35567
35769
  var handleBeforeUnload = function () {
35568
35770
  log("beforeunload - notifying router of user disconnecting");
35569
35771
  if (chatServer.notifyDisconnecting) {
35570
- chatServer.notifyDisconnecting();
35772
+ chatServer.notifyDisconnecting("close_tab");
35571
35773
  }
35572
35774
  };
35573
35775
  window.addEventListener("beforeunload", handleBeforeUnload);
@@ -35721,6 +35923,9 @@ var ChatWidgetUI = function (props) {
35721
35923
  }
35722
35924
  /** Called when cancel is clicked */
35723
35925
  function handleCancelClick() {
35926
+ if (chatServer === null || chatServer === void 0 ? void 0 : chatServer.notifyDisconnecting) {
35927
+ chatServer.notifyDisconnecting("widget_close");
35928
+ }
35724
35929
  // First reset to clear all state
35725
35930
  innerDispatch(reset());
35726
35931
  // Then set hasInteracted to prevent CTA from showing after cancel
@@ -36075,6 +36280,20 @@ function resetReducer(state) {
36075
36280
  function isChatSystemMessage(chat) {
36076
36281
  return chat.type === "chat.systemmessage";
36077
36282
  }
36283
+ /**
36284
+ * Find the LAST streaming message index. We must search from the end because
36285
+ * orphan isStreaming messages from interrupted streams may exist earlier in the
36286
+ * array. The active streaming message is always the most recently inserted one.
36287
+ */
36288
+ function findLastStreamingIndex(chats) {
36289
+ for (var i = chats.length - 1; i >= 0; i--) {
36290
+ var chat = chats[i];
36291
+ if (isChatMsgDetail(chat) && chat.isStreaming) {
36292
+ return i;
36293
+ }
36294
+ }
36295
+ return -1;
36296
+ }
36078
36297
  // todo: create reducer (requires redux-thunk dependency)
36079
36298
  function appendMessageToState(state, msg) {
36080
36299
  state.chats = joinMessages(state.chats, msg);
@@ -36192,8 +36411,19 @@ function update(state, action) {
36192
36411
  case "chat.typing":
36193
36412
  var agent = state.agents[action.detail.user.nick];
36194
36413
  return __assign(__assign({}, state), { lastTimestamp: (_v = action.detail) === null || _v === void 0 ? void 0 : _v.timestamp, agents: __assign(__assign({}, state.agents), (_d = {}, _d[action.detail.user.nick] = __assign(__assign({}, agent), { typing: action.detail.typing }), _d)) });
36195
- case "chat.stream.start":
36196
- // Add placeholder message with streaming state
36414
+ case "chat.stream.start": {
36415
+ // Clean up any orphan streaming messages from interrupted streams
36416
+ // before creating the new placeholder.
36417
+ var cleanedChats = newState.chats.map(function (chat) {
36418
+ if (isChatMsgDetail(chat) && chat.isStreaming) {
36419
+ return __assign(__assign({}, chat), { isStreaming: false, wasStreamed: true });
36420
+ }
36421
+ return chat;
36422
+ });
36423
+ // Add placeholder message with streaming state.
36424
+ // Use joinMessages (insertSorted by timestamp) instead of append to
36425
+ // guarantee correct ordering even if events arrive out of order
36426
+ // (e.g., after an SSE abort/retry during tab backgrounding).
36197
36427
  var streamingMsg = {
36198
36428
  type: "chat.msg",
36199
36429
  user: action.detail.user,
@@ -36203,13 +36433,15 @@ function update(state, action) {
36203
36433
  },
36204
36434
  isStreaming: true,
36205
36435
  };
36206
- return __assign(__assign({}, newState), { chats: __spreadArray$1(__spreadArray$1([], newState.chats, true), [streamingMsg], false), streamingMessageId: action.detail.messageId });
36436
+ var newChats = joinMessages(cleanedChats, streamingMsg);
36437
+ return __assign(__assign({}, newState), { chats: newChats, streamingMessageId: action.detail.messageId });
36438
+ }
36207
36439
  case "chat.stream.chunk": {
36208
36440
  // Find the streaming message by ID and append text
36209
36441
  var chunkDetail = action.detail;
36210
36442
  if (state.streamingMessageId === chunkDetail.messageId) {
36211
36443
  // Find the streaming message by ID (not by index - other messages could be inserted)
36212
- var streamingIndex_1 = state.chats.findIndex(function (chat) { return isChatMsgDetail(chat) && chat.isStreaming; });
36444
+ var streamingIndex_1 = findLastStreamingIndex(state.chats);
36213
36445
  if (streamingIndex_1 >= 0) {
36214
36446
  var chunkText_1 = chunkDetail.text;
36215
36447
  return __assign(__assign({}, state), { lastTimestamp: chunkDetail === null || chunkDetail === void 0 ? void 0 : chunkDetail.timestamp, chats: state.chats.map(function (chat, index) {
@@ -36229,7 +36461,7 @@ function update(state, action) {
36229
36461
  var toolStartDetail_1 = action.detail;
36230
36462
  if (state.streamingMessageId === toolStartDetail_1.messageId) {
36231
36463
  // Find the streaming message by ID (not by index - other messages could be inserted)
36232
- var streamingIndex_2 = state.chats.findIndex(function (chat) { return isChatMsgDetail(chat) && chat.isStreaming; });
36464
+ var streamingIndex_2 = findLastStreamingIndex(state.chats);
36233
36465
  if (streamingIndex_2 >= 0) {
36234
36466
  return __assign(__assign({}, state), { lastTimestamp: toolStartDetail_1.timestamp, chats: state.chats.map(function (chat, index) {
36235
36467
  if (index === streamingIndex_2 && isChatMsgDetail(chat)) {
@@ -36259,7 +36491,7 @@ function update(state, action) {
36259
36491
  var toolEndDetail_1 = action.detail;
36260
36492
  if (state.streamingMessageId === toolEndDetail_1.messageId) {
36261
36493
  // Find the streaming message by ID (not by index - other messages could be inserted)
36262
- var streamingIndex_3 = state.chats.findIndex(function (chat) { return isChatMsgDetail(chat) && chat.isStreaming; });
36494
+ var streamingIndex_3 = findLastStreamingIndex(state.chats);
36263
36495
  if (streamingIndex_3 >= 0) {
36264
36496
  return __assign(__assign({}, state), { lastTimestamp: toolEndDetail_1.timestamp, chats: state.chats.map(function (chat, index) {
36265
36497
  var _a;
@@ -36288,7 +36520,7 @@ function update(state, action) {
36288
36520
  var endDetail = action.detail;
36289
36521
  if (state.streamingMessageId === endDetail.messageId) {
36290
36522
  // Find the streaming message by ID (not by index - other messages could be inserted)
36291
- var streamingIndex_4 = state.chats.findIndex(function (chat) { return isChatMsgDetail(chat) && chat.isStreaming; });
36523
+ var streamingIndex_4 = findLastStreamingIndex(state.chats);
36292
36524
  var endStreamingChat = streamingIndex_4 >= 0 ? state.chats[streamingIndex_4] : undefined;
36293
36525
  // Verify it's the right message using type guard
36294
36526
  if (streamingIndex_4 >= 0 && endStreamingChat && isChatMsgDetail(endStreamingChat)) {
@@ -36307,6 +36539,51 @@ function update(state, action) {
36307
36539
  }
36308
36540
  return state;
36309
36541
  }
36542
+ case "chat.stream.abort": {
36543
+ // Finalize a streaming message interrupted by connection loss during sleep.
36544
+ // Keeps whatever partial text is already in the message.
36545
+ var abortDetail = action.detail;
36546
+ if (state.streamingMessageId === abortDetail.messageId) {
36547
+ var streamingIndex_5 = findLastStreamingIndex(state.chats);
36548
+ if (streamingIndex_5 >= 0) {
36549
+ return __assign(__assign({}, state), { lastTimestamp: abortDetail.timestamp, streamingMessageId: undefined, chats: state.chats.map(function (chat, index) {
36550
+ if (index === streamingIndex_5 && isChatMsgDetail(chat)) {
36551
+ return __assign(__assign({}, chat), { isStreaming: false, wasStreamed: true });
36552
+ }
36553
+ return chat;
36554
+ }) });
36555
+ }
36556
+ }
36557
+ return state;
36558
+ }
36559
+ case "chat.stream.resume": {
36560
+ // Re-enable streaming on the last aborted bot message after replay reconnect.
36561
+ // Targets the most recent wasStreamed message that is NOT currently streaming
36562
+ // (i.e., the one finalized by chat.stream.abort). Updates streamingMessageId
36563
+ // so replayed chunks route to the existing message.
36564
+ // Guard: only resume if no stream is currently active.
36565
+ var resumeDetail = action.detail;
36566
+ if (state.streamingMessageId) {
36567
+ return state;
36568
+ }
36569
+ var lastBotIndex_1 = -1;
36570
+ for (var i = state.chats.length - 1; i >= 0; i--) {
36571
+ var chat = state.chats[i];
36572
+ if (isChatMsgDetail(chat) && chat.wasStreamed && !chat.isStreaming) {
36573
+ lastBotIndex_1 = i;
36574
+ break;
36575
+ }
36576
+ }
36577
+ if (lastBotIndex_1 >= 0) {
36578
+ return __assign(__assign({}, state), { lastTimestamp: resumeDetail.timestamp, streamingMessageId: resumeDetail.messageId, chats: state.chats.map(function (chat, index) {
36579
+ if (index === lastBotIndex_1 && isChatMsgDetail(chat)) {
36580
+ return __assign(__assign({}, chat), { isStreaming: true, wasStreamed: false });
36581
+ }
36582
+ return chat;
36583
+ }) });
36584
+ }
36585
+ return state;
36586
+ }
36310
36587
  default:
36311
36588
  return state;
36312
36589
  }