@shubh90/app-runtime 0.4.1 → 0.6.0

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 (48) hide show
  1. package/dist/auth/core.d.ts +12 -7
  2. package/dist/auth/core.js +9 -11
  3. package/dist/auth/db.js +10 -0
  4. package/dist/auth/index.d.ts +4 -4
  5. package/dist/auth/index.js +6 -6
  6. package/dist/auth/lookup.d.ts +23 -12
  7. package/dist/auth/lookup.js +85 -24
  8. package/dist/auth/plaza.d.ts +30 -10
  9. package/dist/auth/plaza.js +65 -21
  10. package/dist/auth/routes.js +1 -1
  11. package/dist/auth/session.d.ts +3 -2
  12. package/dist/auth/session.js +5 -6
  13. package/dist/chat/index.d.ts +20 -0
  14. package/dist/chat/index.js +132 -0
  15. package/dist/chat/origins.d.ts +9 -0
  16. package/dist/chat/origins.js +37 -0
  17. package/dist/chat/paths.d.ts +2 -0
  18. package/dist/chat/paths.js +2 -0
  19. package/dist/chat/server.d.ts +16 -0
  20. package/dist/chat/server.js +167 -0
  21. package/dist/chat/thread.d.ts +27 -0
  22. package/dist/chat/thread.js +59 -0
  23. package/dist/chat/ui/activity.d.ts +12 -0
  24. package/dist/chat/ui/activity.js +57 -0
  25. package/dist/chat/ui/attachments.d.ts +9 -0
  26. package/dist/chat/ui/attachments.js +50 -0
  27. package/dist/chat/ui/index.d.ts +5 -0
  28. package/dist/chat/ui/index.js +6 -0
  29. package/dist/chat/ui/message-body.d.ts +8 -0
  30. package/dist/chat/ui/message-body.js +57 -0
  31. package/dist/chat/ui/styles.css +109 -0
  32. package/dist/chat/ui/styles.d.ts +16 -0
  33. package/dist/chat/ui/styles.js +126 -0
  34. package/dist/chat/ui/types.d.ts +47 -0
  35. package/dist/chat/ui/types.js +1 -0
  36. package/dist/chat/widget/api.d.ts +34 -0
  37. package/dist/chat/widget/api.js +112 -0
  38. package/dist/chat/widget/app.d.ts +11 -0
  39. package/dist/chat/widget/app.js +304 -0
  40. package/dist/chat/widget/hooks.d.ts +86 -0
  41. package/dist/chat/widget/hooks.js +312 -0
  42. package/dist/chat/widget/icons.d.ts +13 -0
  43. package/dist/chat/widget/icons.js +13 -0
  44. package/dist/chat/widget/styles.d.ts +1 -0
  45. package/dist/chat/widget/styles.js +233 -0
  46. package/dist/chat/widget/thread.d.ts +33 -0
  47. package/dist/chat/widget/thread.js +27 -0
  48. package/package.json +50 -11
@@ -0,0 +1,304 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
+ import { avatarUrl } from "./api.js";
4
+ import { readStorage, useMediaQuery, outgoing, useSeen, useStagedFiles, useThread, writeStorage } from "./hooks.js";
5
+ import { ArrowUp, Sparkle, Check, ChevronDown, Close, Collapse, Expand, Paperclip } from "./icons.js";
6
+ import { ActivityCard, MessageAttachments, MessageBody } from "../ui/index.js";
7
+ import { toMillis } from "../thread.js";
8
+ import { latestCreatedAt, unreadFrom } from "./thread.js";
9
+ const AGENT_KEY = "mii-chat:agent";
10
+ const OPEN_KEY = "mii-chat:open";
11
+ export function ChatApp({ agents, brand }) {
12
+ const [agent, setAgent] = useState(() => {
13
+ const remembered = readStorage(AGENT_KEY);
14
+ return agents.agents.find((candidate) => candidate.id === remembered) ?? agents.agents[0];
15
+ });
16
+ const [open, setOpen] = useState(() => readSession(OPEN_KEY) === "1");
17
+ const [expanded, setExpanded] = useState(false);
18
+ const [picking, setPicking] = useState(false);
19
+ const mobile = useMediaQuery("(max-width: 640px)");
20
+ const thread = useThread(agent.id, open);
21
+ const [seen, markSeen] = useSeen(agent.id);
22
+ useEffect(() => {
23
+ writeSession(OPEN_KEY, open ? "1" : "0");
24
+ }, [open]);
25
+ // What is on screen while the panel is open has been seen. The first load
26
+ // ever marks the history seen, so only replies from here on badge.
27
+ useEffect(() => {
28
+ if (!thread.loaded)
29
+ return;
30
+ if (open || seen.kind === "never") {
31
+ markSeen(latestCreatedAt(thread.messages));
32
+ }
33
+ }, [open, thread.loaded, thread.messages, seen, markSeen]);
34
+ const unread = open ? [] : unreadFrom(thread.messages, agents.viewerId, seen);
35
+ const latestUnread = unread[unread.length - 1];
36
+ const choose = (next) => {
37
+ setAgent(next);
38
+ writeStorage(AGENT_KEY, next.id);
39
+ setPicking(false);
40
+ };
41
+ useBodyLock(open && mobile);
42
+ const viewport = useVisualViewport(open && mobile);
43
+ const brandStyle = {
44
+ ...(brand.brand === null ? {} : { "--brand": brand.brand }),
45
+ ...(brand.foreground === null ? {} : { "--brand-fg": brand.foreground })
46
+ };
47
+ return (_jsxs("div", { className: "root", style: brandStyle, "data-theme": brand.dark ? "dark" : "light", children: [!open && latestUnread !== undefined ? (_jsxs("button", { type: "button", className: "nudge", onClick: () => setOpen(true), children: [_jsx(Avatar, { agent: agent, size: 30 }), _jsxs("span", { children: [_jsx("span", { className: "who", children: agent.name }), _jsx("span", { className: "text", children: latestUnread.body || describeAttachments(latestUnread.attachments) })] })] })) : null, open ? (_jsx(Panel, { agents: agents, agent: agent, thread: thread, expanded: expanded, mobile: mobile, picking: picking, viewport: viewport, onPick: () => setPicking((value) => !value), onChoose: choose, onExpand: () => setExpanded((value) => !value), onClose: () => {
48
+ setOpen(false);
49
+ setPicking(false);
50
+ } })) : null, open && (mobile || expanded) ? null : (_jsxs("button", { type: "button", className: "launcher", "aria-label": open ? "Close chat" : "Chat with your agents", "aria-expanded": open, onClick: () => setOpen((value) => !value), children: [open ? (_jsx("span", { className: "chevron", children: _jsx(ChevronDown, { size: 26 }) })) : (_jsx(Sparkle, { size: 28 })), !open && unread.length > 0 ? _jsx("span", { className: "badge", children: unread.length }) : null] }))] }));
51
+ }
52
+ function Panel(props) {
53
+ const { agent, thread, mobile, expanded, picking } = props;
54
+ const staged = useStagedFiles();
55
+ const [draft, setDraft] = useState("");
56
+ const [dragging, setDragging] = useState(false);
57
+ const textarea = useRef(null);
58
+ useEffect(() => {
59
+ if (!mobile)
60
+ textarea.current?.focus();
61
+ }, [agent.id, mobile]);
62
+ useLayoutEffect(() => {
63
+ const element = textarea.current;
64
+ if (element === null)
65
+ return;
66
+ element.style.height = "auto";
67
+ element.style.height = `${element.scrollHeight}px`;
68
+ }, [draft]);
69
+ const next = outgoing(staged.files, draft);
70
+ const canSend = next !== null;
71
+ const submit = () => {
72
+ if (next === null)
73
+ return;
74
+ thread.send(next.body, next.attachments);
75
+ setDraft("");
76
+ staged.clear();
77
+ };
78
+ const onKeyDown = (event) => {
79
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
80
+ event.preventDefault();
81
+ submit();
82
+ }
83
+ };
84
+ const onPaste = (event) => {
85
+ const files = [...event.clipboardData.files];
86
+ if (files.length > 0) {
87
+ event.preventDefault();
88
+ staged.add(files);
89
+ }
90
+ };
91
+ const dropHandlers = {
92
+ onDragEnter: (event) => {
93
+ if (event.dataTransfer.types.includes("Files"))
94
+ setDragging(true);
95
+ },
96
+ onDragOver: (event) => {
97
+ if (event.dataTransfer.types.includes("Files"))
98
+ event.preventDefault();
99
+ },
100
+ onDragLeave: (event) => {
101
+ if (event.currentTarget === event.target)
102
+ setDragging(false);
103
+ },
104
+ onDrop: (event) => {
105
+ event.preventDefault();
106
+ setDragging(false);
107
+ staged.add([...event.dataTransfer.files]);
108
+ }
109
+ };
110
+ const className = ["panel", mobile ? "mobile" : expanded ? "expanded" : ""].join(" ").trim();
111
+ const style = props.viewport === null
112
+ ? undefined
113
+ : { height: `${props.viewport.height}px`, top: `${props.viewport.top}px`, bottom: "auto" };
114
+ return (_jsxs("section", { className: className, style: style, role: "dialog", "aria-label": `Chat with ${agent.name}`, onKeyDown: (event) => {
115
+ if (event.key === "Escape") {
116
+ event.stopPropagation();
117
+ if (picking)
118
+ props.onPick();
119
+ else
120
+ props.onClose();
121
+ }
122
+ }, ...dropHandlers, children: [_jsxs("header", { className: "header", children: [_jsxs("button", { type: "button", className: "agent-button", "aria-haspopup": "listbox", "aria-expanded": picking, onClick: props.onPick, children: [_jsx(Avatar, { agent: agent, size: 36 }), _jsxs("span", { className: "agent-name", children: [agent.name, _jsx(ChevronDown, { size: 16 })] })] }), mobile ? null : (_jsx("button", { type: "button", className: "icon-button", "aria-label": expanded ? "Shrink chat" : "Expand chat", onClick: props.onExpand, children: expanded ? _jsx(Collapse, { size: 17 }) : _jsx(Expand, { size: 17 }) })), _jsx("button", { type: "button", className: "icon-button", "aria-label": "Close chat", onClick: props.onClose, children: mobile ? _jsx(ChevronDown, { size: 20 }) : _jsx(Close, { size: 18 }) })] }), picking ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "picker-scrim", onClick: props.onPick }), _jsxs("div", { className: "picker", role: "listbox", "aria-label": "Choose an agent", children: [_jsx("div", { className: "picker-title", children: "Message an agent" }), props.agents.agents.map((candidate) => (_jsxs("button", { type: "button", role: "option", className: "option", "aria-selected": candidate.id === agent.id, onClick: () => props.onChoose(candidate), children: [_jsx(Avatar, { agent: candidate, size: 34 }), _jsxs("span", { className: "option-text", children: [_jsxs("span", { className: "option-name", children: [candidate.name, candidate.id === props.agents.agents[0].id ? _jsx("span", { className: "tag", children: "Default" }) : null] }), candidate.description ? (_jsx("span", { className: "option-description", children: candidate.description })) : null] }), candidate.id === agent.id ? _jsx(Check, { size: 18 }) : null] }, candidate.id)))] })] })) : null, _jsx(Thread, { agent: agent, viewerId: props.agents.viewerId, thread: thread }), thread.error !== null && thread.loaded ? _jsx("div", { className: "notice", children: thread.error }) : null, _jsxs("div", { className: "composer", children: [staged.files.map((file) => file.status === "failed" ? (_jsxs("div", { className: "upload-problem", role: "alert", children: [_jsx("strong", { children: file.name }), " ", file.problem, " Remove it to send."] }, file.localId)) : null), staged.files.length > 0 ? (_jsx("div", { className: "staged", children: staged.files.map((file) => file.preview !== null ? (_jsxs("span", { className: `thumb ${file.status}`, children: [_jsx("img", { src: file.preview, alt: file.name }), file.status === "uploading" ? _jsx("span", { className: "spinner", "aria-label": "Uploading" }) : null, _jsx("button", { type: "button", "aria-label": `Remove ${file.name}`, onClick: () => staged.remove(file.localId), children: _jsx(Close, { size: 11 }) })] }, file.localId)) : (_jsxs("span", { className: file.status === "failed" ? "chip failed" : "chip", children: [file.status === "uploading" ? _jsx("span", { className: "spinner", "aria-label": "Uploading" }) : null, _jsx("span", { children: file.name }), _jsx("button", { type: "button", "aria-label": `Remove ${file.name}`, onClick: () => staged.remove(file.localId), children: _jsx(Close, { size: 12 }) })] }, file.localId))) })) : null, _jsxs("div", { className: "box", children: [_jsxs("label", { className: "visually-hidden", htmlFor: "mii-chat-composer", children: ["Message ", agent.name] }), _jsx("textarea", { id: "mii-chat-composer", ref: textarea, rows: 1, placeholder: `Message ${agent.name}…`, value: draft, onChange: (event) => setDraft(event.target.value), onKeyDown: onKeyDown, onPaste: onPaste }), _jsx(FilePicker, { onPick: staged.add }), _jsx("button", { type: "button", className: "send", "aria-label": "Send", disabled: !canSend, onClick: submit, children: _jsx(ArrowUp, { size: 18 }) })] })] }), dragging ? _jsx("div", { className: "drop", children: "Drop to attach" }) : null] }));
123
+ }
124
+ function FilePicker({ onPick }) {
125
+ const input = useRef(null);
126
+ return (_jsxs(_Fragment, { children: [_jsx("input", { ref: input, type: "file", multiple: true, hidden: true, onChange: (event) => {
127
+ onPick([...(event.target.files ?? [])]);
128
+ event.target.value = "";
129
+ } }), _jsx("button", { type: "button", className: "icon-button", style: { width: 36, height: 36 }, "aria-label": "Attach files", onClick: () => input.current?.click(), children: _jsx(Paperclip, { size: 19 }) })] }));
130
+ }
131
+ function Thread({ agent, viewerId, thread }) {
132
+ const scroller = useRef(null);
133
+ const stick = useRef(true);
134
+ const items = useMemo(() => layout(thread.messages, thread.pending, viewerId), [thread.messages, thread.pending, viewerId]);
135
+ useLayoutEffect(() => {
136
+ const element = scroller.current;
137
+ if (element !== null && stick.current)
138
+ element.scrollTop = element.scrollHeight;
139
+ }, [items, thread.activity]);
140
+ const onScroll = useCallback(() => {
141
+ const element = scroller.current;
142
+ if (element === null)
143
+ return;
144
+ stick.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
145
+ }, []);
146
+ return (_jsxs("div", { className: "thread", ref: scroller, onScroll: onScroll, "aria-live": "polite", "aria-busy": !thread.loaded, children: [!thread.loaded ? (_jsx("div", { className: "loading", children: thread.error === null ? _jsx("span", { className: "spinner", "aria-label": "Loading the conversation" }) : thread.error })) : null, thread.loaded && items.length === 0 ? (_jsxs("div", { className: "empty", children: [_jsx(Avatar, { agent: agent, size: 48 }), _jsxs("div", { className: "empty-title", children: ["Ask ", agent.name, " about this app"] }), _jsxs("div", { className: "empty-text", children: ["Report a bug, ask for a change, or ask how something works. It's the same conversation you have with", " ", agent.name, " in Plaza."] })] })) : null, items.map((item) => item.kind === "day" ? (_jsx("div", { className: "day", children: item.label }, item.key)) : (_jsx(MessageRow, { item: item, agent: agent, retry: thread.retry, discard: thread.discard }, item.key))), thread.activity !== null ? _jsx(ActivityCard, { group: thread.activity, showAgent: false }) : null] }));
147
+ }
148
+ function MessageRow({ item, agent, retry, discard }) {
149
+ const { message, mine } = item;
150
+ const pending = "localId" in message ? message : null;
151
+ const content = (_jsxs(_Fragment, { children: [_jsx(MessageAttachments, { attachments: message.attachments, align: mine ? "end" : "start" }), message.body.trim() !== "" ? (_jsx("div", { className: "bubble", children: mine ? _jsx("p", { children: message.body }) : _jsx(MessageBody, { body: message.body }) })) : null] }));
152
+ if (mine) {
153
+ return (_jsxs("div", { className: item.groupStart ? "row mine group-start" : "row mine", children: [content, pending?.status === "sending" ? _jsx("span", { className: "meta", children: "Sending\u2026" }) : null, pending?.status === "failed" ? (_jsxs("span", { className: "meta failed", children: [pending.problem, " \u00B7 ", _jsx("button", { type: "button", onClick: () => retry(pending.localId), children: "Retry" }), " \u00B7", " ", _jsx("button", { type: "button", onClick: () => discard(pending.localId), children: "Discard" })] })) : null, pending === null && item.groupEnd ? _jsx("span", { className: "meta", children: timeOf(message.createdAt) }) : null] }));
154
+ }
155
+ return (_jsxs("div", { className: item.groupStart ? "row theirs group-start" : "row theirs", children: [_jsx("span", { className: "avatar-slot", children: item.groupEnd ? _jsx(Avatar, { agent: agent, size: 26 }) : null }), _jsxs("div", { className: "stack", children: [content, item.groupEnd ? _jsx("span", { className: "meta", children: timeOf(message.createdAt) }) : null] })] }));
156
+ }
157
+ /** The face the agent wears in Plaza: its photo, or the one Plaza draws for it. */
158
+ function Avatar({ agent, size }) {
159
+ const src = useAvatar(agent.id);
160
+ return (_jsx("span", { className: "avatar", style: { width: size, height: size }, "aria-hidden": "true", children: src === null ? null : _jsx("img", { src: src, alt: "", width: size, height: size }) }));
161
+ }
162
+ // Fetched rather than set as an <img> src: a Vite dev server — which is what an
163
+ // agent's live copy of the app runs — answers image requests to unknown paths
164
+ // itself, before they reach the app's routes. One fetch per agent per page.
165
+ const avatars = new Map();
166
+ function useAvatar(agentId) {
167
+ const [src, setSrc] = useState(null);
168
+ useEffect(() => {
169
+ let current = true;
170
+ let load = avatars.get(agentId);
171
+ if (load === undefined) {
172
+ load = fetch(avatarUrl(agentId), { credentials: "same-origin" }).then(async (response) => {
173
+ if (!response.ok)
174
+ throw new Error(`avatar answered ${response.status}`);
175
+ return URL.createObjectURL(await response.blob());
176
+ });
177
+ avatars.set(agentId, load);
178
+ load.catch(() => avatars.delete(agentId));
179
+ }
180
+ load.then((url) => {
181
+ if (current)
182
+ setSrc(url);
183
+ }, (error) => console.error("mii-chat: couldn't load an avatar", error));
184
+ return () => {
185
+ current = false;
186
+ };
187
+ }, [agentId]);
188
+ return src;
189
+ }
190
+ // Messages from one sender within a few minutes of each other read as one
191
+ // group: one avatar, one timestamp, at its end.
192
+ const GROUP_GAP_MS = 5 * 60 * 1000;
193
+ function layout(messages, pending, viewerId) {
194
+ const entries = [
195
+ ...messages.map((message) => ({ message, mine: message.fromMiiId === viewerId, at: toMillis(message.createdAt) })),
196
+ ...pending.map((message) => ({ message, mine: true, at: Date.now() }))
197
+ ];
198
+ const items = [];
199
+ let lastDay = "";
200
+ entries.forEach((entry, index) => {
201
+ const day = new Date(entry.at).toDateString();
202
+ if (day !== lastDay) {
203
+ items.push({ kind: "day", key: `day-${day}`, label: dayLabel(entry.at) });
204
+ lastDay = day;
205
+ }
206
+ const previous = entries[index - 1];
207
+ const next = entries[index + 1];
208
+ const joinsPrevious = previous !== undefined && previous.mine === entry.mine && entry.at - previous.at < GROUP_GAP_MS &&
209
+ new Date(previous.at).toDateString() === day;
210
+ const joinsNext = next !== undefined && next.mine === entry.mine && next.at - entry.at < GROUP_GAP_MS &&
211
+ new Date(next.at).toDateString() === day;
212
+ items.push({
213
+ kind: "message",
214
+ key: "localId" in entry.message ? entry.message.localId : entry.message.id,
215
+ mine: entry.mine,
216
+ groupStart: !joinsPrevious,
217
+ groupEnd: !joinsNext,
218
+ message: entry.message
219
+ });
220
+ });
221
+ return items;
222
+ }
223
+ function timeOf(stamp) {
224
+ return new Date(toMillis(stamp)).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
225
+ }
226
+ function dayLabel(at) {
227
+ const date = new Date(at);
228
+ const today = new Date();
229
+ const yesterday = new Date(today);
230
+ yesterday.setDate(today.getDate() - 1);
231
+ if (date.toDateString() === today.toDateString())
232
+ return "Today";
233
+ if (date.toDateString() === yesterday.toDateString())
234
+ return "Yesterday";
235
+ return date.toLocaleDateString([], { month: "short", day: "numeric", year: date.getFullYear() === today.getFullYear() ? undefined : "numeric" });
236
+ }
237
+ function describeAttachments(attachments) {
238
+ if (attachments.length === 0)
239
+ return "";
240
+ return attachments.length === 1 ? `Sent ${attachments[0].name ?? "a file"}` : `Sent ${attachments.length} files`;
241
+ }
242
+ /**
243
+ * On a phone the chat fills the screen, and the page behind it must not scroll
244
+ * under a finger. iOS ignores overflow:hidden on body, so pin the body where it
245
+ * was and put it back on close.
246
+ */
247
+ function useBodyLock(active) {
248
+ useEffect(() => {
249
+ if (!active)
250
+ return;
251
+ const { body } = document;
252
+ const scrollY = window.scrollY;
253
+ const previous = { position: body.style.position, top: body.style.top, width: body.style.width };
254
+ body.style.position = "fixed";
255
+ body.style.top = `-${scrollY}px`;
256
+ body.style.width = "100%";
257
+ return () => {
258
+ body.style.position = previous.position;
259
+ body.style.top = previous.top;
260
+ body.style.width = previous.width;
261
+ window.scrollTo(0, scrollY);
262
+ };
263
+ }, [active]);
264
+ }
265
+ /**
266
+ * iOS Safari does not resize the layout for the on-screen keyboard; it shrinks
267
+ * only the visual viewport, so a full-screen panel's composer ends up under the
268
+ * keyboard. Follow the visual viewport instead.
269
+ */
270
+ function useVisualViewport(active) {
271
+ const [size, setSize] = useState(null);
272
+ useEffect(() => {
273
+ const viewport = window.visualViewport;
274
+ if (!active || viewport === null) {
275
+ setSize(null);
276
+ return;
277
+ }
278
+ const update = () => setSize({ height: viewport.height, top: viewport.offsetTop });
279
+ update();
280
+ viewport.addEventListener("resize", update);
281
+ viewport.addEventListener("scroll", update);
282
+ return () => {
283
+ viewport.removeEventListener("resize", update);
284
+ viewport.removeEventListener("scroll", update);
285
+ };
286
+ }, [active]);
287
+ return size;
288
+ }
289
+ function readSession(key) {
290
+ try {
291
+ return window.sessionStorage.getItem(key);
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ }
297
+ function writeSession(key, value) {
298
+ try {
299
+ window.sessionStorage.setItem(key, value);
300
+ }
301
+ catch {
302
+ // Storage blocked: the panel just starts closed after a reload.
303
+ }
304
+ }
@@ -0,0 +1,86 @@
1
+ import { type ChatAgents } from "./api.js";
2
+ import { type ChatActivity, type ChatAttachment, type ChatMessage, type Seen } from "./thread.js";
3
+ export declare const OPEN_POLL_MS = 3000;
4
+ export declare const CLOSED_POLL_MS = 30000;
5
+ export type AgentsState = {
6
+ readonly status: "loading";
7
+ } | {
8
+ readonly status: "ready";
9
+ readonly data: ChatAgents;
10
+ } | {
11
+ readonly status: "unavailable";
12
+ };
13
+ /**
14
+ * The org's agents. "unavailable" when this person has no chat here — signed
15
+ * out, removed from the org, or chat turned off for the app — in which case the
16
+ * widget shows nothing at all.
17
+ */
18
+ export declare function useAgents(): AgentsState;
19
+ /** 5s, 10s, 20s, 40s, then every minute. */
20
+ export declare function agentsRetryDelay(attempt: number): number;
21
+ export type PendingMessage = {
22
+ readonly localId: string;
23
+ /** Who it is for: a failed send stays with its agent while you look at another. */
24
+ readonly agentId: string;
25
+ readonly body: string;
26
+ readonly attachments: readonly ChatAttachment[];
27
+ readonly createdAt: string;
28
+ } & ({
29
+ readonly status: "sending";
30
+ }
31
+ /** Why it failed, as Plaza put it. */
32
+ | {
33
+ readonly status: "failed";
34
+ readonly problem: string;
35
+ });
36
+ /** Signed out, or out of the org: polling again on a timer cannot change that. */
37
+ export declare function endsPolling(error: unknown): boolean;
38
+ export type ThreadState = {
39
+ readonly loaded: boolean;
40
+ readonly messages: readonly ChatMessage[];
41
+ readonly pending: readonly PendingMessage[];
42
+ readonly activity: ChatActivity | null;
43
+ readonly error: string | null;
44
+ send(body: string, attachments: readonly ChatAttachment[]): void;
45
+ retry(localId: string): void;
46
+ discard(localId: string): void;
47
+ };
48
+ export declare function useThread(agentId: string, open: boolean): ThreadState;
49
+ export type StagedFile = {
50
+ readonly localId: string;
51
+ readonly name: string;
52
+ /** An image's own pixels, shown while it uploads and after. */
53
+ readonly preview: string | null;
54
+ } & ({
55
+ readonly status: "uploading";
56
+ } | {
57
+ readonly status: "ready";
58
+ readonly attachment: ChatAttachment;
59
+ }
60
+ /** Why it failed, as Plaza put it. */
61
+ | {
62
+ readonly status: "failed";
63
+ readonly problem: string;
64
+ });
65
+ /**
66
+ * What the next message would carry, or null when it cannot go yet: a file
67
+ * still uploading, or one that failed and has not been removed — a message is
68
+ * never sent quietly missing a file its author attached.
69
+ */
70
+ export declare function outgoing(files: readonly StagedFile[], draft: string): {
71
+ readonly body: string;
72
+ readonly attachments: readonly ChatAttachment[];
73
+ } | null;
74
+ /** Files picked for the next message, uploaded the moment they are picked. */
75
+ export declare function useStagedFiles(): {
76
+ readonly files: readonly StagedFile[];
77
+ add(files: readonly File[]): void;
78
+ remove(localId: string): void;
79
+ clear(): void;
80
+ };
81
+ /** "Seen up to" per agent, kept in this browser so the badge survives a reload. */
82
+ export declare function useSeen(agentId: string): readonly [Seen, (at: string | null) => void];
83
+ export declare function readStorage(key: string): string | null;
84
+ export declare function writeStorage(key: string, value: string): void;
85
+ /** Whether a CSS media query matches, kept current. */
86
+ export declare function useMediaQuery(query: string): boolean;