@wix/himalaya-cli 0.1494.0 → 0.1496.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.
Files changed (82) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +3 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/live-activity/src/index.d.ts +4 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +2 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/ecom/src/extensions.d.ts +16 -0
  5. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/stores-v1/src/display.d.ts +24 -0
  6. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/live-activity.mjs +3 -1
  7. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/sdui.mjs +1 -1
  8. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-ecom.mjs +14 -0
  9. package/dist/cli.mjs +3584 -2541
  10. package/dist/preview-assets.generated.bin +0 -0
  11. package/guides/himi-json-output.md +46 -4
  12. package/guides/iap-products.md +68 -9
  13. package/guides/one-app-to-himi-app.md +80 -5
  14. package/guides/sdk-reference.json +11 -256
  15. package/guides/widgets.md +5 -3
  16. package/package.json +2 -2
  17. package/templates/beauty-salon/dev/index.ts +2590 -1034
  18. package/templates/beauty-salon/himalaya.content.json +1 -1
  19. package/templates/beauty-salon/tier5-src/_shared/himi-store-collection.ts +6 -3
  20. package/templates/beauty-salon/tier5-src/_shared/himi-store-saved.ts +7 -1
  21. package/templates/beauty-salon/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  22. package/templates/beauty-salon/tier5-src/himi-store-product/index.ts +196 -34
  23. package/templates/beauty-salon/tier5-src/himi-store-saved/index.ts +21 -5
  24. package/templates/beauty-salon/tier5-src/himi-store-shop/index.ts +14 -2
  25. package/templates/beauty-salon/tokens.json +15 -0
  26. package/templates/branded-lite/dev/index.ts +2696 -1140
  27. package/templates/branded-lite/himalaya.content.json +1 -1
  28. package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +35 -4
  29. package/templates/branded-lite/tier5-src/_shared/business-chat-transport.ts +12 -5
  30. package/templates/branded-lite/tier5-src/_shared/himi-store-collection.ts +6 -3
  31. package/templates/branded-lite/tier5-src/_shared/himi-store-saved.ts +7 -1
  32. package/templates/branded-lite/tier5-src/business-chat/index.ts +50 -17
  33. package/templates/branded-lite/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  34. package/templates/branded-lite/tier5-src/himi-store-product/index.ts +196 -34
  35. package/templates/branded-lite/tier5-src/himi-store-saved/index.ts +21 -5
  36. package/templates/branded-lite/tier5-src/himi-store-shop/index.ts +14 -2
  37. package/templates/branded-lite/tokens.json +8 -0
  38. package/templates/community-club/dev/index.ts +1 -1
  39. package/templates/community-club/tier5-src/_shared/himi-store-saved.ts +7 -1
  40. package/templates/community-club/tokens.json +15 -0
  41. package/templates/events-meetups/dev/index.ts +1 -1
  42. package/templates/events-meetups/tier5-src/_shared/himi-store-saved.ts +7 -1
  43. package/templates/events-meetups/tokens.json +15 -0
  44. package/templates/gym-fitness/dev/index.ts +2695 -1139
  45. package/templates/gym-fitness/himalaya.content.json +1 -1
  46. package/templates/gym-fitness/tier5-src/_shared/himi-store-collection.ts +6 -3
  47. package/templates/gym-fitness/tier5-src/_shared/himi-store-saved.ts +7 -1
  48. package/templates/gym-fitness/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  49. package/templates/gym-fitness/tier5-src/himi-store-product/index.ts +196 -34
  50. package/templates/gym-fitness/tier5-src/himi-store-saved/index.ts +21 -5
  51. package/templates/gym-fitness/tier5-src/himi-store-shop/index.ts +14 -2
  52. package/templates/gym-fitness/tokens.json +15 -0
  53. package/templates/nonprofit-cause/dev/index.ts +1 -1
  54. package/templates/nonprofit-cause/tier5-src/_shared/himi-store-saved.ts +7 -1
  55. package/templates/nonprofit-cause/tokens.json +15 -0
  56. package/templates/podcast-creator/dev/index.ts +1 -1
  57. package/templates/podcast-creator/tier5-src/_shared/himi-store-saved.ts +7 -1
  58. package/templates/podcast-creator/tokens.json +15 -0
  59. package/templates/portfolio-creative/dev/index.ts +2569 -1013
  60. package/templates/portfolio-creative/himalaya.content.json +1 -1
  61. package/templates/portfolio-creative/tier5-src/_shared/himi-store-collection.ts +6 -3
  62. package/templates/portfolio-creative/tier5-src/_shared/himi-store-saved.ts +7 -1
  63. package/templates/portfolio-creative/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  64. package/templates/portfolio-creative/tier5-src/himi-store-product/index.ts +196 -34
  65. package/templates/portfolio-creative/tier5-src/himi-store-saved/index.ts +21 -5
  66. package/templates/portfolio-creative/tier5-src/himi-store-shop/index.ts +14 -2
  67. package/templates/portfolio-creative/tokens.json +15 -0
  68. package/templates/restaurant/dev/index.ts +1 -1
  69. package/templates/restaurant/tier5-src/_shared/himi-store-saved.ts +7 -1
  70. package/templates/restaurant/tokens.json +15 -0
  71. package/templates/retail-store/dev/index.ts +2541 -985
  72. package/templates/retail-store/himalaya.content.json +1 -1
  73. package/templates/retail-store/tier5-src/_shared/himi-store-collection.ts +6 -3
  74. package/templates/retail-store/tier5-src/_shared/himi-store-saved.ts +7 -1
  75. package/templates/retail-store/tier5-src/himi-branded-lite-cart/index.ts +256 -19
  76. package/templates/retail-store/tier5-src/himi-store-product/index.ts +196 -34
  77. package/templates/retail-store/tier5-src/himi-store-saved/index.ts +21 -5
  78. package/templates/retail-store/tier5-src/himi-store-shop/index.ts +14 -2
  79. package/templates/retail-store/tokens.json +15 -0
  80. package/templates/yoga-studio/dev/index.ts +1 -1
  81. package/templates/yoga-studio/tier5-src/_shared/himi-store-saved.ts +7 -1
  82. package/templates/yoga-studio/tokens.json +15 -0
@@ -206,7 +206,7 @@
206
206
  "displayName": "Portfolio Project"
207
207
  },
208
208
  "himi_product": {
209
- "displayName": "Product"
209
+ "displayName": "Product Details"
210
210
  },
211
211
  "himi_program": {
212
212
  "displayName": "Program"
@@ -2,8 +2,11 @@ import { formatClock } from './engine-intl.js';
2
2
  import { mergeMessages, normalizeMessages, type MessageRow } from './business-chat-message-model.js';
3
3
 
4
4
  /** `delivery` is what the composer's own row shows while the server catches up: sending, accepted (shown with its
5
- * time) or unconfirmed (the request failed without a definite answer). */
6
- export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string; createdDate:string; delivery:'sending'|'sent'|'unconfirmed'; senderName?:string }
5
+ * time) or unconfirmed (the request failed without a definite answer). The server names the stored message, so the
6
+ * new one is recognised by its text, among the messages after `afterId` (the newest stored message on screen when it
7
+ * was sent), or among all of them when the room was loaded and empty (`fromStart`); `priorCount` is how many earlier
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 }
7
10
 
8
11
  export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
9
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'};
@@ -12,8 +15,36 @@ export function businessChatSendRejected(error:unknown):boolean{
12
15
  return /\b(400|401|403|404|409|413|422|429)\b|approved first-party|webwix_owner_only|sign_in_required|owner_preview_read_only/i.test(String((error as Error)?.message||error));
13
16
  }
14
17
  export function draftAfterAcceptedSend(currentDraft:string,sentDraft:string):string{return currentDraft===sentDraft?'':currentDraft;}
15
- export function pendingIntentWasObserved(messages:{id:string}[],intent:BusinessChatIntent|null):boolean{
16
- return !!intent&&messages.some(message=>message.id===intent.id);
18
+ type Row={id:string;mine?:boolean;bodyText?:string;pending?:boolean};
19
+ const sameOwnText=(message:Row,text:string)=>!!message.mine&&(message.bodyText??'').trim()===text.trim();
20
+ /** What a send records about the screen it was sent from: the newest stored message (history is one page in time
21
+ * order, so counting only after it ignores old messages, even one that drops off the page), and how many sends with
22
+ * the same words are still waiting for their stored copies. No clock is involved: the phone's and the server's differ. */
23
+ export function sendAnchor(messages:Row[],text:string,roomLoaded:boolean):{afterId?:string;fromStart?:boolean;priorCount:number}{
24
+ const stored=messages.filter(message=>!message.pending);
25
+ const priorCount=messages.filter(message=>message.pending&&sameOwnText(message,text)).length;
26
+ // A room that loaded with nothing in it: every stored message that appears later is newer than this send's screen.
27
+ // One that failed to load has no anchor at all, as it may hold older messages with the same words.
28
+ if(!stored.length)return roomLoaded?{fromStart:true,priorCount}:{priorCount};
29
+ return {afterId:stored[stored.length-1]!.id,priorCount};
30
+ }
31
+ /** Whether history already holds the member's sent message: by its own id, or as one more message of theirs with the
32
+ * same text than there were when it was sent (the Smart Chat send does not let the app choose the stored id). This
33
+ * only places the message on screen: the send itself is settled by its own request, never by what history shows, so
34
+ * a coincidence (the same words sent from the website meanwhile) cannot hide a failure. */
35
+ export function pendingIntentWasObserved(messages:Row[],intent:BusinessChatIntent|null):boolean{
36
+ if(!intent)return false;
37
+ const stored=messages.filter(message=>!message.pending);
38
+ if(stored.some(message=>message.id===intent.id))return true;
39
+ let newer:Row[];
40
+ if(intent.fromStart)newer=stored;
41
+ else{
42
+ // With no anchor (the room had not loaded) or the anchor gone, an older message with these words cannot be told apart.
43
+ const after=intent.afterId?stored.findIndex(message=>message.id===intent.afterId):-1;
44
+ if(after<0)return false;
45
+ newer=stored.slice(after+1);
46
+ }
47
+ return newer.filter(message=>sameOwnText(message,intent.text)).length>(intent.priorCount??0);
17
48
  }
18
49
  export function messageTimeLabel(value:string):string{
19
50
  const date=new Date(value);
@@ -1,6 +1,6 @@
1
- // The business chat's Wix calls: the member-role chat-web SDK for rooms, history and sends, plus the two
2
- // calls the SDK has no helper for (a tapped button, a private attachment's download URL).
3
- import { getChatToken, getMessagesPage, listChatroomsPage, sendChatMessage } from "@himalaya/wix-chat-web";
1
+ // The business chat's Wix calls: the member-role chat-web SDK for rooms and history, plus the calls the SDKs have
2
+ // no helper for (a typed message sent through Smart Chat, a tapped button, a private attachment's download URL).
3
+ import { getChatToken, getMessagesPage, listChatroomsPage } from "@himalaya/wix-chat-web";
4
4
  import { assertMemberAccess, memberAccess } from "./member-access.js";
5
5
 
6
6
  const MANAGE = "https://manage.wix.com";
@@ -70,14 +70,21 @@ export const listRooms = (identity: ChatIdentity): Promise<{ items: Raw[]; more:
70
70
  });
71
71
  export const messagesPage = (identity: ChatIdentity, roomId: string): Promise<Raw[]> => fenced(identity, async () =>
72
72
  (await withToken(identity, (chatToken) => getMessagesPage(roomId, { ...MEMBER, chatToken, pageSize: 50, ordering: "Desc" }))).items as unknown as Raw[]);
73
- export const sendMessage = (identity: ChatIdentity, roomId: string, intent: { id: string; text: string }): Promise<void> =>
74
- fenced(identity, () => withToken(identity, (chatToken) => sendChatMessage(roomId, intent.text, { ...MEMBER, chatToken, messageId: intent.id })));
75
73
 
76
74
  function bridge(): Bridge {
77
75
  const api = (globalThis as unknown as { himalaya?: { wixApi?: Bridge } }).himalaya?.wixApi;
78
76
  if (!api) throw new Error("wix_api_bridge_unavailable");
79
77
  return api;
80
78
  }
79
+ /** A typed message goes through Smart Chat, exactly as the site's chat widget sends it. The Inbox then files it under
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.
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", {
85
+ message: { sender: "SITE_VISITOR", messageType: "QUESTION", body: { mainText: text } },
86
+ });
87
+ });
81
88
  /** A tapped button is a silent, ephemeral message carrying the button's chat action. */
82
89
  export const sendButton = (identity: ChatIdentity, roomId: string, input: { id: string; text: string; appId: string; interactionId: string }): Promise<void> => fenced(identity, async () => {
83
90
  await withToken(identity, (chatToken) => bridge().call("POST", `${MANAGE}/_api/chat-web/v2/chatrooms/${encodeURIComponent(roomId)}/messages`, {
@@ -7,9 +7,12 @@ export const storeCollectionSaving: CollectionSaving = {
7
7
  const products = new Map(readSavedProducts().map(row => [row.id, row]));
8
8
  for (const row of rows) {
9
9
  if (typeof row.id !== 'string' || typeof row.name !== 'string') continue;
10
- products.set(row.id, { id: row.id, name: row.name,
11
- price: typeof row.commercePrice === 'string' ? row.commercePrice : typeof row.subtitle === 'string' ? row.subtitle : '',
12
- image: typeof row.image === 'string' ? row.image : '' });
10
+ const previous = products.get(row.id);
11
+ const freshPrice = typeof row.commercePrice === 'string' ? row.commercePrice : typeof row.subtitle === 'string' ? row.subtitle : '';
12
+ const wasPrice = typeof row.wasPrice === 'string' ? row.wasPrice || undefined : previous?.price === freshPrice ? previous?.wasPrice : undefined;
13
+ products.set(row.id, { ...previous, wasPrice, id: row.id, name: row.name,
14
+ price: freshPrice || previous?.price || '',
15
+ image: typeof row.image === 'string' && row.image ? row.image : previous?.image ?? '' });
13
16
  }
14
17
  const selected = ids.map(id => products.get(id));
15
18
  if (selected.some(row => !row)) throw new Error('Saved product metadata is unavailable');
@@ -5,6 +5,8 @@ export interface SavedProduct {
5
5
  name: string;
6
6
  price: string;
7
7
  image: string;
8
+ wasPrice?: string;
9
+ swatches?: string[];
8
10
  }
9
11
  type StorageLike = { getItem(key: string): string | null; setItem(key: string, value: string): void };
10
12
  const KEY = "himi-store:saved-products:v1";
@@ -24,7 +26,11 @@ export function readSavedProducts(): SavedProduct[] {
24
26
  if (!value || typeof value !== "object") continue;
25
27
  const row = value as Record<string, unknown>;
26
28
  if (typeof row.id !== "string" || !row.id.trim() || typeof row.name !== "string") continue;
27
- rows.set(row.id, { id: row.id, name: row.name, price: typeof row.price === "string" ? row.price : "", image: typeof row.image === "string" ? row.image : "" });
29
+ const swatches = Array.isArray(row.swatches)
30
+ ? row.swatches.filter((color): color is string => typeof color === "string" && /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i.test(color)).slice(0, 5)
31
+ : [];
32
+ rows.set(row.id, { id: row.id, name: row.name, price: typeof row.price === "string" ? row.price : "", image: typeof row.image === "string" ? row.image : "",
33
+ ...(typeof row.wasPrice === "string" && row.wasPrice ? { wasPrice: row.wasPrice } : {}), ...(swatches.length ? { swatches } : {}) });
28
34
  }
29
35
  return [...rows.values()];
30
36
  }
@@ -2,7 +2,7 @@ import { defineService } from '@himalaya/state';
2
2
  import * as chat from '../_shared/business-chat-transport.js';
3
3
  import type { ChatIdentity } from '../_shared/business-chat-transport.js';
4
4
  import { type MessageRow } from '../_shared/business-chat-message-model.js';
5
- import { businessChatSendRejected, messageTimeLabel, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
5
+ import { businessChatSendRejected, messageTimeLabel, sendAnchor, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
6
6
  import { subscribeRealtime, unsubscribeRealtime, getRealtimeStatus, type RealtimeStatus, type RealtimeEventEnvelope } from '@himalaya/wix-chat';
7
7
  import { 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';
@@ -33,7 +33,6 @@ const initial:State={hostedName,hasHostedName:!!hostedName,noHostedName:!hostedN
33
33
  let currentIdentity:ChatIdentity|null=null;
34
34
  let subscriptionId='';
35
35
  let initFlight=false;
36
- let sendFlight=false;
37
36
  let suggestionFlight=false;
38
37
  let refreshFlight=false;
39
38
  let refreshQueued=false;
@@ -71,7 +70,10 @@ function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
71
70
  suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true};
72
71
  }
73
72
  function shape(s:State):State{const used=new Set(s.usedSuggestionIds);const messages=s.messages.map(row=>({...row,suggestions:(row.suggestions||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button)}));const quickReplies=(s.quickReplies||[]).map(button=>used.has(button.id)?{...button,enabled:false,disabled:true}:button);const quickEnabled=quickReplies.some(button=>button.enabled);return {...s,messages,quickReplies,hasQuickReplies:quickEnabled,noQuickReplies:!quickEnabled,scrollAnchor:messages[messages.length-1]?.scrollAnchor||'',liveLabel:liveDescription(s.liveState,s.liveReason),hasActiveRoom:!!s.roomId,noActiveRoom:!s.roomId,hasHostedName:!!s.hostedName,noHostedName:!s.hostedName,isGuest:s.phase==='guest',notGuest:s.phase!=='guest',realtimeDiagnostic:`state=${s.liveState}; reason=${s.liveReason||'none'}; events=${s.realtimeEventCount}; lastType=${/^(message|messagesent)$/i.test(s.lastRealtimeEventType)?s.lastRealtimeEventType:'none'}`,errorText:s.error,loadingHidden:s.phase!=='loading'||!!s.error,noError:!s.error,noNotice:!s.notice,noMessages:!messages.length,hasMessages:!!messages.length,noEmpty:s.phase!=='ready'||!!messages.length,
74
- notLoading:s.phase!=='loading',sendDisabled:!s.member||!s.roomId||s.busy||!s.draft.trim()||!!s.pendingIntent,sendEnabled:!!s.member&&!!s.roomId&&!s.busy&&!!s.draft.trim()&&!s.pendingIntent,noLive:!s.roomId||s.liveState==='connected'};}
73
+ notLoading:s.phase!=='loading',sendDisabled:!canSend(s,s.draft),sendEnabled:canSend(s,s.draft),noLive:!s.roomId||s.liveState==='connected'};}
74
+ /** Send is offered only once the room's history has loaded: a send needs it as its anchor, the Smart Chat send does
75
+ * not let the app name the stored message, and history is how the app finds it. */
76
+ function canSend(s:State,draft:string):boolean{return !!s.member&&!!s.roomId&&s.phase==='ready'&&!s.busy&&!!draft.trim()&&!s.pendingIntent;}
75
77
  function safeLoadFailure(error:unknown):string{
76
78
  const name=error instanceof Error?error.name:'UnknownError';
77
79
  const code=(error as {code?:unknown}|null)?.code;
@@ -215,9 +217,12 @@ async function refreshHistory(s:State):Promise<State|undefined>{
215
217
  const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
216
218
  const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
217
219
  const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
218
- // A send the server has not returned yet stays on screen until history does.
220
+ // A send the server has not returned yet stays on screen until history does. Seeing it in history only replaces the
221
+ // placeholder: a send still in flight stays pending until its own request settles it, so its outcome is never lost.
222
+ // An unconfirmed one (its request is over) no longer holds Send once its words show up; its notice stays.
219
223
  const shown=!intentObserved&&base.pendingIntent?[...messages,pendingRow(base.pendingIntent)]:messages;
220
- return shape({...base,messages:shown,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,pendingIntent:intentObserved?null:base.pendingIntent,phase:'ready',error:'',loadFailure:''});
224
+ const pendingIntent=intentObserved&&base.pendingIntent?.delivery==='unconfirmed'?null:base.pendingIntent;
225
+ return shape({...base,messages:shown,pendingIntent,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,phase:'ready',error:'',loadFailure:''});
221
226
  }catch(error){
222
227
  const wrapped=error instanceof Error?error:new Error('load_failed');
223
228
  Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
@@ -227,24 +232,27 @@ async function refreshHistory(s:State):Promise<State|undefined>{
227
232
  /** What other actions own and may have changed while this one awaited, taken from the live state. A state
228
233
  * built from a snapshot taken before an await must carry these forward or it rolls them back. */
229
234
  function withLiveFields(st:State,latest:State,draft:string):State{
230
- return {...st,draft,pendingIntent:latest.pendingIntent||st.pendingIntent,
235
+ // The pending intent is always the live one: a send settled meanwhile (null now) must not come back from a snapshot.
236
+ return {...st,draft,pendingIntent:latest.pendingIntent,
231
237
  usedSuggestionIds:[...new Set([...st.usedSuggestionIds,...latest.usedSuggestionIds])],
232
238
  liveState:latest.liveState,liveReason:latest.liveReason,realtimeEventCount:Math.max(st.realtimeEventCount,latest.realtimeEventCount),lastRealtimeEventType:latest.lastRealtimeEventType,
233
239
  scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
234
240
  }
235
241
  function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
236
242
  type SendResult={id:string;outcome:'sent'|'rejected'|'unconfirmed'|'signed_out';draft:string;error?:unknown};
237
- /** Posts one message and reports how it ended; never throws. The caller already showed the message. */
243
+ /** Posts one message and reports how it ended; never throws. The caller already showed the message. The call lasts
244
+ * until the AI has answered: history usually shows the stored copy first, which replaces the placeholder, while Send
245
+ * stays held until this settles (the site's widget also waits for the answer). */
238
246
  async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
239
247
  const sendEpoch=epoch;
240
248
  let result:SendResult={id:pending.id,outcome:'sent',draft};
241
249
  try{
242
250
  await Promise.resolve();// the action that started this has returned its state by now
243
- await chat.sendMessage(me(),pending.roomId,{id:pending.id,text:pending.text});
251
+ await chat.sendMessage(me(),pending.text);
244
252
  }catch(error){
245
253
  const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
246
254
  result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
247
- }finally{sendFlight=false;}
255
+ }
248
256
  if(sendEpoch===epoch)void svc.settleSend(result);
249
257
  }
250
258
  const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
@@ -254,15 +262,32 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
254
262
  if(initFlight)return s;initFlight=true;eventDuringInit=false;statusDuringInit=null;
255
263
  const oldPrincipal=s.principal;
256
264
  const initRevision=draftRevision;
257
- // Text typed at any point of the load, subscribe step included, outlives the snapshot this started from.
258
- const keepTyped=(st:State):State=>draftRevision!==initRevision?{...st,draft:readServiceState().draft}:st;
265
+ // Text typed at any point of the load, subscribe step included, outlives the snapshot this started from. So does the
266
+ // live pending send, but only the same member's and only one still in flight: reopening is how a member checks an
267
+ // unconfirmed send (its notice says so), so it does not carry over and cannot hold Send for good.
268
+ // A reset for another member or scope (clearPrivate) ends the old send's settling, so nothing of it is kept.
269
+ let reset=false;
270
+ // Only an unconfirmed send the member reopened the chat to check is dropped. A send still in flight when this load
271
+ // began that settles during it (rejected, or turned unconfirmed) keeps what its settling told the member: its
272
+ // notice, its error and the draft it gave back.
273
+ const checkedId=s.pendingIntent?.delivery==='unconfirmed'?s.pendingIntent.id:'';
274
+ const inFlightId=s.pendingIntent&&s.pendingIntent.delivery!=='unconfirmed'?s.pendingIntent.id:'';
275
+ const keepTyped=(st:State):State=>{
276
+ const live=readServiceState();
277
+ const sameMember=!reset&&live.principal===st.principal;
278
+ const own=sameMember?live.pendingIntent:null;
279
+ const intent=own&&!(own.delivery==='unconfirmed'&&own.id===checkedId)?own:null;
280
+ const settledMeanwhile=sameMember&&!!inFlightId&&!(live.pendingIntent?.id===inFlightId&&live.pendingIntent.delivery!=='unconfirmed');
281
+ return {...st,pendingIntent:intent,messages:st.messages.filter(row=>!row.pending||row.id===intent?.id),
282
+ ...(settledMeanwhile?{notice:live.notice,error:live.error,draft:live.draft}:draftRevision!==initRevision?{draft:live.draft}:{})};
283
+ };
259
284
  let myEpoch=epoch;
260
285
  try{
261
286
  loadStage='room_selection';
262
287
  const identity=await getCurrentMember();
263
288
  if(myEpoch!==epoch)return undefined;
264
289
  if(!identity.authenticated)throw new Error('member_sign_in_required');
265
- if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=clearPrivate(s,'guest');myEpoch=epoch;}
290
+ if(oldPrincipal&&(oldPrincipal!==(identity.userId||identity.email)||s.scope!==identity.scope)){s=clearPrivate(s,'guest');myEpoch=epoch;reset=true;}
266
291
  currentIdentity=identity;
267
292
  s=shape({...s,phase:'loading',member:true,principal:identity.userId||identity.email,scope:identity.scope,error:'',notice:''});
268
293
  const existingRoom=await resolveExistingBusinessRoom(myEpoch);
@@ -284,15 +309,16 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
284
309
  }finally{initFlight=false;}
285
310
  },
286
311
  // Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
287
- setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled=!!s.member&&!!s.roomId&&!s.busy&&!!draft.trim()&&!s.pendingIntent;return {...s,draft,sendEnabled,sendDisabled:!sendEnabled};},
312
+ setDraft:(s,a)=>{draftRevision++;const draft=value(a);const sendEnabled=canSend(s,draft);return {...s,draft,sendEnabled,sendDisabled:!sendEnabled};},
288
313
  // Shows the message and clears the box at once; the network round trip and the history reload that follow
289
314
  // run in the background and settle through `settleSend`.
290
315
  send:s=>{
291
316
  const text=s.draft.trim();
292
- if(!text||sendFlight||!s.roomId||!s.member||s.pendingIntent)return s;
317
+ // One send at a time: the pending intent, set right here, holds the next one until this one is settled or seen.
318
+ if(!text||!canSend(s,text))return s;
293
319
  // The member's name as their earlier messages show it, so the row keeps its size when the real message replaces it.
294
320
  const ownName=[...s.messages].reverse().find(row=>row.mine&&row.senderName)?.senderName??'';
295
- sendFlight=true;const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName};
321
+ const pending={...newBusinessChatIntent(text,s.roomId,s.principal),senderName:ownName,...sendAnchor(s.messages,text,s.phase==='ready')};
296
322
  draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
297
323
  void deliver(pending,s.draft);
298
324
  return shape({...s,draft:'',pendingIntent:pending,messages:[...s.messages,pendingRow(pending)],error:'',notice:'',scrollSend:s.scrollSend+1});
@@ -306,16 +332,23 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
306
332
  // The row goes, and the text comes back unless the member has already started something new.
307
333
  return shape({...s,messages:s.messages.filter(row=>row.id!==pending.id),pendingIntent:null,draft:s.draft.trim()?s.draft:result.draft,error:safeError(result.error,'send'),notice:'The message was rejected. Your draft is retained.'});
308
334
  }
335
+ // History cannot tell this send from the same words written elsewhere (the website), so an indeterminate failure is
336
+ // never reported as sent. If matching words are already there it does not hold Send, and the notice still asks to check.
309
337
  if(result.outcome==='unconfirmed'){
310
338
  const unconfirmed={...pending,delivery:'unconfirmed' as const};
311
- return shape({...s,pendingIntent:unconfirmed,messages:replaceRow(unconfirmed),error:safeError(result.error,'send'),notice:'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
339
+ const seen=pendingIntentWasObserved(s.messages,pending);
340
+ 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.'});
312
341
  }
313
342
  const sent={...pending,delivery:'sent' as const};
314
343
  const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
315
344
  const sendEpoch=epoch;
316
345
  // The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
317
346
  // A refresh that ran while the committed intent still said sending leaves a 'Sending…' row: swap it for the accepted one.
318
- const settled=(st:State):State=>shape({...st,pendingIntent:null,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)]});
347
+ // History that already holds the message under the server's id needs no placeholder for it. Only this send's intent
348
+ // is cleared: a newer one the live state carried in is that send's to settle.
349
+ const ownIntent=(st:State)=>st.pendingIntent&&st.pendingIntent.id!==sent.id?st.pendingIntent:null;
350
+ const settled=(st:State):State=>pendingIntentWasObserved(st.messages,sent)&&!st.messages.some(row=>row.id===sent.id)?shape({...st,pendingIntent:ownIntent(st),notice:''}):
351
+ 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)]});
319
352
  // While live updates are connected the server's own event brings the message back through one refresh; a second
320
353
  // one here would only queue behind it on the worker, delaying the member's next keystroke or send.
321
354
  if(s.liveState==='connected')return settled(accepted);