@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.
- package/README.md +47 -21
- package/dist/AgentAbout.js +6 -2
- package/dist/AgentAbout.js.map +1 -1
- package/dist/AgentChat.d.ts +7 -1
- package/dist/AgentChat.js +154 -2
- package/dist/AgentChat.js.map +1 -1
- package/dist/AgentSpace.d.ts +12 -3
- package/dist/AgentSpace.js +53 -11
- package/dist/AgentSpace.js.map +1 -1
- package/dist/Threads.d.ts +73 -0
- package/dist/Threads.js +140 -0
- package/dist/Threads.js.map +1 -0
- package/dist/automation/marks.js +7 -3
- package/dist/automation/marks.js.map +1 -1
- package/dist/automation/messages.d.ts +4 -0
- package/dist/automation/messages.js +5 -1
- package/dist/automation/messages.js.map +1 -1
- package/dist/automation/when.js +8 -0
- package/dist/automation/when.js.map +1 -1
- package/dist/client.d.ts +14 -4
- package/dist/client.js +7 -2
- package/dist/client.js.map +1 -1
- package/dist/contract.d.ts +9 -4
- package/dist/es.d.ts +2 -0
- package/dist/es.js +22 -3
- package/dist/es.js.map +1 -1
- package/dist/frames.d.ts +7 -1
- package/dist/frames.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/surface/Form.js +1 -1
- package/dist/types.d.ts +11 -0
- package/dist/types.js.map +1 -1
- package/dist/useConversation.d.ts +2 -0
- package/dist/useConversation.js +16 -0
- package/dist/useConversation.js.map +1 -1
- package/dist/widget.js +17 -16
- package/package.json +1 -1
- package/src/AgentAbout.tsx +4 -0
- package/src/AgentChat.tsx +203 -1
- package/src/AgentSpace.tsx +89 -13
- package/src/Threads.tsx +218 -0
- package/src/automation/marks.tsx +7 -0
- package/src/automation/messages.ts +8 -0
- package/src/automation/when.ts +4 -0
- package/src/client.ts +26 -10
- package/src/contract.ts +240 -26
- package/src/es.ts +21 -0
- package/src/frames.ts +3 -1
- package/src/index.ts +9 -1
- package/src/styles.css +191 -0
- package/src/types.ts +11 -0
- package/src/useConversation.ts +10 -0
- package/src/widget/element.tsx +13 -4
- 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.
|
|
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",
|
package/src/AgentAbout.tsx
CHANGED
|
@@ -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)
|
|
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
|
package/src/AgentSpace.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
<
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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}
|
package/src/Threads.tsx
ADDED
|
@@ -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
|
+
}
|