@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 +21 -0
- package/README.md +81 -2
- package/dist/src/briefing.d.ts +9 -0
- package/dist/src/briefing.js +216 -0
- package/dist/src/content.d.ts +9 -0
- package/dist/src/content.js +59 -0
- package/dist/src/context.d.ts +42 -0
- package/dist/src/context.js +71 -0
- package/dist/src/detail.d.ts +6 -0
- package/dist/src/detail.js +239 -0
- package/dist/src/hooks.d.ts +53 -0
- package/dist/src/hooks.js +193 -0
- package/dist/src/inbox.d.ts +7 -0
- package/dist/src/inbox.js +90 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.js +4 -0
- package/dist/src/list.d.ts +13 -0
- package/dist/src/list.js +45 -0
- package/dist/src/page.d.ts +2 -0
- package/dist/src/page.js +2 -0
- package/dist/src/people.d.ts +8 -0
- package/dist/src/people.js +11 -0
- package/dist/src/state.d.ts +19 -0
- package/dist/src/state.js +17 -0
- package/dist/src/thread.d.ts +15 -0
- package/dist/src/thread.js +12 -0
- package/dist/src/update.d.ts +7 -0
- package/dist/src/update.js +31 -0
- package/package.json +63 -3
- package/src/briefing.tsx +491 -0
- package/src/content.tsx +126 -0
- package/src/context.tsx +115 -0
- package/src/detail.tsx +575 -0
- package/src/hooks.ts +212 -0
- package/src/inbox.tsx +191 -0
- package/src/index.ts +4 -0
- package/src/list.tsx +157 -0
- package/src/page.ts +2 -0
- package/src/people.ts +16 -0
- package/src/state.ts +50 -0
- package/src/thread.tsx +49 -0
- package/src/update.tsx +39 -0
- package/styles.css +72 -0
package/src/context.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useAtomValue } from "@effect/atom-react";
|
|
2
|
+
import { initialInbox, type InboxState, type LoopbackApi, type Update } from "@loopbacktools/sdk";
|
|
3
|
+
import { AsyncResult } from "effect/reactivity";
|
|
4
|
+
import {
|
|
5
|
+
type ComponentProps,
|
|
6
|
+
createContext,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type RefObject,
|
|
9
|
+
use,
|
|
10
|
+
useEffect,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { inboxAtom, updateAtom } from "./state.ts";
|
|
13
|
+
|
|
14
|
+
const ClientContext = createContext<LoopbackApi | null>(null);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Gives everything inside it the client the inbox reads and answers through. Hand over another
|
|
18
|
+
* client, for another space or another person, and the inbox follows that one instead.
|
|
19
|
+
*/
|
|
20
|
+
export function InboxProvider({
|
|
21
|
+
client,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
readonly client: LoopbackApi;
|
|
25
|
+
readonly children: ReactNode;
|
|
26
|
+
}) {
|
|
27
|
+
return <ClientContext value={client}>{children}</ClientContext>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The client the nearest `InboxProvider` holds. */
|
|
31
|
+
export const useClient = (): LoopbackApi => {
|
|
32
|
+
const client = use(ClientContext);
|
|
33
|
+
if (client === null) throw new Error("Loopback's inbox renders inside an <InboxProvider>.");
|
|
34
|
+
return client;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The inbox as it stands, kept live while anything renders it. */
|
|
38
|
+
export const useInbox = (): InboxState =>
|
|
39
|
+
AsyncResult.getOrElse(useAtomValue(inboxAtom(useClient())), () => initialInbox);
|
|
40
|
+
|
|
41
|
+
/** Whether Loopback has been updated under the page. */
|
|
42
|
+
export const useUpdate = (): Update =>
|
|
43
|
+
AsyncResult.getOrElse(useAtomValue(updateAtom(useClient())), () => "none" as const);
|
|
44
|
+
|
|
45
|
+
/** Where the inbox moves between asks: in the host's own routes, or anywhere else it likes. */
|
|
46
|
+
export interface InboxNavigation {
|
|
47
|
+
/** The ask the person opened, or `null` for none. */
|
|
48
|
+
readonly selectedId: string | null;
|
|
49
|
+
/** Opens an ask, or with `null` goes back to the list. */
|
|
50
|
+
readonly onSelect: (askId: string | null) => void;
|
|
51
|
+
/** The address of an ask, for links opened in a new tab. */
|
|
52
|
+
readonly hrefFor: (askId: string) => string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const NavigationContext = createContext<InboxNavigation | null>(null);
|
|
56
|
+
|
|
57
|
+
export const useNavigation = (): InboxNavigation => {
|
|
58
|
+
const navigation = use(NavigationContext);
|
|
59
|
+
if (navigation === null) throw new Error("Loopback's inbox needs its navigation.");
|
|
60
|
+
return navigation;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A link to an ask. A plain click opens it in place, through the host's navigation; a modified or
|
|
65
|
+
* middle click is left to the browser, so the ask opens in a new tab at its own address.
|
|
66
|
+
*/
|
|
67
|
+
export function AskLink({
|
|
68
|
+
askId,
|
|
69
|
+
onClick,
|
|
70
|
+
children,
|
|
71
|
+
...props
|
|
72
|
+
}: ComponentProps<"a"> & { readonly askId: string }) {
|
|
73
|
+
const { hrefFor, onSelect } = useNavigation();
|
|
74
|
+
return (
|
|
75
|
+
<a
|
|
76
|
+
{...props}
|
|
77
|
+
href={hrefFor(askId)}
|
|
78
|
+
onClick={(event) => {
|
|
79
|
+
onClick?.(event);
|
|
80
|
+
const modified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
|
|
81
|
+
if (event.defaultPrevented || event.button !== 0 || modified) return;
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
onSelect(askId);
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{children}
|
|
87
|
+
</a>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The element the inbox is drawn in, so its shortcuts leave the rest of the page alone. */
|
|
92
|
+
export const RootContext = createContext<RefObject<HTMLElement | null> | null>(null);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Listens for keys anywhere on the page except the host's own controls: an ask that opens by itself
|
|
96
|
+
* has nothing focused, so its shortcuts must work from the page itself, but keys typed into a search
|
|
97
|
+
* box beside the inbox belong to that box.
|
|
98
|
+
*/
|
|
99
|
+
export const useInboxKeys = (onKey: (event: KeyboardEvent) => void) => {
|
|
100
|
+
const root = use(RootContext);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
const listener = (event: KeyboardEvent) => {
|
|
103
|
+
const target = event.target;
|
|
104
|
+
const onPage = target === document.body || target === document.documentElement;
|
|
105
|
+
const outside =
|
|
106
|
+
root?.current != null &&
|
|
107
|
+
target instanceof Node &&
|
|
108
|
+
!onPage &&
|
|
109
|
+
!root.current.contains(target);
|
|
110
|
+
if (!outside) onKey(event);
|
|
111
|
+
};
|
|
112
|
+
window.addEventListener("keydown", listener);
|
|
113
|
+
return () => window.removeEventListener("keydown", listener);
|
|
114
|
+
});
|
|
115
|
+
};
|
package/src/detail.tsx
ADDED
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { Badge, Banner, Button, InputArea, Text } from "@cloudflare/kumo";
|
|
2
|
+
import { useAtomSet } from "@effect/atom-react";
|
|
3
|
+
import {
|
|
4
|
+
ApiError,
|
|
5
|
+
type Ask,
|
|
6
|
+
type Entry,
|
|
7
|
+
type QuestionAsk,
|
|
8
|
+
type Reply,
|
|
9
|
+
type ReplyBody,
|
|
10
|
+
type UnknownAsk,
|
|
11
|
+
} from "@loopbacktools/sdk";
|
|
12
|
+
import {
|
|
13
|
+
ArrowClockwise,
|
|
14
|
+
ArrowLeft,
|
|
15
|
+
Check,
|
|
16
|
+
ChatTeardropText,
|
|
17
|
+
PaperPlaneRight,
|
|
18
|
+
WarningCircle,
|
|
19
|
+
X,
|
|
20
|
+
} from "@phosphor-icons/react";
|
|
21
|
+
import { Cause, Exit } from "effect";
|
|
22
|
+
import { Component, type ReactNode, useState } from "react";
|
|
23
|
+
import { BriefingBody } from "./briefing.tsx";
|
|
24
|
+
import { Blocks, Prose } from "./content.tsx";
|
|
25
|
+
import { AskLink, useClient, useInboxKeys, useNavigation } from "./context.tsx";
|
|
26
|
+
import { age, SEND_KEY, useNow } from "./hooks.ts";
|
|
27
|
+
import { KindIcon, Waiting } from "./list.tsx";
|
|
28
|
+
import { reloadPage } from "./page.ts";
|
|
29
|
+
import { useMe, whoIs } from "./people.ts";
|
|
30
|
+
import { replyAtom } from "./state.ts";
|
|
31
|
+
import { AgentSaid, PersonSaid } from "./thread.tsx";
|
|
32
|
+
|
|
33
|
+
const STATUS = {
|
|
34
|
+
awaiting_human: { label: "Your turn", variant: "orange" },
|
|
35
|
+
awaiting_agent: { label: "Waiting on the agent", variant: "neutral" },
|
|
36
|
+
closed: { label: "Closed", variant: "green" },
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
/** A reply in the words of the person who gave it. */
|
|
40
|
+
const said = (reply: Reply, who: string): { readonly title: string; readonly body?: string } => {
|
|
41
|
+
switch (reply.outcome) {
|
|
42
|
+
case "answered": {
|
|
43
|
+
const chose =
|
|
44
|
+
reply.selected.length === 0
|
|
45
|
+
? `${who} answered`
|
|
46
|
+
: `${who} chose ${reply.selected.join(", ")}`;
|
|
47
|
+
return reply.text === undefined ? { title: chose } : { title: chose, body: reply.text };
|
|
48
|
+
}
|
|
49
|
+
case "approved":
|
|
50
|
+
return reply.note === undefined
|
|
51
|
+
? { title: `${who} approved` }
|
|
52
|
+
: { title: `${who} approved`, body: reply.note };
|
|
53
|
+
case "changes_requested":
|
|
54
|
+
return { title: `${who} asked for changes`, body: reply.feedback };
|
|
55
|
+
case "follow_up":
|
|
56
|
+
return { title: `${who} asked`, body: reply.question };
|
|
57
|
+
case "dismissed":
|
|
58
|
+
return { title: `${who} dismissed this` };
|
|
59
|
+
case "responded":
|
|
60
|
+
return {
|
|
61
|
+
title: `${who} replied on ${reply.points.length} ${reply.points.length === 1 ? "point" : "points"}`,
|
|
62
|
+
};
|
|
63
|
+
case "unknown":
|
|
64
|
+
return { title: `${who} replied in a way this page can't show` };
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function Turn({ entry, now }: { readonly entry: Entry; readonly now: number }) {
|
|
69
|
+
const me = useMe();
|
|
70
|
+
if (entry.author === "agent") {
|
|
71
|
+
return <AgentSaid answer={entry.answer} content={entry.content} at={entry.at} now={now} />;
|
|
72
|
+
}
|
|
73
|
+
const { title, body } = said(entry.reply, whoIs(entry.by, me));
|
|
74
|
+
return <PersonSaid title={title} body={body} at={entry.at} now={now} />;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** What the person has written or picked on a question or review, and not sent. */
|
|
78
|
+
interface Note {
|
|
79
|
+
readonly text: string;
|
|
80
|
+
readonly picked: readonly string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const NO_NOTE: Note = { text: "", picked: [] };
|
|
84
|
+
|
|
85
|
+
const noteKey = (askId: string) => `loopback.note.${askId}`;
|
|
86
|
+
|
|
87
|
+
/** A note kept in this browser, read as leniently as another version of this page may have left it. */
|
|
88
|
+
const readNote = (askId: string): Note => {
|
|
89
|
+
try {
|
|
90
|
+
const kept = JSON.parse(localStorage.getItem(noteKey(askId)) ?? "null") as {
|
|
91
|
+
readonly text?: unknown;
|
|
92
|
+
readonly picked?: unknown;
|
|
93
|
+
} | null;
|
|
94
|
+
return {
|
|
95
|
+
text: typeof kept?.text === "string" ? kept.text : "",
|
|
96
|
+
picked: Array.isArray(kept?.picked)
|
|
97
|
+
? kept.picked.filter((label): label is string => typeof label === "string")
|
|
98
|
+
: [],
|
|
99
|
+
};
|
|
100
|
+
} catch {
|
|
101
|
+
return NO_NOTE;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Keeps what the person has written or picked on an ask, and not sent, in this browser, as a
|
|
107
|
+
* briefing's drafts are: a reload onto a newer Loopback, or the agent's next turn, loses none of it.
|
|
108
|
+
*/
|
|
109
|
+
const useNote = (askId: string) => {
|
|
110
|
+
const [note, setNote] = useState<Note>(() => readNote(askId));
|
|
111
|
+
const keep = (next: Note) => {
|
|
112
|
+
setNote(next);
|
|
113
|
+
try {
|
|
114
|
+
if (next.text === "" && next.picked.length === 0) localStorage.removeItem(noteKey(askId));
|
|
115
|
+
else localStorage.setItem(noteKey(askId), JSON.stringify(next));
|
|
116
|
+
} catch {
|
|
117
|
+
// Storage can be unavailable (a private window); the note then lasts as long as the page.
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
...note,
|
|
122
|
+
setText: (text: string) => keep({ ...note, text }),
|
|
123
|
+
setPicked: (change: (picked: readonly string[]) => readonly string[]) =>
|
|
124
|
+
keep({ ...note, picked: change(note.picked) }),
|
|
125
|
+
clear: () => keep(NO_NOTE),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** The reply controls: one box for words, and the moves this kind of ask allows. */
|
|
130
|
+
function ReplyPanel({ ask, onReplied }: { readonly ask: Ask; readonly onReplied: () => void }) {
|
|
131
|
+
const send = useAtomSet(replyAtom(useClient()), { mode: "promiseExit" });
|
|
132
|
+
const { text, picked, setText, setPicked, clear } = useNote(ask.id);
|
|
133
|
+
const [sending, setSending] = useState<string | null>(null);
|
|
134
|
+
const [error, setError] = useState<{
|
|
135
|
+
readonly title: string;
|
|
136
|
+
readonly description: string;
|
|
137
|
+
} | null>(null);
|
|
138
|
+
const words = text.trim();
|
|
139
|
+
|
|
140
|
+
const submit = async (key: string, reply: ReplyBody) => {
|
|
141
|
+
setSending(key);
|
|
142
|
+
setError(null);
|
|
143
|
+
const exit = await send({ askId: ask.id, reply });
|
|
144
|
+
setSending(null);
|
|
145
|
+
if (Exit.isSuccess(exit)) {
|
|
146
|
+
clear();
|
|
147
|
+
onReplied();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// A reply Loopback refused will not go through as it is; one that never reached it, or met
|
|
151
|
+
// a fault there, can simply be sent again. Either way the agent has nothing yet.
|
|
152
|
+
const failure = Cause.squash(exit.cause);
|
|
153
|
+
const reason = failure instanceof Error ? failure.message : "no reason given";
|
|
154
|
+
setError(
|
|
155
|
+
failure instanceof ApiError && failure.status >= 400 && failure.status < 500
|
|
156
|
+
? { title: "Your reply was not accepted", description: reason }
|
|
157
|
+
: {
|
|
158
|
+
title: "Your reply was not sent",
|
|
159
|
+
description: `Loopback could not take it just now (${reason}), so the agent still has nothing. Send it again.`,
|
|
160
|
+
},
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
const note = words === "" ? {} : { text: words };
|
|
164
|
+
|
|
165
|
+
const question = ask.kind === "question" ? ask : null;
|
|
166
|
+
const choose = (label: string) => {
|
|
167
|
+
if (question === null) return;
|
|
168
|
+
if (question.prompt.multiple) {
|
|
169
|
+
setPicked((current) =>
|
|
170
|
+
current.includes(label) ? current.filter((item) => item !== label) : [...current, label],
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
void submit(label, { outcome: "answered", selected: [label], ...note });
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const primary = (): [string, ReplyBody] | null =>
|
|
178
|
+
question !== null
|
|
179
|
+
? picked.length > 0 || words !== ""
|
|
180
|
+
? ["send", { outcome: "answered", selected: [...picked], ...note }]
|
|
181
|
+
: null
|
|
182
|
+
: ["approve", { outcome: "approved", ...(words === "" ? {} : { note: words }) }];
|
|
183
|
+
|
|
184
|
+
const busy = sending !== null;
|
|
185
|
+
|
|
186
|
+
// ⌘↵ (Ctrl↵ off Apple's keyboards) sends the main reply wherever focus is, since an ask that opens by itself has nothing
|
|
187
|
+
// focused; 1-9 pick a choice while the reply box does not have focus.
|
|
188
|
+
useInboxKeys((event) => {
|
|
189
|
+
if (busy) return;
|
|
190
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
191
|
+
const move = primary();
|
|
192
|
+
if (move === null) return;
|
|
193
|
+
event.preventDefault();
|
|
194
|
+
void submit(...move);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const target = event.target as HTMLElement | null;
|
|
198
|
+
if (question === null || event.metaKey || event.ctrlKey) return;
|
|
199
|
+
if (target?.closest("input, textarea, [contenteditable]")) return;
|
|
200
|
+
const option = question.prompt.options[Number(event.key) - 1];
|
|
201
|
+
if (option !== undefined) choose(option.label);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
return (
|
|
205
|
+
<div className="flex flex-col gap-3 rounded-lg border border-kumo-line bg-kumo-elevated p-4">
|
|
206
|
+
{error === null ? null : (
|
|
207
|
+
<Banner
|
|
208
|
+
role="alert"
|
|
209
|
+
variant="error"
|
|
210
|
+
icon={<WarningCircle />}
|
|
211
|
+
title={error.title}
|
|
212
|
+
description={error.description}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
{question === null || question.prompt.options.length === 0 ? null : (
|
|
216
|
+
<Choices
|
|
217
|
+
question={question}
|
|
218
|
+
picked={picked}
|
|
219
|
+
busy={busy}
|
|
220
|
+
sending={sending}
|
|
221
|
+
onChoose={choose}
|
|
222
|
+
/>
|
|
223
|
+
)}
|
|
224
|
+
<InputArea
|
|
225
|
+
aria-label={question === null ? "Feedback or a note" : "Your answer, or a note"}
|
|
226
|
+
placeholder={
|
|
227
|
+
question === null
|
|
228
|
+
? "Feedback, a note to go with your approval, or a question"
|
|
229
|
+
: question.prompt.options.length === 0
|
|
230
|
+
? "Your answer, or a question back"
|
|
231
|
+
: "Add a note, answer in your own words, or ask a question back"
|
|
232
|
+
}
|
|
233
|
+
value={text}
|
|
234
|
+
onValueChange={setText}
|
|
235
|
+
autoResize
|
|
236
|
+
minRows={3}
|
|
237
|
+
maxRows={12}
|
|
238
|
+
disabled={busy}
|
|
239
|
+
/>
|
|
240
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
241
|
+
{question === null ? (
|
|
242
|
+
<>
|
|
243
|
+
<Button
|
|
244
|
+
variant="primary"
|
|
245
|
+
icon={<Check />}
|
|
246
|
+
loading={sending === "approve"}
|
|
247
|
+
disabled={busy}
|
|
248
|
+
onClick={() =>
|
|
249
|
+
void submit("approve", {
|
|
250
|
+
outcome: "approved",
|
|
251
|
+
...(words === "" ? {} : { note: words }),
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
>
|
|
255
|
+
Approve
|
|
256
|
+
</Button>
|
|
257
|
+
<Button
|
|
258
|
+
loading={sending === "changes"}
|
|
259
|
+
disabled={busy || words === ""}
|
|
260
|
+
onClick={() =>
|
|
261
|
+
void submit("changes", { outcome: "changes_requested", feedback: words })
|
|
262
|
+
}
|
|
263
|
+
>
|
|
264
|
+
Request changes
|
|
265
|
+
</Button>
|
|
266
|
+
</>
|
|
267
|
+
) : (
|
|
268
|
+
<Button
|
|
269
|
+
variant="primary"
|
|
270
|
+
icon={<PaperPlaneRight />}
|
|
271
|
+
loading={sending === "send"}
|
|
272
|
+
disabled={busy || (picked.length === 0 && words === "")}
|
|
273
|
+
onClick={() =>
|
|
274
|
+
void submit("send", { outcome: "answered", selected: [...picked], ...note })
|
|
275
|
+
}
|
|
276
|
+
>
|
|
277
|
+
Send
|
|
278
|
+
</Button>
|
|
279
|
+
)}
|
|
280
|
+
<Button
|
|
281
|
+
icon={<ChatTeardropText />}
|
|
282
|
+
loading={sending === "ask"}
|
|
283
|
+
disabled={busy || words === ""}
|
|
284
|
+
onClick={() => void submit("ask", { outcome: "follow_up", question: words })}
|
|
285
|
+
>
|
|
286
|
+
Ask back
|
|
287
|
+
</Button>
|
|
288
|
+
<Button
|
|
289
|
+
variant="ghost"
|
|
290
|
+
icon={<X />}
|
|
291
|
+
loading={sending === "dismiss"}
|
|
292
|
+
disabled={busy}
|
|
293
|
+
onClick={() => void submit("dismiss", { outcome: "dismissed" })}
|
|
294
|
+
>
|
|
295
|
+
Dismiss
|
|
296
|
+
</Button>
|
|
297
|
+
<span className="ml-auto hidden text-xs text-kumo-subtle sm:inline">
|
|
298
|
+
{SEND_KEY}↵ {question === null ? "approves" : "sends"}
|
|
299
|
+
</span>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function Choices({
|
|
306
|
+
question,
|
|
307
|
+
picked,
|
|
308
|
+
busy,
|
|
309
|
+
sending,
|
|
310
|
+
onChoose,
|
|
311
|
+
}: {
|
|
312
|
+
readonly question: QuestionAsk;
|
|
313
|
+
readonly picked: readonly string[];
|
|
314
|
+
readonly busy: boolean;
|
|
315
|
+
readonly sending: string | null;
|
|
316
|
+
readonly onChoose: (label: string) => void;
|
|
317
|
+
}) {
|
|
318
|
+
return (
|
|
319
|
+
<div className="flex flex-col gap-2">
|
|
320
|
+
{question.prompt.options.map((option, index) => {
|
|
321
|
+
const on = picked.includes(option.label);
|
|
322
|
+
return (
|
|
323
|
+
<button
|
|
324
|
+
key={option.label}
|
|
325
|
+
type="button"
|
|
326
|
+
disabled={busy}
|
|
327
|
+
aria-pressed={question.prompt.multiple ? on : undefined}
|
|
328
|
+
onClick={() => onChoose(option.label)}
|
|
329
|
+
className={`flex items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors disabled:opacity-60 ${
|
|
330
|
+
on
|
|
331
|
+
? "border-kumo-brand bg-kumo-tint"
|
|
332
|
+
: "border-kumo-line bg-kumo-base hover:bg-kumo-tint"
|
|
333
|
+
}`}
|
|
334
|
+
>
|
|
335
|
+
<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">
|
|
336
|
+
{sending === option.label ? "…" : index + 1}
|
|
337
|
+
</span>
|
|
338
|
+
<span className="flex flex-col">
|
|
339
|
+
<Text bold>{option.label}</Text>
|
|
340
|
+
{option.description === undefined ? null : (
|
|
341
|
+
<Text variant="secondary" size="sm">
|
|
342
|
+
{option.description}
|
|
343
|
+
</Text>
|
|
344
|
+
)}
|
|
345
|
+
</span>
|
|
346
|
+
</button>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
<Text variant="secondary" size="xs">
|
|
350
|
+
{question.prompt.multiple
|
|
351
|
+
? "Pick any that apply, then send."
|
|
352
|
+
: "Picking a choice sends it, with any note you have typed."}
|
|
353
|
+
</Text>
|
|
354
|
+
</div>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const nothing = () => {};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* An ask the inbox could not draw, with the moves still open to the person: dismissing it, and, for
|
|
362
|
+
* one sent by a newer version of Loopback than this page, reloading to see it.
|
|
363
|
+
*/
|
|
364
|
+
function CannotShow({
|
|
365
|
+
ask,
|
|
366
|
+
onReplied,
|
|
367
|
+
newer = false,
|
|
368
|
+
}: {
|
|
369
|
+
readonly ask: Ask;
|
|
370
|
+
readonly onReplied: () => void;
|
|
371
|
+
/** Whether it is an ask of a kind this page doesn't know, from a newer version. */
|
|
372
|
+
readonly newer?: boolean;
|
|
373
|
+
}) {
|
|
374
|
+
const { onSelect } = useNavigation();
|
|
375
|
+
const send = useAtomSet(replyAtom(useClient()), { mode: "promiseExit" });
|
|
376
|
+
const [sending, setSending] = useState(false);
|
|
377
|
+
const [failed, setFailed] = useState<string | null>(null);
|
|
378
|
+
const dismiss = async () => {
|
|
379
|
+
setSending(true);
|
|
380
|
+
setFailed(null);
|
|
381
|
+
const exit = await send({ askId: ask.id, reply: { outcome: "dismissed" } });
|
|
382
|
+
setSending(false);
|
|
383
|
+
if (Exit.isSuccess(exit)) {
|
|
384
|
+
onReplied();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
const failure = Cause.squash(exit.cause);
|
|
388
|
+
setFailed(failure instanceof Error ? failure.message : "no reason given");
|
|
389
|
+
};
|
|
390
|
+
return (
|
|
391
|
+
<article className="mx-auto flex max-w-3xl flex-col gap-4 px-4 py-6 md:px-8">
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
onClick={() => onSelect(null)}
|
|
395
|
+
className="inline-flex w-fit items-center gap-1 text-sm text-kumo-subtle md:hidden"
|
|
396
|
+
>
|
|
397
|
+
<ArrowLeft /> Inbox
|
|
398
|
+
</button>
|
|
399
|
+
<Banner
|
|
400
|
+
variant="error"
|
|
401
|
+
icon={<WarningCircle />}
|
|
402
|
+
title="This ask can't be shown"
|
|
403
|
+
description={
|
|
404
|
+
newer
|
|
405
|
+
? "It came from a newer version of Loopback than this page. Reload the page to see it."
|
|
406
|
+
: "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."
|
|
407
|
+
}
|
|
408
|
+
/>
|
|
409
|
+
{failed === null ? null : (
|
|
410
|
+
<Banner
|
|
411
|
+
role="alert"
|
|
412
|
+
variant="error"
|
|
413
|
+
icon={<WarningCircle />}
|
|
414
|
+
title="It was not dismissed"
|
|
415
|
+
description={failed}
|
|
416
|
+
/>
|
|
417
|
+
)}
|
|
418
|
+
<div className="flex gap-2">
|
|
419
|
+
{newer ? (
|
|
420
|
+
<Button
|
|
421
|
+
className="w-fit"
|
|
422
|
+
variant="secondary"
|
|
423
|
+
icon={<ArrowClockwise />}
|
|
424
|
+
onClick={reloadPage}
|
|
425
|
+
>
|
|
426
|
+
Reload
|
|
427
|
+
</Button>
|
|
428
|
+
) : null}
|
|
429
|
+
{ask.status === "closed" ? null : (
|
|
430
|
+
<Button
|
|
431
|
+
className="w-fit"
|
|
432
|
+
variant="ghost"
|
|
433
|
+
icon={<X />}
|
|
434
|
+
loading={sending}
|
|
435
|
+
onClick={() => void dismiss()}
|
|
436
|
+
>
|
|
437
|
+
Dismiss
|
|
438
|
+
</Button>
|
|
439
|
+
)}
|
|
440
|
+
</div>
|
|
441
|
+
</article>
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Keeps one ask that cannot be drawn from taking the whole inbox down with it. */
|
|
446
|
+
class Drawable extends Component<
|
|
447
|
+
{ readonly ask: Ask; readonly onReplied: () => void; readonly children: ReactNode },
|
|
448
|
+
{ readonly failed: boolean }
|
|
449
|
+
> {
|
|
450
|
+
override state = { failed: false };
|
|
451
|
+
|
|
452
|
+
static getDerivedStateFromError() {
|
|
453
|
+
return { failed: true };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
override componentDidCatch(error: unknown) {
|
|
457
|
+
console.error(`Ask ${this.props.ask.id} could not be shown:`, error);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
override render() {
|
|
461
|
+
return this.state.failed ? (
|
|
462
|
+
<CannotShow ask={this.props.ask} onReplied={this.props.onReplied} />
|
|
463
|
+
) : (
|
|
464
|
+
this.props.children
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function AskDetail({
|
|
470
|
+
ask,
|
|
471
|
+
onReplied = nothing,
|
|
472
|
+
}: {
|
|
473
|
+
readonly ask: Ask;
|
|
474
|
+
/** Called once a reply is sent: the inbox moves on to whatever is next. */
|
|
475
|
+
readonly onReplied?: () => void;
|
|
476
|
+
}) {
|
|
477
|
+
if (ask.kind === "unknown") return <CannotShow ask={ask} onReplied={onReplied} newer />;
|
|
478
|
+
return (
|
|
479
|
+
<Drawable key={ask.id} ask={ask} onReplied={onReplied}>
|
|
480
|
+
<AskView ask={ask} onReplied={onReplied} />
|
|
481
|
+
</Drawable>
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** One ask: what the agent wants, the thread so far, and the moves left. */
|
|
486
|
+
function AskView({
|
|
487
|
+
ask,
|
|
488
|
+
onReplied,
|
|
489
|
+
}: {
|
|
490
|
+
readonly ask: Exclude<Ask, UnknownAsk>;
|
|
491
|
+
readonly onReplied: () => void;
|
|
492
|
+
}) {
|
|
493
|
+
const { onSelect } = useNavigation();
|
|
494
|
+
const now = useNow();
|
|
495
|
+
const me = useMe();
|
|
496
|
+
const status = STATUS[ask.status];
|
|
497
|
+
return (
|
|
498
|
+
<article className="mx-auto flex max-w-3xl flex-col gap-6 px-4 py-6 md:px-8">
|
|
499
|
+
<header className="flex flex-col gap-3">
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
onClick={() => onSelect(null)}
|
|
503
|
+
className="inline-flex w-fit items-center gap-1 text-sm text-kumo-subtle md:hidden"
|
|
504
|
+
>
|
|
505
|
+
<ArrowLeft /> Inbox
|
|
506
|
+
</button>
|
|
507
|
+
<div className="flex flex-wrap items-center gap-2 text-kumo-subtle">
|
|
508
|
+
<KindIcon ask={ask} />
|
|
509
|
+
<Badge variant={status.variant}>{status.label}</Badge>
|
|
510
|
+
{ask.project === undefined ? null : <Badge variant="neutral">{ask.project}</Badge>}
|
|
511
|
+
<Waiting ask={ask} />
|
|
512
|
+
<span className="ml-auto text-xs tabular-nums">
|
|
513
|
+
{ask.raisedBy?.name === undefined || ask.raisedBy.id === me
|
|
514
|
+
? ""
|
|
515
|
+
: `${ask.raisedBy.name}'s agent · `}
|
|
516
|
+
{ask.client === undefined ? "" : `${ask.client} · `}
|
|
517
|
+
{age(ask.createdAt, now)}
|
|
518
|
+
</span>
|
|
519
|
+
</div>
|
|
520
|
+
{ask.kind === "question" ? (
|
|
521
|
+
<div className="text-lg">
|
|
522
|
+
<Prose text={ask.prompt.question} />
|
|
523
|
+
</div>
|
|
524
|
+
) : (
|
|
525
|
+
<>
|
|
526
|
+
<Text variant="heading" as="h1">
|
|
527
|
+
{ask.prompt.title}
|
|
528
|
+
</Text>
|
|
529
|
+
{ask.kind !== "review" || ask.revises === undefined ? null : (
|
|
530
|
+
<AskLink askId={ask.revises} className="text-sm text-kumo-link underline">
|
|
531
|
+
Revises an earlier review
|
|
532
|
+
</AskLink>
|
|
533
|
+
)}
|
|
534
|
+
{ask.prompt.summary === undefined ? null : (
|
|
535
|
+
<div className="text-kumo-subtle">
|
|
536
|
+
<Prose text={ask.prompt.summary} />
|
|
537
|
+
</div>
|
|
538
|
+
)}
|
|
539
|
+
</>
|
|
540
|
+
)}
|
|
541
|
+
</header>
|
|
542
|
+
|
|
543
|
+
{ask.kind === "briefing" ? (
|
|
544
|
+
<BriefingBody key={ask.id} ask={ask} onReplied={onReplied} />
|
|
545
|
+
) : ask.kind === "question" ? (
|
|
546
|
+
ask.prompt.context === undefined ? null : (
|
|
547
|
+
<details className="rounded-lg border border-kumo-line bg-kumo-base p-4">
|
|
548
|
+
<summary className="cursor-pointer text-sm text-kumo-subtle">Context</summary>
|
|
549
|
+
<div className="mt-3">
|
|
550
|
+
<Prose text={ask.prompt.context} />
|
|
551
|
+
</div>
|
|
552
|
+
</details>
|
|
553
|
+
)
|
|
554
|
+
) : (
|
|
555
|
+
<div className="rounded-lg border border-kumo-line bg-kumo-base p-4 md:p-6">
|
|
556
|
+
<Blocks blocks={ask.prompt.content} />
|
|
557
|
+
</div>
|
|
558
|
+
)}
|
|
559
|
+
|
|
560
|
+
{ask.kind === "briefing" || ask.thread.length === 0 ? null : (
|
|
561
|
+
<div className="flex flex-col gap-3">
|
|
562
|
+
{ask.thread.map((entry) => (
|
|
563
|
+
<Turn key={`${entry.author}:${entry.at}`} entry={entry} now={now} />
|
|
564
|
+
))}
|
|
565
|
+
</div>
|
|
566
|
+
)}
|
|
567
|
+
|
|
568
|
+
{ask.kind === "briefing" ? null : ask.status === "awaiting_human" ? (
|
|
569
|
+
<ReplyPanel key={`${ask.id}:${ask.thread.length}`} ask={ask} onReplied={onReplied} />
|
|
570
|
+
) : ask.status === "awaiting_agent" ? (
|
|
571
|
+
<Text variant="secondary">The agent has your question and will answer here.</Text>
|
|
572
|
+
) : null}
|
|
573
|
+
</article>
|
|
574
|
+
);
|
|
575
|
+
}
|