@wix/himalaya-cli 0.1487.0 → 0.1489.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 (59) hide show
  1. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/dev-server/src/builder.d.ts +12 -0
  2. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/ts/src/schema.d.ts +13 -0
  3. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/bookings-v2/src/extensions.d.ts +48 -0
  4. package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-bookings.mjs +22 -0
  5. package/dist/cli.mjs +145 -42
  6. package/dist/preview-assets.generated.bin +0 -0
  7. package/package.json +2 -2
  8. package/templates/beauty-salon/config.ts +13 -0
  9. package/templates/beauty-salon/dev/index.ts +784 -0
  10. package/templates/beauty-salon/himalaya.content.json +3 -0
  11. package/templates/beauty-salon/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  12. package/templates/beauty-salon/tier5-src/_shared/branded-load-state.ts +106 -0
  13. package/templates/beauty-salon/tier5-src/_shared/himi-bookings.ts +14 -0
  14. package/templates/beauty-salon/tier5-src/_shared/member-account-routes.ts +2 -1
  15. package/templates/beauty-salon/tier5-src/himi-booking-time/index.ts +25 -11
  16. package/templates/beauty-salon/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
  17. package/templates/branded-lite/config.ts +13 -0
  18. package/templates/branded-lite/dev/index.ts +930 -66
  19. package/templates/branded-lite/himalaya.content.json +3 -0
  20. package/templates/branded-lite/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  21. package/templates/branded-lite/tier5-src/_shared/business-chat-model.ts +5 -3
  22. package/templates/branded-lite/tier5-src/_shared/business-chat-presenter.ts +84 -5
  23. package/templates/branded-lite/tier5-src/_shared/himi-bookings.ts +14 -0
  24. package/templates/branded-lite/tier5-src/_shared/member-account-routes.ts +2 -1
  25. package/templates/branded-lite/tier5-src/business-chat/index.ts +135 -35
  26. package/templates/branded-lite/tier5-src/himi-booking-time/index.ts +25 -11
  27. package/templates/branded-lite/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
  28. package/templates/community-club/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  29. package/templates/community-club/tier5-src/_shared/member-account-routes.ts +2 -1
  30. package/templates/events-meetups/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  31. package/templates/events-meetups/tier5-src/_shared/member-account-routes.ts +2 -1
  32. package/templates/gym-fitness/config.ts +13 -0
  33. package/templates/gym-fitness/dev/index.ts +784 -0
  34. package/templates/gym-fitness/himalaya.content.json +3 -0
  35. package/templates/gym-fitness/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  36. package/templates/gym-fitness/tier5-src/_shared/branded-load-state.ts +106 -0
  37. package/templates/gym-fitness/tier5-src/_shared/himi-bookings.ts +14 -0
  38. package/templates/gym-fitness/tier5-src/_shared/member-account-routes.ts +2 -1
  39. package/templates/gym-fitness/tier5-src/himi-booking-time/index.ts +25 -11
  40. package/templates/gym-fitness/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
  41. package/templates/nonprofit-cause/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  42. package/templates/nonprofit-cause/tier5-src/_shared/member-account-routes.ts +2 -1
  43. package/templates/podcast-creator/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  44. package/templates/podcast-creator/tier5-src/_shared/member-account-routes.ts +2 -1
  45. package/templates/portfolio-creative/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  46. package/templates/portfolio-creative/tier5-src/_shared/member-account-routes.ts +2 -1
  47. package/templates/restaurant/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  48. package/templates/restaurant/tier5-src/_shared/member-account-routes.ts +2 -1
  49. package/templates/retail-store/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  50. package/templates/retail-store/tier5-src/_shared/member-account-routes.ts +2 -1
  51. package/templates/yoga-studio/config.ts +13 -0
  52. package/templates/yoga-studio/dev/index.ts +784 -0
  53. package/templates/yoga-studio/himalaya.content.json +3 -0
  54. package/templates/yoga-studio/tier5-src/_shared/branded-lite-hosted.ts +2 -1
  55. package/templates/yoga-studio/tier5-src/_shared/branded-load-state.ts +106 -0
  56. package/templates/yoga-studio/tier5-src/_shared/himi-bookings.ts +14 -0
  57. package/templates/yoga-studio/tier5-src/_shared/member-account-routes.ts +2 -1
  58. package/templates/yoga-studio/tier5-src/himi-booking-time/index.ts +25 -11
  59. package/templates/yoga-studio/tier5-src/himi-branded-lite-agenda/index.ts +769 -0
@@ -13,6 +13,9 @@
13
13
  "himi_addresses": {
14
14
  "displayName": "Addresses"
15
15
  },
16
+ "himi_agenda": {
17
+ "displayName": "Agenda"
18
+ },
16
19
  "himi_app_info": {
17
20
  "displayName": "App Info"
18
21
  },
@@ -48,7 +48,7 @@ export const HOSTED_ALWAYS: readonly BrandedLiteVertical[] = ["members"];
48
48
 
49
49
  /** The screen each vertical's nav rows open, so a route to an uninstalled vertical can be answered. */
50
50
  export const VERTICAL_ROOT_SCREENS: Readonly<Record<string, readonly string[]>> = {
51
- store: ["himi_shop"], bookings: ["himi_services"], restaurants: ["himi_menu", "himi_reserve"],
51
+ store: ["himi_shop"], bookings: ["himi_services", "himi_agenda"], restaurants: ["himi_menu", "himi_reserve"],
52
52
  programs: ["himi_programs"], plans: ["himi_plans"], feed: ["himi_feed"], members: ["himi_members"],
53
53
  forum: ["himi_forum"], coupons: ["himi_coupons"], notifications: ["himi_notifications"],
54
54
  loyalty: ["himi_loyalty"], reviews: ["himi_reviews"], portfolio: ["himi_portfolio"],
@@ -67,6 +67,7 @@ export const VERTICAL_ROOT_SCREENS: Readonly<Record<string, readonly string[]>>
67
67
  export const HOSTED_SCREEN_VERTICALS: Readonly<Record<string, readonly BrandedLiteVertical[]>> = {
68
68
  himi_address: ["restaurants", "store"],
69
69
  himi_addresses: ["restaurants", "store"],
70
+ himi_agenda: ["bookings"],
70
71
  himi_badge: ["badges"],
71
72
  himi_badges: ["badges"],
72
73
  himi_blog: ["blog"],
@@ -1,16 +1,18 @@
1
1
  import { formatClock } from './engine-intl.js';
2
2
  import { mergeMessages, normalizeMessages, type MessageRow } from './business-chat-message-model.js';
3
3
 
4
- export interface BusinessChatIntent { id:string; text:string; roomId:string; principal:string }
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
7
 
6
8
  export function newBusinessChatIntent(text:string,roomId:string,principal:string,random=Math.random):BusinessChatIntent{
7
- 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};
9
+ 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'};
8
10
  }
9
11
  export function businessChatSendRejected(error:unknown):boolean{
10
12
  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));
11
13
  }
12
14
  export function draftAfterAcceptedSend(currentDraft:string,sentDraft:string):string{return currentDraft===sentDraft?'':currentDraft;}
13
- export function pendingIntentWasObserved(messages:MessageRow[],intent:BusinessChatIntent|null):boolean{
15
+ export function pendingIntentWasObserved(messages:{id:string}[],intent:BusinessChatIntent|null):boolean{
14
16
  return !!intent&&messages.some(message=>message.id===intent.id);
15
17
  }
16
18
  export function messageTimeLabel(value:string):string{
@@ -14,7 +14,7 @@ export type BusinessMessagePart = {
14
14
 
15
15
  export type BusinessTextRun = { text: string; marks?: string[] };
16
16
  export type BusinessSuggestion = { id: string; messageId: string; label: string; kind: "postback" | "visitor_option"; appId?: string; interactionId?: string; enabled: boolean; disabled: boolean };
17
- export type BusinessArticleAction = { id: string; label: string; url: string };
17
+ export type BusinessArticleAction = { id: string; label: string; url: string; style: "primary" | "secondary" };
18
18
  export type BusinessVisitorOptions = { answerTo: string; labels: string[] };
19
19
 
20
20
  export type BusinessMessage = {
@@ -266,8 +266,8 @@ function articleActions(message: RecordValue, messageId: string): BusinessArticl
266
266
  const rawUrl = typeof button.cta === "string" ? button.cta.trim() : "";
267
267
  if (!label || !rawUrl || button.payload != null) return [];
268
268
  // No `URL` global in a worker: accept only a plain https URL whose authority carries no credentials.
269
- if (!/^https:\/\/[^\s\\@/?#]+(?:[/?#]\S*)?$/.test(rawUrl)) return [];
270
- return [{ id: `${messageId}:article-${index}`, label, url: rawUrl }];
269
+ if (!isSafeLink(rawUrl)) return [];
270
+ return [{ id: `${messageId}:article-${index}`, label, url: rawUrl, style: "secondary" as const }];
271
271
  });
272
272
  }
273
273
 
@@ -301,6 +301,59 @@ function hiddenInternalNotice(message: RecordValue): boolean {
301
301
  Object.keys(content).length === 0 && !hasActionsOrMedia;
302
302
  }
303
303
 
304
+ const SAFE_LINK = /^https:\/\/[^\s\\@/?#]+(?:[/?#]\S*)?$/;
305
+ /** Sentence punctuation after a link ("…/page." or "(see …/page)") is not part of it. */
306
+ function cleanLinkUrl(raw: string): string {
307
+ let url = raw;
308
+ for (;;) {
309
+ const last = url[url.length - 1];
310
+ if (last && (".,;:!?".includes(last) || (last === ")" && !url.includes("(")))) url = url.slice(0, -1); else break;
311
+ }
312
+ return url;
313
+ }
314
+ /** A plain https link with no control characters (a null byte would pass `\S`). */
315
+ const isSafeLink = (url: string): boolean => SAFE_LINK.test(url) && !/[\u0000-\u001f\u007f-\u009f]/.test(url);
316
+
317
+ /** Owner tools (a product, a coupon, a scheduling link) reach a member as plain text: a title, detail
318
+ * lines and `Label: https://…` lines. On a phone that is a wall of text with a URL nobody can tap, so a
319
+ * link line becomes a button and a coupon code its own block. Only plain https links are lifted. */
320
+ export function extractBusinessMessageExtras(markdown: string): { text: string; links: { label: string; url: string }[]; couponCode: string } {
321
+ const kept: string[] = [];
322
+ const links: { label: string; url: string }[] = [];
323
+ let couponCode = "";
324
+ // True only when a button was really added: a repeated link, or one past the sixth, stays in the text.
325
+ const addLink = (label: string, url: string): boolean => {
326
+ if (links.length >= 6 || links.some((link) => link.url === url)) return false;
327
+ links.push({ label: label.replace(/[*_`]/g, "").trim().slice(0, 40) || "Open link", url });
328
+ return true;
329
+ };
330
+ for (const line of markdown.split("\n")) {
331
+ const trimmed = line.trim();
332
+ const labelled = /^(?:[-*•]\s+)?([^:\n]{1,40}):\s*(https:\/\/\S+)$/.exec(trimmed);
333
+ const labelledUrl = labelled ? cleanLinkUrl(labelled[2]!) : "";
334
+ if (labelled && isSafeLink(labelledUrl) && addLink(labelled[1]!, labelledUrl)) continue;
335
+ const bare = /^(https:\/\/\S+)$/.exec(trimmed);
336
+ const bareUrl = bare ? cleanLinkUrl(bare[1]!) : "";
337
+ if (bare && isSafeLink(bareUrl)) {
338
+ // A bare link is named by the list item above it ("- Settle Into Sleep"), else by its site.
339
+ const item = /^[-*•]\s+(.{1,60})$/.exec((kept[kept.length - 1] ?? "").trim());
340
+ if (addLink(item?.[1] ?? `Open ${bareUrl.slice(8).split(/[/?#]/)[0]!.replace(/^www\./, "")}`, bareUrl)) {
341
+ if (item) kept.pop();
342
+ continue;
343
+ }
344
+ }
345
+ const code = /^(.*?)[,;]?\s*\b[Cc]ode:\s*([A-Z0-9_-]{3,32})$/.exec(trimmed);
346
+ if (code && !couponCode) {
347
+ couponCode = code[2]!;
348
+ if (code[1]!.trim()) kept.push(code[1]!.trim());
349
+ continue;
350
+ }
351
+ kept.push(line);
352
+ }
353
+ // Text with nothing lifted out is returned untouched, trailing newline included.
354
+ return { text: links.length || couponCode ? kept.join("\n").trim() : markdown, links, couponCode };
355
+ }
356
+
304
357
  /** Adds only Business business-chat presentation fields to the canonical message rows. */
305
358
  export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "sender" | "mine" | "createdDate" | "plainText" | "parts">>(
306
359
  rows: T[],
@@ -326,6 +379,10 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
326
379
  startsDay: boolean;
327
380
  scrollAnchor: string;
328
381
  notMine: boolean;
382
+ couponCode: string;
383
+ noCoupon: boolean;
384
+ productSlug: string;
385
+ productName: string;
329
386
  })[] {
330
387
  const metadata = new Map<string, RecordValue>();
331
388
  for (const value of raw) {
@@ -377,7 +434,25 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
377
434
  const bodyRuns = htmlMessageRuns(message) ?? plainTextRuns;
378
435
  const bodyMarkdown = markdownFromRuns(bodyRuns);
379
436
  const buttons = messageButtons(message, row.id);
380
- const articles = articleActions(message, row.id);
437
+ const extras = mine ? { text: bodyMarkdown, links: [], couponCode: "" } : extractBusinessMessageExtras(bodyMarkdown);
438
+ // A card is a title and detail lines: Wix sends events as one, and an owner's product, coupon or scheduling link
439
+ // arrives as plain text of the same shape. Its title leads in bold, and a coupon's title is its code, which
440
+ // the coupon block already shows.
441
+ const lines = extras.text.split("\n");
442
+ const first = lines[0]!.trim();
443
+ // A title is short, is not a sentence, and carries no markup of its own: bolding a whole paragraph would flip the
444
+ // meaning of the `**` already inside it.
445
+ const titleLike = lines.length > 1 && first.length <= 60 && !/[.!?:]$/.test(first) && !first.includes("*");
446
+ const cardLike = titleLike && (!!standardEventText || extras.links.length > 0 || !!extras.couponCode);
447
+ const titled = cardLike && extras.couponCode && first === extras.couponCode ? lines.slice(1).join("\n")
448
+ : cardLike && lines.length > 1 && !first.startsWith("**") ? `**${first}**\n${lines.slice(1).join("\n")}` : extras.text;
449
+ // A product card links to its store page; the slug lets the worker find the product and show its picture.
450
+ const productPage = cardLike ? extras.links.map((link) => /^https:\/\/[^/?#]+(?:\/[^/?#]+)*?\/product-page\/([^/?#]+)/i.exec(link.url)?.[1]).find(Boolean) : undefined;
451
+ // A file, recording or video has no inline player here: it gets a button that opens it.
452
+ const attachmentLinks = row.parts.filter((part) => (part.kind === "file" || part.kind === "audio" || part.kind === "video") && !!part.url)
453
+ .map((part, at) => ({ id: `${row.id}:attachment-${at}`, label: (part.fileName || part.caption || `Open ${part.kind}`).slice(0, 40), url: part.url as string, style: "secondary" as const }));
454
+ const articles = [...articleActions(message, row.id), ...attachmentLinks, ...extras.links.map((link, at) => ({ id: `${row.id}:link-${at}`, label: link.label, url: link.url, style: /\b(view|buy|shop|book|pay|order|checkout|reserve|schedule)\b/i.test(link.label) ? "primary" as const : "secondary" as const }))]
455
+ .filter((action, at, all) => all.findIndex((other) => other.url === action.url) === at);
381
456
 
382
457
  return {
383
458
  ...row,
@@ -392,7 +467,11 @@ export function presentBusinessMessages<T extends Pick<BusinessMessage, "id" | "
392
467
  noImage: !imageUrl,
393
468
  bodyText,
394
469
  bodyRuns,
395
- bodyMarkdown,
470
+ bodyMarkdown: titled,
471
+ couponCode: extras.couponCode,
472
+ noCoupon: !extras.couponCode,
473
+ productSlug: productPage ?? "",
474
+ productName: productPage ? first : "",
396
475
  isAiGenerated,
397
476
  suggestions: buttons,
398
477
  articleActions: articles,
@@ -39,6 +39,20 @@ export function slotStartInstant(slot: { startDate?: string; timezone?: unknown
39
39
  return wall - (Number.isFinite(again) ? again : offset);
40
40
  }
41
41
 
42
+ /** `himi_time` reads the next two weeks. A host's `start` past them (an Agenda session weeks
43
+ * ahead) is read on its own — that session's local day only, never everything up to it — so it
44
+ * can be preselected while each read stays bounded. `undefined` when the start is inside the two
45
+ * weeks. */
46
+ export function availabilityBeyond(now: Date, start = ""): { from: Date; until: Date } | undefined {
47
+ const end = now.getTime() + 14 * 86_400_000;
48
+ const at = start ? Date.parse(start) : NaN;
49
+ if (!Number.isFinite(at) || at < end) return undefined;
50
+ const d = new Date(at);
51
+ const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate());
52
+ // Never back into the two weeks already read: a slot in both would be listed twice.
53
+ return { from: new Date(Math.max(dayStart.getTime(), end)), until: new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1) };
54
+ }
55
+
42
56
  /** The slot a host asked to open at (`himi_time` input `start`, an ISO 8601 instant): the id of the
43
57
  * slot that starts at that same instant, or "" when none does (taken since, or out of the window).
44
58
  * Compared as instants, so `…T16:30:00Z` finds a slot listed as `…T18:30:00+02:00`, or as a local
@@ -18,7 +18,8 @@ export const MEMBER_ACCOUNT_ROUTES_MIN_BINARY = "1.4.0";
18
18
  export const MEMBER_MEDIA_FIELD_MIN_BINARY = "1.5.0";
19
19
  export const MEMBER_PASSWORD_ACTIONS_MIN_BINARY = "1.5.0";
20
20
 
21
- function atLeast(version: string, floor: string): boolean {
21
+ /** `version` (a HimalayaCore binary version, pre-release suffix ignored) is `floor` or later. */
22
+ export function atLeast(version: string, floor: string): boolean {
22
23
  const parts = (v: string) => v.split("-")[0].split(".").map((p) => Number.parseInt(p, 10) || 0);
23
24
  const a = parts(version), b = parts(floor);
24
25
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
@@ -2,16 +2,21 @@ 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, draftAfterAcceptedSend, pendingIntentWasObserved, chronologicalMessages, newBusinessChatIntent, type BusinessChatIntent } from '../_shared/business-chat-model.js';
5
+ import { businessChatSendRejected, messageTimeLabel, 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';
9
9
  import { hostSiteFromInput } from '../_shared/branded-lite-hosted.js';
10
+ import { productImage, searchStoreProducts } from '../_shared/himi-store.js';
10
11
 
11
12
  type PendingIntent = BusinessChatIntent;
12
- type BusinessMessageRow = MessageRow & {
13
- senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; bodyRuns:{text:string;marks?:string[]}[]; isAiGenerated:boolean; suggestions:BusinessSuggestion[]; visitorOptions:BusinessVisitorOptions|null; notMine:boolean;
13
+ // Only what the screen reads. This state crosses into the worker on every keystroke, so a row carries no raw
14
+ // parts, plain text or text runs: they would be re-sent each time for nothing.
15
+ type BusinessMessageRow = Pick<MessageRow,'id'|'sender'|'when'|'createdDate'|'mine'> & {
16
+ senderAvatar:string; senderName:string; senderInitials:string; senderBadge:string; imageUrl:string; imageDescription:string; noImage:boolean; bodyText:string; bodyMarkdown:string; suggestions:BusinessSuggestion[]; articleActions:{id:string;label:string;url:string;style:'primary'|'secondary'}[]; couponCode:string; noCoupon:boolean; notMine:boolean;
14
17
  dayLabel:string; dayHidden:boolean; startsDay:boolean; scrollAnchor:string;
18
+ /** Only the member's own row that the server has not returned yet. */
19
+ pending?:boolean;
15
20
  };
16
21
  type State = {
17
22
  phase:'loading'|'ready'|'error'|'guest'; error:string; notice:string; member:boolean;
@@ -57,6 +62,14 @@ async function sendBusinessVisitorOption(options:BusinessVisitorOptions|null|und
57
62
  return true;
58
63
  }
59
64
  function liveDescription(state:string,reason:string):string{return businessRealtimeStatusLabel(state,reason);}
65
+ /** The member's own message as it shows the instant they tap Send, before the server has answered. It carries the
66
+ * id sent to the server, so the real message replaces it once history returns it. */
67
+ function pendingRow(intent:BusinessChatIntent):BusinessMessageRow{
68
+ const when=intent.delivery==='sending'?'Sending…':intent.delivery==='unconfirmed'?'Not confirmed':messageTimeLabel(intent.createdDate);
69
+ return {id:intent.id,sender:'',when,createdDate:intent.createdDate,mine:true,
70
+ senderAvatar:'',senderName:intent.senderName??'',senderInitials:'',senderBadge:'',imageUrl:'',imageDescription:'',noImage:true,bodyText:intent.text,bodyMarkdown:intent.text,
71
+ suggestions:[],articleActions:[],couponCode:'',noCoupon:true,notMine:false,dayLabel:'',dayHidden:true,startsDay:false,scrollAnchor:`himi_business_chat.message_${intent.id}`,pending:true};
72
+ }
60
73
  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,
61
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'};}
62
75
  function safeLoadFailure(error:unknown):string{
@@ -67,19 +80,77 @@ function safeLoadFailure(error:unknown):string{
67
80
  }
68
81
  function value(arg:unknown):string{if(typeof arg==='string')return arg;const a=arg as Record<string,unknown>|null;return typeof a?.value==='string'?a.value:'';}
69
82
  function param(arg:unknown,key:string):string{const a=arg as Record<string,unknown>|null;const p=a?.params as Record<string,unknown>|undefined;return typeof a?.[key]==='string'?a[key] as string:typeof p?.[key]==='string'?p[key] as string:'';}
70
- async function presentMessages(existing:BusinessMessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
71
- const rows=chronologicalMessages(existing,raw,memberId) as BusinessMessageRow[];
83
+ // A private attachment's download URL costs a round trip and stays valid for minutes, so a refresh reuses it.
84
+ const attachmentUrls=new Map<string,{url:string;at:number}>();
85
+ const ATTACHMENT_URL_TTL_MS=4*60*1000;
86
+ async function privateAttachmentUrl(part:{mediaId?:string;mediaProvider?:string;url?:string;fileName?:string}):Promise<string>{
87
+ const key=`${currentIdentity?.email??''}|${part.mediaId??''}`;
88
+ const hit=attachmentUrls.get(key);
89
+ if(hit&&Date.now()-hit.at<ATTACHMENT_URL_TTL_MS)return hit.url;
90
+ const url=await chat.attachmentUrl(me(),part);
91
+ if(url)attachmentUrls.set(key,{url,at:Date.now()});
92
+ return url;
93
+ }
94
+ // A product card's picture comes from the store: the message carries only the product page link. A found picture is
95
+ // reused for ten minutes and a miss for two, so a refresh never repeats the lookup.
96
+ const productImages=new Map<string,{url:string;at:number}>();
97
+ const productImageLookups=new Map<string,Promise<string>>();
98
+ const PRODUCT_IMAGE_TTL_MS=10*60*1000;
99
+ const PRODUCT_IMAGE_MISS_TTL_MS=2*60*1000;
100
+ function productImageFor(slug:string,name:string):Promise<string>{
101
+ // Keyed by both: the search runs on the name, so a miss for one name must not hide a hit for another.
102
+ const key=JSON.stringify([slug,name]);
103
+ const hit=productImages.get(key);
104
+ if(hit&&Date.now()-hit.at<(hit.url?PRODUCT_IMAGE_TTL_MS:PRODUCT_IMAGE_MISS_TTL_MS))return Promise.resolve(hit.url);
105
+ const running=productImageLookups.get(key);
106
+ if(running)return running;
107
+ const lookup=(async()=>{
108
+ let url='';
109
+ try{
110
+ // The name first; then the link's own words, for a product renamed since the message was written. The slug is the
111
+ // identity, so a same-named product with another slug is used only if neither search finds the slug.
112
+ let sameName:Parameters<typeof productImage>[0]|undefined;
113
+ let bySlug:Parameters<typeof productImage>[0]|undefined;
114
+ for(const query of [name,slug.replace(/-/g,' ')]){
115
+ if(!query.trim())continue;
116
+ const found=(await searchStoreProducts({query,limit:10})).products??[];
117
+ bySlug=found.find(product=>product.slug===slug);
118
+ if(bySlug)break;
119
+ if(query===name&&!sameName)sameName=found.find(product=>product.name===name);
120
+ }
121
+ const match=bySlug??sameName;
122
+ if(match)url=productImage(match);
123
+ }catch{url='';}
124
+ productImages.set(key,{url,at:Date.now()});
125
+ return url;
126
+ })().finally(()=>{productImageLookups.delete(key);});
127
+ productImageLookups.set(key,lookup);
128
+ return lookup;
129
+ }
130
+ function slimRow(row:Record<string,unknown>):BusinessMessageRow{
131
+ const pick=(...keys:string[])=>Object.fromEntries(keys.map(key=>[key,row[key]]));
132
+ return pick('id','sender','when','createdDate','mine','senderAvatar','senderName','senderInitials','senderBadge','imageUrl','imageDescription','noImage','bodyText','bodyMarkdown','suggestions','articleActions','couponCode','noCoupon','notMine','dayLabel','dayHidden','startsDay','scrollAnchor') as unknown as BusinessMessageRow;
133
+ }
134
+ async function presentMessages(existing:MessageRow[],raw:unknown[],memberId?:string):Promise<BusinessMessageRow[]>{
135
+ const rows=chronologicalMessages(existing,raw,memberId) as MessageRow[];
72
136
  const prepared=await Promise.all(rows.map(async row=>{
73
137
  const parts=await Promise.all(row.parts.map(async part=>{
74
- if(part.kind!=='image')return part;
138
+ if(part.kind!=='image'&&part.kind!=='file'&&part.kind!=='audio'&&part.kind!=='video')return part;
75
139
  if(part.mediaProvider==='WixMediaPrivate'&&part.mediaId){
76
- try{return {...part,url:await chat.attachmentUrl(me(),part)};}catch{return {...part,url:''};}
140
+ try{return {...part,url:await privateAttachmentUrl(part)};}catch{return {...part,url:''};}
77
141
  }
78
142
  return part;
79
143
  }));
80
144
  return {...row,parts};
81
145
  }));
82
- return presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as BusinessMessageRow[];
146
+ const presented=presentBusinessMessages(prepared as unknown as Parameters<typeof presentBusinessMessages>[0],raw,businessParticipantId) as unknown as Record<string,unknown>[];
147
+ await Promise.all(presented.map(async row=>{
148
+ const slug=typeof row.productSlug==='string'?row.productSlug:'';
149
+ if(!slug||row.imageUrl)return;
150
+ const url=await productImageFor(slug,String(row.productName||''));
151
+ if(url)Object.assign(row,{imageUrl:url,noImage:false,imageDescription:String(row.productName||'Product image')});
152
+ }));
153
+ return presented.map(slimRow);
83
154
  }
84
155
  function me():ChatIdentity{
85
156
  const identity=currentIdentity;
@@ -104,7 +175,7 @@ function safeError(error:unknown,operation:'load'|'send'|'init'):string{
104
175
  }
105
176
  async function endSubscription():Promise<void>{const id=subscriptionId;subscriptionId='';if(id)try{await unsubscribeRealtime({subscriptionId:id});}catch{}}
106
177
  function clearPrivate(s:State,phase:'guest'|'error'='guest'):State{
107
- currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
178
+ attachmentUrls.clear();productImages.clear();currentIdentity=null;businessParticipantId='';epoch++;void endSubscription();
108
179
  return shape({...initial,phase,error:phase==='guest'?'Sign in to send a message.':s.error});
109
180
  }
110
181
  let refreshTail:Promise<unknown>=Promise.resolve();
@@ -144,7 +215,9 @@ async function refreshHistory(s:State):Promise<State|undefined>{
144
215
  const base:State=same?withLiveFields(s,latest,liveDraft):{...s,draft:liveDraft};
145
216
  const intentObserved=pendingIntentWasObserved(messages,base.pendingIntent);
146
217
  const draft=intentObserved&&base.pendingIntent&&base.draft.trim()===base.pendingIntent.text?'':base.draft;
147
- return shape({...base,messages,visitorOptions,quickReplies:businessVisitorOptionSuggestions(visitorOptions),draft,pendingIntent:intentObserved?null:base.pendingIntent,phase:'ready',error:'',loadFailure:''});
218
+ // A send the server has not returned yet stays on screen until history does.
219
+ 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:''});
148
221
  }catch(error){
149
222
  const wrapped=error instanceof Error?error:new Error('load_failed');
150
223
  Object.assign(wrapped,{businessLoadFailure:safeLoadFailure(error)});
@@ -160,6 +233,20 @@ function withLiveFields(st:State,latest:State,draft:string):State{
160
233
  scrollSend:Math.max(st.scrollSend,latest.scrollSend)};
161
234
  }
162
235
  function withLiveDraft(st:State):State{const latest=readServiceState();return withLiveFields(st,latest,latest.draft);}
236
+ 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. */
238
+ async function deliver(pending:BusinessChatIntent,draft:string):Promise<void>{
239
+ const sendEpoch=epoch;
240
+ let result:SendResult={id:pending.id,outcome:'sent',draft};
241
+ try{
242
+ 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});
244
+ }catch(error){
245
+ const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
246
+ result={id:pending.id,draft,error,outcome:lost?'signed_out':businessChatSendRejected(error)?'rejected':'unconfirmed'};
247
+ }finally{sendFlight=false;}
248
+ if(sendEpoch===epoch)void svc.settleSend(result);
249
+ }
163
250
  const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefined|Promise<State|undefined>>>('businessChat',{
164
251
  state:shape(initial),notHydrated:Object.keys(initial) as (keyof State)[],
165
252
  actions:{
@@ -198,33 +285,46 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
198
285
  },
199
286
  // Runs on every keystroke, so it must not rebuild the message list the way `shape` does.
200
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};},
201
- send:async s=>{
288
+ // Shows the message and clears the box at once; the network round trip and the history reload that follow
289
+ // run in the background and settle through `settleSend`.
290
+ send:s=>{
202
291
  const text=s.draft.trim();
203
292
  if(!text||sendFlight||!s.roomId||!s.member||s.pendingIntent)return s;
204
- sendFlight=true;const pending=newBusinessChatIntent(text,s.roomId,s.principal);
205
- const draftAtSend=s.draft;const sendEpoch=epoch;
206
- try{
207
- await chat.sendMessage(me(),pending.roomId,{id:pending.id,text:pending.text});
208
- if(sendEpoch!==epoch)return undefined;
209
- const latest=readServiceState();
210
- if(latest.roomId!==pending.roomId||latest.principal!==pending.principal)return undefined;
211
- draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
212
- s=shape({...latest,draft:draftAfterAcceptedSend(latest.draft,draftAtSend),pendingIntent:null,error:'',notice:'Message sent.',scrollSend:latest.scrollSend+1});
213
- try{const loaded=await loadLatest(s,true);return loaded?shape({...loaded,notice:'Message sent.'}):undefined;}catch(error){
214
- // A session lost by an older operation says nothing about the member who is signed in now.
215
- if(sendEpoch!==epoch)return undefined;
216
- if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(s,'guest');
217
- return withLiveDraft(s);
218
- }
219
- }catch(error){
293
+ // The member's name as their earlier messages show it, so the row keeps its size when the real message replaces it.
294
+ 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};
296
+ draftRevision++;// a refresh that began before this send must not bring the sent text back into the composer
297
+ void deliver(pending,s.draft);
298
+ return shape({...s,draft:'',pendingIntent:pending,messages:[...s.messages,pendingRow(pending)],error:'',notice:'',scrollSend:s.scrollSend+1});
299
+ },
300
+ settleSend:async(s,a)=>{
301
+ const result=a as SendResult|undefined;const pending=s.pendingIntent;
302
+ if(!result||!pending||pending.id!==result.id)return undefined;
303
+ const replaceRow=(intent:BusinessChatIntent)=>s.messages.map(row=>row.id===intent.id?pendingRow(intent):row);
304
+ if(result.outcome==='signed_out')return clearPrivate(s,'guest');
305
+ if(result.outcome==='rejected'){
306
+ // The row goes, and the text comes back unless the member has already started something new.
307
+ 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
+ }
309
+ if(result.outcome==='unconfirmed'){
310
+ 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.'});
312
+ }
313
+ const sent={...pending,delivery:'sent' as const};
314
+ const accepted=shape({...s,pendingIntent:sent,messages:replaceRow(sent),error:'',notice:''});
315
+ const sendEpoch=epoch;
316
+ // The server accepted it, so Send is free again whatever history says; the row stays until history returns it.
317
+ // 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)]});
319
+ // While live updates are connected the server's own event brings the message back through one refresh; a second
320
+ // one here would only queue behind it on the worker, delaying the member's next keystroke or send.
321
+ if(s.liveState==='connected')return settled(accepted);
322
+ try{const loaded=await loadLatest(accepted,true);return loaded?settled(loaded):undefined;}catch(error){
323
+ // A session lost by an older operation says nothing about the member who is signed in now.
220
324
  if(sendEpoch!==epoch)return undefined;
221
- const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));
222
- if(lost)return clearPrivate(s,'guest');
223
- const definitive=businessChatSendRejected(error);
224
- const latest=readServiceState();
225
- if(latest.roomId!==pending.roomId||latest.principal!==pending.principal)return undefined;
226
- return shape({...latest,pendingIntent:definitive?null:pending,error:safeError(error,'send'),notice:definitive?'The message was rejected. Your draft is retained.':'No automatic retry was made. Reopen the conversation to check whether it arrived.'});
227
- }finally{sendFlight=false;}
325
+ if(/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error)))return clearPrivate(accepted,'guest');
326
+ return settled(withLiveDraft(accepted));
327
+ }
228
328
  },
229
329
  sendSuggestion:async(s,a)=>{
230
330
  if(suggestionFlight||!s.member||!s.roomId||!!s.pendingIntent)return s;
@@ -284,7 +384,7 @@ const svc=defineService<State,Record<string,(s:State,a?:unknown)=>State|undefine
284
384
  }
285
385
  catch(error){if(startEpoch!==epoch)return undefined;const lost=/member_sign_in_required|MemberAccessChanged|\b401\b|\b403\b/i.test(String(error));return lost?clearPrivate(next,'guest'):shape({...withLiveDraft(next),error:safeError(error,'load'),loadFailure:typeof (error as {businessLoadFailure?:unknown})?.businessLoadFailure==='string'?(error as {businessLoadFailure:string}).businessLoadFailure:safeLoadFailure(error)});}
286
386
  },
287
- dispose:async s=>{epoch++;currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
387
+ dispose:async s=>{epoch++;attachmentUrls.clear();productImages.clear();currentIdentity=null;businessParticipantId='';await endSubscription();return shape({...initial,phase:'guest'});},
288
388
  }
289
389
  });
290
390
  readServiceState=()=>svc.getState();
@@ -17,7 +17,7 @@ import { stashCopy, copyParam } from "../_shared/himi-copy.js";
17
17
  import { upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "@himalaya/local-db";
18
18
  import { isBridgeUnavailable } from "../_shared/owner-load-error.js";
19
19
  import { emitHostEvent, hostEventInstant } from "../_shared/host-events.js";
20
- import { slotIdAt } from "../_shared/himi-bookings.js";
20
+ import { availabilityBeyond, slotIdAt } from "../_shared/himi-bookings.js";
21
21
  import { wixMediaUrl, WIX_DETAIL_IMAGE } from "@himalaya/wix-media";
22
22
 
23
23
  // Offline-first (data-offline): booking a slot is WRITE-BEHIND against the
@@ -167,6 +167,15 @@ let pendingStart = (() => {
167
167
  const start = (globalThis as { himalaya?: { input?: { start?: unknown } } }).himalaya?.input?.start;
168
168
  return typeof start === "string" && Number.isFinite(Date.parse(start)) ? start : "";
169
169
  })();
170
+ /** The exact session a host opened this screen at (`himalaya.input.sessionId`), when it names one:
171
+ * two sessions of a class can start at the same instant, and `start` alone picks the first. */
172
+ /** A host's `start` past the two-week window, kept for the life of this worker: a pick on that day
173
+ * clears `pendingStart`, yet the reload after the guest sign-in step must still read it. */
174
+ const farStart = pendingStart;
175
+ let pendingSession = (() => {
176
+ const id = (globalThis as { himalaya?: { input?: { sessionId?: unknown } } }).himalaya?.input?.sessionId;
177
+ return typeof id === "string" ? id.trim() : "";
178
+ })();
170
179
 
171
180
  function strOf(arg: unknown): string {
172
181
  if (typeof arg === "string") return arg;
@@ -197,12 +206,6 @@ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
197
206
  });
198
207
  }
199
208
 
200
- function addDays(date: Date, days: number): Date {
201
- const next = new Date(date);
202
- next.setUTCDate(next.getUTCDate() + days);
203
- return next;
204
- }
205
-
206
209
  function isoDate(date: Date): string {
207
210
  return date.toISOString();
208
211
  }
@@ -559,16 +562,21 @@ const svc = defineService<S, {
559
562
  let next = derive({ ...s, status: "loading", errored: false, message: "", successMessage: "" });
560
563
  next = await probeMember(next);
561
564
  try {
562
- const [serviceRes, availability] = await Promise.all([
565
+ const now = new Date();
566
+ const beyond = availabilityBeyond(now, farStart);
567
+ const [serviceRes, availability, target] = await Promise.all([
563
568
  getService(s.id).catch(() => ({ service: undefined })),
564
- queryServiceAvailability(s.id, isoDate(new Date()), isoDate(addDays(new Date(), 14))),
569
+ queryServiceAvailability(s.id, isoDate(now), isoDate(new Date(now.getTime() + WINDOW_DAYS * 86_400_000))),
570
+ // A host's start past the window (an Agenda session weeks out): its day alone, so it can
571
+ // be preselected. Its failure costs only that preselection.
572
+ beyond ? queryServiceAvailability(s.id, isoDate(beyond.from), isoDate(beyond.until)).catch(() => ({ availabilityEntries: [] })) : undefined,
565
573
  ]);
566
574
  slotsById = new Map();
567
575
  const rows: SlotRow[] = [];
568
576
  // Group by LOCAL calendar day, not by the formatted heading: two
569
577
  // distinct days can render the same text, and a UTC-derived key can
570
578
  // name a different day than the heading beside it.
571
- for (const [index, entry] of (availability.availabilityEntries ?? [])
579
+ for (const [index, entry] of [...(availability.availabilityEntries ?? []), ...(target?.availabilityEntries ?? [])]
572
580
  .filter((candidate) => candidate.bookable !== false && candidate.slot?.startDate)
573
581
  .entries()) {
574
582
  const slot = entry.slot as WixBookingSlotFull;
@@ -595,7 +603,11 @@ const svc = defineService<S, {
595
603
  const chosen: S = selectionRevision !== revisionAtStart ? (svc.getState?.() ?? next) : next;
596
604
  // A host's `start` (deep entry): that slot preselected, or — taken since — its day.
597
605
  const wantedStart = pendingStart;
598
- const startRow = wantedStart ? rows.find((r) => r.id === slotIdAt(slotsById, wantedStart)) : undefined;
606
+ // The named session (a slot row's id IS its session id), else the slot at that instant. A
607
+ // named session that is gone is "taken since" — never another session at the same time.
608
+ const startRow = !wantedStart ? undefined
609
+ : pendingSession ? rows.find((r) => r.id === pendingSession)
610
+ : rows.find((r) => r.id === slotIdAt(slotsById, wantedStart));
599
611
  // The requested day even with no time left on it: "taken since" opens there, nothing selected.
600
612
  const startDay = wantedStart ? days.find((d) => d.id === dayKey(wantedStart)) : undefined;
601
613
  const selectionAlive = !!startRow || slotsById.has(chosen.selectedSlotId);
@@ -638,6 +650,7 @@ const svc = defineService<S, {
638
650
  const day = chosenDay(arg);
639
651
  if (!day || !s.days.some((d) => d.id === day)) return derive(s);
640
652
  pendingStart = "";
653
+ pendingSession = "";
641
654
  return derive(withDay(s, day));
642
655
  },
643
656
  refreshMember: (s, arg) => { stashCopy(arg); return probeMember(s); },
@@ -649,6 +662,7 @@ const svc = defineService<S, {
649
662
  if (selected) {
650
663
  selectionRevision++;
651
664
  pendingStart = "";
665
+ pendingSession = "";
652
666
  if (s.isMember) clearPick();
653
667
  else rememberPick(s.id, slotId);
654
668
  }