@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,312 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { ChatRequestError, fetchAgents, fetchThread, sendMessage, uploadFile } from "./api.js";
3
+ import { mergeMessages, pollCursor, toMillis } from "../thread.js";
4
+ import { advanceSeen } from "./thread.js";
5
+ // A thread is read the way Plaza's own chat reads it: the opening screenful,
6
+ // then only what changed. Open, that is often enough to feel live; closed, it
7
+ // is only watching for a reply to badge.
8
+ export const OPEN_POLL_MS = 3000;
9
+ export const CLOSED_POLL_MS = 30000;
10
+ /**
11
+ * The org's agents. "unavailable" when this person has no chat here — signed
12
+ * out, removed from the org, or chat turned off for the app — in which case the
13
+ * widget shows nothing at all.
14
+ */
15
+ export function useAgents() {
16
+ const [state, setState] = useState({ status: "loading" });
17
+ useEffect(() => {
18
+ let cancelled = false;
19
+ let timer;
20
+ const load = (attempt) => {
21
+ fetchAgents().then((data) => {
22
+ if (cancelled)
23
+ return;
24
+ const [first, ...rest] = data.agents;
25
+ setState(first === undefined
26
+ ? { status: "unavailable" }
27
+ : { status: "ready", data: { viewerId: data.viewerId, agents: [first, ...rest] } });
28
+ }, (error) => {
29
+ if (cancelled)
30
+ return;
31
+ // No chat for this person here: that is an answer, not a failure.
32
+ if (error instanceof ChatRequestError && (error.status === 401 || error.status === 403)) {
33
+ setState({ status: "unavailable" });
34
+ return;
35
+ }
36
+ // Anything else is a moment's trouble; try again, backing off.
37
+ console.error("mii-chat: couldn't load agents", error);
38
+ timer = setTimeout(() => load(attempt + 1), agentsRetryDelay(attempt));
39
+ });
40
+ };
41
+ load(0);
42
+ return () => {
43
+ cancelled = true;
44
+ clearTimeout(timer);
45
+ };
46
+ }, []);
47
+ return state;
48
+ }
49
+ /** 5s, 10s, 20s, 40s, then every minute. */
50
+ export function agentsRetryDelay(attempt) {
51
+ return Math.min(5000 * 2 ** attempt, 60000);
52
+ }
53
+ /** Signed out, or out of the org: polling again on a timer cannot change that. */
54
+ export function endsPolling(error) {
55
+ return error instanceof ChatRequestError && (error.status === 401 || error.status === 403);
56
+ }
57
+ export function useThread(agentId, open) {
58
+ const [polled, setPolled] = useState([]);
59
+ // Messages Plaza accepted from a send, shown until a poll returns them. They
60
+ // stay out of the poll cursor: a send can land after a reply the last poll
61
+ // has not seen yet, and a cursor moved past it would never fetch that reply.
62
+ const [sent, setSent] = useState([]);
63
+ const [pending, setPending] = useState([]);
64
+ const [activity, setActivity] = useState(null);
65
+ const [loaded, setLoaded] = useState(false);
66
+ const [error, setError] = useState(null);
67
+ const polledRef = useRef([]);
68
+ const openRef = useRef(open);
69
+ const pollNow = useRef(() => undefined);
70
+ const agentRef = useRef(agentId);
71
+ openRef.current = open;
72
+ agentRef.current = agentId;
73
+ useEffect(() => {
74
+ let cancelled = false;
75
+ let timer;
76
+ let inFlight = false;
77
+ let again = false;
78
+ polledRef.current = [];
79
+ setPolled([]);
80
+ setSent([]);
81
+ setActivity(null);
82
+ setLoaded(false);
83
+ setError(null);
84
+ const poll = async () => {
85
+ // One poll at a time: asking again mid-flight runs once more after it,
86
+ // rather than starting a second loop that never stops.
87
+ if (inFlight) {
88
+ again = true;
89
+ return;
90
+ }
91
+ inFlight = true;
92
+ let ended = false;
93
+ clearTimeout(timer);
94
+ try {
95
+ const thread = await fetchThread(agentId, pollCursor(polledRef.current));
96
+ if (cancelled)
97
+ return;
98
+ const merged = mergeMessages(polledRef.current, thread.messages);
99
+ polledRef.current = merged;
100
+ setPolled(merged);
101
+ setSent((current) => current.filter((message) => !merged.some((m) => m.id === message.id)));
102
+ setActivity(thread.activity);
103
+ setLoaded(true);
104
+ setError(null);
105
+ }
106
+ catch (caught) {
107
+ if (cancelled)
108
+ return;
109
+ if (caught instanceof ChatRequestError) {
110
+ setError(caught.message);
111
+ // Signed out or out of the org: no timer. Opening the panel, the tab
112
+ // or a send still asks, so signing in again brings the chat back.
113
+ ended = endsPolling(caught);
114
+ }
115
+ else {
116
+ console.error("mii-chat: couldn't load the thread", caught);
117
+ setError("Couldn't reach the chat. Try again in a moment.");
118
+ }
119
+ }
120
+ finally {
121
+ inFlight = false;
122
+ }
123
+ if (cancelled || ended)
124
+ return;
125
+ if (again) {
126
+ again = false;
127
+ void poll();
128
+ return;
129
+ }
130
+ const visible = typeof document === "undefined" || document.visibilityState === "visible";
131
+ timer = setTimeout(() => void poll(), openRef.current && visible ? OPEN_POLL_MS : CLOSED_POLL_MS);
132
+ };
133
+ pollNow.current = () => void poll();
134
+ void poll();
135
+ return () => {
136
+ cancelled = true;
137
+ clearTimeout(timer);
138
+ };
139
+ }, [agentId]);
140
+ // Opening the panel, or coming back to the tab, should not wait out a slow poll.
141
+ useEffect(() => {
142
+ if (open)
143
+ pollNow.current();
144
+ }, [open]);
145
+ useEffect(() => {
146
+ const onVisible = () => {
147
+ if (document.visibilityState === "visible")
148
+ pollNow.current();
149
+ };
150
+ document.addEventListener("visibilitychange", onVisible);
151
+ return () => document.removeEventListener("visibilitychange", onVisible);
152
+ }, []);
153
+ const deliver = useCallback((item) => {
154
+ sendMessage(item.agentId, {
155
+ body: item.body,
156
+ attachments: item.attachments,
157
+ pageUrl: window.location.href
158
+ }).then((message) => {
159
+ // The person moved to another agent meanwhile: this thread is not on
160
+ // screen, and switching back reads it from Plaza.
161
+ setPending((current) => current.filter((p) => p.localId !== item.localId));
162
+ if (agentRef.current !== item.agentId)
163
+ return;
164
+ setSent((current) => [...current, message]);
165
+ pollNow.current();
166
+ }, (caught) => {
167
+ const problem = caught instanceof ChatRequestError ? caught.message : "Couldn't reach the chat. Try again in a moment.";
168
+ if (!(caught instanceof ChatRequestError))
169
+ console.error("mii-chat: send failed", caught);
170
+ setPending((current) => current.map((p) => (p.localId === item.localId ? { ...p, status: "failed", problem } : p)));
171
+ });
172
+ }, []);
173
+ const send = useCallback((body, attachments) => {
174
+ const item = {
175
+ localId: `local-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
176
+ agentId,
177
+ body,
178
+ attachments,
179
+ status: "sending",
180
+ createdAt: new Date().toISOString()
181
+ };
182
+ setPending((current) => [...current, item]);
183
+ deliver(item);
184
+ }, [agentId, deliver]);
185
+ const retry = useCallback((localId) => {
186
+ const item = pending.find((p) => p.localId === localId);
187
+ if (item === undefined)
188
+ return;
189
+ const { localId: id, agentId: to, body, attachments, createdAt } = item;
190
+ const again = { localId: id, agentId: to, body, attachments, createdAt, status: "sending" };
191
+ setPending((current) => current.map((p) => (p.localId === localId ? again : p)));
192
+ deliver(again);
193
+ }, [deliver, pending]);
194
+ const discard = useCallback((localId) => {
195
+ setPending((current) => current.filter((p) => p.localId !== localId));
196
+ }, []);
197
+ const messages = useMemo(() => mergeMessages(polled, sent), [polled, sent]);
198
+ const shown = useMemo(() => pending.filter((p) => p.agentId === agentId), [pending, agentId]);
199
+ return { loaded, messages, pending: shown, activity, error, send, retry, discard };
200
+ }
201
+ /**
202
+ * What the next message would carry, or null when it cannot go yet: a file
203
+ * still uploading, or one that failed and has not been removed — a message is
204
+ * never sent quietly missing a file its author attached.
205
+ */
206
+ export function outgoing(files, draft) {
207
+ const attachments = [];
208
+ for (const file of files) {
209
+ if (file.status !== "ready")
210
+ return null;
211
+ attachments.push(file.attachment);
212
+ }
213
+ const body = draft.trim();
214
+ return body === "" && attachments.length === 0 ? null : { body, attachments };
215
+ }
216
+ /** Files picked for the next message, uploaded the moment they are picked. */
217
+ export function useStagedFiles() {
218
+ const [files, setFiles] = useState([]);
219
+ const previews = useRef(new Map());
220
+ const release = (localId) => {
221
+ const url = previews.current.get(localId);
222
+ if (url !== undefined) {
223
+ URL.revokeObjectURL(url);
224
+ previews.current.delete(localId);
225
+ }
226
+ };
227
+ useEffect(() => {
228
+ const held = previews.current;
229
+ return () => {
230
+ for (const url of held.values())
231
+ URL.revokeObjectURL(url);
232
+ held.clear();
233
+ };
234
+ }, []);
235
+ const add = useCallback((picked) => {
236
+ for (const file of picked) {
237
+ const localId = `file-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
238
+ const preview = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
239
+ if (preview !== null)
240
+ previews.current.set(localId, preview);
241
+ setFiles((current) => [
242
+ ...current,
243
+ { localId, name: file.name, preview, status: "uploading" }
244
+ ]);
245
+ uploadFile(file).then((attachment) => setFiles((current) => current.map((f) => (f.localId === localId ? { ...f, status: "ready", attachment } : f))), (caught) => {
246
+ const problem = caught instanceof ChatRequestError ? caught.message : "Couldn't upload it.";
247
+ if (!(caught instanceof ChatRequestError))
248
+ console.error("mii-chat: upload failed", caught);
249
+ setFiles((current) => current.map((f) => (f.localId === localId ? { ...f, status: "failed", problem } : f)));
250
+ });
251
+ }
252
+ }, []);
253
+ const remove = useCallback((localId) => {
254
+ release(localId);
255
+ setFiles((current) => current.filter((f) => f.localId !== localId));
256
+ }, []);
257
+ const clear = useCallback(() => {
258
+ for (const localId of [...previews.current.keys()])
259
+ release(localId);
260
+ setFiles([]);
261
+ }, []);
262
+ return { files, add, remove, clear };
263
+ }
264
+ /** "Seen up to" per agent, kept in this browser so the badge survives a reload. */
265
+ export function useSeen(agentId) {
266
+ const key = `mii-chat:seen:${agentId}`;
267
+ const [seen, setSeen] = useState(() => readSeen(key));
268
+ useEffect(() => {
269
+ setSeen(readSeen(key));
270
+ }, [key]);
271
+ const mark = useCallback((at) => {
272
+ setSeen((current) => {
273
+ const next = advanceSeen(current, at);
274
+ if (next !== current && next.kind === "upTo")
275
+ writeStorage(key, JSON.stringify(next.at));
276
+ return next;
277
+ });
278
+ }, [key]);
279
+ return [seen, mark];
280
+ }
281
+ function readSeen(key) {
282
+ const stored = readStorage(key);
283
+ return stored === null ? { kind: "never" } : { kind: "upTo", at: JSON.parse(stored) };
284
+ }
285
+ export function readStorage(key) {
286
+ try {
287
+ return window.localStorage.getItem(key);
288
+ }
289
+ catch {
290
+ return null;
291
+ }
292
+ }
293
+ export function writeStorage(key, value) {
294
+ try {
295
+ window.localStorage.setItem(key, value);
296
+ }
297
+ catch {
298
+ // Storage blocked (privacy mode): the badge just forgets across reloads.
299
+ }
300
+ }
301
+ /** Whether a CSS media query matches, kept current. */
302
+ export function useMediaQuery(query) {
303
+ const [matches, setMatches] = useState(() => typeof window !== "undefined" && window.matchMedia(query).matches);
304
+ useEffect(() => {
305
+ const list = window.matchMedia(query);
306
+ const update = () => setMatches(list.matches);
307
+ update();
308
+ list.addEventListener("change", update);
309
+ return () => list.removeEventListener("change", update);
310
+ }, [query]);
311
+ return matches;
312
+ }
@@ -0,0 +1,13 @@
1
+ type IconProps = {
2
+ readonly size?: number;
3
+ };
4
+ /** The launcher's mark: a four-point sparkle, for the agents on the other end. */
5
+ export declare const Sparkle: ({ size }: IconProps) => import("react").JSX.Element;
6
+ export declare const ChevronDown: (props: IconProps) => import("react").JSX.Element;
7
+ export declare const Close: (props: IconProps) => import("react").JSX.Element;
8
+ export declare const Expand: (props: IconProps) => import("react").JSX.Element;
9
+ export declare const Collapse: (props: IconProps) => import("react").JSX.Element;
10
+ export declare const Paperclip: (props: IconProps) => import("react").JSX.Element;
11
+ export declare const ArrowUp: (props: IconProps) => import("react").JSX.Element;
12
+ export declare const Check: (props: IconProps) => import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ function Icon({ size = 18, children }) {
3
+ return (_jsx("svg", { className: "i", width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", children: children }));
4
+ }
5
+ /** The launcher's mark: a four-point sparkle, for the agents on the other end. */
6
+ export const Sparkle = ({ size = 26 }) => (_jsx("svg", { className: "solid", width: size, height: size, viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { fill: "currentColor", d: "M12 2.2c.25 0 .47.17.53.42l.96 3.9a4.4 4.4 0 0 0 3.2 3.2l3.9.96a.55.55 0 0 1 0 1.06l-3.9.96a4.4 4.4 0 0 0-3.2 3.2l-.96 3.9a.55.55 0 0 1-1.06 0l-.96-3.9a4.4 4.4 0 0 0-3.2-3.2l-3.9-.96a.55.55 0 0 1 0-1.06l3.9-.96a4.4 4.4 0 0 0 3.2-3.2l.96-3.9A.55.55 0 0 1 12 2.2Z" }) }));
7
+ export const ChevronDown = (props) => (_jsx(Icon, { ...props, children: _jsx("path", { d: "M6 9l6 6 6-6" }) }));
8
+ export const Close = (props) => (_jsxs(Icon, { ...props, children: [_jsx("path", { d: "M18 6L6 18" }), _jsx("path", { d: "M6 6l12 12" })] }));
9
+ export const Expand = (props) => (_jsxs(Icon, { ...props, children: [_jsx("path", { d: "M15 3h6v6" }), _jsx("path", { d: "M9 21H3v-6" }), _jsx("path", { d: "M21 3l-7 7" }), _jsx("path", { d: "M3 21l7-7" })] }));
10
+ export const Collapse = (props) => (_jsxs(Icon, { ...props, children: [_jsx("path", { d: "M4 14h6v6" }), _jsx("path", { d: "M20 10h-6V4" }), _jsx("path", { d: "M14 10l7-7" }), _jsx("path", { d: "M3 21l7-7" })] }));
11
+ export const Paperclip = (props) => (_jsx(Icon, { ...props, children: _jsx("path", { d: "M21.4 11.1l-9.2 9.2a6 6 0 0 1-8.5-8.5l9.2-9.2a4 4 0 0 1 5.7 5.7l-9.2 9.2a2 2 0 0 1-2.8-2.8l8.5-8.5" }) }));
12
+ export const ArrowUp = (props) => (_jsxs(Icon, { ...props, children: [_jsx("path", { d: "M12 19V5" }), _jsx("path", { d: "M5 12l7-7 7 7" })] }));
13
+ export const Check = (props) => (_jsx(Icon, { ...props, children: _jsx("path", { d: "M5 12l5 5L20 7" }) }));
@@ -0,0 +1 @@
1
+ export declare const CHAT_CSS = "\n:host { all: initial; }\n* { box-sizing: border-box; }\n.root {\n --brand: #16181c;\n --brand-fg: #ffffff;\n --ink: #16181c;\n --ink-mid: #5b616b;\n --ink-lo: #7c828c;\n --surface: #ffffff;\n --raised: #ffffff;\n --line: #e8eaed;\n --bubble: #f1f2f4;\n --hover: #f4f5f7;\n --chip: #f4f5f7;\n --field: #ffffff;\n --field-ring: #dfe2e6;\n --scrim: rgba(16,18,24,.06);\n --danger: #c92a3d;\n --danger-soft: #fdecee;\n --panel-shadow: 0 0 0 1px rgba(16,18,24,.06), 0 6px 16px -6px rgba(16,18,24,.18), 0 28px 60px -24px rgba(16,18,24,.35);\n --pop-shadow: 0 0 0 1px rgba(16,18,24,.07), 0 12px 32px -10px rgba(16,18,24,.3);\n --launcher-shadow:\n inset 0 1px 0 rgba(255,255,255,.35),\n inset 0 -2px 3px rgba(0,0,0,.14),\n 0 0 0 1px rgba(16,18,24,.08),\n 0 2px 4px rgba(16,18,24,.16),\n 0 10px 24px -8px rgba(16,18,24,.38);\n --ease: cubic-bezier(0.32, 0.72, 0, 1);\n --mii-ink: var(--ink);\n --mii-ink-mid: var(--ink-mid);\n --mii-ink-lo: var(--ink-lo);\n --mii-surface: var(--raised);\n --mii-surface-2: var(--chip);\n --mii-line: var(--line);\n --mii-link: var(--brand);\n --mii-running: #0b83c4;\n --mii-danger: var(--danger);\n --mii-code-bg: rgba(127,127,127,.16);\n --mii-code-keyword: #7e22ce;\n --mii-code-string: #047857;\n --mii-code-number: #b45309;\n --mii-code-title: #1d4ed8;\n --mii-code-builtin: #0e7490;\n --mii-attachment-width: 240px;\n --mii-activity-ground: var(--surface);\n font-family: Inter, ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\n font-size: 13.5px;\n line-height: 1.45;\n color: var(--ink);\n -webkit-font-smoothing: antialiased;\n}\n.root[data-theme=\"dark\"] {\n --ink: #eceef1;\n --ink-mid: #a9aeb7;\n --ink-lo: #858b95;\n --surface: #17191d;\n --raised: #202328;\n --line: #2a2d33;\n --bubble: #25282e;\n --hover: #25282e;\n --chip: #25282e;\n --field: #1c1e23;\n --field-ring: #34383f;\n --scrim: rgba(0,0,0,.35);\n --danger: #ff7d90;\n --danger-soft: #3a1c22;\n --mii-running: #4cb8f0;\n --mii-code-keyword: #d8b4fe;\n --mii-code-string: #6ee7b7;\n --mii-code-number: #fcd34d;\n --mii-code-title: #93c5fd;\n --mii-code-builtin: #67e8f9;\n --panel-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 8px 20px -6px rgba(0,0,0,.6), 0 30px 64px -24px rgba(0,0,0,.8);\n --pop-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 14px 36px -10px rgba(0,0,0,.75);\n --launcher-shadow:\n inset 0 1px 0 rgba(255,255,255,.45),\n inset 0 -2px 3px rgba(0,0,0,.22),\n 0 0 0 1px rgba(255,255,255,.14),\n 0 2px 5px rgba(0,0,0,.5),\n 0 12px 28px -8px rgba(0,0,0,.85);\n}\nbutton { font: inherit; color: inherit; }\nbutton:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }\nsvg.i { display: block; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }\nsvg.solid { display: block; }\n\n.launcher {\n position: fixed; right: 20px; bottom: 20px; z-index: 2147483000;\n width: 56px; height: 56px; border-radius: 50%; border: 0; cursor: pointer;\n color: var(--brand-fg);\n background:\n linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,0) 48%, rgba(0,0,0,.08)),\n var(--brand);\n display: flex; align-items: center; justify-content: center;\n box-shadow: var(--launcher-shadow);\n transition: transform 160ms var(--ease), box-shadow 160ms var(--ease);\n}\n.launcher:hover { transform: translateY(-1px) scale(1.04); }\n.launcher:active { transform: scale(0.96); }\n/* A chevron's weight sits in its arms, so the geometric centre reads high. */\n.launcher .chevron { display: block; transform: translateY(1.5px); }\n.badge {\n position: absolute; top: -3px; right: -3px; min-width: 20px; height: 20px; padding: 0 5px;\n border-radius: 10px; background: #e5484d; color: #fff; font-size: 11.5px; font-weight: 600;\n display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 2px var(--surface);\n}\n.nudge {\n position: fixed; right: 20px; bottom: 90px; z-index: 2147483000; width: 300px; max-width: calc(100vw - 40px);\n background: var(--raised); color: var(--ink); border: 0; border-radius: 18px; padding: 12px 14px; cursor: pointer; text-align: left;\n display: flex; gap: 10px; align-items: flex-start;\n box-shadow: var(--pop-shadow);\n animation: rise 260ms var(--ease) both;\n}\n.nudge .who { display: block; font-size: 13px; font-weight: 600; }\n.nudge .text { font-size: 13.5px; color: var(--ink-mid); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }\n\n.panel {\n position: fixed; right: 20px; bottom: 90px; z-index: 2147483000;\n width: 380px; height: min(640px, calc(100vh - 110px));\n background: var(--surface); color: var(--ink); border-radius: 20px; overflow: hidden;\n display: flex; flex-direction: column;\n box-shadow: var(--panel-shadow);\n animation: open 280ms var(--ease) both; transform-origin: 100% 100%;\n}\n.panel.expanded { top: 20px; bottom: 20px; width: min(680px, calc(100vw - 40px)); height: auto; }\n.panel.mobile { inset: 0; width: 100%; height: 100%; border-radius: 0; box-shadow: none; animation: sheet 300ms var(--ease) both; }\n@keyframes open { from { opacity: 0; transform: translateY(12px) scale(.96); } to { opacity: 1; transform: none; } }\n@keyframes sheet { from { transform: translateY(100%); } to { transform: none; } }\n@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }\n\n.header { display: flex; align-items: center; gap: 4px; padding: 10px 8px 10px 10px; border-bottom: 1px solid var(--line); position: relative; }\n.panel.mobile .header { padding-top: max(10px, env(safe-area-inset-top)); }\n.agent-button {\n display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; border: 0; background: transparent;\n padding: 6px; border-radius: 12px; cursor: pointer; text-align: left;\n}\n.agent-button:hover { background: var(--hover); }\n.agent-name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; display: flex; align-items: center; gap: 4px; }\n.icon-button {\n width: 40px; height: 40px; border-radius: 10px; border: 0; background: transparent; color: var(--ink-mid);\n display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0;\n}\n.icon-button:hover { background: var(--hover); color: var(--ink); }\n\n.avatar { border-radius: 50%; background: var(--chip); color: #fff; font-weight: 600; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; }\n.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }\n\n.picker-scrim { position: absolute; inset: 0; background: var(--scrim); z-index: 3; }\n.picker {\n position: absolute; left: 10px; right: 10px; top: 64px; z-index: 4; max-height: 60%; overflow: auto;\n background: var(--raised); border-radius: 16px; padding: 6px;\n box-shadow: var(--pop-shadow);\n animation: rise 200ms var(--ease) both;\n}\n.panel.mobile .picker-scrim { background: rgba(0,0,0,.4); }\n.panel.mobile .picker {\n left: 0; right: 0; top: auto; bottom: 0; max-height: 75%; border-radius: 20px 20px 0 0;\n padding: 8px 10px max(24px, env(safe-area-inset-bottom)); animation: sheet 260ms var(--ease) both;\n}\n.picker-title { font-size: 12px; color: var(--ink-mid); font-weight: 500; padding: 8px 10px 6px; }\n.option {\n display: flex; align-items: center; gap: 12px; width: 100%; border: 0; background: transparent;\n padding: 10px; border-radius: 11px; min-height: 56px; text-align: left; cursor: pointer;\n}\n.option:hover, .option[aria-selected=\"true\"] { background: var(--hover); }\n.option-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }\n.option-name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }\n.option-description { font-size: 12.5px; color: var(--ink-mid); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.tag { font-size: 10.5px; font-weight: 600; padding: 2px 6px; border-radius: 6px; background: var(--chip); color: var(--ink-mid); }\n\n.thread { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px 16px 8px; display: flex; flex-direction: column; gap: 4px; }\n.loading { margin: auto; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--ink-mid); font-size: 13px; text-align: center; padding: 0 24px; }\n.loading .spinner { width: 20px; height: 20px; }\n.empty { margin: auto 0 12px; display: flex; flex-direction: column; gap: 10px; padding: 8px 4px; }\n.empty-title { font-size: 18px; font-weight: 600; letter-spacing: -.015em; }\n.empty-text { font-size: 13.5px; color: var(--ink-mid); }\n.day { align-self: center; font-size: 11.5px; color: var(--ink-lo); margin: 8px 0; }\n.row { display: flex; gap: 8px; align-items: flex-end; max-width: 88%; }\n.row.mine { align-self: flex-end; flex-direction: column; align-items: flex-end; max-width: 84%; }\n.row.theirs .stack { display: flex; flex-direction: column; gap: 4px; min-width: 0; }\n.row.group-start { margin-top: 10px; }\n.avatar-slot { width: 26px; flex-shrink: 0; }\n.bubble { padding: 9px 13px; border-radius: 18px; overflow-wrap: anywhere; }\n.mine .bubble { background: var(--brand); color: var(--brand-fg); border-bottom-right-radius: 6px; }\n.theirs .bubble { background: var(--bubble); border-bottom-left-radius: 6px; }\n.mine .bubble p { margin: 0; white-space: pre-wrap; }\n/* The app's accent can be too light to read as link text on a bubble. */\n.theirs .bubble .mii-md a { color: inherit; }\n.thread > .mii-activity { margin: 10px 0 0; max-width: 88%; }\n.meta { font-size: 11px; color: var(--ink-lo); padding: 0 4px; }\n.meta.failed { color: var(--danger); }\n.meta button { border: 0; background: none; padding: 0; color: inherit; text-decoration: underline; cursor: pointer; font-size: 11px; }\n\n.notice { margin: 8px 12px 0; padding: 8px 12px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); font-size: 13px; }\n.composer { padding: 8px 12px 12px; }\n.panel.mobile .composer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }\n.staged { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 8px; }\n.chip { display: flex; align-items: center; gap: 6px; height: 30px; max-width: 220px; padding: 0 4px 0 8px; border-radius: 9px; background: var(--chip); font-size: 12.5px; color: var(--ink); }\n.chip span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.chip.failed { background: var(--danger-soft); color: var(--danger); }\n.upload-problem { margin-bottom: 6px; padding: 6px 10px; border-radius: 9px; background: var(--danger-soft); color: var(--danger); font-size: 12.5px; overflow-wrap: anywhere; }\n.chip button, .thumb button { width: 22px; height: 22px; border: 0; background: transparent; border-radius: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--ink-mid); flex-shrink: 0; }\n.thumb { position: relative; width: 56px; height: 56px; border-radius: 10px; overflow: hidden; background: var(--chip); box-shadow: inset 0 0 0 1px var(--line); flex-shrink: 0; }\n.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }\n.thumb.uploading img { opacity: .55; }\n.thumb.failed { box-shadow: inset 0 0 0 2px var(--danger); }\n.thumb .spinner { position: absolute; left: 50%; top: 50%; margin: -8px 0 0 -8px; width: 16px; height: 16px; }\n.thumb button { position: absolute; top: 3px; right: 3px; width: 20px; height: 20px; border-radius: 50%; background: var(--raised); color: var(--ink-mid); box-shadow: 0 1px 3px rgba(0,0,0,.25); }\n.spinner { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--field-ring); border-top-color: var(--ink-mid); animation: spin .8s linear infinite; flex-shrink: 0; }\n@keyframes spin { to { transform: rotate(360deg); } }\n.box { display: flex; align-items: flex-end; gap: 2px; border-radius: 22px; padding: 5px 5px 5px 14px; box-shadow: inset 0 0 0 1px var(--field-ring); background: var(--field); }\n.box:focus-within { box-shadow: inset 0 0 0 1.5px var(--brand); }\n.box textarea {\n flex: 1; border: 0; outline: none; resize: none; background: transparent; font: inherit; font-size: 13.5px; line-height: 1.45;\n color: var(--ink); padding: 8px 0; max-height: 140px; min-height: 36px;\n}\n/* iOS zooms into any field under 16px. */\n.panel.mobile .box textarea { font-size: 16px; }\n.box textarea::placeholder { color: var(--ink-lo); }\n.send { width: 36px; height: 36px; border-radius: 50%; border: 0; background: var(--brand); color: var(--brand-fg); display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; margin-bottom: 1px; }\n.send:disabled { opacity: .35; cursor: default; }\n.drop { position: absolute; inset: 0; z-index: 5; background: var(--surface); opacity: .94; border: 2px dashed var(--brand); border-radius: inherit; display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--ink); pointer-events: none; }\n.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .launcher, .nudge, .panel, .picker, .spinner { animation: none !important; transition: none !important; }\n}\n";
@@ -0,0 +1,233 @@
1
+ // The chat's whole stylesheet. It lives in the widget's shadow root, so nothing
2
+ // here reaches the app and none of the app's CSS reaches in. `--brand` and
3
+ // `--brand-fg` are the app's own `--primary` pair, read at runtime, and the
4
+ // palette follows the app into dark mode.
5
+ export const CHAT_CSS = `
6
+ :host { all: initial; }
7
+ * { box-sizing: border-box; }
8
+ .root {
9
+ --brand: #16181c;
10
+ --brand-fg: #ffffff;
11
+ --ink: #16181c;
12
+ --ink-mid: #5b616b;
13
+ --ink-lo: #7c828c;
14
+ --surface: #ffffff;
15
+ --raised: #ffffff;
16
+ --line: #e8eaed;
17
+ --bubble: #f1f2f4;
18
+ --hover: #f4f5f7;
19
+ --chip: #f4f5f7;
20
+ --field: #ffffff;
21
+ --field-ring: #dfe2e6;
22
+ --scrim: rgba(16,18,24,.06);
23
+ --danger: #c92a3d;
24
+ --danger-soft: #fdecee;
25
+ --panel-shadow: 0 0 0 1px rgba(16,18,24,.06), 0 6px 16px -6px rgba(16,18,24,.18), 0 28px 60px -24px rgba(16,18,24,.35);
26
+ --pop-shadow: 0 0 0 1px rgba(16,18,24,.07), 0 12px 32px -10px rgba(16,18,24,.3);
27
+ --launcher-shadow:
28
+ inset 0 1px 0 rgba(255,255,255,.35),
29
+ inset 0 -2px 3px rgba(0,0,0,.14),
30
+ 0 0 0 1px rgba(16,18,24,.08),
31
+ 0 2px 4px rgba(16,18,24,.16),
32
+ 0 10px 24px -8px rgba(16,18,24,.38);
33
+ --ease: cubic-bezier(0.32, 0.72, 0, 1);
34
+ --mii-ink: var(--ink);
35
+ --mii-ink-mid: var(--ink-mid);
36
+ --mii-ink-lo: var(--ink-lo);
37
+ --mii-surface: var(--raised);
38
+ --mii-surface-2: var(--chip);
39
+ --mii-line: var(--line);
40
+ --mii-link: var(--brand);
41
+ --mii-running: #0b83c4;
42
+ --mii-danger: var(--danger);
43
+ --mii-code-bg: rgba(127,127,127,.16);
44
+ --mii-code-keyword: #7e22ce;
45
+ --mii-code-string: #047857;
46
+ --mii-code-number: #b45309;
47
+ --mii-code-title: #1d4ed8;
48
+ --mii-code-builtin: #0e7490;
49
+ --mii-attachment-width: 240px;
50
+ --mii-activity-ground: var(--surface);
51
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
52
+ font-size: 13.5px;
53
+ line-height: 1.45;
54
+ color: var(--ink);
55
+ -webkit-font-smoothing: antialiased;
56
+ }
57
+ .root[data-theme="dark"] {
58
+ --ink: #eceef1;
59
+ --ink-mid: #a9aeb7;
60
+ --ink-lo: #858b95;
61
+ --surface: #17191d;
62
+ --raised: #202328;
63
+ --line: #2a2d33;
64
+ --bubble: #25282e;
65
+ --hover: #25282e;
66
+ --chip: #25282e;
67
+ --field: #1c1e23;
68
+ --field-ring: #34383f;
69
+ --scrim: rgba(0,0,0,.35);
70
+ --danger: #ff7d90;
71
+ --danger-soft: #3a1c22;
72
+ --mii-running: #4cb8f0;
73
+ --mii-code-keyword: #d8b4fe;
74
+ --mii-code-string: #6ee7b7;
75
+ --mii-code-number: #fcd34d;
76
+ --mii-code-title: #93c5fd;
77
+ --mii-code-builtin: #67e8f9;
78
+ --panel-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 8px 20px -6px rgba(0,0,0,.6), 0 30px 64px -24px rgba(0,0,0,.8);
79
+ --pop-shadow: 0 0 0 1px rgba(255,255,255,.08), 0 14px 36px -10px rgba(0,0,0,.75);
80
+ --launcher-shadow:
81
+ inset 0 1px 0 rgba(255,255,255,.45),
82
+ inset 0 -2px 3px rgba(0,0,0,.22),
83
+ 0 0 0 1px rgba(255,255,255,.14),
84
+ 0 2px 5px rgba(0,0,0,.5),
85
+ 0 12px 28px -8px rgba(0,0,0,.85);
86
+ }
87
+ button { font: inherit; color: inherit; }
88
+ button:focus-visible, textarea:focus-visible, a:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
89
+ svg.i { display: block; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
90
+ svg.solid { display: block; }
91
+
92
+ .launcher {
93
+ position: fixed; right: 20px; bottom: 20px; z-index: 2147483000;
94
+ width: 56px; height: 56px; border-radius: 50%; border: 0; cursor: pointer;
95
+ color: var(--brand-fg);
96
+ background:
97
+ linear-gradient(180deg, rgba(255,255,255,.22), rgba(255,255,255,0) 48%, rgba(0,0,0,.08)),
98
+ var(--brand);
99
+ display: flex; align-items: center; justify-content: center;
100
+ box-shadow: var(--launcher-shadow);
101
+ transition: transform 160ms var(--ease), box-shadow 160ms var(--ease);
102
+ }
103
+ .launcher:hover { transform: translateY(-1px) scale(1.04); }
104
+ .launcher:active { transform: scale(0.96); }
105
+ /* A chevron's weight sits in its arms, so the geometric centre reads high. */
106
+ .launcher .chevron { display: block; transform: translateY(1.5px); }
107
+ .badge {
108
+ position: absolute; top: -3px; right: -3px; min-width: 20px; height: 20px; padding: 0 5px;
109
+ border-radius: 10px; background: #e5484d; color: #fff; font-size: 11.5px; font-weight: 600;
110
+ display: flex; align-items: center; justify-content: center; box-shadow: 0 0 0 2px var(--surface);
111
+ }
112
+ .nudge {
113
+ position: fixed; right: 20px; bottom: 90px; z-index: 2147483000; width: 300px; max-width: calc(100vw - 40px);
114
+ background: var(--raised); color: var(--ink); border: 0; border-radius: 18px; padding: 12px 14px; cursor: pointer; text-align: left;
115
+ display: flex; gap: 10px; align-items: flex-start;
116
+ box-shadow: var(--pop-shadow);
117
+ animation: rise 260ms var(--ease) both;
118
+ }
119
+ .nudge .who { display: block; font-size: 13px; font-weight: 600; }
120
+ .nudge .text { font-size: 13.5px; color: var(--ink-mid); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
121
+
122
+ .panel {
123
+ position: fixed; right: 20px; bottom: 90px; z-index: 2147483000;
124
+ width: 380px; height: min(640px, calc(100vh - 110px));
125
+ background: var(--surface); color: var(--ink); border-radius: 20px; overflow: hidden;
126
+ display: flex; flex-direction: column;
127
+ box-shadow: var(--panel-shadow);
128
+ animation: open 280ms var(--ease) both; transform-origin: 100% 100%;
129
+ }
130
+ .panel.expanded { top: 20px; bottom: 20px; width: min(680px, calc(100vw - 40px)); height: auto; }
131
+ .panel.mobile { inset: 0; width: 100%; height: 100%; border-radius: 0; box-shadow: none; animation: sheet 300ms var(--ease) both; }
132
+ @keyframes open { from { opacity: 0; transform: translateY(12px) scale(.96); } to { opacity: 1; transform: none; } }
133
+ @keyframes sheet { from { transform: translateY(100%); } to { transform: none; } }
134
+ @keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
135
+
136
+ .header { display: flex; align-items: center; gap: 4px; padding: 10px 8px 10px 10px; border-bottom: 1px solid var(--line); position: relative; }
137
+ .panel.mobile .header { padding-top: max(10px, env(safe-area-inset-top)); }
138
+ .agent-button {
139
+ display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; border: 0; background: transparent;
140
+ padding: 6px; border-radius: 12px; cursor: pointer; text-align: left;
141
+ }
142
+ .agent-button:hover { background: var(--hover); }
143
+ .agent-name { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; display: flex; align-items: center; gap: 4px; }
144
+ .icon-button {
145
+ width: 40px; height: 40px; border-radius: 10px; border: 0; background: transparent; color: var(--ink-mid);
146
+ display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0;
147
+ }
148
+ .icon-button:hover { background: var(--hover); color: var(--ink); }
149
+
150
+ .avatar { border-radius: 50%; background: var(--chip); color: #fff; font-weight: 600; display: flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; }
151
+ .avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
152
+
153
+ .picker-scrim { position: absolute; inset: 0; background: var(--scrim); z-index: 3; }
154
+ .picker {
155
+ position: absolute; left: 10px; right: 10px; top: 64px; z-index: 4; max-height: 60%; overflow: auto;
156
+ background: var(--raised); border-radius: 16px; padding: 6px;
157
+ box-shadow: var(--pop-shadow);
158
+ animation: rise 200ms var(--ease) both;
159
+ }
160
+ .panel.mobile .picker-scrim { background: rgba(0,0,0,.4); }
161
+ .panel.mobile .picker {
162
+ left: 0; right: 0; top: auto; bottom: 0; max-height: 75%; border-radius: 20px 20px 0 0;
163
+ padding: 8px 10px max(24px, env(safe-area-inset-bottom)); animation: sheet 260ms var(--ease) both;
164
+ }
165
+ .picker-title { font-size: 12px; color: var(--ink-mid); font-weight: 500; padding: 8px 10px 6px; }
166
+ .option {
167
+ display: flex; align-items: center; gap: 12px; width: 100%; border: 0; background: transparent;
168
+ padding: 10px; border-radius: 11px; min-height: 56px; text-align: left; cursor: pointer;
169
+ }
170
+ .option:hover, .option[aria-selected="true"] { background: var(--hover); }
171
+ .option-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
172
+ .option-name { font-size: 13.5px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
173
+ .option-description { font-size: 12.5px; color: var(--ink-mid); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
174
+ .tag { font-size: 10.5px; font-weight: 600; padding: 2px 6px; border-radius: 6px; background: var(--chip); color: var(--ink-mid); }
175
+
176
+ .thread { flex: 1; overflow-y: auto; overscroll-behavior: contain; padding: 16px 16px 8px; display: flex; flex-direction: column; gap: 4px; }
177
+ .loading { margin: auto; display: flex; align-items: center; justify-content: center; gap: 8px; color: var(--ink-mid); font-size: 13px; text-align: center; padding: 0 24px; }
178
+ .loading .spinner { width: 20px; height: 20px; }
179
+ .empty { margin: auto 0 12px; display: flex; flex-direction: column; gap: 10px; padding: 8px 4px; }
180
+ .empty-title { font-size: 18px; font-weight: 600; letter-spacing: -.015em; }
181
+ .empty-text { font-size: 13.5px; color: var(--ink-mid); }
182
+ .day { align-self: center; font-size: 11.5px; color: var(--ink-lo); margin: 8px 0; }
183
+ .row { display: flex; gap: 8px; align-items: flex-end; max-width: 88%; }
184
+ .row.mine { align-self: flex-end; flex-direction: column; align-items: flex-end; max-width: 84%; }
185
+ .row.theirs .stack { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
186
+ .row.group-start { margin-top: 10px; }
187
+ .avatar-slot { width: 26px; flex-shrink: 0; }
188
+ .bubble { padding: 9px 13px; border-radius: 18px; overflow-wrap: anywhere; }
189
+ .mine .bubble { background: var(--brand); color: var(--brand-fg); border-bottom-right-radius: 6px; }
190
+ .theirs .bubble { background: var(--bubble); border-bottom-left-radius: 6px; }
191
+ .mine .bubble p { margin: 0; white-space: pre-wrap; }
192
+ /* The app's accent can be too light to read as link text on a bubble. */
193
+ .theirs .bubble .mii-md a { color: inherit; }
194
+ .thread > .mii-activity { margin: 10px 0 0; max-width: 88%; }
195
+ .meta { font-size: 11px; color: var(--ink-lo); padding: 0 4px; }
196
+ .meta.failed { color: var(--danger); }
197
+ .meta button { border: 0; background: none; padding: 0; color: inherit; text-decoration: underline; cursor: pointer; font-size: 11px; }
198
+
199
+ .notice { margin: 8px 12px 0; padding: 8px 12px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); font-size: 13px; }
200
+ .composer { padding: 8px 12px 12px; }
201
+ .panel.mobile .composer { padding-bottom: max(12px, env(safe-area-inset-bottom)); }
202
+ .staged { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 8px; }
203
+ .chip { display: flex; align-items: center; gap: 6px; height: 30px; max-width: 220px; padding: 0 4px 0 8px; border-radius: 9px; background: var(--chip); font-size: 12.5px; color: var(--ink); }
204
+ .chip span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
205
+ .chip.failed { background: var(--danger-soft); color: var(--danger); }
206
+ .upload-problem { margin-bottom: 6px; padding: 6px 10px; border-radius: 9px; background: var(--danger-soft); color: var(--danger); font-size: 12.5px; overflow-wrap: anywhere; }
207
+ .chip button, .thumb button { width: 22px; height: 22px; border: 0; background: transparent; border-radius: 6px; display: flex; align-items: center; justify-content: center; cursor: pointer; color: var(--ink-mid); flex-shrink: 0; }
208
+ .thumb { position: relative; width: 56px; height: 56px; border-radius: 10px; overflow: hidden; background: var(--chip); box-shadow: inset 0 0 0 1px var(--line); flex-shrink: 0; }
209
+ .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
210
+ .thumb.uploading img { opacity: .55; }
211
+ .thumb.failed { box-shadow: inset 0 0 0 2px var(--danger); }
212
+ .thumb .spinner { position: absolute; left: 50%; top: 50%; margin: -8px 0 0 -8px; width: 16px; height: 16px; }
213
+ .thumb button { position: absolute; top: 3px; right: 3px; width: 20px; height: 20px; border-radius: 50%; background: var(--raised); color: var(--ink-mid); box-shadow: 0 1px 3px rgba(0,0,0,.25); }
214
+ .spinner { width: 12px; height: 12px; border-radius: 50%; border: 2px solid var(--field-ring); border-top-color: var(--ink-mid); animation: spin .8s linear infinite; flex-shrink: 0; }
215
+ @keyframes spin { to { transform: rotate(360deg); } }
216
+ .box { display: flex; align-items: flex-end; gap: 2px; border-radius: 22px; padding: 5px 5px 5px 14px; box-shadow: inset 0 0 0 1px var(--field-ring); background: var(--field); }
217
+ .box:focus-within { box-shadow: inset 0 0 0 1.5px var(--brand); }
218
+ .box textarea {
219
+ flex: 1; border: 0; outline: none; resize: none; background: transparent; font: inherit; font-size: 13.5px; line-height: 1.45;
220
+ color: var(--ink); padding: 8px 0; max-height: 140px; min-height: 36px;
221
+ }
222
+ /* iOS zooms into any field under 16px. */
223
+ .panel.mobile .box textarea { font-size: 16px; }
224
+ .box textarea::placeholder { color: var(--ink-lo); }
225
+ .send { width: 36px; height: 36px; border-radius: 50%; border: 0; background: var(--brand); color: var(--brand-fg); display: flex; align-items: center; justify-content: center; cursor: pointer; flex-shrink: 0; margin-bottom: 1px; }
226
+ .send:disabled { opacity: .35; cursor: default; }
227
+ .drop { position: absolute; inset: 0; z-index: 5; background: var(--surface); opacity: .94; border: 2px dashed var(--brand); border-radius: inherit; display: flex; align-items: center; justify-content: center; font-weight: 600; color: var(--ink); pointer-events: none; }
228
+ .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
229
+
230
+ @media (prefers-reduced-motion: reduce) {
231
+ .launcher, .nudge, .panel, .picker, .spinner { animation: none !important; transition: none !important; }
232
+ }
233
+ `;
@@ -0,0 +1,33 @@
1
+ import type { ChatAttachment } from "../ui/types.js";
2
+ export type { ActivityGroup as ChatActivity, ChatAttachment } from "../ui/types.js";
3
+ export type ChatMessage = {
4
+ readonly id: string;
5
+ readonly conversationId: string;
6
+ readonly fromMiiId: string;
7
+ readonly body: string;
8
+ readonly createdAt: string;
9
+ readonly updatedAt: string;
10
+ readonly attachments: readonly ChatAttachment[];
11
+ };
12
+ export type ChatAgent = {
13
+ readonly id: string;
14
+ readonly name: string;
15
+ readonly avatarUrl: string | null;
16
+ readonly description: string | null;
17
+ };
18
+ /**
19
+ * How far the person has read a thread. `never`: this browser has not shown
20
+ * it, so its history is not news. `upTo` null: they saw it while it was empty.
21
+ */
22
+ export type Seen = {
23
+ readonly kind: "never";
24
+ } | {
25
+ readonly kind: "upTo";
26
+ readonly at: string | null;
27
+ };
28
+ /** Having seen a thread up to `at` (null: while it was empty). Never moves back. */
29
+ export declare function advanceSeen(current: Seen, at: string | null): Seen;
30
+ /** Messages from the agent the person has not seen. Their own are never unread. */
31
+ export declare function unreadFrom(messages: readonly ChatMessage[], viewerId: string, seen: Seen): ChatMessage[];
32
+ /** The newest createdAt in a thread, which is what "seen up to" records. */
33
+ export declare function latestCreatedAt(messages: readonly ChatMessage[]): string | null;
@@ -0,0 +1,27 @@
1
+ // The chat's model of a thread, kept free of React so it can be tested alone.
2
+ import { toMillis } from "../thread.js";
3
+ /** Having seen a thread up to `at` (null: while it was empty). Never moves back. */
4
+ export function advanceSeen(current, at) {
5
+ if (current.kind === "upTo") {
6
+ if (at === null || (current.at !== null && toMillis(current.at) >= toMillis(at)))
7
+ return current;
8
+ }
9
+ return { kind: "upTo", at };
10
+ }
11
+ /** Messages from the agent the person has not seen. Their own are never unread. */
12
+ export function unreadFrom(messages, viewerId, seen) {
13
+ if (seen.kind === "never")
14
+ return [];
15
+ const { at } = seen;
16
+ return messages.filter((message) => message.fromMiiId !== viewerId && (at === null || toMillis(message.createdAt) > toMillis(at)));
17
+ }
18
+ /** The newest createdAt in a thread, which is what "seen up to" records. */
19
+ export function latestCreatedAt(messages) {
20
+ let latest = null;
21
+ for (const message of messages) {
22
+ if (latest === null || message.createdAt > latest) {
23
+ latest = message.createdAt;
24
+ }
25
+ }
26
+ return latest;
27
+ }