@loopbacktools/inbox 0.0.0-stage → 0.4.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.
@@ -0,0 +1,45 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Text } from "@cloudflare/kumo";
3
+ import { CaretDown, CaretRight, ChatCircleDots, Eye, ListBullets, Question, } from "@phosphor-icons/react";
4
+ import { AskLink } from "./context.js";
5
+ import { age, headline, pointsNeedingYou, useNow, useRecentOpen } from "./hooks.js";
6
+ export function KindIcon({ ask }) {
7
+ switch (ask.kind) {
8
+ case "question":
9
+ return _jsx(ChatCircleDots, { size: 18, weight: "duotone", "aria-label": "Question" });
10
+ case "review":
11
+ return _jsx(Eye, { size: 18, weight: "duotone", "aria-label": "Review" });
12
+ case "briefing":
13
+ return _jsx(ListBullets, { size: 18, weight: "duotone", "aria-label": "Briefing" });
14
+ case "unknown":
15
+ return _jsx(Question, { size: 18, weight: "duotone", "aria-label": "Can't be shown" });
16
+ }
17
+ }
18
+ /** Whether an agent is blocked on the ask right now. */
19
+ export function Waiting({ ask }) {
20
+ if (ask.status === "closed")
21
+ return null;
22
+ return ask.waiting ? (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs text-kumo-success", children: [_jsx("span", { className: "size-2 animate-pulse rounded-full bg-kumo-success" }), "Agent waiting"] })) : (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs text-kumo-inactive", children: [_jsx("span", { className: "size-2 rounded-full bg-kumo-line" }), "Agent not waiting"] }));
23
+ }
24
+ function Row({ ask, selected, now, }) {
25
+ const needing = pointsNeedingYou(ask);
26
+ return (_jsxs(AskLink, { askId: ask.id, "aria-current": selected ? "page" : undefined, className: `flex gap-3 border-b border-kumo-hairline px-4 py-3 transition-colors hover:bg-kumo-tint ${selected ? "bg-kumo-tint" : ""}`, children: [_jsx("span", { className: "mt-0.5 shrink-0 text-kumo-subtle", children: _jsx(KindIcon, { ask: ask }) }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-1", children: [_jsxs("span", { className: "flex items-baseline gap-2", children: [_jsx(Text, { truncate: true, bold: ask.status === "awaiting_human", children: headline(ask) }), _jsx("span", { className: "ml-auto shrink-0 text-xs text-kumo-subtle tabular-nums", children: age(ask.closedAt ?? ask.updatedAt, now) })] }), _jsxs("span", { className: "flex flex-wrap items-center gap-2", children: [ask.project === undefined ? null : _jsx(Badge, { variant: "neutral", children: ask.project }), needing === 0 ? null : _jsxs(Badge, { variant: "orange", children: [needing, " need you"] }), _jsx(Waiting, { ask: ask })] })] })] }));
27
+ }
28
+ const SECTION_HEADING = "sticky top-0 z-10 flex w-full items-center gap-1.5 border-b border-kumo-hairline bg-kumo-canvas px-4 py-2 text-left text-xs font-medium uppercase tracking-wide text-kumo-subtle";
29
+ function Section({ title, asks, selected, now, collapse, }) {
30
+ if (asks.length === 0)
31
+ return null;
32
+ const count = _jsxs("span", { className: "tabular-nums", children: ["\u00B7 ", asks.length] });
33
+ const open = collapse?.open ?? true;
34
+ return (_jsxs("section", { children: [collapse === undefined ? (_jsxs("h2", { className: SECTION_HEADING, children: [title, " ", count] })) : (_jsx("h2", { children: _jsxs("button", { type: "button", "aria-expanded": open, onClick: collapse.toggle, className: `${SECTION_HEADING} hover:text-kumo-default`, children: [open ? _jsx(CaretDown, { size: 12 }) : _jsx(CaretRight, { size: 12 }), title, " ", count] }) })), open
35
+ ? asks.map((ask) => _jsx(Row, { ask: ask, selected: ask.id === selected, now: now }, ask.id))
36
+ : null] }));
37
+ }
38
+ /** Every ask in the inbox, grouped by whose move it is. */
39
+ export function AskList({ inbox, selected, }) {
40
+ const now = useNow();
41
+ const [recentOpen, toggleRecent] = useRecentOpen();
42
+ const yours = inbox.open.filter((ask) => ask.status === "awaiting_human");
43
+ const theirs = inbox.open.filter((ask) => ask.status === "awaiting_agent");
44
+ return (_jsxs("nav", { "aria-label": "Asks", children: [inbox.open.length === 0 ? (_jsx("p", { className: "px-4 py-6 text-sm text-kumo-subtle", children: "Nothing needs you right now." })) : null, _jsx(Section, { title: "Your turn", asks: yours, selected: selected, now: now }), _jsx(Section, { title: "Waiting on the agent", asks: theirs, selected: selected, now: now }), _jsx(Section, { title: "Recent", asks: inbox.recent, selected: selected, now: now, collapse: { open: recentOpen, toggle: toggleRecent } })] }));
45
+ }
@@ -0,0 +1,2 @@
1
+ /** Reloads the page, onto whatever code now serves it. */
2
+ export declare const reloadPage: () => void;
@@ -0,0 +1,2 @@
1
+ /** Reloads the page, onto whatever code now serves it. */
2
+ export const reloadPage = () => window.location.reload();
@@ -0,0 +1,8 @@
1
+ import type { Actor } from "@loopbacktools/sdk";
2
+ /** Who the inbox is signed in as, once it knows. */
3
+ export declare const useMe: () => string | undefined;
4
+ /**
5
+ * Someone the deployment named, as the person reading sees them: "You" for themselves, and for anyone
6
+ * the deployment could not name, as on a single-person inbox; otherwise their name.
7
+ */
8
+ export declare const whoIs: (actor: Actor | undefined, me: string | undefined) => string;
@@ -0,0 +1,11 @@
1
+ import { useAtomValue } from "@effect/atom-react";
2
+ import { AsyncResult } from "effect/reactivity";
3
+ import { useClient } from "./context.js";
4
+ import { meAtom } from "./state.js";
5
+ /** Who the inbox is signed in as, once it knows. */
6
+ export const useMe = () => AsyncResult.getOrElse(useAtomValue(meAtom(useClient())), () => undefined);
7
+ /**
8
+ * Someone the deployment named, as the person reading sees them: "You" for themselves, and for anyone
9
+ * the deployment could not name, as on a single-person inbox; otherwise their name.
10
+ */
11
+ export const whoIs = (actor, me) => actor === undefined || actor.id === me || actor.name === undefined ? "You" : actor.name;
@@ -0,0 +1,19 @@
1
+ import { type ApiError, type LoopbackApi, type ReplyBody } from "@loopbacktools/sdk";
2
+ import { Atom } from "effect/reactivity";
3
+ /**
4
+ * Everything here is kept per client, so a host that switches the inbox to another space, by
5
+ * handing over another client, starts afresh, and what the last one held is released.
6
+ */
7
+ /** The inbox a client opens, followed live while anything renders it. */
8
+ export declare const inboxAtom: (arg: LoopbackApi) => Atom.Atom<import("effect/reactivity/AsyncResult").AsyncResult<import("@loopbacktools/sdk").InboxState, import("effect/Cause").NoSuchElementError>>;
9
+ /** Whether Loopback has been updated under the page a client is in. */
10
+ export declare const updateAtom: (arg: LoopbackApi) => Atom.Atom<import("effect/reactivity/AsyncResult").AsyncResult<import("@loopbacktools/sdk").Update, import("effect/Cause").NoSuchElementError>>;
11
+ /** Who a client is signed in as: the person whose replies read as "you". */
12
+ export declare const meAtom: (arg: LoopbackApi) => Atom.Atom<import("effect/reactivity/AsyncResult").AsyncResult<string | undefined, never>>;
13
+ /** Sends a reply to an ask. */
14
+ export declare const replyAtom: (arg: LoopbackApi) => Atom.AtomResultFn<{
15
+ readonly askId: string;
16
+ readonly reply: ReplyBody;
17
+ }, unknown, ApiError>;
18
+ /** An uploaded image as an object URL, released when nothing shows it any more. */
19
+ export declare const imageAtom: (arg: LoopbackApi) => (arg: string) => Atom.Atom<import("effect/reactivity/AsyncResult").AsyncResult<string, ApiError>>;
@@ -0,0 +1,17 @@
1
+ import { initialInbox } from "@loopbacktools/sdk";
2
+ import { Effect } from "effect";
3
+ import { Atom } from "effect/reactivity";
4
+ /**
5
+ * Everything here is kept per client, so a host that switches the inbox to another space, by
6
+ * handing over another client, starts afresh, and what the last one held is released.
7
+ */
8
+ /** The inbox a client opens, followed live while anything renders it. */
9
+ export const inboxAtom = Atom.family((client) => Atom.make(() => client.inbox, { initialValue: initialInbox }));
10
+ /** Whether Loopback has been updated under the page a client is in. */
11
+ export const updateAtom = Atom.family((client) => Atom.make(() => client.update, { initialValue: "none" }));
12
+ /** Who a client is signed in as: the person whose replies read as "you". */
13
+ export const meAtom = Atom.family((client) => Atom.make(client.whoami.pipe(Effect.map((whoami) => whoami.actor.id), Effect.catch(() => Effect.succeed(undefined)))));
14
+ /** Sends a reply to an ask. */
15
+ export const replyAtom = Atom.family((client) => Atom.fn((input) => client.reply(input.askId, input.reply)));
16
+ /** An uploaded image as an object URL, released when nothing shows it any more. */
17
+ export const imageAtom = Atom.family((client) => Atom.family((uploadId) => Atom.make(Effect.acquireRelease(Effect.map(client.file(uploadId), (blob) => URL.createObjectURL(blob)), (url) => Effect.sync(() => URL.revokeObjectURL(url))))));
@@ -0,0 +1,15 @@
1
+ import type { Block } from "@loopbacktools/sdk";
2
+ /** A turn the agent took in a thread: its answer, and anything it showed with it. */
3
+ export declare function AgentSaid({ answer, content, at, now, }: {
4
+ readonly answer: string;
5
+ readonly content?: readonly Block[] | undefined;
6
+ readonly at: string;
7
+ readonly now: number;
8
+ }): import("react").JSX.Element;
9
+ /** A turn the person took in a thread: what they did, and their words when they gave any. */
10
+ export declare function PersonSaid({ title, body, at, now, }: {
11
+ readonly title: string;
12
+ readonly body?: string | undefined;
13
+ readonly at: string;
14
+ readonly now: number;
15
+ }): import("react").JSX.Element;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from "@cloudflare/kumo";
3
+ import { Blocks, Prose } from "./content.js";
4
+ import { age } from "./hooks.js";
5
+ /** A turn the agent took in a thread: its answer, and anything it showed with it. */
6
+ export function AgentSaid({ answer, content, at, now, }) {
7
+ return (_jsxs("div", { className: "flex flex-col gap-3 rounded-lg border border-kumo-line bg-kumo-base p-4", children: [_jsxs(Text, { variant: "secondary", size: "sm", children: ["The agent answered \u00B7 ", age(at, now)] }), _jsx(Prose, { text: answer }), content === undefined ? null : _jsx(Blocks, { blocks: content })] }));
8
+ }
9
+ /** A turn the person took in a thread: what they did, and their words when they gave any. */
10
+ export function PersonSaid({ title, body, at, now, }) {
11
+ return (_jsxs("div", { className: "ml-auto flex max-w-[85%] flex-col gap-1 rounded-lg bg-kumo-tint p-4", children: [_jsxs(Text, { size: "sm", bold: true, children: [title, " \u00B7 ", _jsx("span", { className: "font-normal text-kumo-subtle", children: age(at, now) })] }), body === undefined ? null : _jsx(Prose, { text: body })] }));
12
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Says when Loopback has been updated under the page. A later deploy of the same revision of the
3
+ * contract is offered as a reload. A later revision is one this page can't fully read, so it reloads
4
+ * at the first moment that disturbs no one: as soon as the page is hidden, or at once if it already
5
+ * is. What the person has written and not sent is kept in this browser, so the reload loses none of it.
6
+ */
7
+ export declare function UpdateBanner(): import("react").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Banner } from "@cloudflare/kumo";
3
+ import { ArrowClockwise } from "@phosphor-icons/react";
4
+ import { useEffect } from "react";
5
+ import { useUpdate } from "./context.js";
6
+ import { reloadPage } from "./page.js";
7
+ /**
8
+ * Says when Loopback has been updated under the page. A later deploy of the same revision of the
9
+ * contract is offered as a reload. A later revision is one this page can't fully read, so it reloads
10
+ * at the first moment that disturbs no one: as soon as the page is hidden, or at once if it already
11
+ * is. What the person has written and not sent is kept in this browser, so the reload loses none of it.
12
+ */
13
+ export function UpdateBanner() {
14
+ const update = useUpdate();
15
+ useEffect(() => {
16
+ if (update !== "required")
17
+ return undefined;
18
+ const whenHidden = () => {
19
+ if (document.visibilityState === "hidden")
20
+ reloadPage();
21
+ };
22
+ whenHidden();
23
+ document.addEventListener("visibilitychange", whenHidden);
24
+ return () => document.removeEventListener("visibilitychange", whenHidden);
25
+ }, [update]);
26
+ if (update === "none")
27
+ return null;
28
+ return (_jsx(Banner, { size: "sm", className: "m-2", icon: _jsx(ArrowClockwise, {}), title: "Loopback has been updated", description: update === "required"
29
+ ? "This page reloads onto the new version when you next leave it. Anything you haven't sent is kept."
30
+ : "Reload to use the new version.", action: _jsx(Banner.Action, { onClick: reloadPage, children: "Reload" }) }));
31
+ }
package/package.json CHANGED
@@ -1,6 +1,66 @@
1
1
  {
2
2
  "name": "@loopbacktools/inbox",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.4.0",
4
+ "description": "The Loopback inbox as React components: read and answer what agents ask, live, inside your own app.",
5
+ "homepage": "https://github.com/LoopBackTools/loopback/tree/main/packages/inbox#readme",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/LoopBackTools/loopback.git",
10
+ "directory": "packages/inbox"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "src",
15
+ "styles.css"
16
+ ],
17
+ "type": "module",
18
+ "sideEffects": [
19
+ "*.css"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/src/index.d.ts",
24
+ "default": "./dist/src/index.js"
25
+ },
26
+ "./styles.css": "./styles.css"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "dependencies": {
32
+ "@phosphor-icons/react": "2.1.10",
33
+ "react-markdown": "10.1.0",
34
+ "remark-gfm": "4.0.1"
35
+ },
36
+ "devDependencies": {
37
+ "@cloudflare/kumo": "2.15.0",
38
+ "@effect/atom-react": "4.0.6",
39
+ "@testing-library/jest-dom": "7.0.1",
40
+ "@testing-library/react": "16.3.3",
41
+ "@testing-library/user-event": "14.6.8",
42
+ "@types/react": "19.3.0",
43
+ "@types/react-dom": "19.3.0",
44
+ "@vitejs/plugin-react": "6.1.2",
45
+ "effect": "4.0.6",
46
+ "jsdom": "30.1.3",
47
+ "react": "19.3.1",
48
+ "react-dom": "19.3.1",
49
+ "typescript": "7.0.2",
50
+ "vitest": "5.0.5",
51
+ "@loopbacktools/sdk": "0.4.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@cloudflare/kumo": "^2.15.0",
55
+ "@effect/atom-react": "^4.0.6",
56
+ "effect": "^4.0.6",
57
+ "react": "^19.2.0",
58
+ "react-dom": "^19.2.0",
59
+ "@loopbacktools/sdk": "^0.4.0"
60
+ },
61
+ "scripts": {
62
+ "typecheck": "tsc --noEmit",
63
+ "test": "vitest run",
64
+ "build": "tsc -p tsconfig.build.json"
65
+ }
6
66
  }