@narumitw/pi-webui 0.28.0 → 0.29.2

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,910 @@
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
+ <header className="session-header">
265
+ <Container size="3" px={{ initial: "3", sm: "5" }}>
266
+ <Flex className="session-header-content">
267
+ <Box className="session-identity">
268
+ <Text as="p" className="eyebrow" color="jade" size="1" weight="bold">
269
+ Pi WebUI
270
+ </Text>
271
+ <Heading as="h1" id="project-name" size="6">
272
+ {model.session?.projectName ?? "Connecting…"}
273
+ </Heading>
274
+ <Text as="p" color="gray" id="session-name" size="2">
275
+ {model.session?.name ?? "Current session"}
276
+ </Text>
277
+ </Box>
278
+ <Flex align="center" className="session-controls" gap="2">
279
+ <Badge color={connectionColor(model)} id="connection-status" role="status" size="2">
280
+ {model.activity === "running" && !model.stale && !model.closed ? (
281
+ <Spinner size="1" />
282
+ ) : (
283
+ <CheckCircledIcon />
284
+ )}
285
+ {connectionLabel(model)}
286
+ </Badge>
287
+ <Popover.Root>
288
+ <Popover.Trigger asChild>
289
+ <Button color="gray" highContrast variant="ghost">
290
+ <InfoCircledIcon /> Session details <ChevronDownIcon />
291
+ </Button>
292
+ </Popover.Trigger>
293
+ <Popover.Content align="end" className="session-popover" sideOffset={6}>
294
+ <Text as="div" color="gray" size="1" weight="bold">
295
+ Working directory
296
+ </Text>
297
+ <Code id="cwd" size="1" variant="ghost">
298
+ {model.session?.cwd ?? "—"}
299
+ </Code>
300
+ <Popover.Arrow className="popover-arrow" />
301
+ </Popover.Content>
302
+ </Popover.Root>
303
+ </Flex>
304
+ </Flex>
305
+ </Container>
306
+ </header>
307
+ );
308
+ }
309
+
310
+ function Conversation({ model, onForget, view }) {
311
+ const tools = useMemo(() => new Map(model.tools.map((tool) => [tool.id, tool])), [model.tools]);
312
+ const retained = useMemo(
313
+ () => new Set((model.sentImages.items ?? []).map((image) => image.id)),
314
+ [model.sentImages.items],
315
+ );
316
+ return (
317
+ <section className="conversation" aria-labelledby="conversation-title">
318
+ <Heading as="h2" className="visually-hidden" id="conversation-title">
319
+ Current Pi conversation
320
+ </Heading>
321
+ {model.messages.length === 0 && (
322
+ <Flex align="center" className="empty-state" direction="column" justify="center">
323
+ <FileTextIcon className="empty-icon" />
324
+ <Heading as="h3" size="3">
325
+ No messages yet
326
+ </Heading>
327
+ <Text color="gray" size="2">
328
+ Messages from this Pi session will appear here.
329
+ </Text>
330
+ </Flex>
331
+ )}
332
+ <Box asChild>
333
+ <ol id="transcript">
334
+ {model.messages.map((message) => (
335
+ <Message
336
+ key={message.id}
337
+ message={message}
338
+ onForget={onForget}
339
+ retained={retained}
340
+ tools={tools}
341
+ />
342
+ ))}
343
+ </ol>
344
+ </Box>
345
+ <Text
346
+ as="p"
347
+ className="visually-hidden"
348
+ id="transcript-status"
349
+ role="status"
350
+ aria-live="polite"
351
+ >
352
+ {view.transcriptAnnouncement}
353
+ </Text>
354
+ </section>
355
+ );
356
+ }
357
+
358
+ function Message({ message, onForget, retained, tools }) {
359
+ const role = knownRole(message.role);
360
+ const body = (
361
+ <Box className="message-body">
362
+ {withStableKeys(message.content ?? []).map(({ key, value: block }) => (
363
+ <MessageBlock
364
+ key={key}
365
+ block={block}
366
+ onForget={onForget}
367
+ retained={retained}
368
+ tool={tools.get(block.id)}
369
+ />
370
+ ))}
371
+ {message.errorMessage && (
372
+ <Callout.Root color="red" role="alert" size="1" variant="soft">
373
+ <Callout.Icon>
374
+ <ExclamationTriangleIcon />
375
+ </Callout.Icon>
376
+ <Callout.Text>{message.errorMessage}</Callout.Text>
377
+ </Callout.Root>
378
+ )}
379
+ </Box>
380
+ );
381
+ return (
382
+ <Box asChild className={`message ${role}`}>
383
+ <li>
384
+ {isCollapsibleMessageRole(message.role) ? (
385
+ <Collapsible.Root className="message-disclosure">
386
+ <Collapsible.Trigger asChild>
387
+ <Button className="message-heading" color="gray" variant="ghost">
388
+ <CodeIcon /> {roleLabel(message)} <ChevronDownIcon className="disclosure-icon" />
389
+ </Button>
390
+ </Collapsible.Trigger>
391
+ <Collapsible.Content>{body}</Collapsible.Content>
392
+ </Collapsible.Root>
393
+ ) : (
394
+ <>
395
+ <Text as="div" className="message-heading" color="gray" size="1" weight="bold">
396
+ {roleLabel(message)}
397
+ </Text>
398
+ {body}
399
+ </>
400
+ )}
401
+ </li>
402
+ </Box>
403
+ );
404
+ }
405
+
406
+ function MessageBlock({ block, onForget, retained, tool }) {
407
+ if (block.type === "text") return <Markdown text={block.text} />;
408
+ if (block.type === "thinking") {
409
+ return (
410
+ <Collapsible.Root className="thinking">
411
+ <Collapsible.Trigger asChild>
412
+ <Button color="gray" variant="ghost">
413
+ <StopwatchIcon /> Thinking <ChevronDownIcon className="disclosure-icon" />
414
+ </Button>
415
+ </Collapsible.Trigger>
416
+ <Collapsible.Content>
417
+ <pre>{block.text}</pre>
418
+ </Collapsible.Content>
419
+ </Collapsible.Root>
420
+ );
421
+ }
422
+ if (block.type === "toolCall") return <ToolCall call={block} tool={tool} />;
423
+ if (block.type === "image") {
424
+ return <SentImageChip block={block} onForget={onForget} retained={retained} />;
425
+ }
426
+ return null;
427
+ }
428
+
429
+ function Markdown({ text }) {
430
+ return (
431
+ <Box className="message-markdown">
432
+ {withStableKeys(parseMarkdown(text)).map(({ key, value: block }) => (
433
+ <MarkdownBlock block={block} key={key} />
434
+ ))}
435
+ </Box>
436
+ );
437
+ }
438
+
439
+ function MarkdownBlock({ block }) {
440
+ if (block.type === "heading") {
441
+ const level = Math.min(6, block.level + 2);
442
+ return (
443
+ <Heading as={`h${level}`} className="markdown-heading" size="3">
444
+ <MarkdownInline nodes={block.children} />
445
+ </Heading>
446
+ );
447
+ }
448
+ if (block.type === "list") {
449
+ const List = block.ordered ? "ol" : "ul";
450
+ return (
451
+ <List className="markdown-list">
452
+ {withStableKeys(block.items).map(({ key, value: item }) => (
453
+ <li key={key}>
454
+ <MarkdownInline nodes={item} />
455
+ </li>
456
+ ))}
457
+ </List>
458
+ );
459
+ }
460
+ if (block.type === "blockquote") {
461
+ return (
462
+ <blockquote>
463
+ {withStableKeys(block.children).map(({ key, value: child }) => (
464
+ <MarkdownBlock block={child} key={key} />
465
+ ))}
466
+ </blockquote>
467
+ );
468
+ }
469
+ if (block.type === "codeBlock") {
470
+ return (
471
+ <pre className="markdown-code">
472
+ <code data-language={block.language || undefined}>{block.text}</code>
473
+ </pre>
474
+ );
475
+ }
476
+ return (
477
+ <Text as="p" className="message-text">
478
+ <MarkdownInline nodes={block.children} />
479
+ </Text>
480
+ );
481
+ }
482
+
483
+ function MarkdownInline({ nodes }) {
484
+ return withStableKeys(nodes).map(({ key, value: node }) => {
485
+ if (node.type === "text") return node.text;
486
+ if (node.type === "code") return <Code key={key}>{node.text}</Code>;
487
+ if (node.type === "strong") {
488
+ return (
489
+ <strong key={key}>
490
+ <MarkdownInline nodes={node.children} />
491
+ </strong>
492
+ );
493
+ }
494
+ if (node.type === "emphasis") {
495
+ return (
496
+ <em key={key}>
497
+ <MarkdownInline nodes={node.children} />
498
+ </em>
499
+ );
500
+ }
501
+ return (
502
+ <Link href={node.href} key={key} rel="noopener noreferrer" target="_blank">
503
+ <MarkdownInline nodes={node.children} />
504
+ </Link>
505
+ );
506
+ });
507
+ }
508
+
509
+ function ToolCall({ call, tool }) {
510
+ const phase = toolPhaseLabel(tool);
511
+ const command = toolCommandPreview(tool);
512
+ const args = safeJson(tool?.args ?? call.arguments);
513
+ const result = tool?.result === undefined ? "" : safeJson(tool.result);
514
+ return (
515
+ <Collapsible.Root className={`tool ${tool?.isError ? "failed" : ""}`}>
516
+ <Card asChild size="1">
517
+ <Box>
518
+ <Collapsible.Trigger asChild>
519
+ <Button className="tool-trigger" color={tool?.isError ? "red" : "gray"} variant="ghost">
520
+ <CodeIcon />
521
+ <span>{`${call.name} · ${phase}`}</span>
522
+ {command && <Code className="tool-command">{command}</Code>}
523
+ <ChevronDownIcon className="disclosure-icon" />
524
+ </Button>
525
+ </Collapsible.Trigger>
526
+ <Collapsible.Content className="tool-content">
527
+ <pre>{args}</pre>
528
+ {result && <pre>{result}</pre>}
529
+ </Collapsible.Content>
530
+ </Box>
531
+ </Card>
532
+ </Collapsible.Root>
533
+ );
534
+ }
535
+
536
+ function SentImageChip({ block, onForget, retained }) {
537
+ const status = retainedImageStatus(block, retained);
538
+ const label = `Image${block.mimeType ? ` · ${block.mimeType}` : ""}`;
539
+ return (
540
+ <Flex align="center" className="image-chip" gap="2" wrap="wrap">
541
+ <ImageIcon />
542
+ <Text color="gray" size="1">
543
+ {label}
544
+ </Text>
545
+ {status === "eligible" && (
546
+ <>
547
+ <Button
548
+ onClick={() => void webClient.reattachSentImage(block.retainedImageId)}
549
+ size="1"
550
+ variant="soft"
551
+ aria-label={`Attach image again: ${label}`}
552
+ >
553
+ <ImageIcon /> Attach again
554
+ </Button>
555
+ <Button
556
+ color="gray"
557
+ onClick={(event) => onForget(block.retainedImageId, event.currentTarget)}
558
+ size="1"
559
+ variant="ghost"
560
+ aria-label={`Forget retained image: ${label}`}
561
+ >
562
+ <TrashIcon /> Forget
563
+ </Button>
564
+ </>
565
+ )}
566
+ {status === "expired" && (
567
+ <Badge color="gray" variant="soft">
568
+ Expired
569
+ </Badge>
570
+ )}
571
+ </Flex>
572
+ );
573
+ }
574
+
575
+ function BlockingState({ model }) {
576
+ if (!model.closed && !model.stale) return null;
577
+ return (
578
+ <Callout.Root className="blocking-state" color="red" id="blocking-state" role="alert">
579
+ <Callout.Icon>
580
+ <ExclamationTriangleIcon />
581
+ </Callout.Icon>
582
+ <Callout.Text>
583
+ <strong>{model.closed ? "Pi session ended" : "Another tab is active"}</strong>
584
+ <br />
585
+ {model.closed
586
+ ? "Return to the terminal and run /webui for the active session."
587
+ : "This tab remains readable. Refresh it to take control."}
588
+ </Callout.Text>
589
+ </Callout.Root>
590
+ );
591
+ }
592
+
593
+ function Composer({ dragActive, onClear, onPreview, view, ...dropProps }) {
594
+ const inputRef = useRef();
595
+ const fileRef = useRef();
596
+ const composerRef = useRef();
597
+ const model = view.model;
598
+ const locked = composerLocked(view);
599
+ const admissionLocked = locked || model.readingImages > 0;
600
+ useEffect(() => resizeInput(inputRef.current));
601
+ useEffect(() => {
602
+ const composer = composerRef.current;
603
+ if (!composer) return;
604
+ const root = document.documentElement;
605
+ const updateComposerHeight = () =>
606
+ root.style.setProperty("--composer-height", `${composer.offsetHeight}px`);
607
+ updateComposerHeight();
608
+ const observer = new ResizeObserver(updateComposerHeight);
609
+ observer.observe(composer);
610
+ return () => {
611
+ observer.disconnect();
612
+ root.style.removeProperty("--composer-height");
613
+ };
614
+ }, []);
615
+ return (
616
+ <Card asChild className={`composer ${dragActive ? "drag-active" : ""}`} id="composer" size="2">
617
+ <form
618
+ onSubmit={(event) => {
619
+ event.preventDefault();
620
+ void webClient.send(false);
621
+ }}
622
+ ref={composerRef}
623
+ {...dropProps}
624
+ >
625
+ <Flex direction="column" gap="3">
626
+ <Text as="label" htmlFor="message-input" size="2" weight="bold">
627
+ Message Pi
628
+ </Text>
629
+ <TextArea
630
+ aria-label="Message Pi"
631
+ disabled={model.closed || model.stale}
632
+ id="message-input"
633
+ onChange={(event) => webClient.editText(event.target.value)}
634
+ onKeyDown={(event) => {
635
+ if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
636
+ event.preventDefault();
637
+ void webClient.send(false);
638
+ }
639
+ }}
640
+ placeholder="Ask Pi to do something…"
641
+ ref={inputRef}
642
+ resize="none"
643
+ rows={1}
644
+ size="3"
645
+ value={model.text}
646
+ />
647
+ {model.images.length > 0 && (
648
+ <Text
649
+ as="p"
650
+ className="attachment-summary"
651
+ color="gray"
652
+ id="attachment-summary"
653
+ size="1"
654
+ >
655
+ {`${model.images.length}/${model.imageLimits?.maxImages ?? model.images.length} images attached · Sensitive metadata is removed before sending.`}
656
+ </Text>
657
+ )}
658
+ <AttachmentList onPreview={onPreview} view={view} />
659
+ <Text
660
+ as="p"
661
+ aria-atomic="true"
662
+ aria-live="polite"
663
+ className="visually-hidden"
664
+ id="attachment-announcement"
665
+ role="status"
666
+ >
667
+ {model.images.length
668
+ ? `Attachment state: ${attachmentPhaseLabel(model.attachmentPhase)}.`
669
+ : ""}
670
+ </Text>
671
+ {model.error && (
672
+ <Callout.Root color="red" id="composer-error" role="alert" size="1">
673
+ <Callout.Icon>
674
+ <ExclamationTriangleIcon />
675
+ </Callout.Icon>
676
+ <Callout.Text>{model.error}</Callout.Text>
677
+ </Callout.Root>
678
+ )}
679
+ <Flex align="center" className="composer-bottom" gap="2" justify="between" wrap="wrap">
680
+ <Flex align="center" className="composer-support" gap="2" wrap="wrap">
681
+ <Button asChild disabled={admissionLocked} variant="soft">
682
+ <label htmlFor="image-input">
683
+ <ImageIcon /> Add images
684
+ </label>
685
+ </Button>
686
+ <input
687
+ accept={ACCEPTED_IMAGES}
688
+ disabled={admissionLocked}
689
+ hidden
690
+ id="image-input"
691
+ multiple
692
+ onChange={(event) => {
693
+ void webClient.addFiles(event.target.files);
694
+ event.target.value = "";
695
+ }}
696
+ ref={fileRef}
697
+ type="file"
698
+ />
699
+ <Text className="image-hint" color="gray" size="1">
700
+ Paste, drop, or choose images.
701
+ </Text>
702
+ {model.images.length >= 2 && (
703
+ <Button
704
+ color="gray"
705
+ disabled={locked}
706
+ onClick={onClear}
707
+ type="button"
708
+ variant="ghost"
709
+ >
710
+ <TrashIcon /> Clear attachments
711
+ </Button>
712
+ )}
713
+ </Flex>
714
+ <Flex className="send-actions" gap="2">
715
+ {model.activity === "running" && (
716
+ <Button
717
+ disabled={!canSubmit(view)}
718
+ onClick={() => void webClient.send(true)}
719
+ type="button"
720
+ variant="soft"
721
+ >
722
+ <ReloadIcon /> Steer
723
+ </Button>
724
+ )}
725
+ <Button disabled={!canSubmit(view)} id="send-next" type="submit">
726
+ <PaperPlaneIcon /> {primaryActionLabel(view)}
727
+ </Button>
728
+ </Flex>
729
+ </Flex>
730
+ <Text as="p" color="gray" id="composer-status" role="status" size="1">
731
+ {composerStatus(view)}
732
+ </Text>
733
+ </Flex>
734
+ </form>
735
+ </Card>
736
+ );
737
+ }
738
+
739
+ function AttachmentList({ onPreview, view }) {
740
+ const listRef = useRef();
741
+ const [draggedId, setDraggedId] = useState("");
742
+ const [dropTarget, setDropTarget] = useState();
743
+ const model = view.model;
744
+ if (model.images.length === 0) return null;
745
+ return (
746
+ <Flex asChild gap="2" ref={listRef} wrap="wrap">
747
+ <ul
748
+ aria-busy={view.mutatingAttachments || undefined}
749
+ aria-label="Attached images"
750
+ id="image-previews"
751
+ >
752
+ {model.images.map((image, index) => {
753
+ const orderingLocked = composerLocked(view) || model.attachmentPhase !== "ready";
754
+ const reorderable = model.images.length > 1 && image.status === "ready";
755
+ const draggable = reorderable && !orderingLocked;
756
+ const retryable =
757
+ image.status === "error" && (image.retryable || view.retryableIds.has(image.id));
758
+ return (
759
+ <Card
760
+ asChild
761
+ className={`image-preview-item ${draggedId === image.id ? "dragging" : ""} ${dropTarget?.id === image.id ? `drag-${dropTarget.after ? "after" : "before"}` : ""}`}
762
+ key={image.id}
763
+ size="1"
764
+ >
765
+ <li
766
+ aria-keyshortcuts={reorderable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
767
+ aria-label={
768
+ reorderable
769
+ ? `${image.name}, image ${index + 1} of ${model.images.length}. Drag to reorder or press Alt plus Up or Down Arrow.`
770
+ : undefined
771
+ }
772
+ data-image-id={image.id}
773
+ draggable={draggable}
774
+ onDragEnd={() => {
775
+ setDraggedId("");
776
+ setDropTarget(undefined);
777
+ }}
778
+ onDragLeave={(event) => {
779
+ if (!event.currentTarget.contains(event.relatedTarget)) setDropTarget(undefined);
780
+ }}
781
+ onDragOver={(event) => {
782
+ const sourceId = draggedId || event.dataTransfer?.getData(DRAG_TYPE);
783
+ if (orderingLocked || !sourceId || sourceId === image.id) return;
784
+ event.preventDefault();
785
+ const vertical = imagesStackVertically(listRef.current?.children ?? []);
786
+ setDropTarget({
787
+ id: image.id,
788
+ after: dropAfterTarget(event, event.currentTarget, vertical),
789
+ });
790
+ }}
791
+ onDragStart={(event) => {
792
+ if (!draggable) return;
793
+ setDraggedId(image.id);
794
+ event.dataTransfer.effectAllowed = "move";
795
+ event.dataTransfer.setData(DRAG_TYPE, image.id);
796
+ }}
797
+ onDrop={(event) => {
798
+ const sourceId = draggedId || event.dataTransfer?.getData(DRAG_TYPE);
799
+ if (orderingLocked || !sourceId || sourceId === image.id) return;
800
+ event.preventDefault();
801
+ const vertical = imagesStackVertically(listRef.current?.children ?? []);
802
+ void webClient.dropImage(
803
+ sourceId,
804
+ image.id,
805
+ dropAfterTarget(event, event.currentTarget, vertical),
806
+ );
807
+ setDraggedId("");
808
+ setDropTarget(undefined);
809
+ }}
810
+ onKeyDown={(event) => {
811
+ if (event.target !== event.currentTarget || orderingLocked || !event.altKey)
812
+ return;
813
+ const direction =
814
+ event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
815
+ if (
816
+ !direction ||
817
+ index + direction < 0 ||
818
+ index + direction >= model.images.length
819
+ )
820
+ return;
821
+ event.preventDefault();
822
+ void webClient.reorderImage(image.id, direction);
823
+ }}
824
+ tabIndex={reorderable ? 0 : undefined}
825
+ >
826
+ <Tooltip.Root>
827
+ <Tooltip.Trigger asChild>
828
+ <IconButton
829
+ aria-label={`Preview image ${image.name}`}
830
+ className="attachment-preview"
831
+ disabled={composerLocked(view) || image.status !== "ready"}
832
+ onClick={(event) => onPreview(image, event.currentTarget)}
833
+ type="button"
834
+ variant="soft"
835
+ >
836
+ {image.status === "ready" ? (
837
+ <img
838
+ alt=""
839
+ draggable={false}
840
+ src={`/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`}
841
+ />
842
+ ) : (
843
+ <Spinner />
844
+ )}
845
+ </IconButton>
846
+ </Tooltip.Trigger>
847
+ <Tooltip.Content sideOffset={6}>Preview {image.name}</Tooltip.Content>
848
+ </Tooltip.Root>
849
+ <Box className="attachment-details">
850
+ <Text className="attachment-name" size="2" weight="medium">
851
+ {image.name}
852
+ </Text>
853
+ <Text color={image.status === "error" ? "red" : "gray"} size="1">
854
+ {attachmentItemLabel(image, view.uploadProgress.get(image.id))}
855
+ </Text>
856
+ {image.notes?.length > 0 && (
857
+ <Text color="gray" size="1">
858
+ {image.notes.join(" · ")}
859
+ </Text>
860
+ )}
861
+ {model.images.length > 1 && (
862
+ <Text color="gray" size="1" weight="bold">
863
+ Order {index + 1} of {model.images.length}
864
+ </Text>
865
+ )}
866
+ </Box>
867
+ <Flex align="center" className="image-actions" gap="1">
868
+ {retryable && (
869
+ <Button
870
+ disabled={composerLocked(view)}
871
+ onClick={() => void webClient.retryImage(image.id)}
872
+ size="1"
873
+ type="button"
874
+ variant="soft"
875
+ >
876
+ <ReloadIcon /> Retry
877
+ </Button>
878
+ )}
879
+ <Tooltip.Root>
880
+ <Tooltip.Trigger asChild>
881
+ <IconButton
882
+ aria-label={`Remove image ${image.name}`}
883
+ color="red"
884
+ disabled={composerLocked(view)}
885
+ onClick={() => void webClient.removeImage(image.id)}
886
+ type="button"
887
+ variant="ghost"
888
+ >
889
+ <TrashIcon />
890
+ </IconButton>
891
+ </Tooltip.Trigger>
892
+ <Tooltip.Content sideOffset={6}>Remove {image.name}</Tooltip.Content>
893
+ </Tooltip.Root>
894
+ </Flex>
895
+ </li>
896
+ </Card>
897
+ );
898
+ })}
899
+ </ul>
900
+ </Flex>
901
+ );
902
+ }
903
+
904
+ const root = document.querySelector("#root");
905
+ if (!root) throw new Error("Pi WebUI root is unavailable.");
906
+ createRoot(root).render(
907
+ <StrictMode>
908
+ <App />
909
+ </StrictMode>,
910
+ );