@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,239 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Badge, Banner, Button, InputArea, Text } from "@cloudflare/kumo";
3
+ import { useAtomSet } from "@effect/atom-react";
4
+ import { ApiError, } from "@loopbacktools/sdk";
5
+ import { ArrowClockwise, ArrowLeft, Check, ChatTeardropText, PaperPlaneRight, WarningCircle, X, } from "@phosphor-icons/react";
6
+ import { Cause, Exit } from "effect";
7
+ import { Component, useState } from "react";
8
+ import { BriefingBody } from "./briefing.js";
9
+ import { Blocks, Prose } from "./content.js";
10
+ import { AskLink, useClient, useInboxKeys, useNavigation } from "./context.js";
11
+ import { age, SEND_KEY, useNow } from "./hooks.js";
12
+ import { KindIcon, Waiting } from "./list.js";
13
+ import { reloadPage } from "./page.js";
14
+ import { useMe, whoIs } from "./people.js";
15
+ import { replyAtom } from "./state.js";
16
+ import { AgentSaid, PersonSaid } from "./thread.js";
17
+ const STATUS = {
18
+ awaiting_human: { label: "Your turn", variant: "orange" },
19
+ awaiting_agent: { label: "Waiting on the agent", variant: "neutral" },
20
+ closed: { label: "Closed", variant: "green" },
21
+ };
22
+ /** A reply in the words of the person who gave it. */
23
+ const said = (reply, who) => {
24
+ switch (reply.outcome) {
25
+ case "answered": {
26
+ const chose = reply.selected.length === 0
27
+ ? `${who} answered`
28
+ : `${who} chose ${reply.selected.join(", ")}`;
29
+ return reply.text === undefined ? { title: chose } : { title: chose, body: reply.text };
30
+ }
31
+ case "approved":
32
+ return reply.note === undefined
33
+ ? { title: `${who} approved` }
34
+ : { title: `${who} approved`, body: reply.note };
35
+ case "changes_requested":
36
+ return { title: `${who} asked for changes`, body: reply.feedback };
37
+ case "follow_up":
38
+ return { title: `${who} asked`, body: reply.question };
39
+ case "dismissed":
40
+ return { title: `${who} dismissed this` };
41
+ case "responded":
42
+ return {
43
+ title: `${who} replied on ${reply.points.length} ${reply.points.length === 1 ? "point" : "points"}`,
44
+ };
45
+ case "unknown":
46
+ return { title: `${who} replied in a way this page can't show` };
47
+ }
48
+ };
49
+ function Turn({ entry, now }) {
50
+ const me = useMe();
51
+ if (entry.author === "agent") {
52
+ return _jsx(AgentSaid, { answer: entry.answer, content: entry.content, at: entry.at, now: now });
53
+ }
54
+ const { title, body } = said(entry.reply, whoIs(entry.by, me));
55
+ return _jsx(PersonSaid, { title: title, body: body, at: entry.at, now: now });
56
+ }
57
+ const NO_NOTE = { text: "", picked: [] };
58
+ const noteKey = (askId) => `loopback.note.${askId}`;
59
+ /** A note kept in this browser, read as leniently as another version of this page may have left it. */
60
+ const readNote = (askId) => {
61
+ try {
62
+ const kept = JSON.parse(localStorage.getItem(noteKey(askId)) ?? "null");
63
+ return {
64
+ text: typeof kept?.text === "string" ? kept.text : "",
65
+ picked: Array.isArray(kept?.picked)
66
+ ? kept.picked.filter((label) => typeof label === "string")
67
+ : [],
68
+ };
69
+ }
70
+ catch {
71
+ return NO_NOTE;
72
+ }
73
+ };
74
+ /**
75
+ * Keeps what the person has written or picked on an ask, and not sent, in this browser, as a
76
+ * briefing's drafts are: a reload onto a newer Loopback, or the agent's next turn, loses none of it.
77
+ */
78
+ const useNote = (askId) => {
79
+ const [note, setNote] = useState(() => readNote(askId));
80
+ const keep = (next) => {
81
+ setNote(next);
82
+ try {
83
+ if (next.text === "" && next.picked.length === 0)
84
+ localStorage.removeItem(noteKey(askId));
85
+ else
86
+ localStorage.setItem(noteKey(askId), JSON.stringify(next));
87
+ }
88
+ catch {
89
+ // Storage can be unavailable (a private window); the note then lasts as long as the page.
90
+ }
91
+ };
92
+ return {
93
+ ...note,
94
+ setText: (text) => keep({ ...note, text }),
95
+ setPicked: (change) => keep({ ...note, picked: change(note.picked) }),
96
+ clear: () => keep(NO_NOTE),
97
+ };
98
+ };
99
+ /** The reply controls: one box for words, and the moves this kind of ask allows. */
100
+ function ReplyPanel({ ask, onReplied }) {
101
+ const send = useAtomSet(replyAtom(useClient()), { mode: "promiseExit" });
102
+ const { text, picked, setText, setPicked, clear } = useNote(ask.id);
103
+ const [sending, setSending] = useState(null);
104
+ const [error, setError] = useState(null);
105
+ const words = text.trim();
106
+ const submit = async (key, reply) => {
107
+ setSending(key);
108
+ setError(null);
109
+ const exit = await send({ askId: ask.id, reply });
110
+ setSending(null);
111
+ if (Exit.isSuccess(exit)) {
112
+ clear();
113
+ onReplied();
114
+ return;
115
+ }
116
+ // A reply Loopback refused will not go through as it is; one that never reached it, or met
117
+ // a fault there, can simply be sent again. Either way the agent has nothing yet.
118
+ const failure = Cause.squash(exit.cause);
119
+ const reason = failure instanceof Error ? failure.message : "no reason given";
120
+ setError(failure instanceof ApiError && failure.status >= 400 && failure.status < 500
121
+ ? { title: "Your reply was not accepted", description: reason }
122
+ : {
123
+ title: "Your reply was not sent",
124
+ description: `Loopback could not take it just now (${reason}), so the agent still has nothing. Send it again.`,
125
+ });
126
+ };
127
+ const note = words === "" ? {} : { text: words };
128
+ const question = ask.kind === "question" ? ask : null;
129
+ const choose = (label) => {
130
+ if (question === null)
131
+ return;
132
+ if (question.prompt.multiple) {
133
+ setPicked((current) => current.includes(label) ? current.filter((item) => item !== label) : [...current, label]);
134
+ }
135
+ else {
136
+ void submit(label, { outcome: "answered", selected: [label], ...note });
137
+ }
138
+ };
139
+ const primary = () => question !== null
140
+ ? picked.length > 0 || words !== ""
141
+ ? ["send", { outcome: "answered", selected: [...picked], ...note }]
142
+ : null
143
+ : ["approve", { outcome: "approved", ...(words === "" ? {} : { note: words }) }];
144
+ const busy = sending !== null;
145
+ // ⌘↵ (Ctrl↵ off Apple's keyboards) sends the main reply wherever focus is, since an ask that opens by itself has nothing
146
+ // focused; 1-9 pick a choice while the reply box does not have focus.
147
+ useInboxKeys((event) => {
148
+ if (busy)
149
+ return;
150
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
151
+ const move = primary();
152
+ if (move === null)
153
+ return;
154
+ event.preventDefault();
155
+ void submit(...move);
156
+ return;
157
+ }
158
+ const target = event.target;
159
+ if (question === null || event.metaKey || event.ctrlKey)
160
+ return;
161
+ if (target?.closest("input, textarea, [contenteditable]"))
162
+ return;
163
+ const option = question.prompt.options[Number(event.key) - 1];
164
+ if (option !== undefined)
165
+ choose(option.label);
166
+ });
167
+ return (_jsxs("div", { className: "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 })), question === null || question.prompt.options.length === 0 ? null : (_jsx(Choices, { question: question, picked: picked, busy: busy, sending: sending, onChoose: choose })), _jsx(InputArea, { "aria-label": question === null ? "Feedback or a note" : "Your answer, or a note", placeholder: question === null
168
+ ? "Feedback, a note to go with your approval, or a question"
169
+ : question.prompt.options.length === 0
170
+ ? "Your answer, or a question back"
171
+ : "Add a note, answer in your own words, or ask a question back", value: text, onValueChange: setText, autoResize: true, minRows: 3, maxRows: 12, disabled: busy }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [question === null ? (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "primary", icon: _jsx(Check, {}), loading: sending === "approve", disabled: busy, onClick: () => void submit("approve", {
172
+ outcome: "approved",
173
+ ...(words === "" ? {} : { note: words }),
174
+ }), children: "Approve" }), _jsx(Button, { loading: sending === "changes", disabled: busy || words === "", onClick: () => void submit("changes", { outcome: "changes_requested", feedback: words }), children: "Request changes" })] })) : (_jsx(Button, { variant: "primary", icon: _jsx(PaperPlaneRight, {}), loading: sending === "send", disabled: busy || (picked.length === 0 && words === ""), onClick: () => void submit("send", { outcome: "answered", selected: [...picked], ...note }), children: "Send" })), _jsx(Button, { icon: _jsx(ChatTeardropText, {}), loading: sending === "ask", disabled: busy || words === "", onClick: () => void submit("ask", { outcome: "follow_up", question: words }), children: "Ask back" }), _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 ", question === null ? "approves" : "sends"] })] })] }));
175
+ }
176
+ function Choices({ question, picked, busy, sending, onChoose, }) {
177
+ return (_jsxs("div", { className: "flex flex-col gap-2", children: [question.prompt.options.map((option, index) => {
178
+ const on = picked.includes(option.label);
179
+ return (_jsxs("button", { type: "button", disabled: busy, "aria-pressed": question.prompt.multiple ? on : undefined, onClick: () => onChoose(option.label), className: `flex items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors disabled:opacity-60 ${on
180
+ ? "border-kumo-brand bg-kumo-tint"
181
+ : "border-kumo-line bg-kumo-base hover:bg-kumo-tint"}`, children: [_jsx("span", { className: "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded border border-kumo-line text-xs text-kumo-subtle tabular-nums", children: sending === option.label ? "…" : index + 1 }), _jsxs("span", { className: "flex flex-col", children: [_jsx(Text, { bold: true, children: option.label }), option.description === undefined ? null : (_jsx(Text, { variant: "secondary", size: "sm", children: option.description }))] })] }, option.label));
182
+ }), _jsx(Text, { variant: "secondary", size: "xs", children: question.prompt.multiple
183
+ ? "Pick any that apply, then send."
184
+ : "Picking a choice sends it, with any note you have typed." })] }));
185
+ }
186
+ const nothing = () => { };
187
+ /**
188
+ * An ask the inbox could not draw, with the moves still open to the person: dismissing it, and, for
189
+ * one sent by a newer version of Loopback than this page, reloading to see it.
190
+ */
191
+ function CannotShow({ ask, onReplied, newer = false, }) {
192
+ const { onSelect } = useNavigation();
193
+ const send = useAtomSet(replyAtom(useClient()), { mode: "promiseExit" });
194
+ const [sending, setSending] = useState(false);
195
+ const [failed, setFailed] = useState(null);
196
+ const dismiss = async () => {
197
+ setSending(true);
198
+ setFailed(null);
199
+ const exit = await send({ askId: ask.id, reply: { outcome: "dismissed" } });
200
+ setSending(false);
201
+ if (Exit.isSuccess(exit)) {
202
+ onReplied();
203
+ return;
204
+ }
205
+ const failure = Cause.squash(exit.cause);
206
+ setFailed(failure instanceof Error ? failure.message : "no reason given");
207
+ };
208
+ return (_jsxs("article", { className: "mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 md:px-8", children: [_jsxs("button", { type: "button", onClick: () => onSelect(null), className: "inline-flex w-fit items-center gap-1 text-sm text-kumo-subtle md:hidden", children: [_jsx(ArrowLeft, {}), " Inbox"] }), _jsx(Banner, { variant: "error", icon: _jsx(WarningCircle, {}), title: "This ask can't be shown", description: newer
209
+ ? "It came from a newer version of Loopback than this page. Reload the page to see it."
210
+ : "It doesn't match what this inbox knows how to draw, perhaps because it was sent while Loopback was being updated. Dismiss it, and the agent can send it again." }), failed === null ? null : (_jsx(Banner, { role: "alert", variant: "error", icon: _jsx(WarningCircle, {}), title: "It was not dismissed", description: failed })), _jsxs("div", { className: "flex gap-2", children: [newer ? (_jsx(Button, { className: "w-fit", variant: "secondary", icon: _jsx(ArrowClockwise, {}), onClick: reloadPage, children: "Reload" })) : null, ask.status === "closed" ? null : (_jsx(Button, { className: "w-fit", variant: "ghost", icon: _jsx(X, {}), loading: sending, onClick: () => void dismiss(), children: "Dismiss" }))] })] }));
211
+ }
212
+ /** Keeps one ask that cannot be drawn from taking the whole inbox down with it. */
213
+ class Drawable extends Component {
214
+ state = { failed: false };
215
+ static getDerivedStateFromError() {
216
+ return { failed: true };
217
+ }
218
+ componentDidCatch(error) {
219
+ console.error(`Ask ${this.props.ask.id} could not be shown:`, error);
220
+ }
221
+ render() {
222
+ return this.state.failed ? (_jsx(CannotShow, { ask: this.props.ask, onReplied: this.props.onReplied })) : (this.props.children);
223
+ }
224
+ }
225
+ export function AskDetail({ ask, onReplied = nothing, }) {
226
+ if (ask.kind === "unknown")
227
+ return _jsx(CannotShow, { ask: ask, onReplied: onReplied, newer: true });
228
+ return (_jsx(Drawable, { ask: ask, onReplied: onReplied, children: _jsx(AskView, { ask: ask, onReplied: onReplied }) }, ask.id));
229
+ }
230
+ /** One ask: what the agent wants, the thread so far, and the moves left. */
231
+ function AskView({ ask, onReplied, }) {
232
+ const { onSelect } = useNavigation();
233
+ const now = useNow();
234
+ const me = useMe();
235
+ const status = STATUS[ask.status];
236
+ return (_jsxs("article", { className: "mx-auto flex max-w-3xl flex-col gap-6 px-4 py-6 md:px-8", children: [_jsxs("header", { className: "flex flex-col gap-3", children: [_jsxs("button", { type: "button", onClick: () => onSelect(null), className: "inline-flex w-fit items-center gap-1 text-sm text-kumo-subtle md:hidden", children: [_jsx(ArrowLeft, {}), " Inbox"] }), _jsxs("div", { className: "flex flex-wrap items-center gap-2 text-kumo-subtle", children: [_jsx(KindIcon, { ask: ask }), _jsx(Badge, { variant: status.variant, children: status.label }), ask.project === undefined ? null : _jsx(Badge, { variant: "neutral", children: ask.project }), _jsx(Waiting, { ask: ask }), _jsxs("span", { className: "ml-auto text-xs tabular-nums", children: [ask.raisedBy?.name === undefined || ask.raisedBy.id === me
237
+ ? ""
238
+ : `${ask.raisedBy.name}'s agent · `, ask.client === undefined ? "" : `${ask.client} · `, age(ask.createdAt, now)] })] }), ask.kind === "question" ? (_jsx("div", { className: "text-lg", children: _jsx(Prose, { text: ask.prompt.question }) })) : (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "heading", as: "h1", children: ask.prompt.title }), ask.kind !== "review" || ask.revises === undefined ? null : (_jsx(AskLink, { askId: ask.revises, className: "text-sm text-kumo-link underline", children: "Revises an earlier review" })), ask.prompt.summary === undefined ? null : (_jsx("div", { className: "text-kumo-subtle", children: _jsx(Prose, { text: ask.prompt.summary }) }))] }))] }), ask.kind === "briefing" ? (_jsx(BriefingBody, { ask: ask, onReplied: onReplied }, ask.id)) : ask.kind === "question" ? (ask.prompt.context === undefined ? null : (_jsxs("details", { className: "rounded-lg border border-kumo-line bg-kumo-base p-4", children: [_jsx("summary", { className: "cursor-pointer text-sm text-kumo-subtle", children: "Context" }), _jsx("div", { className: "mt-3", children: _jsx(Prose, { text: ask.prompt.context }) })] }))) : (_jsx("div", { className: "rounded-lg border border-kumo-line bg-kumo-base p-4 md:p-6", children: _jsx(Blocks, { blocks: ask.prompt.content }) })), ask.kind === "briefing" || ask.thread.length === 0 ? null : (_jsx("div", { className: "flex flex-col gap-3", children: ask.thread.map((entry) => (_jsx(Turn, { entry: entry, now: now }, `${entry.author}:${entry.at}`))) })), ask.kind === "briefing" ? null : ask.status === "awaiting_human" ? (_jsx(ReplyPanel, { ask: ask, onReplied: onReplied }, `${ask.id}:${ask.thread.length}`)) : ask.status === "awaiting_agent" ? (_jsx(Text, { variant: "secondary", children: "The agent has your question and will answer here." })) : null] }));
239
+ }
@@ -0,0 +1,53 @@
1
+ import { type Ask, type InboxState } from "@loopbacktools/sdk";
2
+ /**
3
+ * The ask to open once `current` has been answered: the next one awaiting the person, below it in
4
+ * the list, or above it if it was last. `null` when nothing else awaits them.
5
+ */
6
+ export declare const nextAfter: (queue: readonly Ask[], current: string) => string | null;
7
+ /**
8
+ * The ask the inbox shows: the one the person picked, or else the top one waiting on them, so
9
+ * something that needs them is never hidden behind a "pick one" screen.
10
+ */
11
+ export declare const focusOf: (inbox: InboxState, picked: string | null) => string | null;
12
+ /** The asks the person's own agents raised: what "Mine" shows. */
13
+ export declare const onlyMine: (inbox: InboxState, me: string) => InboxState;
14
+ /** Whether anyone else's agents have asked here; only then is there a "Mine" to pick. */
15
+ export declare const shared: (inbox: InboxState, me: string | undefined) => boolean;
16
+ /** Which asks the list shows. */
17
+ export type Filter = "all" | "mine";
18
+ /** Everyone's asks, or only the person's own agents', as they last left it in this browser. */
19
+ export declare const useFilter: () => readonly [Filter, (next: Filter) => void];
20
+ /** The inbox list's width in pixels: narrow by default, so a zoomed-in page still leaves room. */
21
+ export declare const SIDEBAR: {
22
+ readonly min: 220;
23
+ readonly max: 560;
24
+ readonly initial: 288;
25
+ };
26
+ /** The inbox list's width, as the person last left it in this browser. */
27
+ export declare const useSidebarWidth: () => readonly [number, (next: number) => void];
28
+ /**
29
+ * Whether the Recent section is open. Closed by default: what has been answered stays a click
30
+ * away without competing with what still needs the person.
31
+ */
32
+ export declare const useRecentOpen: () => readonly [boolean, () => void];
33
+ /** The kind of thing an ask is, in a few words. */
34
+ export declare const headline: (ask: Ask) => string;
35
+ /** How many of a briefing's points need the person; none for any other ask, or when it is not their move. */
36
+ export declare const pointsNeedingYou: (ask: Ask) => number;
37
+ /** The modifier that sends with ↵, as the keyboard labels it: ⌘ on Apple's, Ctrl elsewhere. */
38
+ export declare const SEND_KEY: string;
39
+ /** "just now", "5 min", "3 h", "2 d". */
40
+ export declare const age: (iso: string, now: number) => string;
41
+ /** The current time, ticking every half minute so ages stay true. */
42
+ export declare const useNow: () => number;
43
+ /** Whether this browser shows a notification when an agent asks something. */
44
+ export declare const useNotifications: () => {
45
+ supported: boolean;
46
+ enabled: boolean;
47
+ toggle: () => Promise<void>;
48
+ };
49
+ /**
50
+ * Keeps the person's attention where it is needed: the tab title counts what awaits them, and a
51
+ * new ask raises a notification while the tab is in the background. Clicking one opens the ask.
52
+ */
53
+ export declare const useAttention: (inbox: InboxState, notify: boolean, open: (askId: string) => void) => void;
@@ -0,0 +1,193 @@
1
+ import { awaitingHuman, foldBriefing } from "@loopbacktools/sdk";
2
+ import { useEffect, useRef, useState } from "react";
3
+ /**
4
+ * The ask to open once `current` has been answered: the next one awaiting the person, below it in
5
+ * the list, or above it if it was last. `null` when nothing else awaits them.
6
+ */
7
+ export const nextAfter = (queue, current) => {
8
+ const position = queue.findIndex((ask) => ask.id === current);
9
+ const rest = queue.filter((ask) => ask.id !== current);
10
+ return (rest[Math.max(position, 0)] ?? rest.at(-1))?.id ?? null;
11
+ };
12
+ /**
13
+ * The ask the inbox shows: the one the person picked, or else the top one waiting on them, so
14
+ * something that needs them is never hidden behind a "pick one" screen.
15
+ */
16
+ export const focusOf = (inbox, picked) => picked ?? awaitingHuman(inbox)[0]?.id ?? null;
17
+ /** The asks the person's own agents raised: what "Mine" shows. */
18
+ export const onlyMine = (inbox, me) => {
19
+ const mine = (ask) => ask.raisedBy?.id === me;
20
+ return { ...inbox, open: inbox.open.filter(mine), recent: inbox.recent.filter(mine) };
21
+ };
22
+ /** Whether anyone else's agents have asked here; only then is there a "Mine" to pick. */
23
+ export const shared = (inbox, me) => me !== undefined &&
24
+ [...inbox.open, ...inbox.recent].some((ask) => ask.raisedBy !== undefined && ask.raisedBy.id !== me);
25
+ const FILTER_KEY = "loopback.filter";
26
+ /** Everyone's asks, or only the person's own agents', as they last left it in this browser. */
27
+ export const useFilter = () => {
28
+ const [filter, setFilter] = useState(() => {
29
+ try {
30
+ return localStorage.getItem(FILTER_KEY) === "mine" ? "mine" : "all";
31
+ }
32
+ catch {
33
+ return "all";
34
+ }
35
+ });
36
+ const choose = (next) => {
37
+ setFilter(next);
38
+ try {
39
+ localStorage.setItem(FILTER_KEY, next);
40
+ }
41
+ catch {
42
+ // Remembered for this tab only.
43
+ }
44
+ };
45
+ return [filter, choose];
46
+ };
47
+ const SIDEBAR_KEY = "loopback.sidebar";
48
+ /** The inbox list's width in pixels: narrow by default, so a zoomed-in page still leaves room. */
49
+ export const SIDEBAR = { min: 220, max: 560, initial: 288 };
50
+ const clampWidth = (width) => Math.round(Math.min(SIDEBAR.max, Math.max(SIDEBAR.min, width)));
51
+ /** The inbox list's width, as the person last left it in this browser. */
52
+ export const useSidebarWidth = () => {
53
+ const [width, setWidth] = useState(() => {
54
+ try {
55
+ const saved = Number(localStorage.getItem(SIDEBAR_KEY));
56
+ return Number.isFinite(saved) && saved > 0 ? clampWidth(saved) : SIDEBAR.initial;
57
+ }
58
+ catch {
59
+ return SIDEBAR.initial;
60
+ }
61
+ });
62
+ const resize = (next) => {
63
+ const clamped = clampWidth(next);
64
+ setWidth(clamped);
65
+ try {
66
+ localStorage.setItem(SIDEBAR_KEY, String(clamped));
67
+ }
68
+ catch {
69
+ // Remembered for this tab only.
70
+ }
71
+ };
72
+ return [width, resize];
73
+ };
74
+ const RECENT_KEY = "loopback.recent";
75
+ /**
76
+ * Whether the Recent section is open. Closed by default: what has been answered stays a click
77
+ * away without competing with what still needs the person.
78
+ */
79
+ export const useRecentOpen = () => {
80
+ const [open, setOpen] = useState(() => {
81
+ try {
82
+ return localStorage.getItem(RECENT_KEY) === "open";
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ });
88
+ const toggle = () => {
89
+ setOpen((was) => {
90
+ try {
91
+ localStorage.setItem(RECENT_KEY, was ? "closed" : "open");
92
+ }
93
+ catch {
94
+ // Remembered for this tab only.
95
+ }
96
+ return !was;
97
+ });
98
+ };
99
+ return [open, toggle];
100
+ };
101
+ /** The kind of thing an ask is, in a few words. */
102
+ export const headline = (ask) => ask.kind === "question"
103
+ ? ask.prompt.question
104
+ : ask.kind === "unknown"
105
+ ? "An ask this page can't show"
106
+ : ask.prompt.title;
107
+ /** How many of a briefing's points need the person; none for any other ask, or when it is not their move. */
108
+ export const pointsNeedingYou = (ask) => ask.kind === "briefing" && ask.status === "awaiting_human"
109
+ ? foldBriefing(ask.prompt, ask.thread).needsYou
110
+ : 0;
111
+ /** The modifier that sends with ↵, as the keyboard labels it: ⌘ on Apple's, Ctrl elsewhere. */
112
+ export const SEND_KEY = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl";
113
+ /** "just now", "5 min", "3 h", "2 d". */
114
+ export const age = (iso, now) => {
115
+ const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
116
+ if (minutes < 1)
117
+ return "just now";
118
+ if (minutes < 60)
119
+ return `${minutes} min`;
120
+ const hours = Math.floor(minutes / 60);
121
+ if (hours < 24)
122
+ return `${hours} h`;
123
+ return `${Math.floor(hours / 24)} d`;
124
+ };
125
+ /** The current time, ticking every half minute so ages stay true. */
126
+ export const useNow = () => {
127
+ const [now, setNow] = useState(() => Date.now());
128
+ useEffect(() => {
129
+ const timer = setInterval(() => setNow(Date.now()), 30_000);
130
+ return () => clearInterval(timer);
131
+ }, []);
132
+ return now;
133
+ };
134
+ const NOTIFY_KEY = "loopback.notify";
135
+ const readNotify = () => {
136
+ try {
137
+ return localStorage.getItem(NOTIFY_KEY) === "on";
138
+ }
139
+ catch {
140
+ return false;
141
+ }
142
+ };
143
+ /** Whether this browser shows a notification when an agent asks something. */
144
+ export const useNotifications = () => {
145
+ const [enabled, setEnabled] = useState(() => readNotify() && typeof Notification !== "undefined" && Notification.permission === "granted");
146
+ const toggle = async () => {
147
+ const next = !enabled;
148
+ if (next && Notification.permission !== "granted") {
149
+ if ((await Notification.requestPermission()) !== "granted")
150
+ return;
151
+ }
152
+ try {
153
+ localStorage.setItem(NOTIFY_KEY, next ? "on" : "off");
154
+ }
155
+ catch {
156
+ // Remembered for this tab only.
157
+ }
158
+ setEnabled(next);
159
+ };
160
+ return { supported: typeof Notification !== "undefined", enabled, toggle };
161
+ };
162
+ /**
163
+ * Keeps the person's attention where it is needed: the tab title counts what awaits them, and a
164
+ * new ask raises a notification while the tab is in the background. Clicking one opens the ask.
165
+ */
166
+ export const useAttention = (inbox, notify, open) => {
167
+ const seen = useRef(null);
168
+ const waiting = awaitingHuman(inbox);
169
+ useEffect(() => {
170
+ document.title = waiting.length === 0 ? "Loopback" : `(${waiting.length}) Loopback`;
171
+ }, [waiting.length]);
172
+ useEffect(() => {
173
+ if (inbox.status !== "live")
174
+ return;
175
+ const ids = new Set(waiting.map((ask) => ask.id));
176
+ const previous = seen.current;
177
+ seen.current = ids;
178
+ if (previous === null || !notify || !document.hidden)
179
+ return;
180
+ for (const ask of waiting) {
181
+ if (previous.has(ask.id))
182
+ continue;
183
+ const note = new Notification(ask.project ?? "Loopback", {
184
+ body: headline(ask),
185
+ tag: ask.id,
186
+ });
187
+ note.addEventListener("click", () => {
188
+ window.focus();
189
+ open(ask.id);
190
+ });
191
+ }
192
+ }, [inbox.status, waiting, notify, open]);
193
+ };
@@ -0,0 +1,7 @@
1
+ import { type InboxNavigation } from "./context.tsx";
2
+ /**
3
+ * The inbox: every ask in a list, and the one open beside it, answered in place. It fills the
4
+ * element it is drawn in, which needs a height. Which ask is open is the host's to keep, in its
5
+ * own routes or anywhere else, through `selectedId`, `onSelect` and `hrefFor`.
6
+ */
7
+ export declare function Inbox({ selectedId, onSelect, hrefFor }: InboxNavigation): import("react").JSX.Element;
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Empty, Loader, Tabs } from "@cloudflare/kumo";
3
+ import { ShikiProvider } from "@cloudflare/kumo/code";
4
+ import { awaitingHuman } from "@loopbacktools/sdk";
5
+ import { CheckCircle, Tray } from "@phosphor-icons/react";
6
+ import { useRef } from "react";
7
+ import { NavigationContext, RootContext, useInbox } from "./context.js";
8
+ import { AskDetail } from "./detail.js";
9
+ import { focusOf, nextAfter, onlyMine, shared, SIDEBAR, useFilter, useSidebarWidth, } from "./hooks.js";
10
+ import { AskList } from "./list.js";
11
+ import { useMe } from "./people.js";
12
+ import { UpdateBanner } from "./update.js";
13
+ /** The languages review code is highlighted in; anything else is shown plain. */
14
+ const LANGUAGES = [
15
+ "typescript",
16
+ "tsx",
17
+ "javascript",
18
+ "jsx",
19
+ "json",
20
+ "html",
21
+ "css",
22
+ "python",
23
+ "bash",
24
+ "yaml",
25
+ "toml",
26
+ "sql",
27
+ "markdown",
28
+ "graphql",
29
+ "hcl",
30
+ "diff",
31
+ ];
32
+ /** The edge between the list and the ask: drag it, or focus it and use the arrow keys. */
33
+ function ResizeHandle({ width, onResize, }) {
34
+ const drag = useRef(null);
35
+ const onPointerDown = (event) => {
36
+ drag.current = { x: event.clientX, width };
37
+ event.currentTarget.setPointerCapture(event.pointerId);
38
+ };
39
+ const onPointerMove = (event) => {
40
+ if (drag.current !== null)
41
+ onResize(drag.current.width + event.clientX - drag.current.x);
42
+ };
43
+ const onPointerUp = (event) => {
44
+ drag.current = null;
45
+ event.currentTarget.releasePointerCapture(event.pointerId);
46
+ };
47
+ const onKeyDown = (event) => {
48
+ if (event.key === "ArrowLeft")
49
+ onResize(width - 16);
50
+ else if (event.key === "ArrowRight")
51
+ onResize(width + 16);
52
+ else
53
+ return;
54
+ event.preventDefault();
55
+ };
56
+ return (_jsx("div", {
57
+ // oxlint-disable-next-line jsx-a11y/prefer-tag-over-role
58
+ role: "separator", "aria-orientation": "vertical", "aria-label": "Resize the inbox list", "aria-valuenow": width, "aria-valuemin": SIDEBAR.min, "aria-valuemax": SIDEBAR.max, tabIndex: 0, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, onKeyDown: onKeyDown, 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" }));
59
+ }
60
+ /**
61
+ * The inbox: every ask in a list, and the one open beside it, answered in place. It fills the
62
+ * element it is drawn in, which needs a height. Which ask is open is the host's to keep, in its
63
+ * own routes or anywhere else, through `selectedId`, `onSelect` and `hrefFor`.
64
+ */
65
+ export function Inbox({ selectedId, onSelect, hrefFor }) {
66
+ const root = useRef(null);
67
+ const everything = useInbox();
68
+ const me = useMe();
69
+ const [filter, setFilter] = useFilter();
70
+ // "Mine" is a choice only where other people's agents ask too.
71
+ const choosing = shared(everything, me);
72
+ const inbox = choosing && filter === "mine" && me !== undefined ? onlyMine(everything, me) : everything;
73
+ // On a wide screen the top waiting ask opens by itself; on a phone the list comes first.
74
+ const focusId = focusOf(inbox, selectedId);
75
+ // An ask the person opened stays open, whichever asks the list shows.
76
+ const selected = focusId === null
77
+ ? undefined
78
+ : [...everything.open, ...everything.recent].find((ask) => ask.id === focusId);
79
+ const [width, resize] = useSidebarWidth();
80
+ // Once an ask is answered, open whatever else awaits the person; with nothing left, clear the
81
+ // view so it shows that everything is done rather than the ask just answered.
82
+ const advance = (answered) => onSelect(nextAfter(awaitingHuman(inbox), answered));
83
+ const allDone = inbox.status === "live" && awaitingHuman(inbox).length === 0;
84
+ return (_jsx(NavigationContext, { value: { selectedId, onSelect, hrefFor }, children: _jsx(RootContext, { value: root, children: _jsx(ShikiProvider, { engine: "javascript", languages: [...LANGUAGES], children: _jsxs("div", { ref: root, className: "flex h-full min-h-0 flex-1 flex-col", children: [_jsx(UpdateBanner, {}), _jsxs("div", { className: "flex min-h-0 flex-1", children: [_jsxs("aside", { style: { "--sidebar": `${width}px` }, className: `w-full overflow-y-auto border-r border-kumo-line bg-kumo-base md:block md:w-(--sidebar) md:shrink-0 ${selectedId === null ? "" : "hidden"}`, children: [choosing ? (_jsx("div", { className: "border-b border-kumo-hairline px-4 py-2", children: _jsx(Tabs, { size: "sm", value: filter, onValueChange: (value) => setFilter(value), tabs: [
85
+ { value: "all", label: "Everyone's" },
86
+ { value: "mine", label: "My agents'" },
87
+ ] }) })) : null, inbox.status === "connecting" ? (_jsx("div", { className: "flex justify-center p-8", children: _jsx(Loader, {}) })) : inbox.open.length === 0 && inbox.recent.length === 0 ? (_jsx(Empty, { icon: _jsx(Tray, { size: 32 }), title: "Nothing yet", description: inbox === everything
88
+ ? "When an agent asks you something, it appears here."
89
+ : "When one of your agents asks something, it appears here." })) : (_jsx(AskList, { inbox: inbox, selected: focusId }))] }), _jsx(ResizeHandle, { width: width, onResize: resize }), _jsx("main", { className: `min-w-0 flex-1 overflow-y-auto md:block ${selectedId === null ? "hidden" : ""}`, children: selected !== undefined ? (_jsx(AskDetail, { ask: selected, onReplied: () => advance(selected.id) })) : selectedId !== null && inbox.status === "live" ? (_jsx(Empty, { title: "Not in the inbox", description: "This ask is older than the inbox keeps." })) : (_jsx("div", { className: "hidden h-full items-center justify-center md:flex", children: allDone ? (_jsx("div", { className: "w-full max-w-sm", children: _jsx(Empty, { icon: _jsx(CheckCircle, { size: 32, weight: "duotone" }), title: "All done", description: "Nothing needs you right now." }) })) : null })) })] })] }) }) }) }));
90
+ }
@@ -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";
@@ -0,0 +1,4 @@
1
+ export { InboxProvider, useClient, useInbox } from "./context.js";
2
+ export { Inbox } from "./inbox.js";
3
+ export { useAttention, useNotifications } from "./hooks.js";
4
+ export { useMe, whoIs } from "./people.js";
@@ -0,0 +1,13 @@
1
+ import type { Ask, InboxState } from "@loopbacktools/sdk";
2
+ export declare function KindIcon({ ask }: {
3
+ readonly ask: Ask;
4
+ }): import("react").JSX.Element;
5
+ /** Whether an agent is blocked on the ask right now. */
6
+ export declare function Waiting({ ask }: {
7
+ readonly ask: Ask;
8
+ }): import("react").JSX.Element | null;
9
+ /** Every ask in the inbox, grouped by whose move it is. */
10
+ export declare function AskList({ inbox, selected, }: {
11
+ readonly inbox: InboxState;
12
+ readonly selected: string | null;
13
+ }): import("react").JSX.Element;