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

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 (55) hide show
  1. package/README.md +47 -21
  2. package/dist/AgentAbout.js +6 -2
  3. package/dist/AgentAbout.js.map +1 -1
  4. package/dist/AgentChat.d.ts +7 -1
  5. package/dist/AgentChat.js +154 -2
  6. package/dist/AgentChat.js.map +1 -1
  7. package/dist/AgentSpace.d.ts +12 -3
  8. package/dist/AgentSpace.js +53 -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/automation/marks.js +7 -3
  14. package/dist/automation/marks.js.map +1 -1
  15. package/dist/automation/messages.d.ts +4 -0
  16. package/dist/automation/messages.js +5 -1
  17. package/dist/automation/messages.js.map +1 -1
  18. package/dist/automation/when.js +8 -0
  19. package/dist/automation/when.js.map +1 -1
  20. package/dist/client.d.ts +14 -4
  21. package/dist/client.js +7 -2
  22. package/dist/client.js.map +1 -1
  23. package/dist/contract.d.ts +9 -4
  24. package/dist/es.d.ts +2 -0
  25. package/dist/es.js +22 -3
  26. package/dist/es.js.map +1 -1
  27. package/dist/frames.d.ts +7 -1
  28. package/dist/frames.js.map +1 -1
  29. package/dist/index.d.ts +5 -4
  30. package/dist/index.js +5 -4
  31. package/dist/surface/Form.js +1 -1
  32. package/dist/types.d.ts +11 -0
  33. package/dist/types.js.map +1 -1
  34. package/dist/useConversation.d.ts +2 -0
  35. package/dist/useConversation.js +16 -0
  36. package/dist/useConversation.js.map +1 -1
  37. package/dist/widget.js +17 -16
  38. package/package.json +1 -1
  39. package/src/AgentAbout.tsx +4 -0
  40. package/src/AgentChat.tsx +203 -1
  41. package/src/AgentSpace.tsx +89 -13
  42. package/src/Threads.tsx +218 -0
  43. package/src/automation/marks.tsx +7 -0
  44. package/src/automation/messages.ts +8 -0
  45. package/src/automation/when.ts +4 -0
  46. package/src/client.ts +26 -10
  47. package/src/contract.ts +240 -26
  48. package/src/es.ts +21 -0
  49. package/src/frames.ts +3 -1
  50. package/src/index.ts +9 -1
  51. package/src/styles.css +191 -0
  52. package/src/types.ts +11 -0
  53. package/src/useConversation.ts +10 -0
  54. package/src/widget/element.tsx +13 -4
  55. package/src/widget/widget.css +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@signalcore/ui",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0-beta.3",
4
4
  "description": "SignalCore's components for the people an agent serves: the chat, the call, the agent's space, and the <signalcore-chat> widget.",
5
5
  "keywords": [
6
6
  "signalcore",
@@ -155,6 +155,8 @@ export const ABOUT_MESSAGES: AboutMessages = {
155
155
  webhook: "When called",
156
156
  event: "On an event",
157
157
  platform: "On a message",
158
+ manual: "When started",
159
+ after: "After another",
158
160
  },
159
161
  topics: {
160
162
  apps: "Apps",
@@ -946,6 +948,8 @@ const KIND_ICONS: Readonly<Record<AutomationKind, Icon>> = {
946
948
  webhook: "code",
947
949
  event: "bell",
948
950
  platform: "message",
951
+ manual: "hand",
952
+ after: "check",
949
953
  };
950
954
 
951
955
  /** An app's mark: the host's logo when it draws one, else the provider's, bare. */
package/src/AgentChat.tsx CHANGED
@@ -40,7 +40,10 @@ import { useConversationFiles } from "./conversationFiles";
40
40
  import { FILES_PER_MESSAGE } from "./frames";
41
41
  import { actionMark } from "./marks";
42
42
  import { AudioPlayer } from "./kit/AudioPlayer";
43
+ import { Button } from "./kit/Button";
43
44
  import { KitProvider, type KitPart } from "./kit/context";
45
+ import { Field } from "./kit/Field";
46
+ import { Checkbox, NumberInput, TextArea, TextInput } from "./kit/Inputs";
44
47
  import { carriesFiles, FileChips, UploadChips, useUploads, type Uploads } from "./kit/FileField";
45
48
  import { KIT_MESSAGES, type KitLocale, type KitMessages } from "./kit/messages";
46
49
  import type { FileHost } from "./kit/uploads";
@@ -122,6 +125,12 @@ export interface ChatMessages {
122
125
  inputs: Readonly<Record<string, string>>;
123
126
  allow: string;
124
127
  decline: string;
128
+ /** What heads the card of several actions that wait for the person's leave, by how many. */
129
+ approvals: (count: number) => string;
130
+ /** The card's one button, as many as are ticked: every one, some, or none (all declined). */
131
+ approveAll: string;
132
+ approveSome: (count: number) => string;
133
+ declineAll: string;
125
134
  /**
126
135
  * On a person's page, in place of the approval card: what the agent asked to do waits for the
127
136
  * business's yes (`decides: "business"`), which its people give from their app.
@@ -194,6 +203,10 @@ export const CHAT_MESSAGES: ChatMessages = {
194
203
  inputs: {},
195
204
  allow: "Allow",
196
205
  decline: "Decline",
206
+ approvals: (count) => `${count} actions need your approval`,
207
+ approveAll: "Approve all",
208
+ approveSome: (count) => `Approve ${count}`,
209
+ declineAll: "Decline all",
197
210
  confirming: "Waiting for the business to confirm",
198
211
  handedOff: "Handed to a person",
199
212
  handover: (holder, by, name) => {
@@ -256,6 +269,9 @@ export type ChatPart =
256
269
  | "sc-chat__step--failed"
257
270
  | "sc-chat__output"
258
271
  | "sc-chat__request"
272
+ | "sc-chat__requests"
273
+ | "sc-chat__ask"
274
+ | "sc-chat__ask-fields"
259
275
  | "sc-chat__confirming"
260
276
  | "sc-chat__screen"
261
277
  | "sc-chat__screen--bare"
@@ -460,6 +476,7 @@ function Chat({
460
476
  onTool: onTool ?? (() => Promise.reject(new Error(words.noTool))),
461
477
  });
462
478
  const files = useConversationFiles(client, conversationId, live.messages, onOpenFile);
479
+ useReadAsShown(client, conversationId, live.messages.at(-1)?.id);
463
480
  // What the person attaches to their next message: picked, dropped on the chat, or pasted.
464
481
  const uploads = useUploads(attachments ? files : undefined, { max: FILES_PER_MESSAGE });
465
482
  // Files go with what is said, once the turn waits on nothing; to the host's `write`, at once.
@@ -885,6 +902,7 @@ function Log({
885
902
  components={components}
886
903
  locale={locale}
887
904
  onAction={live.act}
905
+ onActAll={live.actAll}
888
906
  builder={builder}
889
907
  files={files}
890
908
  who={who}
@@ -1234,8 +1252,11 @@ function Reply({
1234
1252
  who,
1235
1253
  steps,
1236
1254
  onChannel,
1255
+ onActAll,
1237
1256
  }: DrawProps & {
1238
1257
  message: Message;
1258
+ /** Several things done at once: a yes or no on each call it waits on, from one card. */
1259
+ onActAll: (actions: readonly Action[]) => void;
1239
1260
  /** It was streaming: its words go on revealing from here. None: they show whole. */
1240
1261
  from: number | undefined;
1241
1262
  awaiting: string | null;
@@ -1251,6 +1272,11 @@ function Reply({
1251
1272
  onChannel: boolean;
1252
1273
  }) {
1253
1274
  const text = useReveal(message.text, from ?? message.text.length);
1275
+ // The calls it waits on the leave of whoever this page speaks for: two or more, in one card.
1276
+ const asked = message.steps.filter(
1277
+ (step): step is Step & { request: Request } =>
1278
+ step.request !== null && !(step.request.decides === "business" && who === "person"),
1279
+ );
1254
1280
  // On a channel, what the person there has got so far; elsewhere, the words as they come.
1255
1281
  const { shown: said, typing } = onChannel
1256
1282
  ? channelParts(message, text)
@@ -1336,9 +1362,21 @@ function Reply({
1336
1362
  </div>
1337
1363
  ),
1338
1364
  )}
1365
+ {asked.length > 1 && (
1366
+ <Approvals
1367
+ key={asked.map((step) => step.id).join()}
1368
+ steps={asked}
1369
+ name={name}
1370
+ words={words}
1371
+ classNames={classNames}
1372
+ onActAll={onActAll}
1373
+ />
1374
+ )}
1339
1375
  {message.steps.map((step) => {
1340
1376
  const { request } = step;
1341
- if (request === null) return null;
1377
+ if (request === null || (asked.length > 1 && asked.some((one) => one.id === step.id))) {
1378
+ return null;
1379
+ }
1342
1380
  if (request.decides === "business" && who === "person") {
1343
1381
  return (
1344
1382
  <p
@@ -1583,6 +1621,170 @@ function Approval({
1583
1621
  );
1584
1622
  }
1585
1623
 
1624
+ /**
1625
+ * Several actions a reply waits on the person's leave for, in one card: a box to tick for each
1626
+ * (all ticked to begin with), what each would take under it, editable while ticked, and one
1627
+ * button that sends a yes for each ticked (with what was changed) and a no for the rest, at once.
1628
+ */
1629
+ function Approvals({
1630
+ steps,
1631
+ name,
1632
+ words,
1633
+ classNames,
1634
+ onActAll,
1635
+ }: {
1636
+ steps: readonly (Step & { request: Request })[];
1637
+ name: string;
1638
+ words: ChatMessages;
1639
+ classNames: ClassNames;
1640
+ onActAll: (actions: readonly Action[]) => void;
1641
+ }) {
1642
+ const [chosen, setChosen] = useState<ReadonlySet<string>>(
1643
+ () => new Set(steps.map((step) => step.id)),
1644
+ );
1645
+ const [edits, setEdits] = useState<Readonly<Record<string, Record<string, unknown>>>>({});
1646
+ const [sent, setSent] = useState(false);
1647
+ const inputOf = (step: Step & { request: Request }) => ({
1648
+ ...step.request.input,
1649
+ ...edits[step.id],
1650
+ });
1651
+ const send = () => {
1652
+ setSent(true);
1653
+ onActAll(
1654
+ steps.map((step) =>
1655
+ chosen.has(step.id)
1656
+ ? {
1657
+ surface: step.id,
1658
+ name: "approve",
1659
+ data: step.id in edits ? { input: inputOf(step) } : {},
1660
+ label: words.allow,
1661
+ }
1662
+ : { surface: step.id, name: "decline", data: {}, label: words.decline },
1663
+ ),
1664
+ );
1665
+ };
1666
+ const label =
1667
+ chosen.size === steps.length
1668
+ ? words.approveAll
1669
+ : chosen.size === 0
1670
+ ? words.declineAll
1671
+ : words.approveSome(chosen.size);
1672
+ return (
1673
+ <div part="approval" className={classes(classNames, "sc-chat__request", "sc-chat__requests")}>
1674
+ <header>
1675
+ <ShieldWarningIcon aria-hidden />
1676
+ {words.approvals(steps.length)}
1677
+ </header>
1678
+ <ul>
1679
+ {steps.map((step) => {
1680
+ const ticked = chosen.has(step.id);
1681
+ return (
1682
+ <li key={step.id} className={classes(classNames, "sc-chat__ask")}>
1683
+ <Checkbox
1684
+ value={ticked}
1685
+ disabled={sent}
1686
+ onChange={(on) => {
1687
+ setChosen((last) => {
1688
+ const next = new Set(last);
1689
+ if (on) next.add(step.id);
1690
+ else next.delete(step.id);
1691
+ return next;
1692
+ });
1693
+ }}
1694
+ >
1695
+ {words.request(name, step.request.action)}
1696
+ </Checkbox>
1697
+ {ticked && (
1698
+ <div className={classes(classNames, "sc-chat__ask-fields")}>
1699
+ {Object.entries(inputOf(step)).map(([field, value]) => (
1700
+ <InputValue
1701
+ key={field}
1702
+ label={words.inputs[field] ?? field}
1703
+ value={value}
1704
+ disabled={sent}
1705
+ onChange={(changed) => {
1706
+ setEdits((last) => ({
1707
+ ...last,
1708
+ [step.id]: { ...last[step.id], [field]: changed },
1709
+ }));
1710
+ }}
1711
+ />
1712
+ ))}
1713
+ </div>
1714
+ )}
1715
+ </li>
1716
+ );
1717
+ })}
1718
+ </ul>
1719
+ <footer>
1720
+ <Button tone="primary" disabled={sent} onClick={send}>
1721
+ {label}
1722
+ </Button>
1723
+ </footer>
1724
+ </div>
1725
+ );
1726
+ }
1727
+
1728
+ /**
1729
+ * One field of an action's input, editable as what it holds: words, a number, a yes or no;
1730
+ * anything else (a list, an object) shown as it is.
1731
+ */
1732
+ function InputValue({
1733
+ label,
1734
+ value,
1735
+ disabled,
1736
+ onChange,
1737
+ }: {
1738
+ label: string;
1739
+ value: unknown;
1740
+ disabled: boolean;
1741
+ onChange: (value: unknown) => void;
1742
+ }) {
1743
+ if (typeof value === "boolean") {
1744
+ return (
1745
+ <Checkbox value={value} disabled={disabled} onChange={onChange}>
1746
+ {label}
1747
+ </Checkbox>
1748
+ );
1749
+ }
1750
+ return (
1751
+ <Field label={label}>
1752
+ {(ids) =>
1753
+ typeof value === "number" ? (
1754
+ <NumberInput {...ids} value={value} disabled={disabled} onChange={onChange} />
1755
+ ) : typeof value === "string" ? (
1756
+ value.length > 80 || value.includes("\n") ? (
1757
+ <TextArea {...ids} value={value} disabled={disabled} onChange={onChange} />
1758
+ ) : (
1759
+ <TextInput {...ids} value={value} disabled={disabled} onChange={onChange} />
1760
+ )
1761
+ ) : (
1762
+ <TextInput {...ids} value={JSON.stringify(value)} disabled onChange={() => undefined} />
1763
+ )
1764
+ }
1765
+ </Field>
1766
+ );
1767
+ }
1768
+
1769
+ /**
1770
+ * On a person's page, what came is read as the chat shows it: each newest message while the page
1771
+ * is in sight, and once it comes back into sight. Their threads' `unread` follows.
1772
+ */
1773
+ function useReadAsShown(client: Client, conversationId: string, newest: string | undefined) {
1774
+ useEffect(() => {
1775
+ if (client.who !== "person" || newest === undefined) return undefined;
1776
+ const read = () => {
1777
+ if (document.visibilityState === "hidden") return;
1778
+ client.conversations.read(conversationId).catch(() => undefined);
1779
+ };
1780
+ read();
1781
+ document.addEventListener("visibilitychange", read);
1782
+ return () => {
1783
+ document.removeEventListener("visibilitychange", read);
1784
+ };
1785
+ }, [client, conversationId, newest]);
1786
+ }
1787
+
1586
1788
  /**
1587
1789
  * Where the person writes: a box that grows a few lines with the text. Enter sends, Shift+Enter
1588
1790
  * breaks the line; nothing goes while a reply is on its way or the socket is closed, but the
@@ -19,6 +19,13 @@ import type { KitLocale } from "./kit/messages";
19
19
  import { Modes, type ModeMessages, type ModePart, type SpaceMode } from "./Modes";
20
20
  import type { SurfaceComponents, SurfacePart } from "./surface/scope";
21
21
  import { agentTint, type AgentTint } from "./tint";
22
+ import {
23
+ THREAD_MESSAGES,
24
+ Threads,
25
+ useUnread,
26
+ type ThreadMessages,
27
+ type ThreadPart,
28
+ } from "./Threads";
22
29
  import { useAgentFace } from "./useAgentFace";
23
30
  import { useConversation, type LiveConversation, type ToolHandler } from "./useConversation";
24
31
  import type { VoiceCallMessages, VoiceCallPart } from "./VoiceCall";
@@ -50,6 +57,8 @@ export interface SpaceMessages {
50
57
  voice: Partial<VoiceCallMessages>;
51
58
  /** What the person's tasks say (`AgentTasks`'s words). */
52
59
  tasks: TaskMessages;
60
+ /** What the person's threads say (`Threads`'s words). */
61
+ threads: ThreadMessages;
53
62
  }
54
63
 
55
64
  export const SPACE_MESSAGES: SpaceMessages = {
@@ -63,6 +72,7 @@ export const SPACE_MESSAGES: SpaceMessages = {
63
72
  chat: {},
64
73
  voice: {},
65
74
  tasks: TASK_MESSAGES,
75
+ threads: THREAD_MESSAGES,
66
76
  };
67
77
 
68
78
  /** The space's parts, by the class each carries; `classNames` adds a host's own under the same keys. */
@@ -86,6 +96,9 @@ export type SpacePart =
86
96
  | "sc-space__caption--agent"
87
97
  | "sc-space__status"
88
98
  | "sc-space__tasks"
99
+ | "sc-space__bar"
100
+ | "sc-space__threads"
101
+ | "sc-space__dot"
89
102
  | ModePart;
90
103
 
91
104
  /**
@@ -123,7 +136,14 @@ export interface AgentSpaceProps {
123
136
  classNames?:
124
137
  | Partial<
125
138
  Record<
126
- SpacePart | ChatPart | SurfacePart | KitPart | BrowserPart | VoiceCallPart | TaskPart,
139
+ | SpacePart
140
+ | ChatPart
141
+ | SurfacePart
142
+ | KitPart
143
+ | BrowserPart
144
+ | VoiceCallPart
145
+ | TaskPart
146
+ | ThreadPart,
127
147
  string
128
148
  >
129
149
  >
@@ -147,6 +167,12 @@ export interface AgentSpaceProps {
147
167
  * while there are any, which opens them. On by default; the business's own space never has it.
148
168
  */
149
169
  tasks?: boolean | undefined;
170
+ /**
171
+ * Whether a person sees their threads with the agent (`Threads`): a line over the chat, a dot on
172
+ * it while another waits on them, which lists them by title with what is unread, to go to one
173
+ * or start another. On by default; the business's own space never has it.
174
+ */
175
+ threads?: boolean | undefined;
150
176
  }
151
177
 
152
178
  /**
@@ -181,10 +207,13 @@ export function AgentSpace({
181
207
  orb,
182
208
  onClose,
183
209
  tasks: showsTasks = true,
210
+ threads: showsThreads = true,
184
211
  }: AgentSpaceProps) {
185
212
  const words = { ...SPACE_MESSAGES, ...messages };
186
213
  const theirs = useTasks(client, agentId, showsTasks && client.who === "person");
187
214
  const [listing, setListing] = useState(false);
215
+ const threads = useUnread(client, agentId, showsThreads && client.who === "person");
216
+ const [browsing, setBrowsing] = useState(false);
188
217
  const { face, loading } = useAgentFace(client, agentId);
189
218
  const opened = useOpened(client, agentId, conversationId, onConversation);
190
219
  const [mode, setMode] = useState<SpaceMode>("text");
@@ -250,6 +279,30 @@ export function AgentSpace({
250
279
  </p>
251
280
  );
252
281
  }
282
+ if (browsing && threads.threads !== null) {
283
+ const went = (next: string) => {
284
+ opened.go(next);
285
+ setBrowsing(false);
286
+ void threads.refresh();
287
+ };
288
+ return (
289
+ <Threads
290
+ threads={threads.threads}
291
+ current={id}
292
+ onSelect={went}
293
+ onNew={() => {
294
+ void client.conversations.start(agentId).then((started) => {
295
+ went(started.id);
296
+ });
297
+ }}
298
+ onBack={() => {
299
+ setBrowsing(false);
300
+ }}
301
+ messages={words.threads}
302
+ classNames={classNames}
303
+ />
304
+ );
305
+ }
253
306
  if (listing && theirs.tasks !== null) {
254
307
  return (
255
308
  <AgentTasks
@@ -320,20 +373,43 @@ export function AgentSpace({
320
373
  );
321
374
  }
322
375
  const count = theirs.tasks?.length ?? 0;
376
+ // Another thread with something for them: the line to the threads carries a dot.
377
+ const news = (threads.threads ?? []).some(
378
+ (thread) =>
379
+ thread.id !== id && ((thread.unread ?? 0) > 0 || thread.waiting_on_person === true),
380
+ );
323
381
  return (
324
382
  <>
325
- {count > 0 && (
326
- <button
327
- type="button"
328
- part="tasks-open"
329
- className={classes(classNames, "sc-space__tasks")}
330
- onClick={() => {
331
- setListing(true);
332
- void theirs.refresh();
333
- }}
334
- >
335
- {words.tasks.open(count)}
336
- </button>
383
+ {(threads.threads !== null || count > 0) && (
384
+ <div className={classes(classNames, "sc-space__bar")}>
385
+ {threads.threads !== null && (
386
+ <button
387
+ type="button"
388
+ part="threads-open"
389
+ className={classes(classNames, "sc-space__threads")}
390
+ onClick={() => {
391
+ setBrowsing(true);
392
+ void threads.refresh();
393
+ }}
394
+ >
395
+ {words.threads.open}
396
+ {news && <span aria-hidden className={classes(classNames, "sc-space__dot")} />}
397
+ </button>
398
+ )}
399
+ {count > 0 && (
400
+ <button
401
+ type="button"
402
+ part="tasks-open"
403
+ className={classes(classNames, "sc-space__tasks")}
404
+ onClick={() => {
405
+ setListing(true);
406
+ void theirs.refresh();
407
+ }}
408
+ >
409
+ {words.tasks.open(count)}
410
+ </button>
411
+ )}
412
+ </div>
337
413
  )}
338
414
  <AgentChat
339
415
  client={client}
@@ -0,0 +1,218 @@
1
+ import { ArrowLeftIcon, PlusIcon } from "@phosphor-icons/react";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+
4
+ import { AgentChat, type AgentChatProps } from "./AgentChat";
5
+ import { classes } from "./classes";
6
+ import type { Client } from "./client";
7
+ import { Button } from "./kit/Button";
8
+ import type { Conversation as Thread } from "./types";
9
+
10
+ /** What the person's threads say; a host gives its own, in its language. */
11
+ export interface ThreadMessages {
12
+ /** Over the chat, to open the list; and the list's title. */
13
+ open: string;
14
+ title: string;
15
+ back: string;
16
+ /** Starts another conversation with the agent. */
17
+ new: string;
18
+ /** A thread nothing names yet. */
19
+ untitled: string;
20
+ /** Beside a thread: how many of the agent's messages are unread; that it waits on them. */
21
+ unread: (count: number) => string;
22
+ waiting: string;
23
+ empty: string;
24
+ }
25
+
26
+ export const THREAD_MESSAGES: ThreadMessages = {
27
+ open: "Conversations",
28
+ title: "Your conversations",
29
+ back: "Back to the conversation",
30
+ new: "New conversation",
31
+ untitled: "New conversation",
32
+ unread: (count) => (count === 1 ? "1 unread message" : `${count} unread messages`),
33
+ waiting: "Waiting for you",
34
+ empty: "Nothing here yet.",
35
+ };
36
+
37
+ export type ThreadPart =
38
+ | "sc-threads"
39
+ | "sc-threads__head"
40
+ | "sc-threads__title"
41
+ | "sc-threads__list"
42
+ | "sc-threads__item"
43
+ | "sc-threads__item--current"
44
+ | "sc-threads__name"
45
+ | "sc-threads__badge"
46
+ | "sc-threads__dot"
47
+ | "sc-threads__empty";
48
+
49
+ /** How often the person's threads are read again, for what is unread. */
50
+ export const THREADS_POLL_MS = 20_000;
51
+
52
+ /** The person's threads with an agent, and what of them waits on the person. */
53
+ export interface Unread {
54
+ /** Newest first; null until read, and whenever they can't be. */
55
+ threads: Thread[] | null;
56
+ /** Every unread message of the agent's, across the threads. */
57
+ unread: number;
58
+ /** Whether a thread waits on the person (a question, their yes). */
59
+ waiting: boolean;
60
+ refresh: () => Promise<void>;
61
+ }
62
+
63
+ /**
64
+ * The threads the person the client speaks for has with the agent, read now, again every
65
+ * `THREADS_POLL_MS`, when the page comes back into sight, and on `refresh`: what draws a dot on
66
+ * a launcher, a count beside a tab. `enabled` off reads nothing.
67
+ */
68
+ export function useUnread(client: Client, agentId: string, enabled = true): Unread {
69
+ const [threads, setThreads] = useState<Thread[] | null>(null);
70
+ const live = useRef(true);
71
+ const refresh = useCallback(async () => {
72
+ try {
73
+ const read = await client.conversations.list(agentId);
74
+ if (live.current) setThreads(read);
75
+ } catch {
76
+ if (live.current) setThreads(null);
77
+ }
78
+ }, [client, agentId]);
79
+ useEffect(() => {
80
+ live.current = true;
81
+ if (!enabled) return undefined;
82
+ void refresh();
83
+ const timer = setInterval(() => void refresh(), THREADS_POLL_MS);
84
+ const shown = () => {
85
+ if (document.visibilityState === "visible") void refresh();
86
+ };
87
+ document.addEventListener("visibilitychange", shown);
88
+ return () => {
89
+ live.current = false;
90
+ clearInterval(timer);
91
+ document.removeEventListener("visibilitychange", shown);
92
+ };
93
+ }, [enabled, refresh]);
94
+ const read = enabled ? threads : null;
95
+ return {
96
+ threads: read,
97
+ unread: (read ?? []).reduce((sum, thread) => sum + (thread.unread ?? 0), 0),
98
+ waiting: (read ?? []).some((thread) => thread.waiting_on_person === true),
99
+ refresh,
100
+ };
101
+ }
102
+
103
+ export interface ThreadsProps {
104
+ /** The threads, newest first (`useUnread`'s). */
105
+ threads: readonly Thread[];
106
+ /** The one open now, marked. */
107
+ current?: string | null | undefined;
108
+ onSelect: (id: string) => void;
109
+ /** Starts another; without it, no button for it. */
110
+ onNew?: (() => void) | undefined;
111
+ /** The way back to the open conversation; without it, none. */
112
+ onBack?: (() => void) | undefined;
113
+ messages?: Partial<ThreadMessages> | undefined;
114
+ classNames?: Partial<Record<ThreadPart, string>> | undefined;
115
+ }
116
+
117
+ /**
118
+ * The person's threads with the agent: each by its title, how many of the agent's messages in
119
+ * it are unread, a dot when it waits on them; the open one marked; another to start.
120
+ */
121
+ export function Threads({
122
+ threads,
123
+ current = null,
124
+ onSelect,
125
+ onNew,
126
+ onBack,
127
+ messages,
128
+ classNames,
129
+ }: ThreadsProps) {
130
+ const words = { ...THREAD_MESSAGES, ...messages };
131
+ return (
132
+ <section aria-label={words.title} part="threads" className={classes(classNames, "sc-threads")}>
133
+ <header className={classes(classNames, "sc-threads__head")}>
134
+ {onBack && (
135
+ <Button
136
+ tone="ghost"
137
+ size="sm"
138
+ icon={ArrowLeftIcon}
139
+ aria-label={words.back}
140
+ title={words.back}
141
+ onClick={onBack}
142
+ />
143
+ )}
144
+ <h3 className={classes(classNames, "sc-threads__title")}>{words.title}</h3>
145
+ {onNew && (
146
+ <Button tone="ghost" size="sm" icon={PlusIcon} onClick={onNew}>
147
+ {words.new}
148
+ </Button>
149
+ )}
150
+ </header>
151
+ {threads.length === 0 ? (
152
+ <p className={classes(classNames, "sc-threads__empty")}>{words.empty}</p>
153
+ ) : (
154
+ <ul className={classes(classNames, "sc-threads__list")}>
155
+ {threads.map((thread) => {
156
+ const unread = thread.unread ?? 0;
157
+ const open = thread.id === current;
158
+ return (
159
+ <li key={thread.id}>
160
+ <button
161
+ type="button"
162
+ aria-current={open || undefined}
163
+ className={classes(
164
+ classNames,
165
+ "sc-threads__item",
166
+ ...(open ? (["sc-threads__item--current"] as const) : []),
167
+ )}
168
+ onClick={() => {
169
+ onSelect(thread.id);
170
+ }}
171
+ >
172
+ <span className={classes(classNames, "sc-threads__name")}>
173
+ {thread.title ?? words.untitled}
174
+ </span>
175
+ {unread > 0 ? (
176
+ <span
177
+ aria-label={words.unread(unread)}
178
+ className={classes(classNames, "sc-threads__badge")}
179
+ >
180
+ {unread}
181
+ </span>
182
+ ) : (
183
+ thread.waiting_on_person === true && (
184
+ <span
185
+ role="img"
186
+ aria-label={words.waiting}
187
+ className={classes(classNames, "sc-threads__dot")}
188
+ />
189
+ )
190
+ )}
191
+ </button>
192
+ </li>
193
+ );
194
+ })}
195
+ </ul>
196
+ )}
197
+ </section>
198
+ );
199
+ }
200
+
201
+ /**
202
+ * A conversation between a person and one agent, as the API reads it (`types.ts`): one name for
203
+ * the shape and for the component that shows one.
204
+ */
205
+ export type Conversation = Thread;
206
+
207
+ export type ConversationProps = Omit<AgentChatProps, "conversationId"> & {
208
+ /** The conversation to show, by its id. */
209
+ id: string;
210
+ };
211
+
212
+ /**
213
+ * One conversation, for a host that lays out its own threads: the chat (`AgentChat`), which reads
214
+ * what comes as it shows it on a person's page, so their `unread` follows.
215
+ */
216
+ export function Conversation({ id, ...chat }: ConversationProps) {
217
+ return <AgentChat conversationId={id} {...chat} />;
218
+ }