@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.
package/src/hooks.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { awaitingHuman, type Ask, foldBriefing, type InboxState } from "@loopbacktools/sdk";
2
+ import { useEffect, useRef, useState } from "react";
3
+
4
+ /**
5
+ * The ask to open once `current` has been answered: the next one awaiting the person, below it in
6
+ * the list, or above it if it was last. `null` when nothing else awaits them.
7
+ */
8
+ export const nextAfter = (queue: readonly Ask[], current: string): string | null => {
9
+ const position = queue.findIndex((ask) => ask.id === current);
10
+ const rest = queue.filter((ask) => ask.id !== current);
11
+ return (rest[Math.max(position, 0)] ?? rest.at(-1))?.id ?? null;
12
+ };
13
+
14
+ /**
15
+ * The ask the inbox shows: the one the person picked, or else the top one waiting on them, so
16
+ * something that needs them is never hidden behind a "pick one" screen.
17
+ */
18
+ export const focusOf = (inbox: InboxState, picked: string | null): string | null =>
19
+ picked ?? awaitingHuman(inbox)[0]?.id ?? null;
20
+
21
+ /** The asks the person's own agents raised: what "Mine" shows. */
22
+ export const onlyMine = (inbox: InboxState, me: string): InboxState => {
23
+ const mine = (ask: Ask) => ask.raisedBy?.id === me;
24
+ return { ...inbox, open: inbox.open.filter(mine), recent: inbox.recent.filter(mine) };
25
+ };
26
+
27
+ /** Whether anyone else's agents have asked here; only then is there a "Mine" to pick. */
28
+ export const shared = (inbox: InboxState, me: string | undefined): boolean =>
29
+ me !== undefined &&
30
+ [...inbox.open, ...inbox.recent].some(
31
+ (ask) => ask.raisedBy !== undefined && ask.raisedBy.id !== me,
32
+ );
33
+
34
+ /** Which asks the list shows. */
35
+ export type Filter = "all" | "mine";
36
+
37
+ const FILTER_KEY = "loopback.filter";
38
+
39
+ /** Everyone's asks, or only the person's own agents', as they last left it in this browser. */
40
+ export const useFilter = () => {
41
+ const [filter, setFilter] = useState<Filter>(() => {
42
+ try {
43
+ return localStorage.getItem(FILTER_KEY) === "mine" ? "mine" : "all";
44
+ } catch {
45
+ return "all";
46
+ }
47
+ });
48
+ const choose = (next: Filter) => {
49
+ setFilter(next);
50
+ try {
51
+ localStorage.setItem(FILTER_KEY, next);
52
+ } catch {
53
+ // Remembered for this tab only.
54
+ }
55
+ };
56
+ return [filter, choose] as const;
57
+ };
58
+
59
+ const SIDEBAR_KEY = "loopback.sidebar";
60
+
61
+ /** The inbox list's width in pixels: narrow by default, so a zoomed-in page still leaves room. */
62
+ export const SIDEBAR = { min: 220, max: 560, initial: 288 } as const;
63
+
64
+ const clampWidth = (width: number) =>
65
+ Math.round(Math.min(SIDEBAR.max, Math.max(SIDEBAR.min, width)));
66
+
67
+ /** The inbox list's width, as the person last left it in this browser. */
68
+ export const useSidebarWidth = () => {
69
+ const [width, setWidth] = useState(() => {
70
+ try {
71
+ const saved = Number(localStorage.getItem(SIDEBAR_KEY));
72
+ return Number.isFinite(saved) && saved > 0 ? clampWidth(saved) : SIDEBAR.initial;
73
+ } catch {
74
+ return SIDEBAR.initial;
75
+ }
76
+ });
77
+ const resize = (next: number) => {
78
+ const clamped = clampWidth(next);
79
+ setWidth(clamped);
80
+ try {
81
+ localStorage.setItem(SIDEBAR_KEY, String(clamped));
82
+ } catch {
83
+ // Remembered for this tab only.
84
+ }
85
+ };
86
+ return [width, resize] as const;
87
+ };
88
+
89
+ const RECENT_KEY = "loopback.recent";
90
+
91
+ /**
92
+ * Whether the Recent section is open. Closed by default: what has been answered stays a click
93
+ * away without competing with what still needs the person.
94
+ */
95
+ export const useRecentOpen = () => {
96
+ const [open, setOpen] = useState(() => {
97
+ try {
98
+ return localStorage.getItem(RECENT_KEY) === "open";
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+ const toggle = () => {
104
+ setOpen((was) => {
105
+ try {
106
+ localStorage.setItem(RECENT_KEY, was ? "closed" : "open");
107
+ } catch {
108
+ // Remembered for this tab only.
109
+ }
110
+ return !was;
111
+ });
112
+ };
113
+ return [open, toggle] as const;
114
+ };
115
+
116
+ /** The kind of thing an ask is, in a few words. */
117
+ export const headline = (ask: Ask): string =>
118
+ ask.kind === "question"
119
+ ? ask.prompt.question
120
+ : ask.kind === "unknown"
121
+ ? "An ask this page can't show"
122
+ : ask.prompt.title;
123
+
124
+ /** How many of a briefing's points need the person; none for any other ask, or when it is not their move. */
125
+ export const pointsNeedingYou = (ask: Ask): number =>
126
+ ask.kind === "briefing" && ask.status === "awaiting_human"
127
+ ? foldBriefing(ask.prompt, ask.thread).needsYou
128
+ : 0;
129
+
130
+ /** The modifier that sends with ↵, as the keyboard labels it: ⌘ on Apple's, Ctrl elsewhere. */
131
+ export const SEND_KEY = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
132
+
133
+ /** "just now", "5 min", "3 h", "2 d". */
134
+ export const age = (iso: string, now: number): string => {
135
+ const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
136
+ if (minutes < 1) return "just now";
137
+ if (minutes < 60) return `${minutes} min`;
138
+ const hours = Math.floor(minutes / 60);
139
+ if (hours < 24) return `${hours} h`;
140
+ return `${Math.floor(hours / 24)} d`;
141
+ };
142
+
143
+ /** The current time, ticking every half minute so ages stay true. */
144
+ export const useNow = (): number => {
145
+ const [now, setNow] = useState(() => Date.now());
146
+ useEffect(() => {
147
+ const timer = setInterval(() => setNow(Date.now()), 30_000);
148
+ return () => clearInterval(timer);
149
+ }, []);
150
+ return now;
151
+ };
152
+
153
+ const NOTIFY_KEY = "loopback.notify";
154
+
155
+ const readNotify = () => {
156
+ try {
157
+ return localStorage.getItem(NOTIFY_KEY) === "on";
158
+ } catch {
159
+ return false;
160
+ }
161
+ };
162
+
163
+ /** Whether this browser shows a notification when an agent asks something. */
164
+ export const useNotifications = () => {
165
+ const [enabled, setEnabled] = useState(
166
+ () =>
167
+ readNotify() && typeof Notification !== "undefined" && Notification.permission === "granted",
168
+ );
169
+ const toggle = async () => {
170
+ const next = !enabled;
171
+ if (next && Notification.permission !== "granted") {
172
+ if ((await Notification.requestPermission()) !== "granted") return;
173
+ }
174
+ try {
175
+ localStorage.setItem(NOTIFY_KEY, next ? "on" : "off");
176
+ } catch {
177
+ // Remembered for this tab only.
178
+ }
179
+ setEnabled(next);
180
+ };
181
+ return { supported: typeof Notification !== "undefined", enabled, toggle };
182
+ };
183
+
184
+ /**
185
+ * Keeps the person's attention where it is needed: the tab title counts what awaits them, and a
186
+ * new ask raises a notification while the tab is in the background. Clicking one opens the ask.
187
+ */
188
+ export const useAttention = (inbox: InboxState, notify: boolean, open: (askId: string) => void) => {
189
+ const seen = useRef<Set<string> | null>(null);
190
+ const waiting = awaitingHuman(inbox);
191
+ useEffect(() => {
192
+ document.title = waiting.length === 0 ? "Loopback" : `(${waiting.length}) Loopback`;
193
+ }, [waiting.length]);
194
+ useEffect(() => {
195
+ if (inbox.status !== "live") return;
196
+ const ids = new Set(waiting.map((ask) => ask.id));
197
+ const previous = seen.current;
198
+ seen.current = ids;
199
+ if (previous === null || !notify || !document.hidden) return;
200
+ for (const ask of waiting) {
201
+ if (previous.has(ask.id)) continue;
202
+ const note = new Notification(ask.project ?? "Loopback", {
203
+ body: headline(ask),
204
+ tag: ask.id,
205
+ });
206
+ note.addEventListener("click", () => {
207
+ window.focus();
208
+ open(ask.id);
209
+ });
210
+ }
211
+ }, [inbox.status, waiting, notify, open]);
212
+ };
package/src/inbox.tsx ADDED
@@ -0,0 +1,191 @@
1
+ import { Empty, Loader, Tabs } from "@cloudflare/kumo";
2
+ import { ShikiProvider } from "@cloudflare/kumo/code";
3
+ import { awaitingHuman } from "@loopbacktools/sdk";
4
+ import { CheckCircle, Tray } from "@phosphor-icons/react";
5
+ import { type CSSProperties, type KeyboardEvent, type PointerEvent, useRef } from "react";
6
+ import { type InboxNavigation, NavigationContext, RootContext, useInbox } from "./context.tsx";
7
+ import { AskDetail } from "./detail.tsx";
8
+ import {
9
+ type Filter,
10
+ focusOf,
11
+ nextAfter,
12
+ onlyMine,
13
+ shared,
14
+ SIDEBAR,
15
+ useFilter,
16
+ useSidebarWidth,
17
+ } from "./hooks.ts";
18
+ import { AskList } from "./list.tsx";
19
+ import { useMe } from "./people.ts";
20
+ import { UpdateBanner } from "./update.tsx";
21
+
22
+ /** The languages review code is highlighted in; anything else is shown plain. */
23
+ const LANGUAGES = [
24
+ "typescript",
25
+ "tsx",
26
+ "javascript",
27
+ "jsx",
28
+ "json",
29
+ "html",
30
+ "css",
31
+ "python",
32
+ "bash",
33
+ "yaml",
34
+ "toml",
35
+ "sql",
36
+ "markdown",
37
+ "graphql",
38
+ "hcl",
39
+ "diff",
40
+ ] as const;
41
+
42
+ /** The edge between the list and the ask: drag it, or focus it and use the arrow keys. */
43
+ function ResizeHandle({
44
+ width,
45
+ onResize,
46
+ }: {
47
+ readonly width: number;
48
+ readonly onResize: (width: number) => void;
49
+ }) {
50
+ const drag = useRef<{ readonly x: number; readonly width: number } | null>(null);
51
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
52
+ drag.current = { x: event.clientX, width };
53
+ event.currentTarget.setPointerCapture(event.pointerId);
54
+ };
55
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
56
+ if (drag.current !== null) onResize(drag.current.width + event.clientX - drag.current.x);
57
+ };
58
+ const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
59
+ drag.current = null;
60
+ event.currentTarget.releasePointerCapture(event.pointerId);
61
+ };
62
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
63
+ if (event.key === "ArrowLeft") onResize(width - 16);
64
+ else if (event.key === "ArrowRight") onResize(width + 16);
65
+ else return;
66
+ event.preventDefault();
67
+ };
68
+ return (
69
+ // An <hr> is a static separator; a splitter that resizes is the focusable kind, which only
70
+ // role="separator" can express.
71
+ <div
72
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
73
+ role="separator"
74
+ aria-orientation="vertical"
75
+ aria-label="Resize the inbox list"
76
+ aria-valuenow={width}
77
+ aria-valuemin={SIDEBAR.min}
78
+ aria-valuemax={SIDEBAR.max}
79
+ tabIndex={0}
80
+ onPointerDown={onPointerDown}
81
+ onPointerMove={onPointerMove}
82
+ onPointerUp={onPointerUp}
83
+ onKeyDown={onKeyDown}
84
+ className="relative z-10 -ml-1 hidden w-2 shrink-0 cursor-col-resize touch-none outline-none transition-colors hover:bg-kumo-line focus-visible:bg-kumo-brand md:block"
85
+ />
86
+ );
87
+ }
88
+
89
+ /**
90
+ * The inbox: every ask in a list, and the one open beside it, answered in place. It fills the
91
+ * element it is drawn in, which needs a height. Which ask is open is the host's to keep, in its
92
+ * own routes or anywhere else, through `selectedId`, `onSelect` and `hrefFor`.
93
+ */
94
+ export function Inbox({ selectedId, onSelect, hrefFor }: InboxNavigation) {
95
+ const root = useRef<HTMLDivElement>(null);
96
+ const everything = useInbox();
97
+ const me = useMe();
98
+ const [filter, setFilter] = useFilter();
99
+ // "Mine" is a choice only where other people's agents ask too.
100
+ const choosing = shared(everything, me);
101
+ const inbox =
102
+ choosing && filter === "mine" && me !== undefined ? onlyMine(everything, me) : everything;
103
+ // On a wide screen the top waiting ask opens by itself; on a phone the list comes first.
104
+ const focusId = focusOf(inbox, selectedId);
105
+ // An ask the person opened stays open, whichever asks the list shows.
106
+ const selected =
107
+ focusId === null
108
+ ? undefined
109
+ : [...everything.open, ...everything.recent].find((ask) => ask.id === focusId);
110
+ const [width, resize] = useSidebarWidth();
111
+ // Once an ask is answered, open whatever else awaits the person; with nothing left, clear the
112
+ // view so it shows that everything is done rather than the ask just answered.
113
+ const advance = (answered: string) => onSelect(nextAfter(awaitingHuman(inbox), answered));
114
+ const allDone = inbox.status === "live" && awaitingHuman(inbox).length === 0;
115
+
116
+ return (
117
+ <NavigationContext value={{ selectedId, onSelect, hrefFor }}>
118
+ <RootContext value={root}>
119
+ <ShikiProvider engine="javascript" languages={[...LANGUAGES]}>
120
+ <div ref={root} className="flex h-full min-h-0 flex-1 flex-col">
121
+ <UpdateBanner />
122
+ <div className="flex min-h-0 flex-1">
123
+ <aside
124
+ style={{ "--sidebar": `${width}px` } as CSSProperties}
125
+ className={`w-full overflow-y-auto border-r border-kumo-line bg-kumo-base md:block md:w-(--sidebar) md:shrink-0 ${
126
+ selectedId === null ? "" : "hidden"
127
+ }`}
128
+ >
129
+ {choosing ? (
130
+ <div className="border-b border-kumo-hairline px-4 py-2">
131
+ <Tabs
132
+ size="sm"
133
+ value={filter}
134
+ onValueChange={(value) => setFilter(value as Filter)}
135
+ tabs={[
136
+ { value: "all", label: "Everyone's" },
137
+ { value: "mine", label: "My agents'" },
138
+ ]}
139
+ />
140
+ </div>
141
+ ) : null}
142
+ {inbox.status === "connecting" ? (
143
+ <div className="flex justify-center p-8">
144
+ <Loader />
145
+ </div>
146
+ ) : inbox.open.length === 0 && inbox.recent.length === 0 ? (
147
+ <Empty
148
+ icon={<Tray size={32} />}
149
+ title="Nothing yet"
150
+ description={
151
+ inbox === everything
152
+ ? "When an agent asks you something, it appears here."
153
+ : "When one of your agents asks something, it appears here."
154
+ }
155
+ />
156
+ ) : (
157
+ <AskList inbox={inbox} selected={focusId} />
158
+ )}
159
+ </aside>
160
+ <ResizeHandle width={width} onResize={resize} />
161
+ <main
162
+ className={`min-w-0 flex-1 overflow-y-auto md:block ${selectedId === null ? "hidden" : ""}`}
163
+ >
164
+ {selected !== undefined ? (
165
+ <AskDetail ask={selected} onReplied={() => advance(selected.id)} />
166
+ ) : selectedId !== null && inbox.status === "live" ? (
167
+ <Empty
168
+ title="Not in the inbox"
169
+ description="This ask is older than the inbox keeps."
170
+ />
171
+ ) : (
172
+ <div className="hidden h-full items-center justify-center md:flex">
173
+ {allDone ? (
174
+ <div className="w-full max-w-sm">
175
+ <Empty
176
+ icon={<CheckCircle size={32} weight="duotone" />}
177
+ title="All done"
178
+ description="Nothing needs you right now."
179
+ />
180
+ </div>
181
+ ) : null}
182
+ </div>
183
+ )}
184
+ </main>
185
+ </div>
186
+ </div>
187
+ </ShikiProvider>
188
+ </RootContext>
189
+ </NavigationContext>
190
+ );
191
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { InboxProvider, type InboxNavigation, useClient, useInbox } from "./context.tsx";
2
+ export { Inbox } from "./inbox.tsx";
3
+ export { useAttention, useNotifications } from "./hooks.ts";
4
+ export { useMe, whoIs } from "./people.ts";
package/src/list.tsx ADDED
@@ -0,0 +1,157 @@
1
+ import { Badge, Text } from "@cloudflare/kumo";
2
+ import type { Ask, InboxState } from "@loopbacktools/sdk";
3
+ import {
4
+ CaretDown,
5
+ CaretRight,
6
+ ChatCircleDots,
7
+ Eye,
8
+ ListBullets,
9
+ Question,
10
+ } from "@phosphor-icons/react";
11
+ import { AskLink } from "./context.tsx";
12
+ import { age, headline, pointsNeedingYou, useNow, useRecentOpen } from "./hooks.ts";
13
+
14
+ export function KindIcon({ ask }: { readonly ask: Ask }) {
15
+ switch (ask.kind) {
16
+ case "question":
17
+ return <ChatCircleDots size={18} weight="duotone" aria-label="Question" />;
18
+ case "review":
19
+ return <Eye size={18} weight="duotone" aria-label="Review" />;
20
+ case "briefing":
21
+ return <ListBullets size={18} weight="duotone" aria-label="Briefing" />;
22
+ case "unknown":
23
+ return <Question size={18} weight="duotone" aria-label="Can't be shown" />;
24
+ }
25
+ }
26
+
27
+ /** Whether an agent is blocked on the ask right now. */
28
+ export function Waiting({ ask }: { readonly ask: Ask }) {
29
+ if (ask.status === "closed") return null;
30
+ return ask.waiting ? (
31
+ <span className="inline-flex items-center gap-1.5 text-xs text-kumo-success">
32
+ <span className="size-2 animate-pulse rounded-full bg-kumo-success" />
33
+ Agent waiting
34
+ </span>
35
+ ) : (
36
+ <span className="inline-flex items-center gap-1.5 text-xs text-kumo-inactive">
37
+ <span className="size-2 rounded-full bg-kumo-line" />
38
+ Agent not waiting
39
+ </span>
40
+ );
41
+ }
42
+
43
+ function Row({
44
+ ask,
45
+ selected,
46
+ now,
47
+ }: {
48
+ readonly ask: Ask;
49
+ readonly selected: boolean;
50
+ readonly now: number;
51
+ }) {
52
+ const needing = pointsNeedingYou(ask);
53
+ return (
54
+ <AskLink
55
+ askId={ask.id}
56
+ aria-current={selected ? "page" : undefined}
57
+ className={`flex gap-3 border-b border-kumo-hairline px-4 py-3 transition-colors hover:bg-kumo-tint ${
58
+ selected ? "bg-kumo-tint" : ""
59
+ }`}
60
+ >
61
+ <span className="mt-0.5 shrink-0 text-kumo-subtle">
62
+ <KindIcon ask={ask} />
63
+ </span>
64
+ <span className="flex min-w-0 flex-1 flex-col gap-1">
65
+ <span className="flex items-baseline gap-2">
66
+ <Text truncate bold={ask.status === "awaiting_human"}>
67
+ {headline(ask)}
68
+ </Text>
69
+ <span className="ml-auto shrink-0 text-xs text-kumo-subtle tabular-nums">
70
+ {age(ask.closedAt ?? ask.updatedAt, now)}
71
+ </span>
72
+ </span>
73
+ <span className="flex flex-wrap items-center gap-2">
74
+ {ask.project === undefined ? null : <Badge variant="neutral">{ask.project}</Badge>}
75
+ {needing === 0 ? null : <Badge variant="orange">{needing} need you</Badge>}
76
+ <Waiting ask={ask} />
77
+ </span>
78
+ </span>
79
+ </AskLink>
80
+ );
81
+ }
82
+
83
+ const SECTION_HEADING =
84
+ "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";
85
+
86
+ function Section({
87
+ title,
88
+ asks,
89
+ selected,
90
+ now,
91
+ collapse,
92
+ }: {
93
+ readonly title: string;
94
+ readonly asks: readonly Ask[];
95
+ readonly selected: string | null;
96
+ readonly now: number;
97
+ /** Makes the section foldable: its heading opens and closes it. */
98
+ readonly collapse?: { readonly open: boolean; readonly toggle: () => void };
99
+ }) {
100
+ if (asks.length === 0) return null;
101
+ const count = <span className="tabular-nums">· {asks.length}</span>;
102
+ const open = collapse?.open ?? true;
103
+ return (
104
+ <section>
105
+ {collapse === undefined ? (
106
+ <h2 className={SECTION_HEADING}>
107
+ {title} {count}
108
+ </h2>
109
+ ) : (
110
+ <h2>
111
+ <button
112
+ type="button"
113
+ aria-expanded={open}
114
+ onClick={collapse.toggle}
115
+ className={`${SECTION_HEADING} hover:text-kumo-default`}
116
+ >
117
+ {open ? <CaretDown size={12} /> : <CaretRight size={12} />}
118
+ {title} {count}
119
+ </button>
120
+ </h2>
121
+ )}
122
+ {open
123
+ ? asks.map((ask) => <Row key={ask.id} ask={ask} selected={ask.id === selected} now={now} />)
124
+ : null}
125
+ </section>
126
+ );
127
+ }
128
+
129
+ /** Every ask in the inbox, grouped by whose move it is. */
130
+ export function AskList({
131
+ inbox,
132
+ selected,
133
+ }: {
134
+ readonly inbox: InboxState;
135
+ readonly selected: string | null;
136
+ }) {
137
+ const now = useNow();
138
+ const [recentOpen, toggleRecent] = useRecentOpen();
139
+ const yours = inbox.open.filter((ask) => ask.status === "awaiting_human");
140
+ const theirs = inbox.open.filter((ask) => ask.status === "awaiting_agent");
141
+ return (
142
+ <nav aria-label="Asks">
143
+ {inbox.open.length === 0 ? (
144
+ <p className="px-4 py-6 text-sm text-kumo-subtle">Nothing needs you right now.</p>
145
+ ) : null}
146
+ <Section title="Your turn" asks={yours} selected={selected} now={now} />
147
+ <Section title="Waiting on the agent" asks={theirs} selected={selected} now={now} />
148
+ <Section
149
+ title="Recent"
150
+ asks={inbox.recent}
151
+ selected={selected}
152
+ now={now}
153
+ collapse={{ open: recentOpen, toggle: toggleRecent }}
154
+ />
155
+ </nav>
156
+ );
157
+ }
package/src/page.ts ADDED
@@ -0,0 +1,2 @@
1
+ /** Reloads the page, onto whatever code now serves it. */
2
+ export const reloadPage = (): void => window.location.reload();
package/src/people.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { useAtomValue } from "@effect/atom-react";
2
+ import type { Actor } from "@loopbacktools/sdk";
3
+ import { AsyncResult } from "effect/reactivity";
4
+ import { useClient } from "./context.tsx";
5
+ import { meAtom } from "./state.ts";
6
+
7
+ /** Who the inbox is signed in as, once it knows. */
8
+ export const useMe = (): string | undefined =>
9
+ AsyncResult.getOrElse(useAtomValue(meAtom(useClient())), () => undefined);
10
+
11
+ /**
12
+ * Someone the deployment named, as the person reading sees them: "You" for themselves, and for anyone
13
+ * the deployment could not name, as on a single-person inbox; otherwise their name.
14
+ */
15
+ export const whoIs = (actor: Actor | undefined, me: string | undefined): string =>
16
+ actor === undefined || actor.id === me || actor.name === undefined ? "You" : actor.name;
package/src/state.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { type ApiError, initialInbox, type LoopbackApi, type ReplyBody } from "@loopbacktools/sdk";
2
+ import { Effect } from "effect";
3
+ import { Atom } from "effect/reactivity";
4
+
5
+ /**
6
+ * Everything here is kept per client, so a host that switches the inbox to another space, by
7
+ * handing over another client, starts afresh, and what the last one held is released.
8
+ */
9
+
10
+ /** The inbox a client opens, followed live while anything renders it. */
11
+ export const inboxAtom = Atom.family((client: LoopbackApi) =>
12
+ Atom.make(() => client.inbox, { initialValue: initialInbox }),
13
+ );
14
+
15
+ /** Whether Loopback has been updated under the page a client is in. */
16
+ export const updateAtom = Atom.family((client: LoopbackApi) =>
17
+ Atom.make(() => client.update, { initialValue: "none" as const }),
18
+ );
19
+
20
+ /** Who a client is signed in as: the person whose replies read as "you". */
21
+ export const meAtom = Atom.family((client: LoopbackApi) =>
22
+ Atom.make(
23
+ client.whoami.pipe(
24
+ Effect.map((whoami) => whoami.actor.id),
25
+ Effect.catch(() => Effect.succeed(undefined)),
26
+ ),
27
+ ),
28
+ );
29
+
30
+ /** Sends a reply to an ask. */
31
+ export const replyAtom = Atom.family((client: LoopbackApi) =>
32
+ Atom.fn(
33
+ (input: {
34
+ readonly askId: string;
35
+ readonly reply: ReplyBody;
36
+ }): Effect.Effect<unknown, ApiError> => client.reply(input.askId, input.reply),
37
+ ),
38
+ );
39
+
40
+ /** An uploaded image as an object URL, released when nothing shows it any more. */
41
+ export const imageAtom = Atom.family((client: LoopbackApi) =>
42
+ Atom.family((uploadId: string) =>
43
+ Atom.make(
44
+ Effect.acquireRelease(
45
+ Effect.map(client.file(uploadId), (blob) => URL.createObjectURL(blob)),
46
+ (url) => Effect.sync(() => URL.revokeObjectURL(url)),
47
+ ),
48
+ ),
49
+ ),
50
+ );
package/src/thread.tsx ADDED
@@ -0,0 +1,49 @@
1
+ import { Text } from "@cloudflare/kumo";
2
+ import type { Block } from "@loopbacktools/sdk";
3
+ import { Blocks, Prose } from "./content.tsx";
4
+ import { age } from "./hooks.ts";
5
+
6
+ /** A turn the agent took in a thread: its answer, and anything it showed with it. */
7
+ export function AgentSaid({
8
+ answer,
9
+ content,
10
+ at,
11
+ now,
12
+ }: {
13
+ readonly answer: string;
14
+ readonly content?: readonly Block[] | undefined;
15
+ readonly at: string;
16
+ readonly now: number;
17
+ }) {
18
+ return (
19
+ <div className="flex flex-col gap-3 rounded-lg border border-kumo-line bg-kumo-base p-4">
20
+ <Text variant="secondary" size="sm">
21
+ The agent answered · {age(at, now)}
22
+ </Text>
23
+ <Prose text={answer} />
24
+ {content === undefined ? null : <Blocks blocks={content} />}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ /** A turn the person took in a thread: what they did, and their words when they gave any. */
30
+ export function PersonSaid({
31
+ title,
32
+ body,
33
+ at,
34
+ now,
35
+ }: {
36
+ readonly title: string;
37
+ readonly body?: string | undefined;
38
+ readonly at: string;
39
+ readonly now: number;
40
+ }) {
41
+ return (
42
+ <div className="ml-auto flex max-w-[85%] flex-col gap-1 rounded-lg bg-kumo-tint p-4">
43
+ <Text size="sm" bold>
44
+ {title} · <span className="font-normal text-kumo-subtle">{age(at, now)}</span>
45
+ </Text>
46
+ {body === undefined ? null : <Prose text={body} />}
47
+ </div>
48
+ );
49
+ }