@signalcore/ui 1.0.0-beta.1 → 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 (138) hide show
  1. package/README.md +55 -22
  2. package/dist/AgentAbout.js +11 -4
  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/AgentTasks.js +2 -1
  11. package/dist/AgentTasks.js.map +1 -1
  12. package/dist/Threads.d.ts +73 -0
  13. package/dist/Threads.js +140 -0
  14. package/dist/Threads.js.map +1 -0
  15. package/dist/VoiceCall.js +1 -1
  16. package/dist/VoiceCall.js.map +1 -1
  17. package/dist/about.d.ts +34 -144
  18. package/dist/about.js +6 -8
  19. package/dist/about.js.map +1 -1
  20. package/dist/automation/cron.d.ts +1 -1
  21. package/dist/automation/cron.js.map +1 -1
  22. package/dist/automation/marks.js +13 -5
  23. package/dist/automation/marks.js.map +1 -1
  24. package/dist/automation/messages.d.ts +6 -0
  25. package/dist/automation/messages.js +7 -1
  26. package/dist/automation/messages.js.map +1 -1
  27. package/dist/automation/types.d.ts +25 -128
  28. package/dist/automation/types.js +36 -0
  29. package/dist/automation/types.js.map +1 -0
  30. package/dist/automation/when.d.ts +1 -1
  31. package/dist/automation/when.js +13 -1
  32. package/dist/automation/when.js.map +1 -1
  33. package/dist/blocks.d.ts +23 -1
  34. package/dist/browser.js +2 -2
  35. package/dist/browser.js.map +1 -1
  36. package/dist/call.d.ts +1 -1
  37. package/dist/call.js.map +1 -1
  38. package/dist/client.d.ts +14 -4
  39. package/dist/client.js +7 -2
  40. package/dist/client.js.map +1 -1
  41. package/dist/contract.d.ts +267 -0
  42. package/dist/credentials.js.map +1 -1
  43. package/dist/dev.js +1 -1
  44. package/dist/dev.js.map +1 -1
  45. package/dist/es.d.ts +2 -0
  46. package/dist/es.js +31 -4
  47. package/dist/es.js.map +1 -1
  48. package/dist/frames.d.ts +7 -1
  49. package/dist/frames.js.map +1 -1
  50. package/dist/index.d.ts +6 -5
  51. package/dist/index.js +6 -4
  52. package/dist/kit/AudioPlayer.js +1 -1
  53. package/dist/kit/AudioPlayer.js.map +1 -1
  54. package/dist/kit/Choices.js +1 -1
  55. package/dist/kit/Choices.js.map +1 -1
  56. package/dist/kit/FileField.js +1 -1
  57. package/dist/kit/FileField.js.map +1 -1
  58. package/dist/kit/Popover.js.map +1 -1
  59. package/dist/kit/Tile.d.ts +1 -1
  60. package/dist/kit/Tile.js +1 -1
  61. package/dist/kit/Tile.js.map +1 -1
  62. package/dist/kit/messages.d.ts +13 -0
  63. package/dist/kit/messages.js +7 -1
  64. package/dist/kit/messages.js.map +1 -1
  65. package/dist/kit/uploads.d.ts +2 -1
  66. package/dist/kit/uploads.js +1 -1
  67. package/dist/kit/uploads.js.map +1 -1
  68. package/dist/kit/waveform.js.map +1 -1
  69. package/dist/markdown.js.map +1 -1
  70. package/dist/marks.js +2 -3
  71. package/dist/marks.js.map +1 -1
  72. package/dist/surface/Form.js +1 -1
  73. package/dist/surface/Markdown.js +1 -1
  74. package/dist/surface/Markdown.js.map +1 -1
  75. package/dist/surface/Surface.js +43 -4
  76. package/dist/surface/Surface.js.map +1 -1
  77. package/dist/surface/scope.d.ts +1 -1
  78. package/dist/surface/scope.js.map +1 -1
  79. package/dist/surface/values.js +1 -1
  80. package/dist/surface/values.js.map +1 -1
  81. package/dist/tint.d.ts +1 -1
  82. package/dist/tint.js +1 -1
  83. package/dist/tint.js.map +1 -1
  84. package/dist/types.d.ts +11 -0
  85. package/dist/types.js.map +1 -1
  86. package/dist/useConversation.d.ts +2 -0
  87. package/dist/useConversation.js +16 -0
  88. package/dist/useConversation.js.map +1 -1
  89. package/dist/usePolled.js +1 -1
  90. package/dist/usePolled.js.map +1 -1
  91. package/dist/voice/webgl.js +1 -1
  92. package/dist/voice/webgl.js.map +1 -1
  93. package/dist/widget.js +17 -16
  94. package/package.json +1 -1
  95. package/src/AgentAbout.tsx +14 -3
  96. package/src/AgentChat.tsx +203 -1
  97. package/src/AgentSpace.tsx +89 -13
  98. package/src/AgentTasks.tsx +2 -2
  99. package/src/Threads.tsx +218 -0
  100. package/src/VoiceCall.tsx +1 -1
  101. package/src/about.ts +42 -144
  102. package/src/automation/cron.ts +1 -1
  103. package/src/automation/marks.tsx +13 -2
  104. package/src/automation/messages.ts +12 -0
  105. package/src/automation/types.ts +58 -118
  106. package/src/automation/when.ts +7 -1
  107. package/src/blocks.ts +25 -0
  108. package/src/browser.ts +4 -4
  109. package/src/call.ts +1 -1
  110. package/src/client.ts +26 -10
  111. package/src/contract.ts +1934 -0
  112. package/src/credentials.ts +2 -2
  113. package/src/dev.ts +1 -1
  114. package/src/es.ts +29 -0
  115. package/src/frames.ts +3 -1
  116. package/src/index.ts +10 -1
  117. package/src/kit/AudioPlayer.tsx +1 -1
  118. package/src/kit/Choices.tsx +1 -1
  119. package/src/kit/FileField.tsx +1 -1
  120. package/src/kit/Popover.tsx +1 -1
  121. package/src/kit/Tile.tsx +1 -1
  122. package/src/kit/messages.ts +20 -0
  123. package/src/kit/uploads.ts +2 -2
  124. package/src/kit/waveform.ts +1 -1
  125. package/src/markdown.ts +1 -1
  126. package/src/marks.tsx +3 -123
  127. package/src/styles.css +206 -0
  128. package/src/surface/Markdown.tsx +1 -1
  129. package/src/surface/Surface.tsx +53 -4
  130. package/src/surface/scope.ts +3 -1
  131. package/src/surface/values.ts +5 -5
  132. package/src/tint.ts +1 -1
  133. package/src/types.ts +11 -0
  134. package/src/useConversation.ts +10 -0
  135. package/src/usePolled.ts +2 -2
  136. package/src/voice/webgl.ts +1 -1
  137. package/src/widget/element.tsx +13 -4
  138. 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.1",
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",
@@ -150,7 +150,14 @@ export const ABOUT_MESSAGES: AboutMessages = {
150
150
  does: "What it does on its own",
151
151
  noAutomations: "Nothing on its own yet: it answers when someone writes to it.",
152
152
  paused: "Paused",
153
- kinds: { schedule: "At a time", webhook: "When called", event: "On an event" },
153
+ kinds: {
154
+ schedule: "At a time",
155
+ webhook: "When called",
156
+ event: "On an event",
157
+ platform: "On a message",
158
+ manual: "When started",
159
+ after: "After another",
160
+ },
154
161
  topics: {
155
162
  apps: "Apps",
156
163
  tools: "Tools",
@@ -934,11 +941,15 @@ function Gone({ classNames, children }: { classNames: ClassNames; children: Reac
934
941
  return <p className={classes(classNames, "sc-about__empty")}>{children}</p>;
935
942
  }
936
943
 
937
- /** An automation's mark says its kind: a time, a call from another system, an event. */
944
+ /** An automation's mark says its kind: a time, a call from another system, an event, a
945
+ * message. */
938
946
  const KIND_ICONS: Readonly<Record<AutomationKind, Icon>> = {
939
947
  schedule: "clock",
940
948
  webhook: "code",
941
949
  event: "bell",
950
+ platform: "message",
951
+ manual: "hand",
952
+ after: "check",
942
953
  };
943
954
 
944
955
  /** An app's mark: the host's logo when it draws one, else the provider's, bare. */
@@ -1080,7 +1091,7 @@ function Automations({
1080
1091
  <Row
1081
1092
  key={automation.id}
1082
1093
  mark={
1083
- automation.provider === undefined ? (
1094
+ automation.provider === undefined || automation.provider === null ? (
1084
1095
  <Mark icon={GLYPHS[KIND_ICONS[automation.kind]]} />
1085
1096
  ) : (
1086
1097
  appMark(
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}
@@ -2,7 +2,7 @@ import { ArrowLeftIcon, TrashIcon } from "@phosphor-icons/react";
2
2
  import { useCallback, useEffect, useRef, useState } from "react";
3
3
 
4
4
  import { Automation } from "./automation/Automation";
5
- import type { Task } from "./automation/types";
5
+ import { taskCard, type Task } from "./automation/types";
6
6
  import { classes } from "./classes";
7
7
  import type { Client } from "./client";
8
8
  import { Button } from "./kit/Button";
@@ -139,7 +139,7 @@ export function AgentTasks({
139
139
  {tasks.map((task) => (
140
140
  <Automation
141
141
  key={task.id}
142
- automation={task}
142
+ automation={taskCard(task)}
143
143
  runs={task.runs}
144
144
  switching={busy === task.id}
145
145
  onSwitch={(on) => void change(task, () => client.tasks.switch(agentId, task.id, on))}