@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.
@@ -0,0 +1,491 @@
1
+ import { Badge, Banner, Button, Checkbox, InputArea, Text } from "@cloudflare/kumo";
2
+ import { useAtomSet } from "@effect/atom-react";
3
+ import {
4
+ ApiError,
5
+ type BriefingAsk,
6
+ type BriefingTree,
7
+ foldBriefing,
8
+ type PointNode,
9
+ type PointReply,
10
+ type PointTurn,
11
+ type ReplyBody,
12
+ respondedProblem,
13
+ } from "@loopbacktools/sdk";
14
+ import {
15
+ ArrowDown,
16
+ CaretDown,
17
+ CaretRight,
18
+ Check,
19
+ Checks,
20
+ PaperPlaneRight,
21
+ WarningCircle,
22
+ X,
23
+ } from "@phosphor-icons/react";
24
+ import { Cause, Exit } from "effect";
25
+ import { useEffect, useState } from "react";
26
+ import { Blocks, Prose } from "./content.tsx";
27
+ import { SEND_KEY, useNow } from "./hooks.ts";
28
+ import { useMe, whoIs } from "./people.ts";
29
+ import { replyAtom } from "./state.ts";
30
+ import { useClient, useInboxKeys } from "./context.tsx";
31
+ import { AgentSaid, PersonSaid } from "./thread.tsx";
32
+
33
+ /** What the person has written on a point, and whether they mean to resolve it, before sending. */
34
+ interface Draft {
35
+ readonly text: string;
36
+ readonly resolved: boolean;
37
+ /**
38
+ * How long the thread was when the draft was written. A draft from before its point was last
39
+ * resolved is stale, even once the point is reopened; one written after is the person reopening it.
40
+ */
41
+ readonly basis?: number;
42
+ }
43
+
44
+ const EMPTY: Draft = { text: "", resolved: false };
45
+
46
+ /** The draft that still counts on a point: one written since the point was last resolved. */
47
+ const draftOn = (drafts: Drafts, point: PointNode): Draft | undefined => {
48
+ const draft = drafts[point.id];
49
+ return draft !== undefined && (draft.basis ?? 0) >= point.resolvedAtEntry ? draft : undefined;
50
+ };
51
+
52
+ type Drafts = Readonly<Record<string, Draft>>;
53
+
54
+ const draftKey = (askId: string) => `loopback.drafts.${askId}`;
55
+
56
+ const readDrafts = (askId: string): Drafts => {
57
+ try {
58
+ return JSON.parse(localStorage.getItem(draftKey(askId)) ?? "{}") as Drafts;
59
+ } catch {
60
+ return {};
61
+ }
62
+ };
63
+
64
+ /** Keeps a briefing's drafts in this browser, so a long one can be worked through over time. */
65
+ const useDrafts = (askId: string, basis: number) => {
66
+ const [drafts, setDrafts] = useState<Drafts>(() => readDrafts(askId));
67
+ const keep = (next: Drafts) => {
68
+ setDrafts(next);
69
+ try {
70
+ if (Object.keys(next).length === 0) localStorage.removeItem(draftKey(askId));
71
+ else localStorage.setItem(draftKey(askId), JSON.stringify(next));
72
+ } catch {
73
+ // Storage can be unavailable (a private window); drafts then last as long as the page.
74
+ }
75
+ };
76
+ const update = (pointId: string, draft: Draft) => {
77
+ const { [pointId]: _, ...rest } = drafts;
78
+ keep(draft.text === "" && !draft.resolved ? rest : { ...rest, [pointId]: { ...draft, basis } });
79
+ };
80
+ return { drafts, update, clear: () => keep({}) };
81
+ };
82
+
83
+ /** Every point, in the order it is read. */
84
+ const inOrder = (points: readonly PointNode[]): PointNode[] =>
85
+ points.flatMap((point) => [point, ...inOrder(point.points)]);
86
+
87
+ const allPoints = (tree: BriefingTree) => inOrder(tree.sections.flatMap((s) => s.points));
88
+
89
+ /**
90
+ * The reply the drafts make. A draft left on a point resolved since it was written is dropped.
91
+ * Words beneath a point being resolved go as resolved too, as the space would read them anyway.
92
+ * Finishing resolves every remaining point: the uppermost of each open branch, and any written on.
93
+ */
94
+ const replyFrom = (tree: BriefingTree, drafts: Drafts, finishing: boolean): PointReply[] => {
95
+ const items: PointReply[] = [];
96
+ const walk = (points: readonly PointNode[], covered: boolean) => {
97
+ for (const point of points) {
98
+ const draft = draftOn(drafts, point);
99
+ const text = draft?.text.trim() ?? "";
100
+ const resolving = draft?.resolved === true || finishing || covered;
101
+ const included =
102
+ text !== "" ||
103
+ draft?.resolved === true ||
104
+ (finishing && !covered && point.state !== "resolved");
105
+ if (included) {
106
+ items.push({
107
+ point: point.id,
108
+ ...(text === "" ? {} : { text }),
109
+ ...(resolving ? { resolved: true } : {}),
110
+ });
111
+ }
112
+ walk(point.points, covered || (included && resolving));
113
+ }
114
+ };
115
+ for (const section of tree.sections) walk(section.points, false);
116
+ return items;
117
+ };
118
+
119
+ const MARKER = {
120
+ needs_you: { label: "Needs you", variant: "orange" },
121
+ with_agent: { label: "With the agent", variant: "neutral" },
122
+ } as const;
123
+
124
+ /** The small uppercase label the inbox gives every group, as the list does "Your turn". */
125
+ const GROUP_LABEL = "text-xs font-medium uppercase tracking-wide text-kumo-subtle";
126
+
127
+ /** A point's number, in the chip a question's choices are numbered with. */
128
+ function PointId({ id }: { readonly id: string }) {
129
+ return (
130
+ <span className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded border border-kumo-line px-1 text-xs text-kumo-subtle tabular-nums">
131
+ {id}
132
+ </span>
133
+ );
134
+ }
135
+
136
+ /** Where a folded point stands: a badge for its own state, and a count of what waits beneath it. */
137
+ function Marker({ point }: { readonly point: PointNode }) {
138
+ const below = point.needsYou - (point.state === "needs_you" ? 1 : 0);
139
+ return (
140
+ <>
141
+ {below === 0 ? null : (
142
+ <span className="hidden text-xs text-kumo-subtle tabular-nums sm:inline">
143
+ {below} below
144
+ </span>
145
+ )}
146
+ {point.state === "needs_you" || point.state === "with_agent" ? (
147
+ <Badge variant={MARKER[point.state].variant}>{MARKER[point.state].label}</Badge>
148
+ ) : null}
149
+ </>
150
+ );
151
+ }
152
+
153
+ /** That a reply is drafted on a point, marked as the list marks an agent waiting. */
154
+ function Drafted() {
155
+ return (
156
+ <span className="inline-flex items-center gap-1.5 text-xs text-kumo-subtle">
157
+ <span className="size-2 rounded-full bg-kumo-brand" />
158
+ <span className="sr-only sm:not-sr-only">Draft</span>
159
+ </span>
160
+ );
161
+ }
162
+
163
+ function Turn({ turn, now }: { readonly turn: PointTurn; readonly now: number }) {
164
+ const me = useMe();
165
+ if (turn.author === "agent") {
166
+ return <AgentSaid answer={turn.answer} content={turn.content} at={turn.at} now={now} />;
167
+ }
168
+ const who = whoIs(turn.by, me);
169
+ const words = turn.text?.trim() ?? "";
170
+ return (
171
+ <PersonSaid
172
+ title={turn.resolved ? `${who} resolved this` : `${who} wrote`}
173
+ body={words === "" ? undefined : words}
174
+ at={turn.at}
175
+ now={now}
176
+ />
177
+ );
178
+ }
179
+
180
+ interface Shared {
181
+ readonly opened: ReadonlySet<string>;
182
+ readonly toggle: (id: string) => void;
183
+ readonly drafts: Drafts;
184
+ readonly update: (id: string, draft: Draft) => void;
185
+ /** Whether the person can still write: the briefing is open. */
186
+ readonly writable: boolean;
187
+ readonly now: number;
188
+ }
189
+
190
+ function PointItem({ point, shared }: { readonly point: PointNode; readonly shared: Shared }) {
191
+ const open = shared.opened.has(point.id);
192
+ const draft = draftOn(shared.drafts, point) ?? EMPTY;
193
+ const resolved = point.state === "resolved";
194
+ const drafted = draft.text.trim() !== "" || draft.resolved;
195
+ const panel = `point-${point.id}`;
196
+ return (
197
+ <li id={`row-${point.id}`}>
198
+ <button
199
+ type="button"
200
+ aria-expanded={open}
201
+ aria-controls={panel}
202
+ onClick={() => shared.toggle(point.id)}
203
+ className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-kumo-tint ${
204
+ open ? "bg-kumo-tint" : ""
205
+ }`}
206
+ >
207
+ <span className="shrink-0 text-kumo-subtle">
208
+ {open ? <CaretDown size={12} /> : <CaretRight size={12} />}
209
+ </span>
210
+ <PointId id={point.id} />
211
+ <Text truncate bold={point.state === "needs_you"}>
212
+ {point.title}
213
+ </Text>
214
+ <span className="ml-auto flex shrink-0 items-center gap-2">
215
+ {drafted ? <Drafted /> : null}
216
+ <Marker point={point} />
217
+ </span>
218
+ </button>
219
+ {open ? (
220
+ <section
221
+ id={panel}
222
+ aria-label={`${point.id} ${point.title}`}
223
+ className="flex flex-col gap-3 px-4 pt-1 pb-4 sm:pl-[4.25rem]"
224
+ >
225
+ {point.detail === undefined ? null : <Prose text={point.detail} />}
226
+ {point.content === undefined ? null : <Blocks blocks={point.content} />}
227
+ {point.turns.map((turn) => (
228
+ <Turn key={`${turn.author}:${turn.at}`} turn={turn} now={shared.now} />
229
+ ))}
230
+ {shared.writable ? (
231
+ <div className="flex flex-col gap-2">
232
+ <InputArea
233
+ aria-label={`Your reply on point ${point.id}`}
234
+ placeholder={
235
+ resolved
236
+ ? "Write here to reopen this point"
237
+ : "A question, a push-back or a comment"
238
+ }
239
+ value={draft.text}
240
+ onValueChange={(text) => shared.update(point.id, { ...draft, text })}
241
+ autoResize
242
+ minRows={2}
243
+ maxRows={10}
244
+ />
245
+ {resolved ? null : (
246
+ <Checkbox
247
+ label="Resolved"
248
+ checked={draft.resolved}
249
+ onCheckedChange={(checked) =>
250
+ shared.update(point.id, { ...draft, resolved: checked })
251
+ }
252
+ />
253
+ )}
254
+ </div>
255
+ ) : null}
256
+ {point.points.length === 0 ? null : <Points points={point.points} shared={shared} />}
257
+ </section>
258
+ ) : null}
259
+ </li>
260
+ );
261
+ }
262
+
263
+ /**
264
+ * One level of points, as a card of rows: what is still open, in order, then the resolved folded
265
+ * into a count that opens like the list's "Recent".
266
+ */
267
+ function Points({
268
+ points,
269
+ shared,
270
+ }: {
271
+ readonly points: readonly PointNode[];
272
+ readonly shared: Shared;
273
+ }) {
274
+ const [showResolved, setShowResolved] = useState(false);
275
+ const live = points.filter((point) => point.state !== "resolved");
276
+ const resolved = points.filter((point) => point.state === "resolved");
277
+ return (
278
+ <ul className="divide-y divide-kumo-hairline overflow-hidden rounded-lg border border-kumo-line bg-kumo-base">
279
+ {live.map((point) => (
280
+ <PointItem key={point.id} point={point} shared={shared} />
281
+ ))}
282
+ {resolved.length === 0 ? null : (
283
+ <li>
284
+ <button
285
+ type="button"
286
+ aria-expanded={showResolved}
287
+ onClick={() => setShowResolved((shown) => !shown)}
288
+ className={`flex w-full items-center gap-1.5 px-4 py-2 text-left hover:text-kumo-default ${GROUP_LABEL}`}
289
+ >
290
+ {showResolved ? <CaretDown size={12} /> : <CaretRight size={12} />}
291
+ Resolved <span className="tabular-nums">· {resolved.length}</span>
292
+ </button>
293
+ {showResolved ? (
294
+ <ul className="divide-y divide-kumo-hairline border-t border-kumo-hairline">
295
+ {resolved.map((point) => (
296
+ <PointItem key={point.id} point={point} shared={shared} />
297
+ ))}
298
+ </ul>
299
+ ) : null}
300
+ </li>
301
+ )}
302
+ </ul>
303
+ );
304
+ }
305
+
306
+ /** The ids of a point and every point above it. */
307
+ const pathTo = (id: string): string[] =>
308
+ id.split(".").map((_, depth, parts) => parts.slice(0, depth + 1).join("."));
309
+
310
+ /**
311
+ * A briefing's points, to work through one at a time: each folded to a line until opened, with its
312
+ * own thread and a box to reply on it, and one bar that sends everything written since last time.
313
+ */
314
+ export function BriefingBody({
315
+ ask,
316
+ onReplied,
317
+ }: {
318
+ readonly ask: BriefingAsk;
319
+ readonly onReplied: () => void;
320
+ }) {
321
+ const now = useNow();
322
+ const send = useAtomSet(replyAtom(useClient()), { mode: "promiseExit" });
323
+ const basis = ask.thread.length;
324
+ const { drafts, update, clear } = useDrafts(ask.id, basis);
325
+ const [opened, setOpened] = useState<ReadonlySet<string>>(() => new Set());
326
+ const [cursor, setCursor] = useState<string | null>(null);
327
+ const [sending, setSending] = useState<string | null>(null);
328
+ const [error, setError] = useState<{
329
+ readonly title: string;
330
+ readonly description: string;
331
+ } | null>(null);
332
+
333
+ const closed = ask.status === "closed";
334
+ useEffect(() => {
335
+ if (closed) clear();
336
+ // Only a briefing closing clears its drafts; `clear` is new on every render.
337
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
338
+ }, [closed]);
339
+
340
+ const tree = foldBriefing(ask.prompt, ask.thread);
341
+ const yourMove = ask.status === "awaiting_human";
342
+ const busy = sending !== null;
343
+ const items = replyFrom(tree, drafts, false);
344
+ const reply: ReplyBody = { outcome: "responded", points: items };
345
+ const problem = items.length === 0 ? null : respondedProblem(ask.prompt, ask.thread, reply);
346
+ const finishes =
347
+ items.length > 0 &&
348
+ problem === null &&
349
+ foldBriefing(ask.prompt, [...ask.thread, { author: "human", reply, at: "" }]).resolved;
350
+
351
+ const toggle = (id: string) =>
352
+ setOpened((current) => {
353
+ const next = new Set(current);
354
+ if (!next.delete(id)) next.add(id);
355
+ return next;
356
+ });
357
+
358
+ const needing = allPoints(tree).filter((point) => point.state === "needs_you");
359
+ const next = () => {
360
+ const at = needing.findIndex((point) => point.id === cursor);
361
+ const target = needing[(at + 1) % needing.length];
362
+ if (target === undefined) return;
363
+ setCursor(target.id);
364
+ setOpened((current) => new Set([...current, ...pathTo(target.id)]));
365
+ requestAnimationFrame(() =>
366
+ document.getElementById(`row-${target.id}`)?.scrollIntoView?.({ block: "nearest" }),
367
+ );
368
+ };
369
+
370
+ const submit = async (key: string, sent: ReplyBody) => {
371
+ setSending(key);
372
+ setError(null);
373
+ const exit = await send({ askId: ask.id, reply: sent });
374
+ setSending(null);
375
+ if (Exit.isSuccess(exit)) {
376
+ clear();
377
+ onReplied();
378
+ return;
379
+ }
380
+ // As with any reply: one Loopback refused will not go through as it is; one that never
381
+ // reached it can simply be sent again. Either way the drafts are kept.
382
+ const failure = Cause.squash(exit.cause);
383
+ const reason = failure instanceof Error ? failure.message : "no reason given";
384
+ setError(
385
+ failure instanceof ApiError && failure.status >= 400 && failure.status < 500
386
+ ? { title: "Your replies were not accepted", description: reason }
387
+ : {
388
+ title: "Your replies were not sent",
389
+ description: `Loopback could not take them just now (${reason}), so the agent still has nothing. Send them again.`,
390
+ },
391
+ );
392
+ };
393
+
394
+ const shared: Shared = { opened, toggle, drafts, update, writable: !closed, now };
395
+ const sendLabel = finishes
396
+ ? "Finish"
397
+ : items.length === 0
398
+ ? "Send"
399
+ : `Send ${items.length === 1 ? "1 reply" : `${items.length} replies`}`;
400
+ const canSend = !busy && yourMove && items.length > 0 && problem === null;
401
+
402
+ // ⌘↵ (Ctrl↵ off Apple's keyboards) sends, as it does on every other ask.
403
+ useInboxKeys((event) => {
404
+ if (!canSend || event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
405
+ event.preventDefault();
406
+ void submit("send", reply);
407
+ });
408
+
409
+ return (
410
+ <div className="flex flex-col gap-6">
411
+ {needing.length === 0 || !yourMove ? null : (
412
+ <div className="flex items-center gap-2">
413
+ <Text variant="secondary" size="sm">
414
+ {needing.length === 1 ? "1 point needs you" : `${needing.length} points need you`}
415
+ </Text>
416
+ <Button size="sm" variant="ghost" icon={<ArrowDown />} onClick={next}>
417
+ Next point that needs you
418
+ </Button>
419
+ </div>
420
+ )}
421
+
422
+ {tree.sections.map((section, index) => (
423
+ <section key={section.title ?? `section-${index}`} className="flex flex-col gap-2">
424
+ {section.title === undefined ? null : <h2 className={GROUP_LABEL}>{section.title}</h2>}
425
+ <Points points={section.points} shared={shared} />
426
+ </section>
427
+ ))}
428
+
429
+ {closed ? null : (
430
+ <div className="sticky bottom-4 flex flex-col gap-3 rounded-lg border border-kumo-line bg-kumo-elevated p-4">
431
+ {error === null ? null : (
432
+ <Banner
433
+ role="alert"
434
+ variant="error"
435
+ icon={<WarningCircle />}
436
+ title={error.title}
437
+ description={error.description}
438
+ />
439
+ )}
440
+ {yourMove ? (
441
+ problem === null ? null : (
442
+ <Text variant="secondary" size="sm">
443
+ {problem}
444
+ </Text>
445
+ )
446
+ ) : (
447
+ <Text variant="secondary" size="sm">
448
+ The agent is answering your replies. You can keep drafting meanwhile.
449
+ </Text>
450
+ )}
451
+ <div className="flex flex-wrap items-center gap-2">
452
+ <Button
453
+ variant="primary"
454
+ icon={finishes ? <Check /> : <PaperPlaneRight />}
455
+ loading={sending === "send"}
456
+ disabled={!canSend}
457
+ onClick={() => void submit("send", reply)}
458
+ >
459
+ {sendLabel}
460
+ </Button>
461
+ <Button
462
+ icon={<Checks />}
463
+ loading={sending === "finish"}
464
+ disabled={busy || !yourMove}
465
+ onClick={() =>
466
+ void submit("finish", {
467
+ outcome: "responded",
468
+ points: replyFrom(tree, drafts, true),
469
+ })
470
+ }
471
+ >
472
+ Finish now
473
+ </Button>
474
+ <Button
475
+ variant="ghost"
476
+ icon={<X />}
477
+ loading={sending === "dismiss"}
478
+ disabled={busy}
479
+ onClick={() => void submit("dismiss", { outcome: "dismissed" })}
480
+ >
481
+ Dismiss
482
+ </Button>
483
+ <span className="ml-auto hidden text-xs text-kumo-subtle sm:inline">
484
+ {SEND_KEY}↵ sends
485
+ </span>
486
+ </div>
487
+ </div>
488
+ )}
489
+ </div>
490
+ );
491
+ }
@@ -0,0 +1,126 @@
1
+ import { Loader, Text } from "@cloudflare/kumo";
2
+ import { CodeHighlighted } from "@cloudflare/kumo/code";
3
+ import { useAtomValue } from "@effect/atom-react";
4
+ import type { Block } from "@loopbacktools/sdk";
5
+ import { AsyncResult } from "effect/reactivity";
6
+ import type { ReactNode } from "react";
7
+ import Markdown, { type Components } from "react-markdown";
8
+ import remarkGfm from "remark-gfm";
9
+ import { useClient } from "./context.tsx";
10
+ import { imageAtom } from "./state.ts";
11
+
12
+ /** The text inside a markdown node, which react-markdown hands over as children. */
13
+ const textOf = (node: ReactNode): string =>
14
+ typeof node === "string" ? node : Array.isArray(node) ? node.map(textOf).join("") : "";
15
+
16
+ const markdownComponents: Components = {
17
+ a: ({ href, children }) => (
18
+ <a href={href} target="_blank" rel="noreferrer noopener">
19
+ {children}
20
+ </a>
21
+ ),
22
+ pre: ({ children }) => <>{children}</>,
23
+ code: ({ className, children }) => {
24
+ const language = /language-(\S+)/.exec(className ?? "")?.[1];
25
+ const source = textOf(children).replace(/\n$/, "");
26
+ if (language === undefined && !source.includes("\n")) return <code>{children}</code>;
27
+ return <CodeHighlighted code={source} lang={language ?? "text"} showCopyButton />;
28
+ },
29
+ };
30
+
31
+ /** Markdown an agent wrote. Links open in a new tab; fenced code is highlighted. */
32
+ export function Prose({ text }: { readonly text: string }) {
33
+ return (
34
+ <div className="loopback-markdown text-kumo-default">
35
+ <Markdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
36
+ {text}
37
+ </Markdown>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ function Caption({ children }: { readonly children: string }) {
43
+ return (
44
+ <Text variant="secondary" size="sm">
45
+ {children}
46
+ </Text>
47
+ );
48
+ }
49
+
50
+ function Picture({ uploadId, caption }: { readonly uploadId: string; readonly caption?: string }) {
51
+ const result = useAtomValue(imageAtom(useClient())(uploadId));
52
+ if (AsyncResult.isFailure(result))
53
+ return <Text variant="error">This image could not be loaded.</Text>;
54
+ if (!AsyncResult.isSuccess(result)) {
55
+ return (
56
+ <div className="flex h-40 items-center justify-center rounded-lg bg-kumo-recessed">
57
+ <Loader />
58
+ </div>
59
+ );
60
+ }
61
+ return (
62
+ <a href={result.value} target="_blank" rel="noreferrer noopener">
63
+ <img
64
+ src={result.value}
65
+ alt={caption ?? "An image the agent uploaded"}
66
+ className="max-h-[70vh] max-w-full rounded-lg border border-kumo-line"
67
+ />
68
+ </a>
69
+ );
70
+ }
71
+
72
+ function Image({ uploadId, caption }: { readonly uploadId: string; readonly caption?: string }) {
73
+ return (
74
+ <figure className="flex flex-col gap-2">
75
+ <Picture uploadId={uploadId} {...(caption === undefined ? {} : { caption })} />
76
+ {caption === undefined ? null : <Caption>{caption}</Caption>}
77
+ </figure>
78
+ );
79
+ }
80
+
81
+ function BlockView({ block }: { readonly block: Block }) {
82
+ switch (block.type) {
83
+ case "markdown":
84
+ return <Prose text={block.text} />;
85
+ case "code":
86
+ return (
87
+ <div className="flex flex-col gap-1">
88
+ {block.filename === undefined ? null : <Caption>{block.filename}</Caption>}
89
+ <CodeHighlighted code={block.code} lang={block.language ?? "text"} showCopyButton />
90
+ </div>
91
+ );
92
+ case "diff":
93
+ return (
94
+ <div className="flex flex-col gap-1">
95
+ {block.filename === undefined ? null : <Caption>{block.filename}</Caption>}
96
+ <CodeHighlighted code={block.diff} lang="diff" showCopyButton />
97
+ </div>
98
+ );
99
+ case "image":
100
+ return (
101
+ <Image
102
+ uploadId={block.uploadId}
103
+ {...(block.caption === undefined ? {} : { caption: block.caption })}
104
+ />
105
+ );
106
+ case "unknown":
107
+ return (
108
+ <Caption>
109
+ Part of this came from a newer version of Loopback and can't be shown here.
110
+ </Caption>
111
+ );
112
+ }
113
+ }
114
+
115
+ /** What an agent put up for review, in order. */
116
+ export function Blocks({ blocks }: { readonly blocks: readonly Block[] }) {
117
+ return (
118
+ <div className="flex flex-col gap-4">
119
+ {blocks.map((block, index) => (
120
+ // Blocks are an ordered snapshot that never changes and has no ids: position is identity.
121
+ // oxlint-disable-next-line react/no-array-index-key
122
+ <BlockView key={index} block={block} />
123
+ ))}
124
+ </div>
125
+ );
126
+ }