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