@loopbacktools/inbox 0.0.0-stage → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bison Labs Limited
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # @loopbacktools/inbox
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The [Loopback](https://github.com/LoopBackTools/loopback) inbox as React components: every ask your agents
4
+ raise, in a list, and the one open beside it, answered in place, kept live as agents ask and reply.
5
+ It is the inbox Loopback deploys, made to render inside an app of your own, with your sign-in and
6
+ your routes around it.
7
+
8
+ ```sh
9
+ pnpm add @loopbacktools/inbox @loopbacktools/sdk @cloudflare/kumo @effect/atom-react effect react react-dom
10
+ ```
11
+
12
+ ## Rendering it
13
+
14
+ Hand it a client from [`@loopbacktools/sdk`](https://www.npmjs.com/package/@loopbacktools/sdk), and tell it
15
+ how your app moves between asks:
16
+
17
+ ```tsx
18
+ import { RegistryProvider } from "@effect/atom-react";
19
+ import { Inbox, InboxProvider } from "@loopbacktools/inbox";
20
+ import { make } from "@loopbacktools/sdk";
21
+ import { useMemo } from "react";
22
+ import { useNavigate, useParams } from "react-router";
23
+
24
+ function Asks({ workspace }: { readonly workspace: string }) {
25
+ const { askId } = useParams();
26
+ const navigate = useNavigate();
27
+ // A client per workspace: hand over another, and the inbox follows that one instead.
28
+ const client = useMemo(
29
+ () => make({ baseUrl: location.origin, token: () => tokenFor(workspace) }),
30
+ [workspace],
31
+ );
32
+ return (
33
+ <InboxProvider client={client}>
34
+ <Inbox
35
+ selectedId={askId ?? null}
36
+ onSelect={(id) => navigate(id === null ? `/w/${workspace}` : `/w/${workspace}/asks/${id}`)}
37
+ hrefFor={(id) => `/w/${workspace}/asks/${id}`}
38
+ />
39
+ </InboxProvider>
40
+ );
41
+ }
42
+
43
+ // Once, around the app: the inbox keeps its state in Effect atoms.
44
+ <RegistryProvider>{/* your app */}</RegistryProvider>;
45
+ ```
46
+
47
+ `tokenFor` stands for however your app gets a fresh token; the SDK calls it for every request, so a
48
+ short-lived JWT is never sent stale.
49
+
50
+ `Inbox` fills the element it is drawn in, which needs a height. Its shortcuts (digits to pick a
51
+ choice, ⌘↵ or Ctrl↵ to send) work from anywhere on the page except your own controls. Where other
52
+ people's agents ask in the same space, it offers a switch between everyone's asks and the person's
53
+ own agents'.
54
+
55
+ When Loopback is updated under the page, the inbox says so above the list. A later deploy is offered
56
+ as a reload. A later revision of the contract reloads the page, your app around it included, the next
57
+ time the page is hidden, never under the person. What they have written and not sent, on any kind of
58
+ ask, is kept in the browser's `localStorage` and is there after the reload.
59
+
60
+ ## Styling
61
+
62
+ The components are styled with [Kumo](https://www.npmjs.com/package/@cloudflare/kumo) and Tailwind 4
63
+ utility classes, generated by your app's Tailwind build. In your stylesheet:
64
+
65
+ ```css
66
+ @source "../node_modules/@cloudflare/kumo/dist/**/*.{js,jsx,ts,tsx}";
67
+ @import "@cloudflare/kumo/styles/tailwind";
68
+ @import "tailwindcss";
69
+ @import "@loopbacktools/inbox/styles.css";
70
+ ```
71
+
72
+ Kumo themes by `data-mode="light"` or `"dark"` on the root element, which your app sets.
73
+
74
+ ## Around it
75
+
76
+ - `useInbox()` is the inbox state, for a header that shows whether it is live.
77
+ - `useAttention(inbox, notify, open)` keeps the tab title counting what awaits the person, and raises
78
+ a notification for a new ask while the tab is in the background; `useNotifications()` asks the
79
+ browser's permission and remembers the choice.
80
+ - `useMe()` and `whoIs(actor, me)` name people as the inbox does: "You", or their name.
81
+
82
+ MIT licensed.
@@ -0,0 +1,9 @@
1
+ import { type BriefingAsk } from "@loopbacktools/sdk";
2
+ /**
3
+ * A briefing's points, to work through one at a time: each folded to a line until opened, with its
4
+ * own thread and a box to reply on it, and one bar that sends everything written since last time.
5
+ */
6
+ export declare function BriefingBody({ ask, onReplied, }: {
7
+ readonly ask: BriefingAsk;
8
+ readonly onReplied: () => void;
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,216 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Banner, Button, Checkbox, InputArea, Text } from "@cloudflare/kumo";
3
+ import { useAtomSet } from "@effect/atom-react";
4
+ import { ApiError, foldBriefing, respondedProblem, } from "@loopbacktools/sdk";
5
+ import { ArrowDown, CaretDown, CaretRight, Check, Checks, PaperPlaneRight, WarningCircle, X, } from "@phosphor-icons/react";
6
+ import { Cause, Exit } from "effect";
7
+ import { useEffect, useState } from "react";
8
+ import { Blocks, Prose } from "./content.js";
9
+ import { SEND_KEY, useNow } from "./hooks.js";
10
+ import { useMe, whoIs } from "./people.js";
11
+ import { replyAtom } from "./state.js";
12
+ import { useClient, useInboxKeys } from "./context.js";
13
+ import { AgentSaid, PersonSaid } from "./thread.js";
14
+ const EMPTY = { text: "", resolved: false };
15
+ /** The draft that still counts on a point: one written since the point was last resolved. */
16
+ const draftOn = (drafts, point) => {
17
+ const draft = drafts[point.id];
18
+ return draft !== undefined && (draft.basis ?? 0) >= point.resolvedAtEntry ? draft : undefined;
19
+ };
20
+ const draftKey = (askId) => `loopback.drafts.${askId}`;
21
+ const readDrafts = (askId) => {
22
+ try {
23
+ return JSON.parse(localStorage.getItem(draftKey(askId)) ?? "{}");
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ };
29
+ /** Keeps a briefing's drafts in this browser, so a long one can be worked through over time. */
30
+ const useDrafts = (askId, basis) => {
31
+ const [drafts, setDrafts] = useState(() => readDrafts(askId));
32
+ const keep = (next) => {
33
+ setDrafts(next);
34
+ try {
35
+ if (Object.keys(next).length === 0)
36
+ localStorage.removeItem(draftKey(askId));
37
+ else
38
+ localStorage.setItem(draftKey(askId), JSON.stringify(next));
39
+ }
40
+ catch {
41
+ // Storage can be unavailable (a private window); drafts then last as long as the page.
42
+ }
43
+ };
44
+ const update = (pointId, draft) => {
45
+ const { [pointId]: _, ...rest } = drafts;
46
+ keep(draft.text === "" && !draft.resolved ? rest : { ...rest, [pointId]: { ...draft, basis } });
47
+ };
48
+ return { drafts, update, clear: () => keep({}) };
49
+ };
50
+ /** Every point, in the order it is read. */
51
+ const inOrder = (points) => points.flatMap((point) => [point, ...inOrder(point.points)]);
52
+ const allPoints = (tree) => inOrder(tree.sections.flatMap((s) => s.points));
53
+ /**
54
+ * The reply the drafts make. A draft left on a point resolved since it was written is dropped.
55
+ * Words beneath a point being resolved go as resolved too, as the space would read them anyway.
56
+ * Finishing resolves every remaining point: the uppermost of each open branch, and any written on.
57
+ */
58
+ const replyFrom = (tree, drafts, finishing) => {
59
+ const items = [];
60
+ const walk = (points, covered) => {
61
+ for (const point of points) {
62
+ const draft = draftOn(drafts, point);
63
+ const text = draft?.text.trim() ?? "";
64
+ const resolving = draft?.resolved === true || finishing || covered;
65
+ const included = text !== "" ||
66
+ draft?.resolved === true ||
67
+ (finishing && !covered && point.state !== "resolved");
68
+ if (included) {
69
+ items.push({
70
+ point: point.id,
71
+ ...(text === "" ? {} : { text }),
72
+ ...(resolving ? { resolved: true } : {}),
73
+ });
74
+ }
75
+ walk(point.points, covered || (included && resolving));
76
+ }
77
+ };
78
+ for (const section of tree.sections)
79
+ walk(section.points, false);
80
+ return items;
81
+ };
82
+ const MARKER = {
83
+ needs_you: { label: "Needs you", variant: "orange" },
84
+ with_agent: { label: "With the agent", variant: "neutral" },
85
+ };
86
+ /** The small uppercase label the inbox gives every group, as the list does "Your turn". */
87
+ const GROUP_LABEL = "text-xs font-medium uppercase tracking-wide text-kumo-subtle";
88
+ /** A point's number, in the chip a question's choices are numbered with. */
89
+ function PointId({ id }) {
90
+ return (_jsx("span", { className: "flex h-5 min-w-5 shrink-0 items-center justify-center rounded border border-kumo-line px-1 text-xs text-kumo-subtle tabular-nums", children: id }));
91
+ }
92
+ /** Where a folded point stands: a badge for its own state, and a count of what waits beneath it. */
93
+ function Marker({ point }) {
94
+ const below = point.needsYou - (point.state === "needs_you" ? 1 : 0);
95
+ return (_jsxs(_Fragment, { children: [below === 0 ? null : (_jsxs("span", { className: "hidden text-xs text-kumo-subtle tabular-nums sm:inline", children: [below, " below"] })), point.state === "needs_you" || point.state === "with_agent" ? (_jsx(Badge, { variant: MARKER[point.state].variant, children: MARKER[point.state].label })) : null] }));
96
+ }
97
+ /** That a reply is drafted on a point, marked as the list marks an agent waiting. */
98
+ function Drafted() {
99
+ return (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs text-kumo-subtle", children: [_jsx("span", { className: "size-2 rounded-full bg-kumo-brand" }), _jsx("span", { className: "sr-only sm:not-sr-only", children: "Draft" })] }));
100
+ }
101
+ function Turn({ turn, now }) {
102
+ const me = useMe();
103
+ if (turn.author === "agent") {
104
+ return _jsx(AgentSaid, { answer: turn.answer, content: turn.content, at: turn.at, now: now });
105
+ }
106
+ const who = whoIs(turn.by, me);
107
+ const words = turn.text?.trim() ?? "";
108
+ return (_jsx(PersonSaid, { title: turn.resolved ? `${who} resolved this` : `${who} wrote`, body: words === "" ? undefined : words, at: turn.at, now: now }));
109
+ }
110
+ function PointItem({ point, shared }) {
111
+ const open = shared.opened.has(point.id);
112
+ const draft = draftOn(shared.drafts, point) ?? EMPTY;
113
+ const resolved = point.state === "resolved";
114
+ const drafted = draft.text.trim() !== "" || draft.resolved;
115
+ const panel = `point-${point.id}`;
116
+ return (_jsxs("li", { id: `row-${point.id}`, children: [_jsxs("button", { type: "button", "aria-expanded": open, "aria-controls": panel, onClick: () => shared.toggle(point.id), className: `flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-kumo-tint ${open ? "bg-kumo-tint" : ""}`, children: [_jsx("span", { className: "shrink-0 text-kumo-subtle", children: open ? _jsx(CaretDown, { size: 12 }) : _jsx(CaretRight, { size: 12 }) }), _jsx(PointId, { id: point.id }), _jsx(Text, { truncate: true, bold: point.state === "needs_you", children: point.title }), _jsxs("span", { className: "ml-auto flex shrink-0 items-center gap-2", children: [drafted ? _jsx(Drafted, {}) : null, _jsx(Marker, { point: point })] })] }), open ? (_jsxs("section", { id: panel, "aria-label": `${point.id} ${point.title}`, className: "flex flex-col gap-3 px-4 pt-1 pb-4 sm:pl-[4.25rem]", children: [point.detail === undefined ? null : _jsx(Prose, { text: point.detail }), point.content === undefined ? null : _jsx(Blocks, { blocks: point.content }), point.turns.map((turn) => (_jsx(Turn, { turn: turn, now: shared.now }, `${turn.author}:${turn.at}`))), shared.writable ? (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(InputArea, { "aria-label": `Your reply on point ${point.id}`, placeholder: resolved
117
+ ? "Write here to reopen this point"
118
+ : "A question, a push-back or a comment", value: draft.text, onValueChange: (text) => shared.update(point.id, { ...draft, text }), autoResize: true, minRows: 2, maxRows: 10 }), resolved ? null : (_jsx(Checkbox, { label: "Resolved", checked: draft.resolved, onCheckedChange: (checked) => shared.update(point.id, { ...draft, resolved: checked }) }))] })) : null, point.points.length === 0 ? null : _jsx(Points, { points: point.points, shared: shared })] })) : null] }));
119
+ }
120
+ /**
121
+ * One level of points, as a card of rows: what is still open, in order, then the resolved folded
122
+ * into a count that opens like the list's "Recent".
123
+ */
124
+ function Points({ points, shared, }) {
125
+ const [showResolved, setShowResolved] = useState(false);
126
+ const live = points.filter((point) => point.state !== "resolved");
127
+ const resolved = points.filter((point) => point.state === "resolved");
128
+ return (_jsxs("ul", { className: "divide-y divide-kumo-hairline overflow-hidden rounded-lg border border-kumo-line bg-kumo-base", children: [live.map((point) => (_jsx(PointItem, { point: point, shared: shared }, point.id))), resolved.length === 0 ? null : (_jsxs("li", { children: [_jsxs("button", { type: "button", "aria-expanded": showResolved, onClick: () => setShowResolved((shown) => !shown), className: `flex w-full items-center gap-1.5 px-4 py-2 text-left hover:text-kumo-default ${GROUP_LABEL}`, children: [showResolved ? _jsx(CaretDown, { size: 12 }) : _jsx(CaretRight, { size: 12 }), "Resolved ", _jsxs("span", { className: "tabular-nums", children: ["\u00B7 ", resolved.length] })] }), showResolved ? (_jsx("ul", { className: "divide-y divide-kumo-hairline border-t border-kumo-hairline", children: resolved.map((point) => (_jsx(PointItem, { point: point, shared: shared }, point.id))) })) : null] }))] }));
129
+ }
130
+ /** The ids of a point and every point above it. */
131
+ const pathTo = (id) => id.split(".").map((_, depth, parts) => parts.slice(0, depth + 1).join("."));
132
+ /**
133
+ * A briefing's points, to work through one at a time: each folded to a line until opened, with its
134
+ * own thread and a box to reply on it, and one bar that sends everything written since last time.
135
+ */
136
+ export function BriefingBody({ ask, onReplied, }) {
137
+ const now = useNow();
138
+ const send = useAtomSet(replyAtom(useClient()), { mode: "promiseExit" });
139
+ const basis = ask.thread.length;
140
+ const { drafts, update, clear } = useDrafts(ask.id, basis);
141
+ const [opened, setOpened] = useState(() => new Set());
142
+ const [cursor, setCursor] = useState(null);
143
+ const [sending, setSending] = useState(null);
144
+ const [error, setError] = useState(null);
145
+ const closed = ask.status === "closed";
146
+ useEffect(() => {
147
+ if (closed)
148
+ clear();
149
+ // Only a briefing closing clears its drafts; `clear` is new on every render.
150
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
151
+ }, [closed]);
152
+ const tree = foldBriefing(ask.prompt, ask.thread);
153
+ const yourMove = ask.status === "awaiting_human";
154
+ const busy = sending !== null;
155
+ const items = replyFrom(tree, drafts, false);
156
+ const reply = { outcome: "responded", points: items };
157
+ const problem = items.length === 0 ? null : respondedProblem(ask.prompt, ask.thread, reply);
158
+ const finishes = items.length > 0 &&
159
+ problem === null &&
160
+ foldBriefing(ask.prompt, [...ask.thread, { author: "human", reply, at: "" }]).resolved;
161
+ const toggle = (id) => setOpened((current) => {
162
+ const next = new Set(current);
163
+ if (!next.delete(id))
164
+ next.add(id);
165
+ return next;
166
+ });
167
+ const needing = allPoints(tree).filter((point) => point.state === "needs_you");
168
+ const next = () => {
169
+ const at = needing.findIndex((point) => point.id === cursor);
170
+ const target = needing[(at + 1) % needing.length];
171
+ if (target === undefined)
172
+ return;
173
+ setCursor(target.id);
174
+ setOpened((current) => new Set([...current, ...pathTo(target.id)]));
175
+ requestAnimationFrame(() => document.getElementById(`row-${target.id}`)?.scrollIntoView?.({ block: "nearest" }));
176
+ };
177
+ const submit = async (key, sent) => {
178
+ setSending(key);
179
+ setError(null);
180
+ const exit = await send({ askId: ask.id, reply: sent });
181
+ setSending(null);
182
+ if (Exit.isSuccess(exit)) {
183
+ clear();
184
+ onReplied();
185
+ return;
186
+ }
187
+ // As with any reply: one Loopback refused will not go through as it is; one that never
188
+ // reached it can simply be sent again. Either way the drafts are kept.
189
+ const failure = Cause.squash(exit.cause);
190
+ const reason = failure instanceof Error ? failure.message : "no reason given";
191
+ setError(failure instanceof ApiError && failure.status >= 400 && failure.status < 500
192
+ ? { title: "Your replies were not accepted", description: reason }
193
+ : {
194
+ title: "Your replies were not sent",
195
+ description: `Loopback could not take them just now (${reason}), so the agent still has nothing. Send them again.`,
196
+ });
197
+ };
198
+ const shared = { opened, toggle, drafts, update, writable: !closed, now };
199
+ const sendLabel = finishes
200
+ ? "Finish"
201
+ : items.length === 0
202
+ ? "Send"
203
+ : `Send ${items.length === 1 ? "1 reply" : `${items.length} replies`}`;
204
+ const canSend = !busy && yourMove && items.length > 0 && problem === null;
205
+ // ⌘↵ (Ctrl↵ off Apple's keyboards) sends, as it does on every other ask.
206
+ useInboxKeys((event) => {
207
+ if (!canSend || event.key !== "Enter" || !(event.metaKey || event.ctrlKey))
208
+ return;
209
+ event.preventDefault();
210
+ void submit("send", reply);
211
+ });
212
+ return (_jsxs("div", { className: "flex flex-col gap-6", children: [needing.length === 0 || !yourMove ? null : (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Text, { variant: "secondary", size: "sm", children: needing.length === 1 ? "1 point needs you" : `${needing.length} points need you` }), _jsx(Button, { size: "sm", variant: "ghost", icon: _jsx(ArrowDown, {}), onClick: next, children: "Next point that needs you" })] })), tree.sections.map((section, index) => (_jsxs("section", { className: "flex flex-col gap-2", children: [section.title === undefined ? null : _jsx("h2", { className: GROUP_LABEL, children: section.title }), _jsx(Points, { points: section.points, shared: shared })] }, section.title ?? `section-${index}`))), closed ? null : (_jsxs("div", { className: "sticky bottom-4 flex flex-col gap-3 rounded-lg border border-kumo-line bg-kumo-elevated p-4", children: [error === null ? null : (_jsx(Banner, { role: "alert", variant: "error", icon: _jsx(WarningCircle, {}), title: error.title, description: error.description })), yourMove ? (problem === null ? null : (_jsx(Text, { variant: "secondary", size: "sm", children: problem }))) : (_jsx(Text, { variant: "secondary", size: "sm", children: "The agent is answering your replies. You can keep drafting meanwhile." })), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx(Button, { variant: "primary", icon: finishes ? _jsx(Check, {}) : _jsx(PaperPlaneRight, {}), loading: sending === "send", disabled: !canSend, onClick: () => void submit("send", reply), children: sendLabel }), _jsx(Button, { icon: _jsx(Checks, {}), loading: sending === "finish", disabled: busy || !yourMove, onClick: () => void submit("finish", {
213
+ outcome: "responded",
214
+ points: replyFrom(tree, drafts, true),
215
+ }), children: "Finish now" }), _jsx(Button, { variant: "ghost", icon: _jsx(X, {}), loading: sending === "dismiss", disabled: busy, onClick: () => void submit("dismiss", { outcome: "dismissed" }), children: "Dismiss" }), _jsxs("span", { className: "ml-auto hidden text-xs text-kumo-subtle sm:inline", children: [SEND_KEY, "\u21B5 sends"] })] })] }))] }));
216
+ }
@@ -0,0 +1,9 @@
1
+ import type { Block } from "@loopbacktools/sdk";
2
+ /** Markdown an agent wrote. Links open in a new tab; fenced code is highlighted. */
3
+ export declare function Prose({ text }: {
4
+ readonly text: string;
5
+ }): import("react").JSX.Element;
6
+ /** What an agent put up for review, in order. */
7
+ export declare function Blocks({ blocks }: {
8
+ readonly blocks: readonly Block[];
9
+ }): import("react").JSX.Element;
@@ -0,0 +1,59 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Loader, Text } from "@cloudflare/kumo";
3
+ import { CodeHighlighted } from "@cloudflare/kumo/code";
4
+ import { useAtomValue } from "@effect/atom-react";
5
+ import { AsyncResult } from "effect/reactivity";
6
+ import Markdown, {} from "react-markdown";
7
+ import remarkGfm from "remark-gfm";
8
+ import { useClient } from "./context.js";
9
+ import { imageAtom } from "./state.js";
10
+ /** The text inside a markdown node, which react-markdown hands over as children. */
11
+ const textOf = (node) => typeof node === "string" ? node : Array.isArray(node) ? node.map(textOf).join("") : "";
12
+ const markdownComponents = {
13
+ a: ({ href, children }) => (_jsx("a", { href: href, target: "_blank", rel: "noreferrer noopener", children: children })),
14
+ pre: ({ children }) => _jsx(_Fragment, { children: children }),
15
+ code: ({ className, children }) => {
16
+ const language = /language-(\S+)/.exec(className ?? "")?.[1];
17
+ const source = textOf(children).replace(/\n$/, "");
18
+ if (language === undefined && !source.includes("\n"))
19
+ return _jsx("code", { children: children });
20
+ return _jsx(CodeHighlighted, { code: source, lang: language ?? "text", showCopyButton: true });
21
+ },
22
+ };
23
+ /** Markdown an agent wrote. Links open in a new tab; fenced code is highlighted. */
24
+ export function Prose({ text }) {
25
+ return (_jsx("div", { className: "loopback-markdown text-kumo-default", children: _jsx(Markdown, { remarkPlugins: [remarkGfm], components: markdownComponents, children: text }) }));
26
+ }
27
+ function Caption({ children }) {
28
+ return (_jsx(Text, { variant: "secondary", size: "sm", children: children }));
29
+ }
30
+ function Picture({ uploadId, caption }) {
31
+ const result = useAtomValue(imageAtom(useClient())(uploadId));
32
+ if (AsyncResult.isFailure(result))
33
+ return _jsx(Text, { variant: "error", children: "This image could not be loaded." });
34
+ if (!AsyncResult.isSuccess(result)) {
35
+ return (_jsx("div", { className: "flex h-40 items-center justify-center rounded-lg bg-kumo-recessed", children: _jsx(Loader, {}) }));
36
+ }
37
+ return (_jsx("a", { href: result.value, target: "_blank", rel: "noreferrer noopener", children: _jsx("img", { src: result.value, alt: caption ?? "An image the agent uploaded", className: "max-h-[70vh] max-w-full rounded-lg border border-kumo-line" }) }));
38
+ }
39
+ function Image({ uploadId, caption }) {
40
+ return (_jsxs("figure", { className: "flex flex-col gap-2", children: [_jsx(Picture, { uploadId: uploadId, ...(caption === undefined ? {} : { caption }) }), caption === undefined ? null : _jsx(Caption, { children: caption })] }));
41
+ }
42
+ function BlockView({ block }) {
43
+ switch (block.type) {
44
+ case "markdown":
45
+ return _jsx(Prose, { text: block.text });
46
+ case "code":
47
+ return (_jsxs("div", { className: "flex flex-col gap-1", children: [block.filename === undefined ? null : _jsx(Caption, { children: block.filename }), _jsx(CodeHighlighted, { code: block.code, lang: block.language ?? "text", showCopyButton: true })] }));
48
+ case "diff":
49
+ return (_jsxs("div", { className: "flex flex-col gap-1", children: [block.filename === undefined ? null : _jsx(Caption, { children: block.filename }), _jsx(CodeHighlighted, { code: block.diff, lang: "diff", showCopyButton: true })] }));
50
+ case "image":
51
+ return (_jsx(Image, { uploadId: block.uploadId, ...(block.caption === undefined ? {} : { caption: block.caption }) }));
52
+ case "unknown":
53
+ return (_jsx(Caption, { children: "Part of this came from a newer version of Loopback and can't be shown here." }));
54
+ }
55
+ }
56
+ /** What an agent put up for review, in order. */
57
+ export function Blocks({ blocks }) {
58
+ return (_jsx("div", { className: "flex flex-col gap-4", children: blocks.map((block, index) => (_jsx(BlockView, { block: block }, index))) }));
59
+ }
@@ -0,0 +1,42 @@
1
+ import { type InboxState, type LoopbackApi, type Update } from "@loopbacktools/sdk";
2
+ import { type ComponentProps, type ReactNode, type RefObject } from "react";
3
+ /**
4
+ * Gives everything inside it the client the inbox reads and answers through. Hand over another
5
+ * client, for another space or another person, and the inbox follows that one instead.
6
+ */
7
+ export declare function InboxProvider({ client, children, }: {
8
+ readonly client: LoopbackApi;
9
+ readonly children: ReactNode;
10
+ }): import("react").JSX.Element;
11
+ /** The client the nearest `InboxProvider` holds. */
12
+ export declare const useClient: () => LoopbackApi;
13
+ /** The inbox as it stands, kept live while anything renders it. */
14
+ export declare const useInbox: () => InboxState;
15
+ /** Whether Loopback has been updated under the page. */
16
+ export declare const useUpdate: () => Update;
17
+ /** Where the inbox moves between asks: in the host's own routes, or anywhere else it likes. */
18
+ export interface InboxNavigation {
19
+ /** The ask the person opened, or `null` for none. */
20
+ readonly selectedId: string | null;
21
+ /** Opens an ask, or with `null` goes back to the list. */
22
+ readonly onSelect: (askId: string | null) => void;
23
+ /** The address of an ask, for links opened in a new tab. */
24
+ readonly hrefFor: (askId: string) => string;
25
+ }
26
+ export declare const NavigationContext: import("react").Context<InboxNavigation | null>;
27
+ export declare const useNavigation: () => InboxNavigation;
28
+ /**
29
+ * A link to an ask. A plain click opens it in place, through the host's navigation; a modified or
30
+ * middle click is left to the browser, so the ask opens in a new tab at its own address.
31
+ */
32
+ export declare function AskLink({ askId, onClick, children, ...props }: ComponentProps<"a"> & {
33
+ readonly askId: string;
34
+ }): import("react").JSX.Element;
35
+ /** The element the inbox is drawn in, so its shortcuts leave the rest of the page alone. */
36
+ export declare const RootContext: import("react").Context<RefObject<HTMLElement | null> | null>;
37
+ /**
38
+ * Listens for keys anywhere on the page except the host's own controls: an ask that opens by itself
39
+ * has nothing focused, so its shortcuts must work from the page itself, but keys typed into a search
40
+ * box beside the inbox belong to that box.
41
+ */
42
+ export declare const useInboxKeys: (onKey: (event: KeyboardEvent) => void) => void;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useAtomValue } from "@effect/atom-react";
3
+ import { initialInbox } from "@loopbacktools/sdk";
4
+ import { AsyncResult } from "effect/reactivity";
5
+ import { createContext, use, useEffect, } from "react";
6
+ import { inboxAtom, updateAtom } from "./state.js";
7
+ const ClientContext = createContext(null);
8
+ /**
9
+ * Gives everything inside it the client the inbox reads and answers through. Hand over another
10
+ * client, for another space or another person, and the inbox follows that one instead.
11
+ */
12
+ export function InboxProvider({ client, children, }) {
13
+ return _jsx(ClientContext, { value: client, children: children });
14
+ }
15
+ /** The client the nearest `InboxProvider` holds. */
16
+ export const useClient = () => {
17
+ const client = use(ClientContext);
18
+ if (client === null)
19
+ throw new Error("Loopback's inbox renders inside an <InboxProvider>.");
20
+ return client;
21
+ };
22
+ /** The inbox as it stands, kept live while anything renders it. */
23
+ export const useInbox = () => AsyncResult.getOrElse(useAtomValue(inboxAtom(useClient())), () => initialInbox);
24
+ /** Whether Loopback has been updated under the page. */
25
+ export const useUpdate = () => AsyncResult.getOrElse(useAtomValue(updateAtom(useClient())), () => "none");
26
+ export const NavigationContext = createContext(null);
27
+ export const useNavigation = () => {
28
+ const navigation = use(NavigationContext);
29
+ if (navigation === null)
30
+ throw new Error("Loopback's inbox needs its navigation.");
31
+ return navigation;
32
+ };
33
+ /**
34
+ * A link to an ask. A plain click opens it in place, through the host's navigation; a modified or
35
+ * middle click is left to the browser, so the ask opens in a new tab at its own address.
36
+ */
37
+ export function AskLink({ askId, onClick, children, ...props }) {
38
+ const { hrefFor, onSelect } = useNavigation();
39
+ return (_jsx("a", { ...props, href: hrefFor(askId), onClick: (event) => {
40
+ onClick?.(event);
41
+ const modified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
42
+ if (event.defaultPrevented || event.button !== 0 || modified)
43
+ return;
44
+ event.preventDefault();
45
+ onSelect(askId);
46
+ }, children: children }));
47
+ }
48
+ /** The element the inbox is drawn in, so its shortcuts leave the rest of the page alone. */
49
+ export const RootContext = createContext(null);
50
+ /**
51
+ * Listens for keys anywhere on the page except the host's own controls: an ask that opens by itself
52
+ * has nothing focused, so its shortcuts must work from the page itself, but keys typed into a search
53
+ * box beside the inbox belong to that box.
54
+ */
55
+ export const useInboxKeys = (onKey) => {
56
+ const root = use(RootContext);
57
+ useEffect(() => {
58
+ const listener = (event) => {
59
+ const target = event.target;
60
+ const onPage = target === document.body || target === document.documentElement;
61
+ const outside = root?.current != null &&
62
+ target instanceof Node &&
63
+ !onPage &&
64
+ !root.current.contains(target);
65
+ if (!outside)
66
+ onKey(event);
67
+ };
68
+ window.addEventListener("keydown", listener);
69
+ return () => window.removeEventListener("keydown", listener);
70
+ });
71
+ };
@@ -0,0 +1,6 @@
1
+ import { type Ask } from "@loopbacktools/sdk";
2
+ export declare function AskDetail({ ask, onReplied, }: {
3
+ readonly ask: Ask;
4
+ /** Called once a reply is sent: the inbox moves on to whatever is next. */
5
+ readonly onReplied?: () => void;
6
+ }): import("react").JSX.Element;