@wix/himalaya-cli 0.1518.0 → 0.1520.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/cli.mjs CHANGED
@@ -9455,6 +9455,7 @@ function buttonVariant(style) {
9455
9455
  var SYSTEM_FONT_FAMILY, TONAL_ELEVATION_ALPHA, BUTTON_MIN_HIT_TARGET, nonNegative, nonEmpty2, own, COLOR_TOKEN_PROPS, TYPOGRAPHY_TOKEN_PROPS, BUTTON_VARIANTS;
9456
9456
  var init_tokens = __esm({
9457
9457
  "../../core/ts/src/tokens.ts"() {
9458
+ "use strict";
9458
9459
  SYSTEM_FONT_FAMILY = "$system";
9459
9460
  TONAL_ELEVATION_ALPHA = [0, 0.05, 0.08, 0.11, 0.12, 0.14];
9460
9461
  BUTTON_MIN_HIT_TARGET = 44;
@@ -22306,6 +22307,7 @@ function resolveIdiom(tokens2, ctx = {}) {
22306
22307
  var IDIOM_CONTEXTS, UNSAFE_KEYS;
22307
22308
  var init_idiom = __esm({
22308
22309
  "../../core/ts/src/idiom.ts"() {
22310
+ "use strict";
22309
22311
  IDIOM_CONTEXTS = {
22310
22312
  ios: { native: ["ios"] },
22311
22313
  android: { native: ["material"] },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/himalaya-cli",
3
- "version": "0.1518.0",
3
+ "version": "0.1520.0",
4
4
  "type": "module",
5
5
  "description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
6
6
  "keywords": [
@@ -71,5 +71,5 @@
71
71
  "artifactId": "himalaya-cli"
72
72
  }
73
73
  },
74
- "falconPackageHash": "6d3356378dc26bca1c7ee0fbf3b01e7675aeccdc2b30f7bcfbdcddf8"
74
+ "falconPackageHash": "2f14d091246ba420325b49874295aab17b517eb05c32671ab36da912"
75
75
  }
@@ -0,0 +1,48 @@
1
+ /** Smart Chat resolves its conversation from the caller's Wix identity. Its history must not
2
+ * be replaced by a different conversation returned by the Members chat room list. */
3
+ type Raw = Record<string, unknown>;
4
+ const object = (value: unknown): Raw => value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Raw : {};
5
+ const text = (value: unknown): string => typeof value === 'string' ? value : '';
6
+
7
+ // Positive lists: navigation, cart events, owner notifications and training messages are not a transcript.
8
+ const VISITOR_TYPES = new Set(['QUESTION', 'VISITOR_MESSAGE', 'VISITOR_OPTION_SELECTED', 'FAQ_QUESTION']);
9
+ const ASSISTANT_TYPES = new Set(['ANSWER', 'INTRO', 'LEGAL', 'OFFLINE', 'OUT_OF_QUOTA', 'FAQ_ANSWER']);
10
+ const STAFF_TYPES = new Set(['USER_ANSWER', 'WIX_USER_CARD', 'WIX_USER_MINIMAL_BODY', 'WIX_USER_MEDIA', 'WIX_USER_FORM']);
11
+
12
+ /** Adapt the current identity-scoped Smart Chat conversation to the existing presentation pipeline.
13
+ * `inboxId` identifies an Inbox MESSAGE, never a room. Where that exact message exists in the
14
+ * member page, keep its rich content; otherwise use the persisted Smart Chat body. No text/id guesses. */
15
+ export function smartChatHistory(aiMessages: unknown[], memberPage: unknown[], participantId: string): { conversationId: string; messages: Raw[] } | null {
16
+ const dated = aiMessages.map(object)
17
+ .filter(message => text(message.id) && text(message.conversationId) && Number.isFinite(Date.parse(text(message.createdDate))))
18
+ .sort((a, b) => Date.parse(text(a.createdDate)) - Date.parse(text(b.createdDate)));
19
+ const conversationId = text(dated.at(-1)?.conversationId);
20
+ if (!conversationId) return null;
21
+ const memberRows = new Map(memberPage.map(object).map(row => [text(row.id), row]));
22
+ const messages: Raw[] = [];
23
+ for (const message of dated) {
24
+ if (message.conversationId !== conversationId) continue;
25
+ const sender = text(message.sender);
26
+ const kind = text(message.messageType);
27
+ const mine = sender === 'SITE_VISITOR' && VISITOR_TYPES.has(kind);
28
+ if (!mine && !(sender === 'ASSISTANT' && ASSISTANT_TYPES.has(kind)) && !(sender === 'USER' && STAFF_TYPES.has(kind))) continue;
29
+ const body = object(message.body);
30
+ const bodyText = [text(body.mainText), text(body.footerText)].filter(Boolean).join('\n');
31
+ const rich = memberRows.get(text(message.inboxId));
32
+ // A quick-reply-only metadata answer is not an empty bubble (the options are shown by the composer).
33
+ if (!bodyText && !rich) continue;
34
+ messages.push({
35
+ ...rich,
36
+ id: `smart-chat:${message.id}`,
37
+ // Member-room sequence numbers cannot order a different Smart Chat conversation.
38
+ sequence: undefined,
39
+ createdDate: message.createdDate,
40
+ sender: { userId: mine ? participantId : `smart-chat:${sender}` },
41
+ aiSiteChatSender: sender,
42
+ messageType: rich?.messageType ?? 'message',
43
+ data: rich && (Array.isArray(rich.data) && rich.data.length || rich.templateData)
44
+ ? rich.data : [{ mimeType: 'text/plain', data: { text: bodyText } }],
45
+ });
46
+ }
47
+ return messages.length ? { conversationId, messages } : null;
48
+ }
@@ -6,7 +6,7 @@ import { mergeMessages, normalizeMessages, type MessageRow } from './business-ch
6
6
  * new one is recognised by its text, among the messages after `afterId` (the newest stored message on screen when it
7
7
  * was sent), or among all of them when the room was loaded and empty (`fromStart`); `priorCount` is how many earlier
8
8
  * sends with the same text were still waiting for their stored copy. */
9
- export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string; afterId?:string; fromStart?:boolean; priorCount?:number }
9
+ export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string; storedId?:string; afterId?:string; fromStart?:boolean; priorCount?:number }
10
10
 
11
11
  export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
12
12
  return {id:'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,c=>{const r=Math.floor(random()*16);return(c==='x'?r:(r&3)|8).toString(16);}),text,roomId,principal,createdDate:new Date().toISOString(),delivery:'sending'};
@@ -35,7 +35,7 @@ export function sendAnchor(messages:Row[],text:string,roomLoaded:boolean):{after
35
35
  export function pendingIntentWasObserved(messages:Row[],intent:BusinessChatIntent|null):boolean{
36
36
  if(!intent)return false;
37
37
  const stored=messages.filter(message=>!message.pending);
38
- if(stored.some(message=>message.id===intent.id))return true;
38
+ if(stored.some(message=>message.id===(intent.storedId||intent.id)))return true;
39
39
  let newer:Row[];
40
40
  if(intent.fromStart)newer=stored;
41
41
  else{
@@ -161,7 +161,7 @@ export function businessVisitorOptionSuggestions(options: BusinessVisitorOptions
161
161
  }
162
162
 
163
163
  /** Wix Chat realtime currently sends `{type, timestamp, summary, message?}` events. */
164
- export function isBusinessMessageEvent(envelope: unknown, roomId: string): boolean {
164
+ export function isBusinessMessageEvent(envelope: unknown, roomId: string, smartChat = false): boolean {
165
165
  const event = object(object(envelope).event);
166
166
  if (!roomId || typeof event.type !== "string" || !/^(message|messagesent)$/i.test(event.type)) return false;
167
167
  const message = object(event.message);
@@ -169,16 +169,19 @@ export function isBusinessMessageEvent(envelope: unknown, roomId: string): boole
169
169
  const nested = object(data.message);
170
170
  const eventRoomId = [event.chatroomId, event.chatRoomId, message.chatroomId, message.chatRoomId, data.chatroomId, nested.chatroomId]
171
171
  .find((value): value is string => typeof value === "string" && value.length > 0);
172
- return !eventRoomId || eventRoomId === roomId;
172
+ // Smart Chat's room can differ from the Members room. The subsequent read is scoped by
173
+ // the current member/site identity, never by the event's untrusted room id.
174
+ return smartChat || !eventRoomId || eventRoomId === roomId;
173
175
  }
174
176
 
175
177
  export function shouldRefreshBusinessHistory(
176
178
  envelope: unknown,
177
179
  roomId: string,
178
180
  session: { member: boolean; principal: string; currentPrincipal: string; scope: string; currentScope: string },
181
+ smartChat = false,
179
182
  ): boolean {
180
183
  return !!session.member && !!roomId && !!session.principal && session.principal === session.currentPrincipal &&
181
- session.scope === session.currentScope && isBusinessMessageEvent(envelope, roomId);
184
+ session.scope === session.currentScope && isBusinessMessageEvent(envelope, roomId, smartChat);
182
185
  }
183
186
 
184
187
  function safeAvatar(value: unknown): string {
@@ -468,8 +471,8 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
468
471
  : standardEventText || row.plainText;
469
472
  const senderAvatar = safeAvatar(image.url);
470
473
  const mine = participantId && typeof sender.userId === "string" ? sender.userId === participantId : row.mine;
471
- const isAiGenerated = !!participantId && !mine && !!message.appId && typeof message.originMethod === "string" && /:AI_SITE_CHAT$/i.test(message.originMethod) &&
472
- (message.messageType === "message" || message.messageType === "template/standard-event" || message.messageType === "template/card");
474
+ const isAiGenerated = message.aiSiteChatSender === "ASSISTANT" || (message.aiSiteChatSender !== "USER" && !!participantId && !mine && !!message.appId && typeof message.originMethod === "string" && /:AI_SITE_CHAT$/i.test(message.originMethod) &&
475
+ (message.messageType === "message" || message.messageType === "template/standard-event" || message.messageType === "template/card"));
473
476
  const rawSenderName = typeof display.name === "string" && display.name.trim()
474
477
  ? display.name.trim()
475
478
  : typeof display.displayName === "string" && display.displayName.trim() ? display.displayName.trim() : "";
@@ -78,12 +78,42 @@ function bridge(): Bridge {
78
78
  }
79
79
  /** A typed message goes through Smart Chat, exactly as the site's chat widget sends it. The Inbox then files it under
80
80
  * Wix Smart Chat and the AI can answer it; a message posted straight into the room has no channel, so the AI never
81
- * saw it and the owner's reply went out by email. It lands in the same room, under an id the server chooses.
81
+ * saw it and the owner's reply went out by email. Smart Chat chooses the conversation and stored message id.
82
82
  * The bridge resolves once the AI has finished answering, not when the message is stored. */
83
- export const sendMessage = (identity: ChatIdentity, text: string): Promise<void> => fenced(identity, async () => {
84
- await bridge().call("POST", "/wix-assistant-widget/v1/messages/stream", {
83
+ export const sendMessage = (identity: ChatIdentity, text: string): Promise<string | undefined> => fenced(identity, async () => {
84
+ const response = await bridge().call("POST", "/wix-assistant-widget/v1/messages/stream", {
85
85
  message: { sender: "SITE_VISITOR", messageType: "QUESTION", body: { mainText: text } },
86
86
  });
87
+ // The first stream frame names the persisted question. Keep its exact id so a send
88
+ // that moves from an old Members transcript to Smart Chat never relies on a text match.
89
+ let frames: unknown = response;
90
+ if (typeof frames === "string") {
91
+ const raw = frames;
92
+ try { frames = JSON.parse(raw); }
93
+ catch {
94
+ frames = [];
95
+ // The bridge buffers SSE verbatim. Ignore event/comment lines and decode the
96
+ // data of each frame; some hosts instead hand over NDJSON or parsed frames.
97
+ for (const block of raw.split(/\r?\n\r?\n/)) {
98
+ const lines = block.split(/\r?\n/);
99
+ const event = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
100
+ const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).trimStart()).join('\n');
101
+ try {
102
+ const value = JSON.parse(data || block);
103
+ (frames as unknown[]).push(event === 'inputMessage' && !value?.inputMessage ? { inputMessage: value } : value);
104
+ } catch { /* Comments and other non-JSON frames carry no stored question id. */ }
105
+ }
106
+ // A buffered NDJSON response has no SSE separators.
107
+ if (!(frames as unknown[]).length) {
108
+ try { frames = [JSON.parse(raw.split('\n', 1)[0]!)]; } catch { return undefined; }
109
+ }
110
+ }
111
+ }
112
+ for (const frame of Array.isArray(frames) ? frames : [frames]) {
113
+ const input = (frame as { inputMessage?: { id?: unknown; sender?: unknown; messageType?: unknown } } | null)?.inputMessage;
114
+ if (input?.sender === "SITE_VISITOR" && input.messageType === "QUESTION" && typeof input.id === "string" && input.id) return `smart-chat:${input.id}`;
115
+ }
116
+ return undefined;
87
117
  });
88
118
  /** A tapped button is a silent, ephemeral message carrying the button's chat action. */
89
119
  export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
@@ -6,6 +6,7 @@ import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWa
6
6
  import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
7
7
  import { businessInAppRoute, type BusinessArticleAction, dispatchBusinessSuggestion, businessRealtimeStatusLabel, businessVisitorOptionSuggestions, presentBusinessMessages, selectExistingBusinessRoom, shouldRefreshBusinessHistory, type BusinessSuggestion, type BusinessVisitorOptions, businessVisitorOptionRequest, businessVisitorOptionsFromAiMessages, newBusinessSelectionId} from '../_shared/business-chat-presenter.js';
8
8
  import { listMessages, streamMessages } from '@himalaya/wix-ai-site-chat';
9
+ import { smartChatHistory } from '../_shared/business-chat-history.js';
9
10
  import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
10
11
  import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
11
12
  import { queryServices } from '@himalaya/wix-bookings';
@@ -23,6 +24,7 @@ type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mi
23
24
  type State = {
24
25
  phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
25
26
  roomId:string; principal:string; scope:string; messages:BusinessMessageRow[]; draft:string; busy:boolean;
27
+ historySource:'members'|'smart-chat';
26
28
  liveState:string; liveReason:string; realtimeEventCount:number; lastRealtimeEventType:string; pendingIntent:PendingIntent|null; scrollAnchor:string; scrollSend:number; usedSuggestionIds:string[]; visitorOptions:BusinessVisitorOptions|null; quickReplies:BusinessSuggestion[]; hasQuickReplies:boolean; noQuickReplies:boolean;
27
29
  liveLabel:string; hasActiveRoom:boolean; noActiveRoom:boolean; isGuest:boolean; notGuest:boolean; realtimeDiagnostic:string; noError:boolean; noNotice:boolean; noMessages:boolean; hasMessages:boolean; noEmpty:boolean; notLoading:boolean;
28
30
  hostedName:string; hasHostedName:boolean; noHostedName:boolean;
@@ -45,7 +47,7 @@ function initialsOf(name:string):string{
45
47
  const words=name.trim().split(/\s+/).filter(Boolean);
46
48
  return (words.length>1?`${first(words[0]!)}${first(words[words.length-1]!)}`:first(words[0]??'')).toUpperCase();
47
49
  }
48
- const initial:State={hostedName,businessName:hostedName,businessInitials:initialsOf(hostedName),hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,
50
+ const initial:State={hostedName,businessName:hostedName,businessInitials:initialsOf(hostedName),hasHostedName:!!hostedName,noHostedName:!hostedName,phase:'loading',error:'',notice:'',member:false,roomId:'',principal:'',scope:'',messages:[],draft:'',busy:false,historySource:'members',
49
51
  liveState:'unavailable',liveReason:'',realtimeEventCount:0,lastRealtimeEventType:'',liveLabel:'',hasActiveRoom:false,noActiveRoom:true,isGuest:false,notGuest:true,realtimeDiagnostic:'',pendingIntent:null,scrollAnchor:'',scrollSend:0,usedSuggestionIds:[],visitorOptions:null,quickReplies:[],hasQuickReplies:false,noQuickReplies:true,noError:true,noNotice:true,noMessages:true,hasMessages:false,noEmpty:true,notLoading:false,sendDisabled:true,sendEnabled:false,noLive:true,loadingHidden:false,errorText:'',loadFailure:''};
50
52
  let currentIdentity:ChatIdentity|null=null;
51
53
  let subscriptionId='';
@@ -65,11 +67,34 @@ let businessParticipantId='';
65
67
  let loadStage:'room_selection'|'history_request'|'history_normalization'|'ai_enrichment'='room_selection';
66
68
  let readServiceState:()=>State=()=>shape(initial);
67
69
 
68
- /** The member-scoped AI conversation's current unanswered quick replies. */
69
- async function loadBusinessVisitorOptions():Promise<BusinessVisitorOptions|null>{
70
- const response=await chat.fence(me(),()=>listMessages({cursorPaging:{limit:20}}));
71
- return businessVisitorOptionsFromAiMessages(response.messages??[]);
70
+ /** Follow the identity-scoped history cursor: the first page need not contain the latest reply.
71
+ * Refuse an incomplete transcript rather than silently returning another conversation. */
72
+ async function loadSmartChatMessages():Promise<unknown[]>{
73
+ const messages:unknown[]=[];
74
+ const seen=new Set<string>();
75
+ let cursor:string|undefined;
76
+ const incomplete=()=>Object.assign(new Error('smart_chat_history_incomplete'),{code:'smart_chat_history_incomplete'});
77
+ for(let page=0;page<20;page++){
78
+ let response:Awaited<ReturnType<typeof listMessages>>;
79
+ try{response=await chat.fence(me(),()=>listMessages({cursorPaging:{limit:100,...(cursor?{cursor}:{})}}));}
80
+ catch(error){
81
+ if(/member_sign_in_required|MemberAccessChanged|\b401\b/i.test(String(error))||(page>0&&/\b403\b/.test(String(error))))throw Object.assign(new Error('member_sign_in_required'),{code:'smart_chat_history_incomplete'});
82
+ // Only an explicit unavailable feature permits the legacy fallback. A timeout or
83
+ // 5xx says nothing about whether a Smart Chat conversation exists.
84
+ if(!page&&/\b403\b|\b404\b/.test(String(error)))throw error;
85
+ throw incomplete();
86
+ }
87
+ if(!Array.isArray(response?.messages))throw incomplete();
88
+ messages.push(...response.messages);
89
+ const paging=response.pagingMetadata;
90
+ const next=paging?.cursors?.next;
91
+ if(paging?.hasNext===false||(!paging?.hasNext&&!next))return messages;
92
+ if(typeof next!=='string'||!next.trim()||seen.has(next))throw incomplete();
93
+ seen.add(next);cursor=next;
94
+ }
95
+ throw incomplete();
72
96
  }
97
+
73
98
  /** Submit a displayed quick reply through the AI endpoint; a label counts only while it is still a current option. */
74
99
  async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|undefined,label:string):Promise<boolean>{
75
100
  const request=businessVisitorOptionRequest(options,label,newBusinessSelectionId());
@@ -260,11 +285,22 @@ async function refreshHistory(s:State):Promise<State|undefined>{
260
285
  const stale=()=>startEpoch!==epoch||(currentIdentity?.userId||currentIdentity?.email)!==principal;
261
286
  try{
262
287
  loadStage='history_request';
263
- // The room history and the AI's open options are independent reads: fetch them together.
264
- const optionsRead:Promise<BusinessVisitorOptions|null>=currentIdentity?.authenticated?loadBusinessVisitorOptions().catch(()=>null/* Optional AI list failure must not hide Inbox history. */):Promise.resolve(null);
265
- const page=await chat.messagesPage(me(),roomId);
266
- const visitorOptions=await optionsRead;
288
+ // The Members room and Smart Chat may be different conversations. Read Smart Chat's
289
+ // persisted transcript, not only its quick replies; retain Members history for sites without it.
290
+ const [memberRead,aiRead]=await Promise.all([
291
+ chat.messagesPage(me(),roomId).then(page=>({page,error:null})).catch(error=>({page:[],error})),
292
+ loadSmartChatMessages().then(messages=>({messages,error:null})).catch(error=>({messages:[],error})),
293
+ ]);
267
294
  if(stale())return undefined;
295
+ // Once this screen has followed Smart Chat, an unavailable read must not switch it back
296
+ // to the unrelated Members thread.
297
+ if(aiRead.error&&(s.historySource==='smart-chat'||aiRead.error.code==='smart_chat_history_incomplete'))throw aiRead.error;
298
+ const smart=smartChatHistory(aiRead.messages,memberRead.page,businessParticipantId);
299
+ const useSmart=!!smart||s.historySource==='smart-chat';
300
+ if(!useSmart&&memberRead.error)throw memberRead.error;
301
+ const page=useSmart?smart?.messages??[]:memberRead.page;
302
+ const historySource=useSmart?'smart-chat' as const:'members' as const;
303
+ const visitorOptions=businessVisitorOptionsFromAiMessages(aiRead.messages);
268
304
  loadStage='history_normalization';
269
305
  // Presented from the page alone: a row kept from an earlier page would be re-presented without the
270
306
  // metadata that only the page carries (sender, avatar, AI badge, article buttons).
@@ -283,7 +319,7 @@ async function refreshHistory(s:State):Promise<State|undefined>{
283
319
  // An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
284
320
  const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
285
321
  const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
286
- return shape({...base,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
322
+ return shape({...base,historySource,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
287
323
  }catch(error){
288
324
  const wrapped=error instanceof Error?error:new Error('load_failed');
289
325
  Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
@@ -300,7 +336,7 @@ function withLiveFields(st:State,latest:State,draft:string):State{
300
336
  scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
301
337
  }
302
338
  function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
303
- type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;error?:unknown};
339
+ type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;storedId?:string;error?:unknown};
304
340
  /** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
305
341
  * until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
306
342
  * stays held until this settles (the site's widget also waits for the answer). */
@@ -309,7 +345,7 @@ async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
309
345
  let result:SendResult={id:pending.id,outcome:'sent',draft};
310
346
  try{
311
347
  await Promise.resolve();// the action that started this has returned its state by now
312
- await chat.sendMessage(me(),pending.text);
348
+ result.storedId=await chat.sendMessage(me(),pending.text);
313
349
  }catch(error){
314
350
  const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
315
351
  result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
@@ -408,7 +444,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
408
444
  const seen=pendingIntentWasObserved(s.messages,pending);
409
445
  return shape({...s,pendingIntent:seen?null:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
410
446
  }
411
- const sent={...pending,delivery:'sent' as const};
447
+ const sent={...pending,storedId:result.storedId,delivery:'sent' as const};
412
448
  const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
413
449
  const sendEpoch=epoch;
414
450
  // The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
@@ -416,11 +452,13 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
416
452
  // History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
417
453
  // is cleared: a newer one the live state carried in is that send's to settle.
418
454
  const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
419
- const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)&&!st.messages.some(row=>row.id===sent.id)?shape({...st,pendingIntent:ownIntent(st),notice:''}):
455
+ const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)?shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.filter(row=>!(row.id===sent.id&&row.pending))}):
420
456
  shape({...st,pendingIntent:ownIntent(st),notice:'',messages:st.messages.some(row=>row.id===sent.id)?st.messages.map(row=>row.id===sent.id&&row.pending?pendingRow(sent):row):[...st.messages,pendingRow(sent)]});
421
- // While live updates are connected the server's own event brings the message back through one refresh; a second
422
- // one here would only queue behind it on the worker, delaying the member's next keystroke or send.
423
- if(s.liveState==='connected')return settled(accepted);
457
+ // The stream finishes after the AI has persisted its answer. Reload even while connected:
458
+ // the Members-room subscription may never see the separate Smart Chat room's event.
459
+ // A refresh already running owns the next read. Queue one more pass without keeping Send
460
+ // locked behind that request (or allowing its old snapshot to restore this intent).
461
+ if(refreshFlight){refreshQueued=true;return settled(accepted);}
424
462
  try{const loaded=await loadLatest(accepted,true);return loaded?settled(loaded):undefined;}catch(error){
425
463
  // A session lost by an older operation says nothing about the member who is signed in now.
426
464
  if(sendEpoch!==epoch)return undefined;
@@ -461,7 +499,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
461
499
  const envelope=a as RealtimeEventEnvelope|undefined;
462
500
  const eventType=typeof envelope?.event?.type==='string'?envelope.event.type:'';
463
501
  s=shape({...s,realtimeEventCount:s.realtimeEventCount+1,lastRealtimeEventType:eventType});
464
- if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope}))return s;
502
+ if(!currentIdentity||!shouldRefreshBusinessHistory(envelope,s.roomId,{member:s.member,principal:s.principal,currentPrincipal:currentIdentity.userId||currentIdentity.email,scope:s.scope,currentScope:currentIdentity.scope},s.historySource==='smart-chat'))return s;
465
503
  if(refreshFlight){refreshQueued=true;return withLiveDraft(s);}
466
504
  try{return await loadLatest(s);}catch(error){
467
505
  if(startEpoch!==epoch)return undefined;