@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,132 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The chat bubble every org app carries: message the org's agents from inside
4
+ // the app, in the same thread as Plaza and texting.
5
+ //
6
+ // Mount it once, for a signed-in person, beside the app's routes:
7
+ //
8
+ // <MiiChat enabled={user !== null} />
9
+ //
10
+ // It renders into its own shadow root on <body>, with its own React root, so
11
+ // the app's styles cannot reach it and it cannot disturb the app's tree. The
12
+ // server half is ./server.ts, mounted at CHAT_BASE_PATH.
13
+ import { Component, lazy, Suspense, useEffect, useState } from "react";
14
+ import { createRoot } from "react-dom/client";
15
+ import { CHAT_UI_CSS } from "./ui/styles.js";
16
+ import { useAgents } from "./widget/hooks.js";
17
+ import { CHAT_CSS } from "./widget/styles.js";
18
+ // The window — markdown and code highlighting with it — loads once there is
19
+ // someone to talk to, not with every page of the app.
20
+ const ChatApp = lazy(() => import("./widget/app.js").then((module) => ({ default: module.ChatApp })));
21
+ export { CHAT_BASE_PATH } from "./paths.js";
22
+ /**
23
+ * Whether the bubble belongs on this host: the app as people use it, not a
24
+ * draft of it. The pane itself is covered by not mounting when framed; this is
25
+ * for the same draft opened in a tab of its own, which the pane's toolbar does.
26
+ *
27
+ * A draft is a working copy (`wc-<12 hex>.miis.run`) or the instance an agent is
28
+ * editing (`<sandbox>.app.miis.run`). Anything else keeps the bubble, including
29
+ * localhost: guessing wrong should leave a live app with its chat, not take it
30
+ * away. Vercel names a branch preview two ways and only one is recognisable
31
+ * here, which is the acceptable side to be wrong on.
32
+ */
33
+ export function chatBelongsOnHost(hostname) {
34
+ const host = hostname.toLowerCase();
35
+ const [label = ""] = host.split(".");
36
+ if (/^wc-[0-9a-f]{12}$/.test(label))
37
+ return false;
38
+ if (host.endsWith(".app.miis.run"))
39
+ return false;
40
+ // Qualified, because a bare <project>.vercel.app is a live alias.
41
+ if (host.includes("-git-") && host.endsWith(".vercel.app"))
42
+ return false;
43
+ return true;
44
+ }
45
+ export function MiiChat({ enabled }) {
46
+ useEffect(() => {
47
+ // Inside Plaza's App pane, Plaza's own chat is already beside the app.
48
+ if (!enabled || window.self !== window.top)
49
+ return;
50
+ if (!chatBelongsOnHost(window.location.hostname))
51
+ return;
52
+ const host = document.createElement("div");
53
+ host.setAttribute("data-mii-chat", "");
54
+ document.body.appendChild(host);
55
+ const shadow = host.attachShadow({ mode: "open" });
56
+ const style = document.createElement("style");
57
+ style.textContent = CHAT_UI_CSS + CHAT_CSS;
58
+ shadow.appendChild(style);
59
+ const container = document.createElement("div");
60
+ shadow.appendChild(container);
61
+ const root = createRoot(container);
62
+ root.render(_jsx(Widget, {}));
63
+ return () => {
64
+ root.unmount();
65
+ host.remove();
66
+ };
67
+ }, [enabled]);
68
+ return null;
69
+ }
70
+ function Widget() {
71
+ const agents = useAgents();
72
+ const brand = useBrand();
73
+ return agents.status === "ready" ? (_jsx(WindowBoundary, { children: _jsx(Suspense, { fallback: null, children: _jsx(ChatApp, { agents: agents.data, brand: brand }) }) })) : null;
74
+ }
75
+ // A window that cannot load or crashes — a chunk gone after the app redeploys —
76
+ // takes only the chat with it, not the widget's root, and says why.
77
+ class WindowBoundary extends Component {
78
+ state = { failed: false };
79
+ static getDerivedStateFromError() {
80
+ return { failed: true };
81
+ }
82
+ componentDidCatch(error) {
83
+ console.error("mii-chat: the chat window failed", error);
84
+ }
85
+ render() {
86
+ return this.state.failed ? null : this.props.children;
87
+ }
88
+ }
89
+ /**
90
+ * Wear the app's own accent. Org apps declare `--primary` and
91
+ * `--primary-foreground` (shadcn's convention: an HSL triplet, or a whole
92
+ * colour); read them live, because a dark-mode switch changes them.
93
+ */
94
+ function useBrand() {
95
+ const [brand, setBrand] = useState(readBrand);
96
+ useEffect(() => {
97
+ const update = () => setBrand((current) => {
98
+ const next = readBrand();
99
+ return next.brand === current.brand &&
100
+ next.foreground === current.foreground &&
101
+ next.dark === current.dark
102
+ ? current
103
+ : next;
104
+ });
105
+ update();
106
+ const observer = new MutationObserver(update);
107
+ observer.observe(document.documentElement, {
108
+ attributes: true,
109
+ attributeFilter: ["class", "style", "data-theme"]
110
+ });
111
+ return () => observer.disconnect();
112
+ }, []);
113
+ return brand;
114
+ }
115
+ export function readBrand() {
116
+ const root = document.documentElement;
117
+ const styles = getComputedStyle(root);
118
+ return {
119
+ brand: asColour(styles.getPropertyValue("--primary")),
120
+ foreground: asColour(styles.getPropertyValue("--primary-foreground")),
121
+ // Apps switch themes with a `dark` class (next-themes) or declare it as
122
+ // their color-scheme; either way the chat follows.
123
+ dark: root.classList.contains("dark") || styles.colorScheme === "dark"
124
+ };
125
+ }
126
+ /** `217 84% 52%` is shadcn's bare HSL; anything else is already a colour. */
127
+ export function asColour(value) {
128
+ const trimmed = value.trim();
129
+ if (trimmed === "")
130
+ return null;
131
+ return /^\d/.test(trimmed) ? `hsl(${trimmed})` : trimmed;
132
+ }
@@ -0,0 +1,9 @@
1
+ export declare const MII_CHAT_MEDIA_ORIGINS: readonly string[];
2
+ /**
3
+ * The policy directives chat needs, ready to join into a CSP.
4
+ *
5
+ * Apps compose their own policy; this only says what chat adds to it. Pass the
6
+ * app's own sources for a directive and they are kept — `img-src` in particular
7
+ * usually already carries `data:` and `blob:` for the app's own pictures.
8
+ */
9
+ export declare function miiChatCspSources(directive: "img-src" | "media-src" | "connect-src", own?: readonly string[]): string;
@@ -0,0 +1,37 @@
1
+ // The one place outside this app's own origin that chat touches, for apps that
2
+ // set a Content-Security-Policy.
3
+ //
4
+ // Chat is otherwise same-origin by design: the widget talks to the app's own
5
+ // server, which forwards to Plaza. Files are the exception. mii hands the
6
+ // browser a presigned URL on its media bucket and the browser posts the
7
+ // attachment there itself, and the pictures and video already in a thread are
8
+ // served from the same place. An agent's photo is a redirect from the app's own
9
+ // avatar route to that bucket, which is a connect check rather than an img one
10
+ // because the widget fetches it and renders a blob.
11
+ //
12
+ // So an app with a policy needs the bucket named in three directives:
13
+ //
14
+ // import { MII_CHAT_MEDIA_ORIGINS } from "@shubh90/app-runtime/chat/origins";
15
+ // const media = MII_CHAT_MEDIA_ORIGINS.join(" ");
16
+ // `img-src 'self' data: blob: ${media}`
17
+ // `media-src 'self' ${media}`
18
+ // `connect-src 'self' ${media}`
19
+ //
20
+ // It lives here, versioned with the package, because the bucket has moved twice
21
+ // (miiplaza-media -> -2 -> -3, us-east-1 -> us-west-2) and each move would
22
+ // otherwise be a hunt through every app's config for a string that only fails
23
+ // in production, only on attachments, and only after a deploy.
24
+ export const MII_CHAT_MEDIA_ORIGINS = [
25
+ "https://miiplaza-media-3.s3.us-west-2.amazonaws.com"
26
+ ];
27
+ /**
28
+ * The policy directives chat needs, ready to join into a CSP.
29
+ *
30
+ * Apps compose their own policy; this only says what chat adds to it. Pass the
31
+ * app's own sources for a directive and they are kept — `img-src` in particular
32
+ * usually already carries `data:` and `blob:` for the app's own pictures.
33
+ */
34
+ export function miiChatCspSources(directive, own = ["'self'"]) {
35
+ const sources = [...own, ...MII_CHAT_MEDIA_ORIGINS];
36
+ return `${directive} ${sources.join(" ")}`;
37
+ }
@@ -0,0 +1,2 @@
1
+ /** Where an app mounts its chat routes, and where the widget calls them. */
2
+ export declare const CHAT_BASE_PATH = "/api/mii/chat";
@@ -0,0 +1,2 @@
1
+ /** Where an app mounts its chat routes, and where the widget calls them. */
2
+ export const CHAT_BASE_PATH = "/api/mii/chat";
@@ -0,0 +1,16 @@
1
+ import type { MiiAuthCore } from "../auth/core.js";
2
+ import { CHAT_BASE_PATH } from "./paths.js";
3
+ export { CHAT_BASE_PATH };
4
+ export type MiiChatHandlerOptions = {
5
+ /** Where to report failures that are ours. Defaults to console.error. */
6
+ readonly reportError?: (error: unknown, context: Record<string, string>) => void;
7
+ };
8
+ export declare function createMiiChatHandler(auth: Pick<MiiAuthCore, "resolveSessionWithPlaza" | "revokeSession" | "SESSION_COOKIE">, options?: MiiChatHandlerOptions): (request: Request) => Promise<Response>;
9
+ /**
10
+ * Whether the request came from this app's own pages. Browsers send
11
+ * `Sec-Fetch-Site` on every fetch; `Origin` covers the ones that predate it.
12
+ * A request with neither is not from a browser page, and a non-browser caller
13
+ * has no cookie to borrow.
14
+ */
15
+ export declare function isSameOrigin(request: Request, url: URL): boolean;
16
+ export declare function readCookie(header: string | null, name: string): string | null;
@@ -0,0 +1,167 @@
1
+ // The server half of an org app's chat: the routes the widget calls, on the
2
+ // app's own origin, forwarded to Plaza as the signed-in person.
3
+ //
4
+ // Framework-free: a Web `Request` in, a `Response` out. The app mounts it once
5
+ // on a catch-all route under CHAT_BASE_PATH:
6
+ //
7
+ // // src/routes/api/mii/chat/$.ts (TanStack Start)
8
+ // const chat = createMiiChatHandler(auth);
9
+ // export const Route = createFileRoute("/api/mii/chat/$")({
10
+ // server: { handlers: { GET: ({ request }) => chat(request), POST: ({ request }) => chat(request) } }
11
+ // });
12
+ //
13
+ // The browser never holds a credential for Plaza. It sends this app's own
14
+ // session cookie; this server looks the session up and sends Plaza the token
15
+ // Plaza signed when the person signed in. Plaza checks that token and the
16
+ // person's membership on every request.
17
+ import { miiAuthConfig } from "../auth/config.js";
18
+ import { CHAT_BASE_PATH } from "./paths.js";
19
+ export { CHAT_BASE_PATH };
20
+ const ID = "([0-9a-fA-F-]{36})";
21
+ const ROUTES = [
22
+ { method: "GET", pattern: /^\/agents$/, plazaPath: () => "/agents" },
23
+ {
24
+ method: "GET",
25
+ pattern: new RegExp(`^/agents/${ID}/messages$`),
26
+ plazaPath: (match) => `/agents/${match[1]}/messages`
27
+ },
28
+ {
29
+ method: "POST",
30
+ pattern: new RegExp(`^/agents/${ID}/messages$`),
31
+ plazaPath: (match) => `/agents/${match[1]}/messages`
32
+ },
33
+ {
34
+ method: "GET",
35
+ pattern: new RegExp(`^/agents/${ID}/avatar$`),
36
+ plazaPath: (match) => `/agents/${match[1]}/avatar`
37
+ },
38
+ { method: "POST", pattern: /^\/uploads$/, plazaPath: () => "/uploads" }
39
+ ];
40
+ // What the thread poll may carry through. Everything else in the query string
41
+ // is dropped rather than forwarded.
42
+ const FORWARDED_QUERY = ["after", "afterId"];
43
+ export function createMiiChatHandler(auth, options = {}) {
44
+ const reportError = options.reportError ??
45
+ ((error, context) => console.error("mii-chat error", context, error));
46
+ return async (request) => {
47
+ const url = new URL(request.url);
48
+ if (!url.pathname.startsWith(`${CHAT_BASE_PATH}/`)) {
49
+ return refuse(404, "not_found", "There's no chat here.");
50
+ }
51
+ const path = url.pathname.slice(CHAT_BASE_PATH.length);
52
+ let route;
53
+ let match = null;
54
+ for (const candidate of ROUTES) {
55
+ match = candidate.method === request.method ? path.match(candidate.pattern) : null;
56
+ if (match !== null) {
57
+ route = candidate;
58
+ break;
59
+ }
60
+ }
61
+ if (route === undefined || match === null) {
62
+ return refuse(404, "not_found", "There's no chat here.");
63
+ }
64
+ // Every org app shares one site with every other (*.miis.run), so a page
65
+ // on another org's app can send this app's cookie along. Only this app's
66
+ // own pages may use its chat.
67
+ if (!isSameOrigin(request, url)) {
68
+ return refuse(403, "cross_site", "Chat is only available from this app's own pages.");
69
+ }
70
+ const token = readCookie(request.headers.get("cookie"), auth.SESSION_COOKIE);
71
+ const session = token === null ? null : await auth.resolveSessionWithPlaza(token);
72
+ if (token === null || session === null) {
73
+ return refuse(401, "not_signed_in", "Sign in again to keep chatting.");
74
+ }
75
+ const { plazaUrl } = miiAuthConfig();
76
+ const target = new URL(`${plazaUrl}/api/app-chat/v1${route.plazaPath(match)}`);
77
+ for (const key of FORWARDED_QUERY) {
78
+ const value = url.searchParams.get(key);
79
+ if (value !== null)
80
+ target.searchParams.set(key, value);
81
+ }
82
+ let answer;
83
+ try {
84
+ answer = await fetch(target, {
85
+ method: route.method,
86
+ headers: {
87
+ Authorization: `Bearer ${session.plazaToken}`,
88
+ ...(route.method === "POST" ? { "Content-Type": "application/json" } : {})
89
+ },
90
+ body: route.method === "POST" ? await request.text() : undefined,
91
+ cache: "no-store",
92
+ // An agent with a photo answers with a redirect to it: the browser
93
+ // follows that itself, rather than this server fetching the photo.
94
+ redirect: "manual"
95
+ });
96
+ }
97
+ catch (error) {
98
+ reportError(error, { where: "mii-chat", step: "forward" });
99
+ return refuse(502, "unavailable", "Couldn't reach the chat. Try again in a moment.");
100
+ }
101
+ // Plaza no longer stands behind this session (the token is no good, or the
102
+ // person left the org): end it here too, rather than on the next recheck.
103
+ if (answer.status === 401 || answer.status === 403) {
104
+ await auth.revokeSession(token);
105
+ }
106
+ if (answer.status >= 500) {
107
+ reportError(new Error(`Plaza chat answered ${answer.status}: ${(await answer.clone().text()).slice(0, 300)}`), { where: "mii-chat", step: "plaza" });
108
+ }
109
+ // An avatar may be cached for the person; chat data never is.
110
+ const cacheControl = answer.headers.get("cache-control");
111
+ const headers = new Headers({
112
+ "Cache-Control": cacheControl !== null && cacheControl.startsWith("private") ? cacheControl : "no-store"
113
+ });
114
+ const contentType = answer.headers.get("content-type");
115
+ if (contentType !== null)
116
+ headers.set("Content-Type", contentType);
117
+ const location = answer.headers.get("location");
118
+ if (answer.status >= 300 && answer.status < 400 && location !== null) {
119
+ headers.set("Location", location);
120
+ }
121
+ const policy = answer.headers.get("content-security-policy");
122
+ if (policy !== null)
123
+ headers.set("Content-Security-Policy", policy);
124
+ return new Response(answer.body, { status: answer.status, headers });
125
+ };
126
+ }
127
+ /**
128
+ * Whether the request came from this app's own pages. Browsers send
129
+ * `Sec-Fetch-Site` on every fetch; `Origin` covers the ones that predate it.
130
+ * A request with neither is not from a browser page, and a non-browser caller
131
+ * has no cookie to borrow.
132
+ */
133
+ export function isSameOrigin(request, url) {
134
+ const site = request.headers.get("sec-fetch-site");
135
+ if (site !== null && site !== "same-origin") {
136
+ return false;
137
+ }
138
+ const origin = request.headers.get("origin");
139
+ if (origin !== null && origin !== url.origin) {
140
+ // Behind a proxy the request URL can carry the internal host; the browser's
141
+ // Host header is what the page was served from.
142
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
143
+ return host !== null && new URL(origin).host === host;
144
+ }
145
+ return true;
146
+ }
147
+ export function readCookie(header, name) {
148
+ if (header === null)
149
+ return null;
150
+ for (const part of header.split(";")) {
151
+ const index = part.indexOf("=");
152
+ if (index === -1)
153
+ continue;
154
+ if (part.slice(0, index).trim() === name) {
155
+ const value = part.slice(index + 1).trim();
156
+ return value === "" ? null : value;
157
+ }
158
+ }
159
+ return null;
160
+ }
161
+ // Plaza's error shape: the sentence a person reads, and the reason as a code.
162
+ function refuse(status, code, error) {
163
+ return new Response(JSON.stringify({ error, code }), {
164
+ status,
165
+ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }
166
+ });
167
+ }
@@ -0,0 +1,27 @@
1
+ export type ThreadMessage = {
2
+ readonly id: string;
3
+ readonly createdAt: string;
4
+ readonly updatedAt: string;
5
+ };
6
+ export type MessageCursor = {
7
+ readonly updatedAt: string;
8
+ readonly id: string;
9
+ };
10
+ /**
11
+ * Plaza's timestamps as milliseconds. They arrive as Postgres writes them
12
+ * ("2026-09-16 18:40:00.12+00"), which Safari will not parse, so they are put
13
+ * in ISO form first.
14
+ */
15
+ export declare function toMillis(stamp: string): number;
16
+ /**
17
+ * The newest `(updatedAt, id)` pair held. The id breaks ties so a limited batch
18
+ * can resume partway through a timestamp group without skipping the rest.
19
+ */
20
+ export declare function latestMessageCursor(messages: readonly ThreadMessage[]): MessageCursor | undefined;
21
+ /** The cursor to poll with: the newest pair, rewound past any row it could hide. */
22
+ export declare function pollCursor(messages: readonly ThreadMessage[]): MessageCursor | undefined;
23
+ /**
24
+ * Fold a batch into what is held: replace what is already there (an edit or a
25
+ * reaction), add the rest, and keep the thread in the order it was written.
26
+ */
27
+ export declare function mergeMessages<T extends ThreadMessage>(existing: readonly T[], delta: readonly T[]): T[];
@@ -0,0 +1,59 @@
1
+ // How a chat keeps a thread current by polling: the cursor it asks from and how
2
+ // a batch folds into what is on screen. Plaza's chat and the org-app widget
3
+ // both poll Plaza this way.
4
+ /**
5
+ * Plaza's timestamps as milliseconds. They arrive as Postgres writes them
6
+ * ("2026-09-16 18:40:00.12+00"), which Safari will not parse, so they are put
7
+ * in ISO form first.
8
+ */
9
+ export function toMillis(stamp) {
10
+ const iso = stamp.replace(" ", "T").replace(/([+-]\d{2})$/, "$1:00");
11
+ const parsed = Date.parse(iso);
12
+ if (Number.isNaN(parsed)) {
13
+ throw new Error(`Unreadable timestamp: ${stamp}`);
14
+ }
15
+ return parsed;
16
+ }
17
+ /**
18
+ * The newest `(updatedAt, id)` pair held. The id breaks ties so a limited batch
19
+ * can resume partway through a timestamp group without skipping the rest.
20
+ */
21
+ export function latestMessageCursor(messages) {
22
+ let cursor;
23
+ for (const message of messages) {
24
+ if (cursor === undefined ||
25
+ message.updatedAt > cursor.updatedAt ||
26
+ (message.updatedAt === cursor.updatedAt && message.id > cursor.id)) {
27
+ cursor = { updatedAt: message.updatedAt, id: message.id };
28
+ }
29
+ }
30
+ return cursor;
31
+ }
32
+ // A cursor of exactly the newest pair can skip a row forever. Writes stamp
33
+ // `updatedAt` to the millisecond, so two can share one, and ids are random, so
34
+ // the tiebreak is a coin flip; a commit that lands after the time it wrote
35
+ // loses the same way. Rewinding re-reads a few rows the caller already holds,
36
+ // and `mergeMessages` replaces by id, so that changes nothing.
37
+ const POLL_OVERLAP_MS = 2_000;
38
+ /** The cursor to poll with: the newest pair, rewound past any row it could hide. */
39
+ export function pollCursor(messages) {
40
+ const latest = latestMessageCursor(messages);
41
+ if (latest === undefined)
42
+ return undefined;
43
+ const rewound = toMillis(latest.updatedAt) - POLL_OVERLAP_MS;
44
+ return { updatedAt: new Date(rewound).toISOString(), id: latest.id };
45
+ }
46
+ /**
47
+ * Fold a batch into what is held: replace what is already there (an edit or a
48
+ * reaction), add the rest, and keep the thread in the order it was written.
49
+ */
50
+ export function mergeMessages(existing, delta) {
51
+ if (delta.length === 0) {
52
+ return [...existing];
53
+ }
54
+ const byId = new Map(existing.map((message) => [message.id, message]));
55
+ for (const message of delta) {
56
+ byId.set(message.id, message);
57
+ }
58
+ return [...byId.values()].sort((a, b) => toMillis(a.createdAt) - toMillis(b.createdAt) || a.id.localeCompare(b.id));
59
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from "react";
2
+ import type { ActivityGroup } from "./types.js";
3
+ /**
4
+ * The one progress indicator: what an agent is doing about a message while it
5
+ * works, and what it did once it is done. `avatar` is drawn when `showAgent`
6
+ * is set — a group chat, where more than one agent could be at work.
7
+ */
8
+ export declare function ActivityCard({ group, showAgent, avatar }: {
9
+ readonly group: ActivityGroup;
10
+ readonly showAgent: boolean;
11
+ readonly avatar?: ReactNode;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from "react";
3
+ import { Camera, Check, ChevronDown, ChevronUp, FileCode2, Globe2, Keyboard, LoaderCircle, Mail, MousePointer2, Search, Send, SquareCheckBig, TriangleAlert } from "lucide-react";
4
+ /**
5
+ * The one progress indicator: what an agent is doing about a message while it
6
+ * works, and what it did once it is done. `avatar` is drawn when `showAgent`
7
+ * is set — a group chat, where more than one agent could be at work.
8
+ */
9
+ export function ActivityCard({ group, showAgent, avatar }) {
10
+ const [expanded, setExpanded] = useState(false);
11
+ // Running with no steps yet is the queue, the runner boot and the first turn.
12
+ const running = group.status === "running";
13
+ const steps = running ? group.visibleSteps : group.steps;
14
+ const hiddenStepCount = running ? group.hiddenStepCount : 0;
15
+ const showHeader = running || showAgent || steps.length > 0;
16
+ const showSteps = running || expanded;
17
+ return (_jsxs("section", { "aria-label": `${group.agentName} activity: ${group.contextLabel}`, className: "mii-activity", role: running && !showAgent ? "status" : undefined, children: [showHeader ? (_jsxs("div", { className: "mii-activity-head", children: [showAgent ? (avatar) : (_jsx("span", { "aria-hidden": "true", className: "mii-activity-mark", "data-status": group.status, children: running ? _jsx(LoaderCircle, { className: "mii-spin", size: 13 }) : _jsx(Check, { size: 12, strokeWidth: 2.25 }) })), !running && steps.length > 0 ? (_jsxs("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((current) => !current), className: "mii-activity-toggle", children: [showAgent ? _jsx("span", { className: "mii-activity-agent", children: group.agentName }) : null, _jsx("span", { className: "mii-activity-label", children: group.contextLabel }), _jsxs("span", { className: "mii-activity-details", children: ["Details", expanded ? _jsx(ChevronUp, { size: 13 }) : _jsx(ChevronDown, { size: 13 })] })] })) : (_jsxs("div", { className: "mii-activity-title", children: [showAgent ? _jsx("span", { className: "mii-activity-agent", children: group.agentName }) : null, _jsx("span", { className: "mii-activity-label", children: group.contextLabel })] }))] })) : null, showSteps && steps.length > 0 ? (_jsxs("div", { className: "mii-steps", children: [hiddenStepCount > 0 ? _jsxs("p", { className: "mii-steps-hidden", children: [hiddenStepCount, " earlier actions"] }) : null, steps.map((event) => (_jsx(ActivityStep, { event: event }, event.id)))] })) : null, group.outcomes.length > 0 ? (_jsx("div", { className: "mii-outcomes", "data-spaced": showHeader, children: group.outcomes.map((event) => (_jsx(OutcomeRow, { event: event }, event.id))) })) : null] }));
18
+ }
19
+ function ActivityStep({ event }) {
20
+ return (_jsxs("div", { className: "mii-step", "data-status": event.status, children: [_jsx("span", { className: "mii-step-glyph", "aria-hidden": "true", children: _jsx(ActivityGlyph, { event: event }) }), _jsxs("div", { className: "mii-step-text", children: [_jsx("p", { children: event.summary }), event.detail.type === "files" && event.detail.paths.length > 0 ? (_jsx("div", { className: "mii-step-paths", children: event.detail.paths.slice(0, 3).map((path) => (_jsx("span", { children: path }, path))) })) : null] }), event.status === "running" ? _jsx("span", { "aria-hidden": "true", className: "mii-step-pulse" }) : null] }));
21
+ }
22
+ function ActivityGlyph({ event }) {
23
+ const props = { size: 11, strokeWidth: 1.9 };
24
+ switch (event.detail.type) {
25
+ case "browser":
26
+ switch (event.detail.action) {
27
+ case "navigate":
28
+ case "tabs":
29
+ return _jsx(Globe2, { ...props });
30
+ case "inspect":
31
+ return _jsx(Search, { ...props });
32
+ case "input":
33
+ return _jsx(Keyboard, { ...props });
34
+ case "capture":
35
+ return _jsx(Camera, { ...props });
36
+ case "interact":
37
+ return _jsx(MousePointer2, { ...props });
38
+ }
39
+ return _jsx(Globe2, { ...props });
40
+ case "files":
41
+ return _jsx(FileCode2, { ...props });
42
+ case "web":
43
+ return _jsx(Search, { ...props });
44
+ case "verification":
45
+ return _jsx(SquareCheckBig, { ...props });
46
+ case "integration":
47
+ return _jsx(Globe2, { ...props });
48
+ default:
49
+ return _jsx(Check, { ...props });
50
+ }
51
+ }
52
+ function OutcomeRow({ event }) {
53
+ const [expanded, setExpanded] = useState(false);
54
+ const communication = event.detail.type === "communication" ? event.detail : null;
55
+ const canExpand = communication !== null && (communication.body !== undefined || communication.subject !== undefined);
56
+ return (_jsxs("div", { className: "mii-outcome", "data-status": event.status, children: [_jsxs("div", { className: "mii-outcome-row", children: [_jsx("span", { "aria-hidden": "true", className: "mii-outcome-mark", children: event.status === "failed" ? (_jsx(TriangleAlert, { size: 11 })) : communication?.channel === "email" ? (_jsx(Mail, { size: 11 })) : communication === null ? (_jsx(Check, { size: 11 })) : (_jsx(Send, { size: 11 })) }), _jsx("span", { className: "mii-outcome-summary", children: event.summary }), canExpand ? (_jsx("button", { type: "button", className: "mii-outcome-toggle", "aria-expanded": expanded, "aria-label": `${expanded ? "Hide" : "Show"} details for ${event.summary}`, onClick: () => setExpanded((current) => !current), children: expanded ? "Hide" : "View" })) : null] }), expanded && communication !== null ? (_jsxs("div", { className: "mii-outcome-detail", children: [communication.subject === undefined ? null : _jsx("p", { className: "mii-outcome-subject", children: communication.subject }), communication.body === undefined ? null : _jsx("p", { className: "mii-outcome-body", children: communication.body }), communication.attachments.length === 0 ? null : (_jsxs("p", { className: "mii-outcome-attached", children: ["Attached: ", communication.attachments.join(", ")] }))] })) : null] }));
57
+ }
@@ -0,0 +1,9 @@
1
+ import type { ChatAttachment } from "./types.js";
2
+ export type AttachmentKind = "image" | "video" | "file";
3
+ export declare function attachmentKind(type: string): AttachmentKind;
4
+ export declare function formatBytes(bytes: number | null | undefined): string;
5
+ /** A message's files: images to open large, videos to play, documents to download. */
6
+ export declare function MessageAttachments({ attachments, align }: {
7
+ readonly attachments: readonly ChatAttachment[];
8
+ readonly align?: "start" | "end";
9
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { FileText, X } from "lucide-react";
4
+ export function attachmentKind(type) {
5
+ if (type.startsWith("image/"))
6
+ return "image";
7
+ if (type.startsWith("video/"))
8
+ return "video";
9
+ return "file";
10
+ }
11
+ export function formatBytes(bytes) {
12
+ if (bytes === null || bytes === undefined)
13
+ return "";
14
+ if (bytes < 1024)
15
+ return `${bytes} B`;
16
+ if (bytes < 1024 * 1024)
17
+ return `${Math.round(bytes / 1024)} KB`;
18
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
19
+ }
20
+ /** A message's files: images to open large, videos to play, documents to download. */
21
+ export function MessageAttachments({ attachments, align = "start" }) {
22
+ const [preview, setPreview] = useState(null);
23
+ if (attachments.length === 0) {
24
+ return null;
25
+ }
26
+ const images = attachments.filter((a) => attachmentKind(a.type) === "image");
27
+ const videos = attachments.filter((a) => attachmentKind(a.type) === "video");
28
+ const files = attachments.filter((a) => attachmentKind(a.type) === "file");
29
+ return (_jsxs("div", { className: "mii-attachments", "data-align": align, children: [images.length > 0 ? (_jsx("div", { className: "mii-images", "data-count": images.length === 1 ? "one" : "many", children: images.map((attachment, index) => (_jsx("button", { className: "mii-image", onClick: (event) => setPreview({ attachment, opener: event.currentTarget }), type: "button", "aria-label": `Open ${attachment.name ?? "image"}`, children: _jsx("img", { alt: attachment.name ?? "Image attachment", loading: "lazy", src: attachment.url, width: attachment.width ?? undefined, height: attachment.height ?? undefined }) }, `${attachment.url}-${index}`))) })) : null, videos.map((attachment, index) => (_jsx("video", { className: "mii-video", controls: true, preload: "metadata", children: _jsx("source", { src: attachment.url, type: attachment.type }) }, `${attachment.url}-${index}`))), files.map((attachment, index) => (_jsxs("a", { className: "mii-file", download: attachment.name ?? true, href: attachment.url, rel: "noopener noreferrer", target: "_blank", children: [_jsx(FileText, { "aria-hidden": "true", size: 16 }), _jsx("span", { className: "mii-file-name", children: attachment.name ?? "Document" }), attachment.sizeBytes ? _jsx("span", { className: "mii-file-size", children: formatBytes(attachment.sizeBytes) }) : null] }, `${attachment.url}-${index}`))), preview !== null ? (_jsx(Lightbox, { attachment: preview.attachment, opener: preview.opener, onClose: () => setPreview(null) })) : null] }));
30
+ }
31
+ // Escape closes the preview and nothing else: the chat around it may close on
32
+ // Escape too. Focus stays in the preview while it is open and goes back to the
33
+ // image it was opened from.
34
+ function Lightbox({ attachment, opener, onClose }) {
35
+ const dialog = useRef(null);
36
+ useEffect(() => {
37
+ dialog.current?.focus();
38
+ return () => opener.focus();
39
+ }, [opener]);
40
+ return (_jsxs("div", { ref: dialog, className: "mii-lightbox", role: "dialog", "aria-modal": "true", "aria-label": attachment.name ?? "Image preview", tabIndex: -1, onClick: onClose, onKeyDown: (event) => {
41
+ if (event.key === "Escape") {
42
+ event.stopPropagation();
43
+ onClose();
44
+ }
45
+ else if (event.key === "Tab") {
46
+ event.preventDefault();
47
+ dialog.current?.querySelector(".mii-lightbox-close")?.focus();
48
+ }
49
+ }, children: [_jsx("button", { "aria-label": "Close preview", className: "mii-lightbox-close", onClick: onClose, type: "button", children: _jsx(X, { "aria-hidden": "true", size: 20 }) }), _jsx("img", { alt: attachment.name ?? "Image preview", onClick: (event) => event.stopPropagation(), src: attachment.url })] }));
50
+ }
@@ -0,0 +1,5 @@
1
+ export { MessageBody, getCodeLanguage } from "./message-body.js";
2
+ export { MessageAttachments, attachmentKind, formatBytes, type AttachmentKind } from "./attachments.js";
3
+ export { ActivityCard } from "./activity.js";
4
+ export { CHAT_UI_CSS } from "./styles.js";
5
+ export type { ActivityDetail, ActivityEvent, ActivityGroup, ChatAttachment } from "./types.js";
@@ -0,0 +1,6 @@
1
+ // Chat pieces Plaza and the org-app widget share. Hosts set the variables
2
+ // styles.ts lists; the stylesheet is CHAT_UI_CSS or `chat/ui.css`.
3
+ export { MessageBody, getCodeLanguage } from "./message-body.js";
4
+ export { MessageAttachments, attachmentKind, formatBytes } from "./attachments.js";
5
+ export { ActivityCard } from "./activity.js";
6
+ export { CHAT_UI_CSS } from "./styles.js";
@@ -0,0 +1,8 @@
1
+ export declare function getCodeLanguage(className: string | undefined): string | null;
2
+ /**
3
+ * An agent's message: markdown with tables, code and links, never raw HTML.
4
+ * The one renderer behind Plaza's chat and the chat in every org app.
5
+ */
6
+ export declare const MessageBody: import("react").NamedExoticComponent<{
7
+ readonly body: string;
8
+ }>;