@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
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.4",
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
@@ -1,4 +1,5 @@
1
1
  import {
2
+ ArrowsOutSimpleIcon,
2
3
  ArrowUpIcon,
3
4
  CaretRightIcon,
4
5
  CheckIcon,
@@ -7,6 +8,7 @@ import {
7
8
  MicrophoneIcon,
8
9
  PaperclipIcon,
9
10
  ShieldWarningIcon,
11
+ SidebarSimpleIcon,
10
12
  StopIcon,
11
13
  UploadSimpleIcon,
12
14
  WarningCircleIcon,
@@ -40,13 +42,18 @@ import { useConversationFiles } from "./conversationFiles";
40
42
  import { FILES_PER_MESSAGE } from "./frames";
41
43
  import { actionMark } from "./marks";
42
44
  import { AudioPlayer } from "./kit/AudioPlayer";
45
+ import { Button } from "./kit/Button";
43
46
  import { KitProvider, type KitPart } from "./kit/context";
47
+ import { Field } from "./kit/Field";
48
+ import { Checkbox, NumberInput, TextArea, TextInput } from "./kit/Inputs";
44
49
  import { carriesFiles, FileChips, UploadChips, useUploads, type Uploads } from "./kit/FileField";
45
50
  import { KIT_MESSAGES, type KitLocale, type KitMessages } from "./kit/messages";
46
51
  import type { FileHost } from "./kit/uploads";
47
52
  import { stepGlyph } from "./stepGlyphs";
48
53
  import { Markdown } from "./surface/Markdown";
49
- import type { SurfaceComponents, SurfacePart } from "./surface/scope";
54
+ import { Placed, type PlacedPart } from "./surface/Placed";
55
+ import { placeOf, type SurfacePlaces } from "./surface/places";
56
+ import type { HostBlocks, SurfaceComponents, SurfacePart } from "./surface/scope";
50
57
  import { framesItself, Surface, usePlayed } from "./surface/Surface";
51
58
  import type { SurfaceAction } from "./surface/values";
52
59
  import type {
@@ -122,6 +129,12 @@ export interface ChatMessages {
122
129
  inputs: Readonly<Record<string, string>>;
123
130
  allow: string;
124
131
  decline: string;
132
+ /** What heads the card of several actions that wait for the person's leave, by how many. */
133
+ approvals: (count: number) => string;
134
+ /** The card's one button, as many as are ticked: every one, some, or none (all declined). */
135
+ approveAll: string;
136
+ approveSome: (count: number) => string;
137
+ declineAll: string;
125
138
  /**
126
139
  * On a person's page, in place of the approval card: what the agent asked to do waits for the
127
140
  * business's yes (`decides: "business"`), which its people give from their app.
@@ -129,6 +142,13 @@ export interface ChatMessages {
129
142
  confirming: string;
130
143
  /** The line across the log where the agent handed the conversation to a person. */
131
144
  handedOff: string;
145
+ /**
146
+ * The line where the agent put what it showed in a panel or a modal, which opens it again:
147
+ * the agent's name and the surface's title ("Ray opened your week").
148
+ */
149
+ opened: (name: string, title: string) => string;
150
+ /** The button that closes a panel or a modal. */
151
+ closePlaced: string;
132
152
  /**
133
153
  * The line across the log where who answers the conversation changed (`handovers`), before
134
154
  * why: to whom (`holder`), and who changed it (`by`).
@@ -194,8 +214,14 @@ export const CHAT_MESSAGES: ChatMessages = {
194
214
  inputs: {},
195
215
  allow: "Allow",
196
216
  decline: "Decline",
217
+ approvals: (count) => `${count} actions need your approval`,
218
+ approveAll: "Approve all",
219
+ approveSome: (count) => `Approve ${count}`,
220
+ declineAll: "Decline all",
197
221
  confirming: "Waiting for the business to confirm",
198
222
  handedOff: "Handed to a person",
223
+ opened: (name, title) => `${name} opened ${title}`,
224
+ closePlaced: "Close",
199
225
  handover: (holder, by, name) => {
200
226
  const automation = by.kind === "automation" ? ` by “${by.title}”` : "";
201
227
  switch (holder) {
@@ -234,6 +260,7 @@ export type ChatPart =
234
260
  | "sc-chat"
235
261
  | "sc-chat--builder"
236
262
  | "sc-chat--console"
263
+ | "sc-chat--placed"
237
264
  | "sc-chat__log"
238
265
  | "sc-chat__log--channel"
239
266
  | "sc-chat__message"
@@ -256,10 +283,15 @@ export type ChatPart =
256
283
  | "sc-chat__step--failed"
257
284
  | "sc-chat__output"
258
285
  | "sc-chat__request"
286
+ | "sc-chat__requests"
287
+ | "sc-chat__ask"
288
+ | "sc-chat__ask-fields"
259
289
  | "sc-chat__confirming"
260
290
  | "sc-chat__screen"
261
291
  | "sc-chat__screen--bare"
262
292
  | "sc-chat__handoff"
293
+ | "sc-chat__placed"
294
+ | "sc-chat__placed--open"
263
295
  | "sc-chat__handover"
264
296
  | "sc-chat__decision"
265
297
  | "sc-chat__message--heard"
@@ -284,7 +316,7 @@ export type ChatPart =
284
316
  | "sc-chat__stop";
285
317
 
286
318
  type ClassNames =
287
- Partial<Record<ChatPart | SurfacePart | KitPart | BrowserPart, string>> | undefined;
319
+ Partial<Record<ChatPart | SurfacePart | KitPart | BrowserPart | PlacedPart, string>> | undefined;
288
320
 
289
321
  /**
290
322
  * How the chat looks. `agent` is an agent's conversation. `builder` is the builder's, v2's: the
@@ -328,6 +360,13 @@ export interface AgentChatProps {
328
360
  classNames?: ClassNames;
329
361
  /** The host's own drawing of a block type, for every surface the agent shows. */
330
362
  components?: SurfaceComponents | undefined;
363
+ /** The host's own blocks, by the name each was declared with (`PUT /v1/blocks/{name}`). */
364
+ blocks?: HostBlocks | undefined;
365
+ /**
366
+ * Where the agent may put what it shows besides the conversation: a panel beside it, a modal
367
+ * over the page (both by default). One the host doesn't allow shows in the conversation.
368
+ */
369
+ surfaces?: SurfacePlaces | undefined;
331
370
  /** The language of the surfaces' calendars, days, times and numbers; English by default. */
332
371
  locale?: KitLocale | undefined;
333
372
  /** An error's code, from the socket or `load` for a history that could not be read; the host words it. */
@@ -399,6 +438,86 @@ export interface AgentChatProps {
399
438
  onTool?: ToolHandler | undefined;
400
439
  }
401
440
 
441
+ /** Where nothing is put apart: a conversation on a channel. */
442
+ const NO_PLACES: SurfacePlaces = {};
443
+
444
+ /**
445
+ * What the person can still do on the conversation's surfaces: what they did on each, by its id
446
+ * (each draws its answer), and whether one takes input. A surface takes input while the turn
447
+ * waits on it or, once nothing runs or waits, when the agent showed it since the person last
448
+ * spoke; older ones are read, not used. On a channel the person answered there: here it reads.
449
+ */
450
+ export function surfacesOf(
451
+ live: Pick<LiveConversation, "messages" | "pending" | "awaiting">,
452
+ onChannel: boolean,
453
+ ): {
454
+ answers: ReadonlyMap<string, Action>;
455
+ /** Whether a reply at `index` of the messages is since the person last spoke, all settled. */
456
+ current: (index: number) => boolean;
457
+ /** Whether the surface `id`, of the reply at `index`, takes input. */
458
+ takes: (id: string, index: number) => boolean;
459
+ } {
460
+ const { messages, pending, awaiting } = live;
461
+ const idle = !onChannel && !pending && awaiting === null;
462
+ const lastSaid = messages.findLastIndex((message) => message.role === "user");
463
+ const answers = new Map<string, Action>();
464
+ for (const message of messages) {
465
+ if (message.action) answers.set(message.action.surface, message.action);
466
+ }
467
+ const current = (index: number) => idle && index > lastSaid;
468
+ return {
469
+ answers,
470
+ current,
471
+ takes: (id, index) => (!onChannel && id === awaiting) || current(index),
472
+ };
473
+ }
474
+
475
+ /** A step that drew a surface, by its id: the surface, and the index of its reply. */
476
+ function placedStep(
477
+ messages: readonly Message[],
478
+ id: string,
479
+ ): { step: Step; surface: Shown; index: number } | null {
480
+ for (const [index, message] of messages.entries()) {
481
+ const step = message.steps.find((each) => each.id === id);
482
+ if (step?.surface) return { step, surface: step.surface, index };
483
+ }
484
+ return null;
485
+ }
486
+
487
+ /**
488
+ * The surface open in a panel or a modal, by its step's id: each one the agent puts apart opens
489
+ * as it comes, and a conversation that opens waiting on one opens it; one read with the history
490
+ * waits for its line. Closing it, or opening another, is the person's.
491
+ */
492
+ function useOpenedPlace(
493
+ live: Pick<LiveConversation, "messages" | "loading" | "awaiting">,
494
+ places: SurfacePlaces | undefined,
495
+ ): [string | null, (id: string | null) => void] {
496
+ const [opened, setOpened] = useState<string | null>(null);
497
+ const ids = live.messages
498
+ .flatMap((message) => message.steps)
499
+ .filter((step) => step.surface && placeOf(step.surface, places) !== "chat")
500
+ .map((step) => step.id);
501
+ const seen = useRef<Set<string> | null>(null);
502
+ const key = ids.join(" ");
503
+ const { loading, awaiting } = live;
504
+ useEffect(() => {
505
+ if (loading) return;
506
+ const known = seen.current;
507
+ const placed = key === "" ? [] : key.split(" ");
508
+ if (known === null) {
509
+ seen.current = new Set(placed);
510
+ if (awaiting !== null && placed.includes(awaiting)) setOpened(awaiting);
511
+ return;
512
+ }
513
+ const fresh = placed.filter((id) => !known.has(id));
514
+ for (const id of fresh) known.add(id);
515
+ const newest = fresh.at(-1);
516
+ if (newest !== undefined) setOpened(newest);
517
+ }, [key, loading, awaiting]);
518
+ return [opened, setOpened];
519
+ }
520
+
402
521
  /** How close to the foot, in pixels, still counts as being at it (v2's). */
403
522
  const NEAR_FOOT = 80;
404
523
 
@@ -433,6 +552,8 @@ function Chat({
433
552
  messages,
434
553
  classNames,
435
554
  components,
555
+ blocks,
556
+ surfaces,
436
557
  locale,
437
558
  onError,
438
559
  actions,
@@ -460,6 +581,7 @@ function Chat({
460
581
  onTool: onTool ?? (() => Promise.reject(new Error(words.noTool))),
461
582
  });
462
583
  const files = useConversationFiles(client, conversationId, live.messages, onOpenFile);
584
+ useReadAsShown(client, conversationId, live.messages.at(-1)?.id);
463
585
  // What the person attaches to their next message: picked, dropped on the chat, or pasted.
464
586
  const uploads = useUploads(attachments ? files : undefined, { max: FILES_PER_MESSAGE });
465
587
  // Files go with what is said, once the turn waits on nothing; to the host's `write`, at once.
@@ -474,6 +596,11 @@ function Chat({
474
596
  const { pending } = live;
475
597
  // A conversation on a channel: its messages say who said them.
476
598
  const onChannel = live.messages.some((message) => message.author != null);
599
+ // What the agent put in a panel or a modal, where the host allows it (on a channel, nothing:
600
+ // it went there in the conversation); the one open now.
601
+ const places = onChannel ? NO_PLACES : surfaces;
602
+ const [opened, setOpened] = useOpenedPlace(live, places);
603
+ const open = opened === null ? null : placedStep(live.messages, opened);
477
604
 
478
605
  // The builder stops on Esc from anywhere on the page, as v2's did; an agent's chat, from inside it.
479
606
  const stop = useRef(live.stop);
@@ -493,6 +620,8 @@ function Chat({
493
620
 
494
621
  const looks: ChatPart[] = builder ? ["sc-chat--builder"] : [];
495
622
  if (look === "console") looks.push("sc-chat--console");
623
+ // A panel open makes room for itself at the chat's right.
624
+ if (open !== null && placeOf(open.surface, places) === "panel") looks.push("sc-chat--placed");
496
625
  const stepsShown = steps === "shown";
497
626
 
498
627
  return (
@@ -554,6 +683,10 @@ function Chat({
554
683
  words={words}
555
684
  classNames={classNames}
556
685
  components={components}
686
+ blocks={blocks}
687
+ places={places}
688
+ opened={opened}
689
+ onOpen={setOpened}
557
690
  locale={locale}
558
691
  following={following}
559
692
  took={took}
@@ -565,6 +698,35 @@ function Chat({
565
698
  steps={stepsShown}
566
699
  />
567
700
  )}
701
+ {open !== null && (
702
+ <Placed
703
+ key={open.step.id}
704
+ where={placeOf(open.surface, places) === "modal" ? "modal" : "panel"}
705
+ title={open.surface.title ?? ""}
706
+ closeLabel={words.closePlaced}
707
+ onClose={() => {
708
+ setOpened(null);
709
+ }}
710
+ classNames={classNames}
711
+ >
712
+ <Surface
713
+ surface={open.surface}
714
+ active={surfacesOf(live, onChannel).takes(open.step.id, open.index)}
715
+ answered={surfacesOf(live, onChannel).answers.get(open.step.id)}
716
+ components={components}
717
+ blocks={blocks}
718
+ classNames={classNames}
719
+ messages={words.kit}
720
+ locale={locale}
721
+ files={files}
722
+ onAction={(action) => {
723
+ live.act({ surface: open.step.id, ...action });
724
+ // A modal is for now: once used, the conversation goes on under it.
725
+ if (placeOf(open.surface, places) === "modal") setOpened(null);
726
+ }}
727
+ />
728
+ </Placed>
729
+ )}
568
730
  {/* The browser the agent drives, over the composer while the conversation has one. */}
569
731
  <BrowserView live={live} name={name} messages={words.browser} classNames={classNames} />
570
732
  {live.pending && (
@@ -748,8 +910,17 @@ function Log({
748
910
  handovers,
749
911
  who,
750
912
  steps,
913
+ blocks,
914
+ places,
915
+ opened,
916
+ onOpen,
751
917
  }: PartProps & {
752
918
  components: SurfaceComponents | undefined;
919
+ blocks: HostBlocks | undefined;
920
+ places: SurfacePlaces | undefined;
921
+ /** The surface open in a panel or a modal, and the way to open one from its line. */
922
+ opened: string | null;
923
+ onOpen: (id: string) => void;
753
924
  locale: KitLocale | undefined;
754
925
  following: RefObject<boolean>;
755
926
  took: number | null;
@@ -811,16 +982,7 @@ function Log({
811
982
  };
812
983
  }, [following]);
813
984
 
814
- // A surface takes input while the turn waits on it or, once nothing runs or waits, when the
815
- // agent showed it since the person last spoke; older ones are read, not used. On a channel the
816
- // person answered it there: here it only reads.
817
- const idle = !onChannel && !pending && awaiting === null;
818
- const lastSaid = messages.findLastIndex((message) => message.role === "user");
819
- // What the person did on each surface, by the surface's id: a surface draws its answer.
820
- const answers = new Map<string, Action>();
821
- for (const message of messages) {
822
- if (message.action) answers.set(message.action.surface, message.action);
823
- }
985
+ const { answers, current } = surfacesOf(live, onChannel);
824
986
  // Drawn, who answers says where the agent handed it over; otherwise its `escalate` step does.
825
987
  const handed = handovers === undefined ? handoffs(messages) : NO_HANDOFFS;
826
988
  const placed = handoversAt(messages, handovers ?? []);
@@ -877,14 +1039,19 @@ function Log({
877
1039
  message={message}
878
1040
  from={carried.current?.id === message.id ? carried.current.from : undefined}
879
1041
  awaiting={onChannel ? null : awaiting}
880
- current={idle && index > lastSaid}
1042
+ current={current(index)}
881
1043
  answers={answers}
882
1044
  name={name}
883
1045
  words={words}
884
1046
  classNames={classNames}
885
1047
  components={components}
1048
+ blocks={blocks}
1049
+ places={places}
1050
+ opened={opened}
1051
+ onOpen={onOpen}
886
1052
  locale={locale}
887
1053
  onAction={live.act}
1054
+ onActAll={live.actAll}
888
1055
  builder={builder}
889
1056
  files={files}
890
1057
  who={who}
@@ -1234,8 +1401,19 @@ function Reply({
1234
1401
  who,
1235
1402
  steps,
1236
1403
  onChannel,
1404
+ onActAll,
1405
+ blocks,
1406
+ places,
1407
+ opened,
1408
+ onOpen,
1237
1409
  }: DrawProps & {
1410
+ blocks: HostBlocks | undefined;
1411
+ places: SurfacePlaces | undefined;
1412
+ opened: string | null;
1413
+ onOpen: (id: string) => void;
1238
1414
  message: Message;
1415
+ /** Several things done at once: a yes or no on each call it waits on, from one card. */
1416
+ onActAll: (actions: readonly Action[]) => void;
1239
1417
  /** It was streaming: its words go on revealing from here. None: they show whole. */
1240
1418
  from: number | undefined;
1241
1419
  awaiting: string | null;
@@ -1251,6 +1429,11 @@ function Reply({
1251
1429
  onChannel: boolean;
1252
1430
  }) {
1253
1431
  const text = useReveal(message.text, from ?? message.text.length);
1432
+ // The calls it waits on the leave of whoever this page speaks for: two or more, in one card.
1433
+ const asked = message.steps.filter(
1434
+ (step): step is Step & { request: Request } =>
1435
+ step.request !== null && !(step.request.decides === "business" && who === "person"),
1436
+ );
1254
1437
  // On a channel, what the person there has got so far; elsewhere, the words as they come.
1255
1438
  const { shown: said, typing } = onChannel
1256
1439
  ? channelParts(message, text)
@@ -1310,35 +1493,71 @@ function Reply({
1310
1493
  ))}
1311
1494
  </ol>
1312
1495
  )}
1313
- {message.steps.map(
1314
- (step) =>
1315
- step.surface && (
1316
- <div
1496
+ {message.steps.map((step) => {
1497
+ if (!step.surface) return null;
1498
+ const place = placeOf(step.surface, places);
1499
+ if (place !== "chat") {
1500
+ const Glyph = place === "panel" ? SidebarSimpleIcon : ArrowsOutSimpleIcon;
1501
+ return (
1502
+ <button
1317
1503
  key={step.id}
1318
- part="surface"
1504
+ type="button"
1505
+ part="placed"
1506
+ aria-expanded={opened === step.id}
1507
+ onClick={() => {
1508
+ onOpen(step.id);
1509
+ }}
1319
1510
  className={classes(
1320
1511
  classNames,
1321
- "sc-chat__screen",
1322
- ...(framesItself(step.surface) ? (["sc-chat__screen--bare"] as const) : []),
1512
+ "sc-chat__placed",
1513
+ ...(opened === step.id ? (["sc-chat__placed--open"] as const) : []),
1323
1514
  )}
1324
1515
  >
1325
- <Surface
1326
- surface={step.surface}
1327
- active={step.id === awaiting || current}
1328
- answered={answers.get(step.id)}
1329
- components={components}
1330
- classNames={classNames}
1331
- messages={words.kit}
1332
- locale={locale}
1333
- files={files}
1334
- onAction={(action) => onAction({ surface: step.id, ...action })}
1335
- />
1336
- </div>
1337
- ),
1516
+ <Glyph aria-hidden />
1517
+ <span>{words.opened(name, step.surface.title ?? "")}</span>
1518
+ </button>
1519
+ );
1520
+ }
1521
+ return (
1522
+ <div
1523
+ key={step.id}
1524
+ part="surface"
1525
+ className={classes(
1526
+ classNames,
1527
+ "sc-chat__screen",
1528
+ ...(framesItself(step.surface) ? (["sc-chat__screen--bare"] as const) : []),
1529
+ )}
1530
+ >
1531
+ <Surface
1532
+ surface={step.surface}
1533
+ active={step.id === awaiting || current}
1534
+ answered={answers.get(step.id)}
1535
+ components={components}
1536
+ blocks={blocks}
1537
+ classNames={classNames}
1538
+ messages={words.kit}
1539
+ locale={locale}
1540
+ files={files}
1541
+ onAction={(action) => onAction({ surface: step.id, ...action })}
1542
+ />
1543
+ </div>
1544
+ );
1545
+ })}
1546
+ {asked.length > 1 && (
1547
+ <Approvals
1548
+ key={asked.map((step) => step.id).join()}
1549
+ steps={asked}
1550
+ name={name}
1551
+ words={words}
1552
+ classNames={classNames}
1553
+ onActAll={onActAll}
1554
+ />
1338
1555
  )}
1339
1556
  {message.steps.map((step) => {
1340
1557
  const { request } = step;
1341
- if (request === null) return null;
1558
+ if (request === null || (asked.length > 1 && asked.some((one) => one.id === step.id))) {
1559
+ return null;
1560
+ }
1342
1561
  if (request.decides === "business" && who === "person") {
1343
1562
  return (
1344
1563
  <p
@@ -1583,6 +1802,170 @@ function Approval({
1583
1802
  );
1584
1803
  }
1585
1804
 
1805
+ /**
1806
+ * Several actions a reply waits on the person's leave for, in one card: a box to tick for each
1807
+ * (all ticked to begin with), what each would take under it, editable while ticked, and one
1808
+ * button that sends a yes for each ticked (with what was changed) and a no for the rest, at once.
1809
+ */
1810
+ function Approvals({
1811
+ steps,
1812
+ name,
1813
+ words,
1814
+ classNames,
1815
+ onActAll,
1816
+ }: {
1817
+ steps: readonly (Step & { request: Request })[];
1818
+ name: string;
1819
+ words: ChatMessages;
1820
+ classNames: ClassNames;
1821
+ onActAll: (actions: readonly Action[]) => void;
1822
+ }) {
1823
+ const [chosen, setChosen] = useState<ReadonlySet<string>>(
1824
+ () => new Set(steps.map((step) => step.id)),
1825
+ );
1826
+ const [edits, setEdits] = useState<Readonly<Record<string, Record<string, unknown>>>>({});
1827
+ const [sent, setSent] = useState(false);
1828
+ const inputOf = (step: Step & { request: Request }) => ({
1829
+ ...step.request.input,
1830
+ ...edits[step.id],
1831
+ });
1832
+ const send = () => {
1833
+ setSent(true);
1834
+ onActAll(
1835
+ steps.map((step) =>
1836
+ chosen.has(step.id)
1837
+ ? {
1838
+ surface: step.id,
1839
+ name: "approve",
1840
+ data: step.id in edits ? { input: inputOf(step) } : {},
1841
+ label: words.allow,
1842
+ }
1843
+ : { surface: step.id, name: "decline", data: {}, label: words.decline },
1844
+ ),
1845
+ );
1846
+ };
1847
+ const label =
1848
+ chosen.size === steps.length
1849
+ ? words.approveAll
1850
+ : chosen.size === 0
1851
+ ? words.declineAll
1852
+ : words.approveSome(chosen.size);
1853
+ return (
1854
+ <div part="approval" className={classes(classNames, "sc-chat__request", "sc-chat__requests")}>
1855
+ <header>
1856
+ <ShieldWarningIcon aria-hidden />
1857
+ {words.approvals(steps.length)}
1858
+ </header>
1859
+ <ul>
1860
+ {steps.map((step) => {
1861
+ const ticked = chosen.has(step.id);
1862
+ return (
1863
+ <li key={step.id} className={classes(classNames, "sc-chat__ask")}>
1864
+ <Checkbox
1865
+ value={ticked}
1866
+ disabled={sent}
1867
+ onChange={(on) => {
1868
+ setChosen((last) => {
1869
+ const next = new Set(last);
1870
+ if (on) next.add(step.id);
1871
+ else next.delete(step.id);
1872
+ return next;
1873
+ });
1874
+ }}
1875
+ >
1876
+ {words.request(name, step.request.action)}
1877
+ </Checkbox>
1878
+ {ticked && (
1879
+ <div className={classes(classNames, "sc-chat__ask-fields")}>
1880
+ {Object.entries(inputOf(step)).map(([field, value]) => (
1881
+ <InputValue
1882
+ key={field}
1883
+ label={words.inputs[field] ?? field}
1884
+ value={value}
1885
+ disabled={sent}
1886
+ onChange={(changed) => {
1887
+ setEdits((last) => ({
1888
+ ...last,
1889
+ [step.id]: { ...last[step.id], [field]: changed },
1890
+ }));
1891
+ }}
1892
+ />
1893
+ ))}
1894
+ </div>
1895
+ )}
1896
+ </li>
1897
+ );
1898
+ })}
1899
+ </ul>
1900
+ <footer>
1901
+ <Button tone="primary" disabled={sent} onClick={send}>
1902
+ {label}
1903
+ </Button>
1904
+ </footer>
1905
+ </div>
1906
+ );
1907
+ }
1908
+
1909
+ /**
1910
+ * One field of an action's input, editable as what it holds: words, a number, a yes or no;
1911
+ * anything else (a list, an object) shown as it is.
1912
+ */
1913
+ function InputValue({
1914
+ label,
1915
+ value,
1916
+ disabled,
1917
+ onChange,
1918
+ }: {
1919
+ label: string;
1920
+ value: unknown;
1921
+ disabled: boolean;
1922
+ onChange: (value: unknown) => void;
1923
+ }) {
1924
+ if (typeof value === "boolean") {
1925
+ return (
1926
+ <Checkbox value={value} disabled={disabled} onChange={onChange}>
1927
+ {label}
1928
+ </Checkbox>
1929
+ );
1930
+ }
1931
+ return (
1932
+ <Field label={label}>
1933
+ {(ids) =>
1934
+ typeof value === "number" ? (
1935
+ <NumberInput {...ids} value={value} disabled={disabled} onChange={onChange} />
1936
+ ) : typeof value === "string" ? (
1937
+ value.length > 80 || value.includes("\n") ? (
1938
+ <TextArea {...ids} value={value} disabled={disabled} onChange={onChange} />
1939
+ ) : (
1940
+ <TextInput {...ids} value={value} disabled={disabled} onChange={onChange} />
1941
+ )
1942
+ ) : (
1943
+ <TextInput {...ids} value={JSON.stringify(value)} disabled onChange={() => undefined} />
1944
+ )
1945
+ }
1946
+ </Field>
1947
+ );
1948
+ }
1949
+
1950
+ /**
1951
+ * On a person's page, what came is read as the chat shows it: each newest message while the page
1952
+ * is in sight, and once it comes back into sight. Their threads' `unread` follows.
1953
+ */
1954
+ function useReadAsShown(client: Client, conversationId: string, newest: string | undefined) {
1955
+ useEffect(() => {
1956
+ if (client.who !== "person" || newest === undefined) return undefined;
1957
+ const read = () => {
1958
+ if (document.visibilityState === "hidden") return;
1959
+ client.conversations.read(conversationId).catch(() => undefined);
1960
+ };
1961
+ read();
1962
+ document.addEventListener("visibilitychange", read);
1963
+ return () => {
1964
+ document.removeEventListener("visibilitychange", read);
1965
+ };
1966
+ }, [client, conversationId, newest]);
1967
+ }
1968
+
1586
1969
  /**
1587
1970
  * Where the person writes: a box that grows a few lines with the text. Enter sends, Shift+Enter
1588
1971
  * breaks the line; nothing goes while a reply is on its way or the socket is closed, but the