@signalcore/ui 1.0.0-beta.2 → 1.0.0-beta.4

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 (99) hide show
  1. package/README.md +126 -21
  2. package/dist/AgentAbout.js +6 -2
  3. package/dist/AgentAbout.js.map +1 -1
  4. package/dist/AgentChat.d.ts +25 -3
  5. package/dist/AgentChat.js +302 -28
  6. package/dist/AgentChat.js.map +1 -1
  7. package/dist/AgentSpace.d.ts +25 -4
  8. package/dist/AgentSpace.js +55 -11
  9. package/dist/AgentSpace.js.map +1 -1
  10. package/dist/Threads.d.ts +73 -0
  11. package/dist/Threads.js +140 -0
  12. package/dist/Threads.js.map +1 -0
  13. package/dist/View.d.ts +48 -0
  14. package/dist/View.js +101 -0
  15. package/dist/View.js.map +1 -0
  16. package/dist/automation/marks.js +7 -3
  17. package/dist/automation/marks.js.map +1 -1
  18. package/dist/automation/messages.d.ts +4 -0
  19. package/dist/automation/messages.js +5 -1
  20. package/dist/automation/messages.js.map +1 -1
  21. package/dist/automation/when.js +8 -0
  22. package/dist/automation/when.js.map +1 -1
  23. package/dist/blocks.d.ts +83 -2
  24. package/dist/client.d.ts +23 -5
  25. package/dist/client.js +8 -2
  26. package/dist/client.js.map +1 -1
  27. package/dist/contract.d.ts +9 -4
  28. package/dist/es.d.ts +2 -0
  29. package/dist/es.js +34 -3
  30. package/dist/es.js.map +1 -1
  31. package/dist/frames.d.ts +7 -1
  32. package/dist/frames.js.map +1 -1
  33. package/dist/index.d.ts +14 -7
  34. package/dist/index.js +11 -4
  35. package/dist/kit/Agenda.d.ts +25 -0
  36. package/dist/kit/Agenda.js +119 -0
  37. package/dist/kit/Agenda.js.map +1 -0
  38. package/dist/kit/Combobox.js +1 -1
  39. package/dist/kit/DateField.js +1 -1
  40. package/dist/kit/Kanban.d.ts +17 -0
  41. package/dist/kit/Kanban.js +157 -0
  42. package/dist/kit/Kanban.js.map +1 -0
  43. package/dist/kit/TimeField.js +1 -1
  44. package/dist/kit/Timeline.d.ts +14 -0
  45. package/dist/kit/Timeline.js +71 -0
  46. package/dist/kit/Timeline.js.map +1 -0
  47. package/dist/kit/context.d.ts +1 -1
  48. package/dist/kit/context.js.map +1 -1
  49. package/dist/kit/messages.d.ts +22 -0
  50. package/dist/kit/messages.js +10 -0
  51. package/dist/kit/messages.js.map +1 -1
  52. package/dist/surface/Form.js +1 -1
  53. package/dist/surface/Placed.d.ts +20 -0
  54. package/dist/surface/Placed.js +70 -0
  55. package/dist/surface/Placed.js.map +1 -0
  56. package/dist/surface/Surface.d.ts +4 -2
  57. package/dist/surface/Surface.js +78 -2
  58. package/dist/surface/Surface.js.map +1 -1
  59. package/dist/surface/places.d.ts +16 -0
  60. package/dist/surface/places.js +15 -0
  61. package/dist/surface/places.js.map +1 -0
  62. package/dist/surface/scope.d.ts +17 -1
  63. package/dist/surface/scope.js +1 -0
  64. package/dist/surface/scope.js.map +1 -1
  65. package/dist/types.d.ts +23 -0
  66. package/dist/types.js.map +1 -1
  67. package/dist/useConversation.d.ts +2 -0
  68. package/dist/useConversation.js +16 -0
  69. package/dist/useConversation.js.map +1 -1
  70. package/dist/widget.js +17 -16
  71. package/package.json +1 -1
  72. package/src/AgentAbout.tsx +4 -0
  73. package/src/AgentChat.tsx +417 -34
  74. package/src/AgentSpace.tsx +106 -14
  75. package/src/Threads.tsx +218 -0
  76. package/src/View.tsx +117 -0
  77. package/src/automation/marks.tsx +7 -0
  78. package/src/automation/messages.ts +8 -0
  79. package/src/automation/when.ts +4 -0
  80. package/src/blocks.ts +100 -2
  81. package/src/client.ts +42 -10
  82. package/src/contract.ts +404 -29
  83. package/src/es.ts +30 -0
  84. package/src/frames.ts +3 -1
  85. package/src/index.ts +36 -3
  86. package/src/kit/Agenda.tsx +161 -0
  87. package/src/kit/Kanban.tsx +182 -0
  88. package/src/kit/Timeline.tsx +58 -0
  89. package/src/kit/context.tsx +45 -1
  90. package/src/kit/messages.ts +31 -0
  91. package/src/styles.css +825 -66
  92. package/src/surface/Placed.tsx +98 -0
  93. package/src/surface/Surface.tsx +92 -1
  94. package/src/surface/places.ts +19 -0
  95. package/src/surface/scope.ts +24 -1
  96. package/src/types.ts +24 -0
  97. package/src/useConversation.ts +10 -0
  98. package/src/widget/element.tsx +54 -7
  99. package/src/widget/widget.css +13 -0
@@ -0,0 +1,98 @@
1
+ import { XIcon } from "@phosphor-icons/react";
2
+ import { useEffect, useRef, type KeyboardEvent, type ReactNode } from "react";
3
+
4
+ import { classes } from "../classes";
5
+ import { Button } from "../kit/Button";
6
+
7
+ /** The parts of a surface shown apart from the conversation, by the class each carries. */
8
+ export type PlacedPart =
9
+ | "sc-placed"
10
+ | "sc-placed--panel"
11
+ | "sc-placed--modal"
12
+ | "sc-placed__head"
13
+ | "sc-placed__title"
14
+ | "sc-placed__body";
15
+
16
+ /**
17
+ * A surface the agent put apart from the conversation, tied to it all the same: in a `panel` that
18
+ * stands beside the conversation (over it where there is no room), or in a `modal` over the page,
19
+ * whose backdrop, Escape or cross close it. Its title heads it; closed, the conversation's line
20
+ * opens it again.
21
+ */
22
+ export function Placed({
23
+ where,
24
+ title,
25
+ closeLabel,
26
+ onClose,
27
+ classNames,
28
+ children,
29
+ }: {
30
+ where: "panel" | "modal";
31
+ title: string;
32
+ closeLabel: string;
33
+ onClose: () => void;
34
+ classNames: Partial<Record<PlacedPart, string>> | undefined;
35
+ children: ReactNode;
36
+ }) {
37
+ const dialog = useRef<HTMLDialogElement>(null);
38
+ useEffect(() => {
39
+ const opened = dialog.current;
40
+ if (opened === null) return undefined;
41
+ // jsdom has no top layer: a dialog there is only shown.
42
+ if (typeof opened.showModal === "function") opened.showModal();
43
+ else opened.setAttribute("open", "");
44
+ return () => {
45
+ if (typeof opened.close === "function") opened.close();
46
+ };
47
+ }, []);
48
+ const head = (
49
+ <header className={classes(classNames, "sc-placed__head")}>
50
+ <h2 className={classes(classNames, "sc-placed__title")}>{title}</h2>
51
+ <Button
52
+ tone="ghost"
53
+ size="sm"
54
+ icon={XIcon}
55
+ aria-label={closeLabel}
56
+ title={closeLabel}
57
+ onClick={onClose}
58
+ />
59
+ </header>
60
+ );
61
+ // Escape closes this, not the reply the conversation is writing.
62
+ const onKeyDown = (event: KeyboardEvent) => {
63
+ if (event.key !== "Escape") return;
64
+ event.stopPropagation();
65
+ if (where === "panel") onClose();
66
+ };
67
+ const body = <div className={classes(classNames, "sc-placed__body")}>{children}</div>;
68
+ if (where === "panel") {
69
+ return (
70
+ <aside
71
+ part="placed panel"
72
+ aria-label={title}
73
+ onKeyDown={onKeyDown}
74
+ className={classes(classNames, "sc-placed", "sc-placed--panel")}
75
+ >
76
+ {head}
77
+ {body}
78
+ </aside>
79
+ );
80
+ }
81
+ return (
82
+ <dialog
83
+ ref={dialog}
84
+ part="placed modal"
85
+ aria-label={title}
86
+ onKeyDown={onKeyDown}
87
+ onClose={onClose}
88
+ // A press on the backdrop (the dialog itself, outside its box) closes it.
89
+ onClick={(event) => {
90
+ if (event.target === event.currentTarget) onClose();
91
+ }}
92
+ className={`sc ${classes(classNames, "sc-placed", "sc-placed--modal")}`}
93
+ >
94
+ {head}
95
+ {body}
96
+ </dialog>
97
+ );
98
+ }
@@ -5,10 +5,13 @@ import { Automation } from "../automation/Automation";
5
5
  import type {
6
6
  AutomationBlock,
7
7
  Block,
8
+ BoardBlock,
8
9
  ButtonBlock,
10
+ CalendarBlock,
9
11
  ChoiceBlock,
10
12
  ConnectBlock,
11
13
  FieldsBlock,
14
+ HostBlock,
12
15
  ImageBlock,
13
16
  ListBlock,
14
17
  Surface as Shown,
@@ -18,6 +21,8 @@ import { classes } from "../classes";
18
21
  import { Button } from "../kit/Button";
19
22
  import { formatSize } from "../about";
20
23
  import { baseName } from "../files";
24
+ import { Agenda } from "../kit/Agenda";
25
+ import { Kanban } from "../kit/Kanban";
21
26
  import { Card } from "../kit/Card";
22
27
  import { Chart } from "../kit/Chart";
23
28
  import { KitProvider, useKit, useParts } from "../kit/context";
@@ -29,6 +34,7 @@ import type { FileHost } from "../kit/uploads";
29
34
  import { AudioPlayer } from "../kit/AudioPlayer";
30
35
  import { Note } from "../kit/Note";
31
36
  import { Stat } from "../kit/Stat";
37
+ import { Timeline } from "../kit/Timeline";
32
38
  import type { Attachment, FileBlock } from "../types";
33
39
  import { FormView } from "./Form";
34
40
  import { Markdown } from "./Markdown";
@@ -36,6 +42,8 @@ import {
36
42
  ScopeContext,
37
43
  useScope,
38
44
  useSurfaceParts,
45
+ type HostBlockProps,
46
+ type HostBlocks,
39
47
  type Scope,
40
48
  type SurfaceClassNames,
41
49
  type SurfaceComponents,
@@ -50,6 +58,8 @@ export interface SurfaceProps {
50
58
  /** What the person already did on it: the option picked, the button pressed, the form sent. */
51
59
  answered?: SurfaceAction | undefined;
52
60
  components?: SurfaceComponents | undefined;
61
+ /** The host's own blocks, by the name each was declared with. */
62
+ blocks?: HostBlocks | undefined;
53
63
  classNames?: SurfaceClassNames;
54
64
  /** The words its controls say, in the host's language; English by default. */
55
65
  messages?: Partial<KitMessages> | undefined;
@@ -80,6 +90,10 @@ const BLOCKS: ReadonlySet<string> = new Set([
80
90
  "button",
81
91
  "choice",
82
92
  "form",
93
+ "calendar",
94
+ "timeline",
95
+ "board",
96
+ "host",
83
97
  ]);
84
98
 
85
99
  /** Whether every block down the tree, and every field of its forms, is one this renderer draws. */
@@ -116,12 +130,13 @@ export function Surface({
116
130
  active = true,
117
131
  answered,
118
132
  components,
133
+ blocks,
119
134
  classNames,
120
135
  messages,
121
136
  locale,
122
137
  files,
123
138
  }: SurfaceProps) {
124
- const scope: Scope = { active, answered, onAction, components, classNames, files };
139
+ const scope: Scope = { active, answered, onAction, components, blocks, classNames, files };
125
140
  return (
126
141
  <KitProvider classNames={classNames} messages={messages} locale={locale}>
127
142
  <ScopeContext value={scope}>
@@ -227,9 +242,85 @@ function Draw({ block }: { block: Block }) {
227
242
  return <Choice block={block} />;
228
243
  case "form":
229
244
  return <FormView block={block} />;
245
+ case "calendar":
246
+ return <CalendarShown block={block} />;
247
+ case "timeline":
248
+ return <Timeline items={block.items} />;
249
+ case "board":
250
+ return <BoardShown block={block} />;
251
+ case "host":
252
+ return <Hosted block={block} />;
230
253
  }
231
254
  }
232
255
 
256
+ /** A calendar; with its action, pressing an event sends it (`{event}`). */
257
+ function CalendarShown({ block }: { block: CalendarBlock }) {
258
+ const { active, onAction } = useScope();
259
+ const { action } = block;
260
+ return (
261
+ <Agenda
262
+ span={block.span}
263
+ start={block.start}
264
+ events={block.events}
265
+ disabled={!active}
266
+ onEvent={
267
+ action === undefined
268
+ ? undefined
269
+ : (event) => {
270
+ onAction({ name: action, data: { event: event.id }, label: event.title });
271
+ }
272
+ }
273
+ />
274
+ );
275
+ }
276
+
277
+ /** A board; with its action, a card moved to another column sends it (`{card, column}`). */
278
+ function BoardShown({ block }: { block: BoardBlock }) {
279
+ const { active, onAction } = useScope();
280
+ const { messages } = useKit();
281
+ const { action } = block;
282
+ return (
283
+ <Kanban
284
+ columns={block.columns}
285
+ disabled={!active}
286
+ onMove={
287
+ action === undefined
288
+ ? undefined
289
+ : (card, column) => {
290
+ onAction({
291
+ name: action,
292
+ data: { card: card.id, column: column.id },
293
+ label: messages.board.moved(card.title, column.title),
294
+ });
295
+ }
296
+ }
297
+ />
298
+ );
299
+ }
300
+
301
+ /**
302
+ * One of the host's own blocks, drawn by the component it registered under the block's name; a
303
+ * name it gave none for, or a component that fails as it draws, leaves the block's words in a
304
+ * quiet card in its place, and the rest of the surface draws.
305
+ */
306
+ function Hosted({ block }: { block: HostBlock }) {
307
+ const { blocks, active, onAction } = useScope();
308
+ const part = useSurfaceParts();
309
+ const twin = (
310
+ <div part="host" className={part("sc-surface__host")} data-block={block.name}>
311
+ <Markdown text={block.text} />
312
+ </div>
313
+ );
314
+ // Registered under this name, for these props: the API checked them against its schema.
315
+ const Own = blocks?.[block.name] as ComponentType<HostBlockProps> | undefined;
316
+ if (Own === undefined) return twin;
317
+ return (
318
+ <Whole twin={twin}>
319
+ <Own props={block.props} text={block.text} active={active} onAction={onAction} />
320
+ </Whole>
321
+ );
322
+ }
323
+
233
324
  /**
234
325
  * A file the agent put in front of the person, compact: its kind's mark, its title (its name
235
326
  * under it when the title is another), its size at the right; pressed, it opens whole in the
@@ -0,0 +1,19 @@
1
+ import type { Place, Surface } from "../blocks";
2
+
3
+ /**
4
+ * Where a host lets the agent put what it shows besides the conversation: a panel beside it, a
5
+ * modal over the page. A place it doesn't allow shows in the conversation.
6
+ */
7
+ export interface SurfacePlaces {
8
+ panel?: boolean | undefined;
9
+ modal?: boolean | undefined;
10
+ }
11
+
12
+ /** Both, unless the host says otherwise. */
13
+ export const EVERY_PLACE: SurfacePlaces = { panel: true, modal: true };
14
+
15
+ /** Where a surface shows in this page: the place the agent chose when the host allows it. */
16
+ export function placeOf(surface: Surface, places: SurfacePlaces = EVERY_PLACE): Place {
17
+ const where = surface.where ?? "chat";
18
+ return where !== "chat" && places[where] === true ? where : "chat";
19
+ }
@@ -30,7 +30,8 @@ export type SurfacePart =
30
30
  | "sc-surface__foot"
31
31
  | "sc-surface__sent"
32
32
  | "sc-surface__connect"
33
- | "sc-surface__connect--done";
33
+ | "sc-surface__connect--done"
34
+ | "sc-surface__host";
34
35
 
35
36
  export type SurfaceClassNames = Partial<Record<SurfacePart | KitPart, string>> | undefined;
36
37
 
@@ -52,6 +53,25 @@ export type BlockProps<Type extends Block["type"]> = Omit<
52
53
  /** The host's own drawing of a block type, in place of the package's: `{ table: TheirTable }`. */
53
54
  export type SurfaceComponents = { [Type in Block["type"]]?: ComponentType<BlockProps<Type>> };
54
55
 
56
+ /**
57
+ * What a host's own block receives (`HostBlocks`): its `props`, as the API checked them against
58
+ * the schema the host declared; `text`, the same in words; whether the surface still takes
59
+ * input, and `onAction` to send what the person did with it, as any block does.
60
+ */
61
+ export interface HostBlockProps<Props = Record<string, unknown>> {
62
+ props: Props;
63
+ text: string;
64
+ active: boolean;
65
+ onAction: (action: SurfaceAction) => void;
66
+ }
67
+
68
+ /**
69
+ * The host's own blocks, by the name it declared each with (`PUT /v1/blocks/{name}`):
70
+ * `{ "demoprep.deal-card": DealCard }`. A block of a name it doesn't give shows its words.
71
+ */
72
+ // `never`: a component typed for its own props (`HostBlockProps<Deal>`) takes its place.
73
+ export type HostBlocks = Readonly<Record<string, ComponentType<HostBlockProps<never>>>>;
74
+
55
75
  /** What every block of one surface shares: whether it takes input, what was done on it. */
56
76
  export interface Scope {
57
77
  active: boolean;
@@ -59,6 +79,8 @@ export interface Scope {
59
79
  answered: SurfaceAction | undefined;
60
80
  onAction: (action: SurfaceAction) => void;
61
81
  components: SurfaceComponents | undefined;
82
+ /** The host's own blocks, by name. */
83
+ blocks: HostBlocks | undefined;
62
84
  classNames: SurfaceClassNames;
63
85
  /** Where its file fields' files go and are found; none: they take none. */
64
86
  files: FileHost | undefined;
@@ -69,6 +91,7 @@ export const ScopeContext = createContext<Scope>({
69
91
  answered: undefined,
70
92
  onAction: () => undefined,
71
93
  components: undefined,
94
+ blocks: undefined,
72
95
  classNames: undefined,
73
96
  files: undefined,
74
97
  });
package/src/types.ts CHANGED
@@ -90,6 +90,12 @@ export interface Conversation {
90
90
  * host's app (`host_person`, its `contact` then) or the widget's visitor.
91
91
  */
92
92
  person?: ConversationPerson | null | undefined;
93
+ /**
94
+ * For the person it is with: how many of the business's messages came since they last read it,
95
+ * and whether it waits on them (a question, their yes); left out by an older API.
96
+ */
97
+ unread?: number | undefined;
98
+ waiting_on_person?: boolean | undefined;
93
99
  }
94
100
 
95
101
  /** Who a person's token or a publishable key started a conversation for. */
@@ -237,6 +243,8 @@ export interface Request {
237
243
  input: Record<string, unknown>;
238
244
  /** The person's when left out (an API from before it). */
239
245
  decides?: Decides | undefined;
246
+ /** The JSON Schema of its input, which an edit given with the yes must fit. */
247
+ schema?: Record<string, unknown> | null | undefined;
240
248
  }
241
249
 
242
250
  /** An action a reply called: what, how it went, and what it drew. */
@@ -306,6 +314,9 @@ export interface Message {
306
314
  heard?: string | null | undefined;
307
315
  /** On a channel, a person's message: what was decided of it; null otherwise. */
308
316
  decision?: Decision | null | undefined;
317
+ /** The agent's words first, without a turn of the person's, and the run whose code sent them. */
318
+ proactive?: boolean | undefined;
319
+ run_id?: string | null | undefined;
309
320
  created_at: string;
310
321
  }
311
322
 
@@ -485,6 +496,19 @@ export interface FileBlock {
485
496
  file: Attachment | null;
486
497
  }
487
498
 
499
+ /**
500
+ * An agent's view rendered for one contact (`GET /views/{name}/render`): its blocks as the kit
501
+ * draws them, filled from the rows when it was read, and `text`, the same in words.
502
+ */
503
+ export interface ViewRendered {
504
+ name: string;
505
+ title: string;
506
+ contact_id: string;
507
+ blocks: Surface["blocks"];
508
+ text: string;
509
+ rendered_at: string;
510
+ }
511
+
488
512
  /** Who holds the conversation's browser: the agent drives it, or the person does. */
489
513
  export type BrowserControl = "agent" | "person";
490
514
 
@@ -444,6 +444,8 @@ export interface LiveConversation {
444
444
  say: (text: string, files?: readonly string[]) => boolean;
445
445
  /** What the person did on a surface; false when the socket is closed. */
446
446
  act: (action: Action) => boolean;
447
+ /** Several things done at once (a yes or no on each call a reply waits on), sent together. */
448
+ actAll: (actions: readonly Action[]) => boolean;
447
449
  /** Stops the reply on its way; false when the socket is closed. */
448
450
  stop: () => boolean;
449
451
  /** The conversation's browser while it is open: where it is, who holds it. */
@@ -605,6 +607,13 @@ export function useConversation(
605
607
  return true;
606
608
  };
607
609
 
610
+ const actAll = (actions: readonly Action[]): boolean => {
611
+ const sent = actions.map((one) => ({ type: "action" as const, ...one }));
612
+ if (!live.send({ type: "actions", actions: sent })) return false;
613
+ for (const action of actions) dispatch({ type: "acted", action });
614
+ return true;
615
+ };
616
+
608
617
  return {
609
618
  messages: state.messages,
610
619
  draft: state.draft,
@@ -615,6 +624,7 @@ export function useConversation(
615
624
  error: state.error,
616
625
  say,
617
626
  act,
627
+ actAll,
618
628
  stop: () => live.send({ type: "stop" }),
619
629
  browser: state.browser,
620
630
  watchScreen: screen.watch,
@@ -5,8 +5,11 @@ import { createRoot, type Root } from "react-dom/client";
5
5
 
6
6
  import type { ChatSteps } from "../AgentChat";
7
7
  import { AgentSpace, SPACE_MESSAGES } from "../AgentSpace";
8
+ import { useUnread } from "../Threads";
8
9
  import { createClient } from "../client";
9
10
  import type { Credentials } from "../credentials";
11
+ import type { SurfacePlaces } from "../surface/places";
12
+ import type { HostBlocks } from "../surface/scope";
10
13
  import { SPACE_MESSAGES_ES } from "../es";
11
14
  import { es } from "../locale";
12
15
  import styles from "../styles.css?inline";
@@ -31,6 +34,7 @@ const ATTRIBUTES = [
31
34
  "unstyled",
32
35
  "shadow",
33
36
  "contained",
37
+ "surfaces",
34
38
  ] as const;
35
39
 
36
40
  type Attribute = (typeof ATTRIBUTES)[number];
@@ -58,12 +62,19 @@ export interface WidgetSettings {
58
62
  steps: ChatSteps | undefined;
59
63
  /** Floating within the element's own box (`contained`), not at the page's corner: a preview. */
60
64
  contained: boolean;
65
+ /**
66
+ * Where the agent may put what it shows besides the conversation (`surfaces`: `"panel modal"`,
67
+ * either, or `""` for neither); both unless the page says.
68
+ */
69
+ surfaces: SurfacePlaces | undefined;
61
70
  }
62
71
 
63
72
  /** The launcher's words, by language. */
64
- const LAUNCHER: Readonly<Record<Language, { open: string; close: string; chat: string }>> = {
65
- en: { open: "Open the chat", close: "Close the chat", chat: "Chat" },
66
- es: { open: "Abrir el chat", close: "Cerrar el chat", chat: "Chat" },
73
+ const LAUNCHER: Readonly<
74
+ Record<Language, { open: string; close: string; chat: string; news: string }>
75
+ > = {
76
+ en: { open: "Open the chat", close: "Close the chat", chat: "Chat", news: "something new" },
77
+ es: { open: "Abrir el chat", close: "Cerrar el chat", chat: "Chat", news: "hay novedades" },
67
78
  };
68
79
 
69
80
  /**
@@ -106,9 +117,17 @@ export function settingsOf(element: Element): WidgetSettings | null {
106
117
  header: on(element, "header"),
107
118
  steps: steps === "shown" || steps === "hidden" ? steps : undefined,
108
119
  contained: on(element, "contained"),
120
+ surfaces: placesOf(element.getAttribute("surfaces")),
109
121
  };
110
122
  }
111
123
 
124
+ /** The places a `surfaces` attribute names (`"panel modal"`); none given: the default, both. */
125
+ export function placesOf(named: string | null): SurfacePlaces | undefined {
126
+ if (named === null) return undefined;
127
+ const places = new Set(named.trim().toLowerCase().split(/\s+/));
128
+ return { panel: places.has("panel"), modal: places.has("modal") };
129
+ }
130
+
112
131
  /**
113
132
  * A look as the widget's own stylesheet: its tokens on the host (`:host` in its shadow root, the
114
133
  * element itself when drawn into the page), where the page's own rules for the element still
@@ -239,6 +258,8 @@ function stylePage() {
239
258
  export interface WidgetProps {
240
259
  settings: WidgetSettings & { agent: string };
241
260
  look?: WidgetLook | null | undefined;
261
+ /** The page's own blocks, by name (the element's `blocks`). */
262
+ blocks?: HostBlocks | undefined;
242
263
  }
243
264
 
244
265
  /**
@@ -246,14 +267,19 @@ export interface WidgetProps {
246
267
  * page's corner (the left one if its look says so), or (`inline`) in the page's flow at the
247
268
  * element's size. The conversation it opens is kept in the page's storage for the next visit.
248
269
  */
249
- export function Widget({ settings, look = null }: WidgetProps) {
250
- const { baseUrl, agent, credential, language, mode, steps, contained } = settings;
270
+ export function Widget({ settings, look = null, blocks }: WidgetProps) {
271
+ const { baseUrl, agent, credential, language, mode, steps, contained, surfaces } = settings;
251
272
  const signedAs = credential.token ?? credential.publishableKey ?? "cookie";
252
273
  // One client while the page says the same; a new token (the host's, renewed) makes another.
253
274
  const client = useMemo(() => createClient({ baseUrl, ...credential }), [baseUrl, signedAs]);
254
275
  const [open, setOpen] = useState(false);
255
276
  const words = LAUNCHER[language];
256
277
  const floating = mode === "floating";
278
+ // While closed, a dot on the launcher says something waits (an unread message, a question), for
279
+ // someone who talked before: nobody new is asked after until they open it.
280
+ const returning = keptConversation(agent) !== undefined;
281
+ const news = useUnread(client, agent, floating && !open && returning && client.who === "person");
282
+ const waits = news.unread > 0 || news.waiting;
257
283
  const header = settings.header || look?.header === true;
258
284
 
259
285
  const space = (
@@ -269,6 +295,8 @@ export function Widget({ settings, look = null }: WidgetProps) {
269
295
  layout={floating ? "compact" : "full"}
270
296
  header={header}
271
297
  steps={steps}
298
+ surfaces={surfaces}
299
+ blocks={blocks}
272
300
  messages={language === "es" ? SPACE_MESSAGES_ES : SPACE_MESSAGES}
273
301
  locale={language === "es" ? es : undefined}
274
302
  onClose={
@@ -295,7 +323,7 @@ export function Widget({ settings, look = null }: WidgetProps) {
295
323
  <button
296
324
  type="button"
297
325
  part="launcher"
298
- aria-label={open ? words.close : words.open}
326
+ aria-label={open ? words.close : waits ? `${words.open}: ${words.news}` : words.open}
299
327
  aria-expanded={open}
300
328
  onClick={() => {
301
329
  setOpen(!open);
@@ -307,6 +335,7 @@ export function Widget({ settings, look = null }: WidgetProps) {
307
335
  ) : (
308
336
  <ChatCircleDotsIcon aria-hidden weight="fill" />
309
337
  )}
338
+ {!open && waits && <span aria-hidden className="sc-widget__dot" />}
310
339
  </button>
311
340
  </div>
312
341
  );
@@ -337,6 +366,8 @@ export class SignalCoreChat extends HTMLElement {
337
366
  #lookSheet: HTMLStyleElement | null = null;
338
367
  /** The look set on the element, over its channel's. */
339
368
  #look: WidgetLook | null = null;
369
+ /** The page's own blocks, by name. */
370
+ #blocks: HostBlocks | undefined = undefined;
340
371
  /** The web channel its publishable key belongs to, as read for that key (null while read). */
341
372
  #channel: { key: string; config: WidgetConfig | null } | null = null;
342
373
  /** The page's type and ink as last given to the widget. */
@@ -363,6 +394,20 @@ export class SignalCoreChat extends HTMLElement {
363
394
  this.#draw();
364
395
  }
365
396
 
397
+ /**
398
+ * The page's own blocks, by the name each was declared with (`PUT /v1/blocks/{name}`), set as
399
+ * a property: `chat.blocks = { "demoprep.deal-card": DealCard }`, React components the
400
+ * widget's React draws. One it doesn't give shows its words.
401
+ */
402
+ get blocks(): HostBlocks | undefined {
403
+ return this.#blocks;
404
+ }
405
+
406
+ set blocks(value: HostBlocks | undefined) {
407
+ this.#blocks = value;
408
+ this.#draw();
409
+ }
410
+
366
411
  connectedCallback() {
367
412
  this.#mount ??= this.#build();
368
413
  this.#root = createRoot(this.#mount);
@@ -521,7 +566,9 @@ export class SignalCoreChat extends HTMLElement {
521
566
  }
522
567
  const agent = settings?.agent ?? config?.agent_id ?? null;
523
568
  this.#root.render(
524
- settings && agent && <Widget settings={{ ...settings, agent }} look={this.#worn} />,
569
+ settings && agent && (
570
+ <Widget settings={{ ...settings, agent }} look={this.#worn} blocks={this.#blocks} />
571
+ ),
525
572
  );
526
573
  }
527
574
  }
@@ -104,6 +104,19 @@ signalcore-chat[shadow="none"][mode="inline"] {
104
104
  height: 46%;
105
105
  }
106
106
 
107
+ /* While the chat is closed and something waits in it (an unread message, a question): a dot at
108
+ the launcher's corner, ringed in the page's surface so it reads on the accent. */
109
+ .sc-widget__dot {
110
+ position: absolute;
111
+ top: 6%;
112
+ right: 6%;
113
+ width: 22%;
114
+ height: 22%;
115
+ border: 2px solid var(--_surface);
116
+ border-radius: var(--_radius-full);
117
+ background: var(--_danger);
118
+ }
119
+
107
120
  .sc-widget--floating > .sc-widget__panel {
108
121
  position: fixed;
109
122
  right: var(--_offset);