@narumitw/pi-webui 0.28.0 → 0.29.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,908 @@
1
+ import "@radix-ui/themes/styles.css";
2
+ import "@radix-ui/colors/jade.css";
3
+ import "@radix-ui/colors/jade-dark.css";
4
+ import "@radix-ui/colors/red.css";
5
+ import "@radix-ui/colors/red-dark.css";
6
+ import {
7
+ ArrowDownIcon,
8
+ CheckCircledIcon,
9
+ ChevronDownIcon,
10
+ CodeIcon,
11
+ ExclamationTriangleIcon,
12
+ FileTextIcon,
13
+ ImageIcon,
14
+ InfoCircledIcon,
15
+ PaperPlaneIcon,
16
+ ReloadIcon,
17
+ StopwatchIcon,
18
+ TrashIcon,
19
+ } from "@radix-ui/react-icons";
20
+ import {
21
+ Badge,
22
+ Box,
23
+ Button,
24
+ Callout,
25
+ Card,
26
+ Code,
27
+ Container,
28
+ Flex,
29
+ Heading,
30
+ IconButton,
31
+ Link,
32
+ Spinner,
33
+ Text,
34
+ TextArea,
35
+ Theme,
36
+ } from "@radix-ui/themes";
37
+ import { Collapsible, Popover, Tooltip } from "radix-ui";
38
+ import { StrictMode, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
39
+ import { createRoot } from "react-dom/client";
40
+ import { dropAfterTarget, imagesStackVertically } from "../image-drag.js";
41
+ import { parseMarkdown } from "../markdown.js";
42
+ import {
43
+ isCollapsibleMessageRole,
44
+ retainedImageStatus,
45
+ toolCommandPreview,
46
+ toolPhaseLabel,
47
+ } from "../transcript.js";
48
+ import {
49
+ attachmentItemLabel,
50
+ attachmentPhaseLabel,
51
+ canSubmit,
52
+ composerLocked,
53
+ composerStatus,
54
+ connectionLabel,
55
+ primaryActionLabel,
56
+ webClient,
57
+ } from "./client.js";
58
+ import {
59
+ ClearAttachmentsDialog,
60
+ ForgetImageDialog,
61
+ ImagePreviewDialog,
62
+ PageFooter,
63
+ } from "./overlays.jsx";
64
+ import "./styles.css";
65
+ import {
66
+ connectionColor,
67
+ hasDraggedFile,
68
+ isNearBottom,
69
+ isSupportedImageFile,
70
+ knownRole,
71
+ resizeInput,
72
+ roleLabel,
73
+ safeJson,
74
+ withStableKeys,
75
+ } from "./view-helpers.js";
76
+
77
+ const ACCEPTED_IMAGES =
78
+ "image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif";
79
+ const DRAG_TYPE = "application/x-pi-webui-image";
80
+
81
+ function useAppearance() {
82
+ const query = "(prefers-color-scheme: dark)";
83
+ const [appearance, setAppearance] = useState(() =>
84
+ window.matchMedia(query).matches ? "dark" : "light",
85
+ );
86
+ useEffect(() => {
87
+ const media = window.matchMedia(query);
88
+ const update = () => setAppearance(media.matches ? "dark" : "light");
89
+ media.addEventListener("change", update);
90
+ return () => media.removeEventListener("change", update);
91
+ }, []);
92
+ return appearance;
93
+ }
94
+
95
+ function App() {
96
+ const view = useSyncExternalStore(webClient.subscribe, webClient.getSnapshot);
97
+ const appearance = useAppearance();
98
+ const [clearOpen, setClearOpen] = useState(false);
99
+ const [forgetId, setForgetId] = useState("");
100
+ const [previewImage, setPreviewImage] = useState();
101
+ const [dragActive, setDragActive] = useState(false);
102
+ const dragDepth = useRef(0);
103
+ const clearReturnFocus = useRef();
104
+ const forgetReturnFocus = useRef();
105
+ const previewReturnFocus = useRef();
106
+ const model = view.model;
107
+
108
+ useEffect(() => webClient.start(), []);
109
+ useEffect(() => {
110
+ const update = () => webClient.setNearBottom(isNearBottom());
111
+ window.addEventListener("scroll", update, { passive: true });
112
+ return () => window.removeEventListener("scroll", update);
113
+ }, []);
114
+ useEffect(() => {
115
+ if (!view.scrollToLatest) return;
116
+ requestAnimationFrame(() =>
117
+ window.scrollTo({
118
+ top: document.body.scrollHeight,
119
+ behavior: model.following ? "auto" : "smooth",
120
+ }),
121
+ );
122
+ }, [view, model.following]);
123
+ useEffect(() => {
124
+ if (!view.focusTarget) return;
125
+ requestAnimationFrame(() => {
126
+ if (view.focusTarget === "input") document.querySelector("#message-input")?.focus();
127
+ else {
128
+ const id = CSS.escape(view.focusTarget);
129
+ document.querySelector(`[data-image-id="${id}"]`)?.focus();
130
+ }
131
+ });
132
+ }, [view]);
133
+ useEffect(() => {
134
+ if (previewImage && !model.images.some((image) => image.id === previewImage.id)) {
135
+ closeImagePreview();
136
+ }
137
+ });
138
+ useEffect(() => {
139
+ const paste = (event) => {
140
+ const files = [...(event.clipboardData?.files ?? [])].filter(isSupportedImageFile);
141
+ if (files.length === 0 || composerLocked(view)) return;
142
+ event.preventDefault();
143
+ void webClient.addFiles(files);
144
+ };
145
+ document.addEventListener("paste", paste);
146
+ return () => document.removeEventListener("paste", paste);
147
+ }, [view]);
148
+
149
+ function openClearDialog(trigger) {
150
+ clearReturnFocus.current = trigger;
151
+ setClearOpen(true);
152
+ }
153
+
154
+ function setClearDialogOpen(open) {
155
+ setClearOpen(open);
156
+ if (!open) requestAnimationFrame(() => clearReturnFocus.current?.focus());
157
+ }
158
+
159
+ function openForgetDialog(id, trigger) {
160
+ forgetReturnFocus.current = trigger;
161
+ setForgetId(id);
162
+ }
163
+
164
+ function closeForgetDialog() {
165
+ setForgetId("");
166
+ requestAnimationFrame(() => forgetReturnFocus.current?.focus());
167
+ }
168
+
169
+ function openImagePreview(image, trigger) {
170
+ previewReturnFocus.current = trigger ?? document.activeElement;
171
+ setPreviewImage(image);
172
+ }
173
+
174
+ function closeImagePreview() {
175
+ setPreviewImage(undefined);
176
+ requestAnimationFrame(() => previewReturnFocus.current?.focus());
177
+ }
178
+
179
+ const fileDropProps = {
180
+ onDragEnter(event) {
181
+ if (!hasDraggedFile(event.nativeEvent) || composerLocked(view)) return;
182
+ event.preventDefault();
183
+ dragDepth.current += 1;
184
+ setDragActive(true);
185
+ },
186
+ onDragOver(event) {
187
+ if (!hasDraggedFile(event.nativeEvent) || composerLocked(view)) return;
188
+ event.preventDefault();
189
+ },
190
+ onDragLeave() {
191
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
192
+ if (dragDepth.current === 0) setDragActive(false);
193
+ },
194
+ onDrop(event) {
195
+ dragDepth.current = 0;
196
+ setDragActive(false);
197
+ const files = [...(event.dataTransfer?.files ?? [])].filter(isSupportedImageFile);
198
+ if (files.length === 0 || composerLocked(view)) return;
199
+ event.preventDefault();
200
+ void webClient.addFiles(files);
201
+ },
202
+ };
203
+
204
+ return (
205
+ <Theme
206
+ appearance={appearance}
207
+ accentColor="jade"
208
+ grayColor="sand"
209
+ panelBackground="translucent"
210
+ radius="large"
211
+ scaling="100%"
212
+ >
213
+ <Tooltip.Provider delayDuration={350}>
214
+ <Link className="skip-link" href="#message-input" highContrast>
215
+ Skip to message
216
+ </Link>
217
+ <SessionHeader model={model} />
218
+ <Container asChild size="3" px={{ initial: "3", sm: "5" }}>
219
+ <main>
220
+ <Conversation model={model} onForget={openForgetDialog} view={view} />
221
+ {model.unseenUpdateIds.length > 0 && (
222
+ <Button
223
+ className="jump-latest"
224
+ highContrast
225
+ id="jump-latest"
226
+ onClick={() => webClient.followLatest()}
227
+ variant="surface"
228
+ >
229
+ <ArrowDownIcon />
230
+ {model.unseenUpdateIds.length > 1
231
+ ? `${model.unseenUpdateIds.length} new updates`
232
+ : "Jump to latest"}
233
+ </Button>
234
+ )}
235
+ <BlockingState model={model} />
236
+ <Composer
237
+ dragActive={dragActive}
238
+ onClear={(event) => openClearDialog(event.currentTarget)}
239
+ onPreview={openImagePreview}
240
+ view={view}
241
+ {...fileDropProps}
242
+ />
243
+ </main>
244
+ </Container>
245
+ <PageFooter />
246
+ <ClearAttachmentsDialog
247
+ count={model.images.length}
248
+ onOpenChange={setClearDialogOpen}
249
+ open={clearOpen}
250
+ />
251
+ <ForgetImageDialog id={forgetId} onOpenChange={(open) => !open && closeForgetDialog()} />
252
+ <ImagePreviewDialog
253
+ image={previewImage}
254
+ onOpenChange={(open) => !open && closeImagePreview()}
255
+ revision={model.attachmentRevision}
256
+ />
257
+ </Tooltip.Provider>
258
+ </Theme>
259
+ );
260
+ }
261
+
262
+ function SessionHeader({ model }) {
263
+ return (
264
+ <Container asChild size="3" px={{ initial: "3", sm: "5" }}>
265
+ <header className="session-header">
266
+ <Box className="session-identity">
267
+ <Text as="p" className="eyebrow" color="jade" size="1" weight="bold">
268
+ Pi WebUI
269
+ </Text>
270
+ <Heading as="h1" id="project-name" size="6">
271
+ {model.session?.projectName ?? "Connecting…"}
272
+ </Heading>
273
+ <Text as="p" color="gray" id="session-name" size="2">
274
+ {model.session?.name ?? "Current session"}
275
+ </Text>
276
+ </Box>
277
+ <Flex align="center" className="session-controls" gap="2">
278
+ <Badge color={connectionColor(model)} id="connection-status" role="status" size="2">
279
+ {model.activity === "running" && !model.stale && !model.closed ? (
280
+ <Spinner size="1" />
281
+ ) : (
282
+ <CheckCircledIcon />
283
+ )}
284
+ {connectionLabel(model)}
285
+ </Badge>
286
+ <Popover.Root>
287
+ <Popover.Trigger asChild>
288
+ <Button color="gray" highContrast variant="ghost">
289
+ <InfoCircledIcon /> Session details <ChevronDownIcon />
290
+ </Button>
291
+ </Popover.Trigger>
292
+ <Popover.Content align="end" className="session-popover" sideOffset={6}>
293
+ <Text as="div" color="gray" size="1" weight="bold">
294
+ Working directory
295
+ </Text>
296
+ <Code id="cwd" size="1" variant="ghost">
297
+ {model.session?.cwd ?? "—"}
298
+ </Code>
299
+ <Popover.Arrow className="popover-arrow" />
300
+ </Popover.Content>
301
+ </Popover.Root>
302
+ </Flex>
303
+ </header>
304
+ </Container>
305
+ );
306
+ }
307
+
308
+ function Conversation({ model, onForget, view }) {
309
+ const tools = useMemo(() => new Map(model.tools.map((tool) => [tool.id, tool])), [model.tools]);
310
+ const retained = useMemo(
311
+ () => new Set((model.sentImages.items ?? []).map((image) => image.id)),
312
+ [model.sentImages.items],
313
+ );
314
+ return (
315
+ <section className="conversation" aria-labelledby="conversation-title">
316
+ <Heading as="h2" className="visually-hidden" id="conversation-title">
317
+ Current Pi conversation
318
+ </Heading>
319
+ {model.messages.length === 0 && (
320
+ <Flex align="center" className="empty-state" direction="column" justify="center">
321
+ <FileTextIcon className="empty-icon" />
322
+ <Heading as="h3" size="3">
323
+ No messages yet
324
+ </Heading>
325
+ <Text color="gray" size="2">
326
+ Messages from this Pi session will appear here.
327
+ </Text>
328
+ </Flex>
329
+ )}
330
+ <Box asChild>
331
+ <ol id="transcript">
332
+ {model.messages.map((message) => (
333
+ <Message
334
+ key={message.id}
335
+ message={message}
336
+ onForget={onForget}
337
+ retained={retained}
338
+ tools={tools}
339
+ />
340
+ ))}
341
+ </ol>
342
+ </Box>
343
+ <Text
344
+ as="p"
345
+ className="visually-hidden"
346
+ id="transcript-status"
347
+ role="status"
348
+ aria-live="polite"
349
+ >
350
+ {view.transcriptAnnouncement}
351
+ </Text>
352
+ </section>
353
+ );
354
+ }
355
+
356
+ function Message({ message, onForget, retained, tools }) {
357
+ const role = knownRole(message.role);
358
+ const body = (
359
+ <Box className="message-body">
360
+ {withStableKeys(message.content ?? []).map(({ key, value: block }) => (
361
+ <MessageBlock
362
+ key={key}
363
+ block={block}
364
+ onForget={onForget}
365
+ retained={retained}
366
+ tool={tools.get(block.id)}
367
+ />
368
+ ))}
369
+ {message.errorMessage && (
370
+ <Callout.Root color="red" role="alert" size="1" variant="soft">
371
+ <Callout.Icon>
372
+ <ExclamationTriangleIcon />
373
+ </Callout.Icon>
374
+ <Callout.Text>{message.errorMessage}</Callout.Text>
375
+ </Callout.Root>
376
+ )}
377
+ </Box>
378
+ );
379
+ return (
380
+ <Box asChild className={`message ${role}`}>
381
+ <li>
382
+ {isCollapsibleMessageRole(message.role) ? (
383
+ <Collapsible.Root className="message-disclosure">
384
+ <Collapsible.Trigger asChild>
385
+ <Button className="message-heading" color="gray" variant="ghost">
386
+ <CodeIcon /> {roleLabel(message)} <ChevronDownIcon className="disclosure-icon" />
387
+ </Button>
388
+ </Collapsible.Trigger>
389
+ <Collapsible.Content>{body}</Collapsible.Content>
390
+ </Collapsible.Root>
391
+ ) : (
392
+ <>
393
+ <Text as="div" className="message-heading" color="gray" size="1" weight="bold">
394
+ {roleLabel(message)}
395
+ </Text>
396
+ {body}
397
+ </>
398
+ )}
399
+ </li>
400
+ </Box>
401
+ );
402
+ }
403
+
404
+ function MessageBlock({ block, onForget, retained, tool }) {
405
+ if (block.type === "text") return <Markdown text={block.text} />;
406
+ if (block.type === "thinking") {
407
+ return (
408
+ <Collapsible.Root className="thinking">
409
+ <Collapsible.Trigger asChild>
410
+ <Button color="gray" variant="ghost">
411
+ <StopwatchIcon /> Thinking <ChevronDownIcon className="disclosure-icon" />
412
+ </Button>
413
+ </Collapsible.Trigger>
414
+ <Collapsible.Content>
415
+ <pre>{block.text}</pre>
416
+ </Collapsible.Content>
417
+ </Collapsible.Root>
418
+ );
419
+ }
420
+ if (block.type === "toolCall") return <ToolCall call={block} tool={tool} />;
421
+ if (block.type === "image") {
422
+ return <SentImageChip block={block} onForget={onForget} retained={retained} />;
423
+ }
424
+ return null;
425
+ }
426
+
427
+ function Markdown({ text }) {
428
+ return (
429
+ <Box className="message-markdown">
430
+ {withStableKeys(parseMarkdown(text)).map(({ key, value: block }) => (
431
+ <MarkdownBlock block={block} key={key} />
432
+ ))}
433
+ </Box>
434
+ );
435
+ }
436
+
437
+ function MarkdownBlock({ block }) {
438
+ if (block.type === "heading") {
439
+ const level = Math.min(6, block.level + 2);
440
+ return (
441
+ <Heading as={`h${level}`} className="markdown-heading" size="3">
442
+ <MarkdownInline nodes={block.children} />
443
+ </Heading>
444
+ );
445
+ }
446
+ if (block.type === "list") {
447
+ const List = block.ordered ? "ol" : "ul";
448
+ return (
449
+ <List className="markdown-list">
450
+ {withStableKeys(block.items).map(({ key, value: item }) => (
451
+ <li key={key}>
452
+ <MarkdownInline nodes={item} />
453
+ </li>
454
+ ))}
455
+ </List>
456
+ );
457
+ }
458
+ if (block.type === "blockquote") {
459
+ return (
460
+ <blockquote>
461
+ {withStableKeys(block.children).map(({ key, value: child }) => (
462
+ <MarkdownBlock block={child} key={key} />
463
+ ))}
464
+ </blockquote>
465
+ );
466
+ }
467
+ if (block.type === "codeBlock") {
468
+ return (
469
+ <pre className="markdown-code">
470
+ <code data-language={block.language || undefined}>{block.text}</code>
471
+ </pre>
472
+ );
473
+ }
474
+ return (
475
+ <Text as="p" className="message-text">
476
+ <MarkdownInline nodes={block.children} />
477
+ </Text>
478
+ );
479
+ }
480
+
481
+ function MarkdownInline({ nodes }) {
482
+ return withStableKeys(nodes).map(({ key, value: node }) => {
483
+ if (node.type === "text") return node.text;
484
+ if (node.type === "code") return <Code key={key}>{node.text}</Code>;
485
+ if (node.type === "strong") {
486
+ return (
487
+ <strong key={key}>
488
+ <MarkdownInline nodes={node.children} />
489
+ </strong>
490
+ );
491
+ }
492
+ if (node.type === "emphasis") {
493
+ return (
494
+ <em key={key}>
495
+ <MarkdownInline nodes={node.children} />
496
+ </em>
497
+ );
498
+ }
499
+ return (
500
+ <Link href={node.href} key={key} rel="noopener noreferrer" target="_blank">
501
+ <MarkdownInline nodes={node.children} />
502
+ </Link>
503
+ );
504
+ });
505
+ }
506
+
507
+ function ToolCall({ call, tool }) {
508
+ const phase = toolPhaseLabel(tool);
509
+ const command = toolCommandPreview(tool);
510
+ const args = safeJson(tool?.args ?? call.arguments);
511
+ const result = tool?.result === undefined ? "" : safeJson(tool.result);
512
+ return (
513
+ <Collapsible.Root className={`tool ${tool?.isError ? "failed" : ""}`}>
514
+ <Card asChild size="1">
515
+ <Box>
516
+ <Collapsible.Trigger asChild>
517
+ <Button className="tool-trigger" color={tool?.isError ? "red" : "gray"} variant="ghost">
518
+ <CodeIcon />
519
+ <span>{`${call.name} · ${phase}`}</span>
520
+ {command && <Code className="tool-command">{command}</Code>}
521
+ <ChevronDownIcon className="disclosure-icon" />
522
+ </Button>
523
+ </Collapsible.Trigger>
524
+ <Collapsible.Content className="tool-content">
525
+ <pre>{args}</pre>
526
+ {result && <pre>{result}</pre>}
527
+ </Collapsible.Content>
528
+ </Box>
529
+ </Card>
530
+ </Collapsible.Root>
531
+ );
532
+ }
533
+
534
+ function SentImageChip({ block, onForget, retained }) {
535
+ const status = retainedImageStatus(block, retained);
536
+ const label = `Image${block.mimeType ? ` · ${block.mimeType}` : ""}`;
537
+ return (
538
+ <Flex align="center" className="image-chip" gap="2" wrap="wrap">
539
+ <ImageIcon />
540
+ <Text color="gray" size="1">
541
+ {label}
542
+ </Text>
543
+ {status === "eligible" && (
544
+ <>
545
+ <Button
546
+ onClick={() => void webClient.reattachSentImage(block.retainedImageId)}
547
+ size="1"
548
+ variant="soft"
549
+ aria-label={`Attach image again: ${label}`}
550
+ >
551
+ <ImageIcon /> Attach again
552
+ </Button>
553
+ <Button
554
+ color="gray"
555
+ onClick={(event) => onForget(block.retainedImageId, event.currentTarget)}
556
+ size="1"
557
+ variant="ghost"
558
+ aria-label={`Forget retained image: ${label}`}
559
+ >
560
+ <TrashIcon /> Forget
561
+ </Button>
562
+ </>
563
+ )}
564
+ {status === "expired" && (
565
+ <Badge color="gray" variant="soft">
566
+ Expired
567
+ </Badge>
568
+ )}
569
+ </Flex>
570
+ );
571
+ }
572
+
573
+ function BlockingState({ model }) {
574
+ if (!model.closed && !model.stale) return null;
575
+ return (
576
+ <Callout.Root className="blocking-state" color="red" id="blocking-state" role="alert">
577
+ <Callout.Icon>
578
+ <ExclamationTriangleIcon />
579
+ </Callout.Icon>
580
+ <Callout.Text>
581
+ <strong>{model.closed ? "Pi session ended" : "Another tab is active"}</strong>
582
+ <br />
583
+ {model.closed
584
+ ? "Return to the terminal and run /webui for the active session."
585
+ : "This tab remains readable. Refresh it to take control."}
586
+ </Callout.Text>
587
+ </Callout.Root>
588
+ );
589
+ }
590
+
591
+ function Composer({ dragActive, onClear, onPreview, view, ...dropProps }) {
592
+ const inputRef = useRef();
593
+ const fileRef = useRef();
594
+ const composerRef = useRef();
595
+ const model = view.model;
596
+ const locked = composerLocked(view);
597
+ const admissionLocked = locked || model.readingImages > 0;
598
+ useEffect(() => resizeInput(inputRef.current));
599
+ useEffect(() => {
600
+ const composer = composerRef.current;
601
+ if (!composer) return;
602
+ const root = document.documentElement;
603
+ const updateComposerHeight = () =>
604
+ root.style.setProperty("--composer-height", `${composer.offsetHeight}px`);
605
+ updateComposerHeight();
606
+ const observer = new ResizeObserver(updateComposerHeight);
607
+ observer.observe(composer);
608
+ return () => {
609
+ observer.disconnect();
610
+ root.style.removeProperty("--composer-height");
611
+ };
612
+ }, []);
613
+ return (
614
+ <Card asChild className={`composer ${dragActive ? "drag-active" : ""}`} id="composer" size="2">
615
+ <form
616
+ onSubmit={(event) => {
617
+ event.preventDefault();
618
+ void webClient.send(false);
619
+ }}
620
+ ref={composerRef}
621
+ {...dropProps}
622
+ >
623
+ <Flex direction="column" gap="3">
624
+ <Text as="label" htmlFor="message-input" size="2" weight="bold">
625
+ Message Pi
626
+ </Text>
627
+ <TextArea
628
+ aria-label="Message Pi"
629
+ disabled={model.closed || model.stale}
630
+ id="message-input"
631
+ onChange={(event) => webClient.editText(event.target.value)}
632
+ onKeyDown={(event) => {
633
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
634
+ event.preventDefault();
635
+ void webClient.send(false);
636
+ }
637
+ }}
638
+ placeholder="Ask Pi to do something…"
639
+ ref={inputRef}
640
+ resize="none"
641
+ rows={1}
642
+ size="3"
643
+ value={model.text}
644
+ />
645
+ {model.images.length > 0 && (
646
+ <Text
647
+ as="p"
648
+ className="attachment-summary"
649
+ color="gray"
650
+ id="attachment-summary"
651
+ size="1"
652
+ >
653
+ {`${model.images.length}/${model.imageLimits?.maxImages ?? model.images.length} images attached · Sensitive metadata is removed before sending.`}
654
+ </Text>
655
+ )}
656
+ <AttachmentList onPreview={onPreview} view={view} />
657
+ <Text
658
+ as="p"
659
+ aria-atomic="true"
660
+ aria-live="polite"
661
+ className="visually-hidden"
662
+ id="attachment-announcement"
663
+ role="status"
664
+ >
665
+ {model.images.length
666
+ ? `Attachment state: ${attachmentPhaseLabel(model.attachmentPhase)}.`
667
+ : ""}
668
+ </Text>
669
+ {model.error && (
670
+ <Callout.Root color="red" id="composer-error" role="alert" size="1">
671
+ <Callout.Icon>
672
+ <ExclamationTriangleIcon />
673
+ </Callout.Icon>
674
+ <Callout.Text>{model.error}</Callout.Text>
675
+ </Callout.Root>
676
+ )}
677
+ <Flex align="center" className="composer-bottom" gap="2" justify="between" wrap="wrap">
678
+ <Flex align="center" className="composer-support" gap="2" wrap="wrap">
679
+ <Button asChild disabled={admissionLocked} variant="soft">
680
+ <label htmlFor="image-input">
681
+ <ImageIcon /> Add images
682
+ </label>
683
+ </Button>
684
+ <input
685
+ accept={ACCEPTED_IMAGES}
686
+ disabled={admissionLocked}
687
+ hidden
688
+ id="image-input"
689
+ multiple
690
+ onChange={(event) => {
691
+ void webClient.addFiles(event.target.files);
692
+ event.target.value = "";
693
+ }}
694
+ ref={fileRef}
695
+ type="file"
696
+ />
697
+ <Text className="image-hint" color="gray" size="1">
698
+ Paste, drop, or choose images.
699
+ </Text>
700
+ {model.images.length >= 2 && (
701
+ <Button
702
+ color="gray"
703
+ disabled={locked}
704
+ onClick={onClear}
705
+ type="button"
706
+ variant="ghost"
707
+ >
708
+ <TrashIcon /> Clear attachments
709
+ </Button>
710
+ )}
711
+ </Flex>
712
+ <Flex className="send-actions" gap="2">
713
+ {model.activity === "running" && (
714
+ <Button
715
+ disabled={!canSubmit(view)}
716
+ onClick={() => void webClient.send(true)}
717
+ type="button"
718
+ variant="soft"
719
+ >
720
+ <ReloadIcon /> Steer
721
+ </Button>
722
+ )}
723
+ <Button disabled={!canSubmit(view)} id="send-next" type="submit">
724
+ <PaperPlaneIcon /> {primaryActionLabel(view)}
725
+ </Button>
726
+ </Flex>
727
+ </Flex>
728
+ <Text as="p" color="gray" id="composer-status" role="status" size="1">
729
+ {composerStatus(view)}
730
+ </Text>
731
+ </Flex>
732
+ </form>
733
+ </Card>
734
+ );
735
+ }
736
+
737
+ function AttachmentList({ onPreview, view }) {
738
+ const listRef = useRef();
739
+ const [draggedId, setDraggedId] = useState("");
740
+ const [dropTarget, setDropTarget] = useState();
741
+ const model = view.model;
742
+ if (model.images.length === 0) return null;
743
+ return (
744
+ <Flex asChild gap="2" ref={listRef} wrap="wrap">
745
+ <ul
746
+ aria-busy={view.mutatingAttachments || undefined}
747
+ aria-label="Attached images"
748
+ id="image-previews"
749
+ >
750
+ {model.images.map((image, index) => {
751
+ const orderingLocked = composerLocked(view) || model.attachmentPhase !== "ready";
752
+ const reorderable = model.images.length > 1 && image.status === "ready";
753
+ const draggable = reorderable && !orderingLocked;
754
+ const retryable =
755
+ image.status === "error" && (image.retryable || view.retryableIds.has(image.id));
756
+ return (
757
+ <Card
758
+ asChild
759
+ className={`image-preview-item ${draggedId === image.id ? "dragging" : ""} ${dropTarget?.id === image.id ? `drag-${dropTarget.after ? "after" : "before"}` : ""}`}
760
+ key={image.id}
761
+ size="1"
762
+ >
763
+ <li
764
+ aria-keyshortcuts={reorderable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
765
+ aria-label={
766
+ reorderable
767
+ ? `${image.name}, image ${index + 1} of ${model.images.length}. Drag to reorder or press Alt plus Up or Down Arrow.`
768
+ : undefined
769
+ }
770
+ data-image-id={image.id}
771
+ draggable={draggable}
772
+ onDragEnd={() => {
773
+ setDraggedId("");
774
+ setDropTarget(undefined);
775
+ }}
776
+ onDragLeave={(event) => {
777
+ if (!event.currentTarget.contains(event.relatedTarget)) setDropTarget(undefined);
778
+ }}
779
+ onDragOver={(event) => {
780
+ const sourceId = draggedId || event.dataTransfer?.getData(DRAG_TYPE);
781
+ if (orderingLocked || !sourceId || sourceId === image.id) return;
782
+ event.preventDefault();
783
+ const vertical = imagesStackVertically(listRef.current?.children ?? []);
784
+ setDropTarget({
785
+ id: image.id,
786
+ after: dropAfterTarget(event, event.currentTarget, vertical),
787
+ });
788
+ }}
789
+ onDragStart={(event) => {
790
+ if (!draggable) return;
791
+ setDraggedId(image.id);
792
+ event.dataTransfer.effectAllowed = "move";
793
+ event.dataTransfer.setData(DRAG_TYPE, image.id);
794
+ }}
795
+ onDrop={(event) => {
796
+ const sourceId = draggedId || event.dataTransfer?.getData(DRAG_TYPE);
797
+ if (orderingLocked || !sourceId || sourceId === image.id) return;
798
+ event.preventDefault();
799
+ const vertical = imagesStackVertically(listRef.current?.children ?? []);
800
+ void webClient.dropImage(
801
+ sourceId,
802
+ image.id,
803
+ dropAfterTarget(event, event.currentTarget, vertical),
804
+ );
805
+ setDraggedId("");
806
+ setDropTarget(undefined);
807
+ }}
808
+ onKeyDown={(event) => {
809
+ if (event.target !== event.currentTarget || orderingLocked || !event.altKey)
810
+ return;
811
+ const direction =
812
+ event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
813
+ if (
814
+ !direction ||
815
+ index + direction < 0 ||
816
+ index + direction >= model.images.length
817
+ )
818
+ return;
819
+ event.preventDefault();
820
+ void webClient.reorderImage(image.id, direction);
821
+ }}
822
+ tabIndex={reorderable ? 0 : undefined}
823
+ >
824
+ <Tooltip.Root>
825
+ <Tooltip.Trigger asChild>
826
+ <IconButton
827
+ aria-label={`Preview image ${image.name}`}
828
+ className="attachment-preview"
829
+ disabled={composerLocked(view) || image.status !== "ready"}
830
+ onClick={(event) => onPreview(image, event.currentTarget)}
831
+ type="button"
832
+ variant="soft"
833
+ >
834
+ {image.status === "ready" ? (
835
+ <img
836
+ alt=""
837
+ draggable={false}
838
+ src={`/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`}
839
+ />
840
+ ) : (
841
+ <Spinner />
842
+ )}
843
+ </IconButton>
844
+ </Tooltip.Trigger>
845
+ <Tooltip.Content sideOffset={6}>Preview {image.name}</Tooltip.Content>
846
+ </Tooltip.Root>
847
+ <Box className="attachment-details">
848
+ <Text className="attachment-name" size="2" weight="medium">
849
+ {image.name}
850
+ </Text>
851
+ <Text color={image.status === "error" ? "red" : "gray"} size="1">
852
+ {attachmentItemLabel(image, view.uploadProgress.get(image.id))}
853
+ </Text>
854
+ {image.notes?.length > 0 && (
855
+ <Text color="gray" size="1">
856
+ {image.notes.join(" · ")}
857
+ </Text>
858
+ )}
859
+ {model.images.length > 1 && (
860
+ <Text color="gray" size="1" weight="bold">
861
+ Order {index + 1} of {model.images.length}
862
+ </Text>
863
+ )}
864
+ </Box>
865
+ <Flex align="center" className="image-actions" gap="1">
866
+ {retryable && (
867
+ <Button
868
+ disabled={composerLocked(view)}
869
+ onClick={() => void webClient.retryImage(image.id)}
870
+ size="1"
871
+ type="button"
872
+ variant="soft"
873
+ >
874
+ <ReloadIcon /> Retry
875
+ </Button>
876
+ )}
877
+ <Tooltip.Root>
878
+ <Tooltip.Trigger asChild>
879
+ <IconButton
880
+ aria-label={`Remove image ${image.name}`}
881
+ color="red"
882
+ disabled={composerLocked(view)}
883
+ onClick={() => void webClient.removeImage(image.id)}
884
+ type="button"
885
+ variant="ghost"
886
+ >
887
+ <TrashIcon />
888
+ </IconButton>
889
+ </Tooltip.Trigger>
890
+ <Tooltip.Content sideOffset={6}>Remove {image.name}</Tooltip.Content>
891
+ </Tooltip.Root>
892
+ </Flex>
893
+ </li>
894
+ </Card>
895
+ );
896
+ })}
897
+ </ul>
898
+ </Flex>
899
+ );
900
+ }
901
+
902
+ const root = document.querySelector("#root");
903
+ if (!root) throw new Error("Pi WebUI root is unavailable.");
904
+ createRoot(root).render(
905
+ <StrictMode>
906
+ <App />
907
+ </StrictMode>,
908
+ );