@narumitw/pi-webui 0.20.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ const IMAGE_DRAG_TYPE = "application/x-pi-webui-image";
2
+
3
+ export function createImageDragController(previews, { isLocked, onDrop }) {
4
+ let draggedId = "";
5
+
6
+ function clearDropTargets() {
7
+ previews.classList.remove("vertical-drop");
8
+ for (const candidate of previews.children) {
9
+ candidate.classList.remove("drag-before", "drag-after");
10
+ }
11
+ }
12
+
13
+ function setDropTarget(item, after, vertical) {
14
+ previews.classList.toggle("vertical-drop", vertical);
15
+ for (const candidate of previews.children) {
16
+ candidate.classList.toggle("drag-before", candidate === item && !after);
17
+ candidate.classList.toggle("drag-after", candidate === item && after);
18
+ }
19
+ }
20
+
21
+ function focus(id) {
22
+ requestAnimationFrame(() => {
23
+ const escapedId = CSS.escape(id);
24
+ const item = previews.querySelector(`[data-image-id="${escapedId}"]`);
25
+ (item?.tabIndex === 0 ? item : item?.querySelector(".remove-image"))?.focus();
26
+ });
27
+ }
28
+
29
+ function bind(item, { id, orderingLocked }) {
30
+ item.addEventListener("dragstart", (event) => {
31
+ if (isLocked() || orderingLocked || !event.dataTransfer) return;
32
+ draggedId = id;
33
+ event.dataTransfer.effectAllowed = "move";
34
+ event.dataTransfer.setData(IMAGE_DRAG_TYPE, id);
35
+ item.classList.add("dragging");
36
+ });
37
+ item.addEventListener("dragend", () => {
38
+ draggedId = "";
39
+ item.classList.remove("dragging");
40
+ clearDropTargets();
41
+ });
42
+ item.addEventListener("dragover", (event) => {
43
+ const sourceId = draggedId || event.dataTransfer?.getData(IMAGE_DRAG_TYPE);
44
+ if (isLocked() || orderingLocked || !sourceId) return;
45
+ if (sourceId === id) {
46
+ clearDropTargets();
47
+ return;
48
+ }
49
+ event.preventDefault();
50
+ if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
51
+ const vertical = imagesStackVertically(previews.children);
52
+ setDropTarget(item, dropAfterTarget(event, item, vertical), vertical);
53
+ });
54
+ item.addEventListener("dragleave", (event) => {
55
+ if (event.relatedTarget && item.contains(event.relatedTarget)) return;
56
+ item.classList.remove("drag-before", "drag-after");
57
+ });
58
+ item.addEventListener("drop", (event) => {
59
+ const sourceId = draggedId || event.dataTransfer?.getData(IMAGE_DRAG_TYPE);
60
+ if (isLocked() || orderingLocked || !sourceId || sourceId === id) return;
61
+ event.preventDefault();
62
+ event.stopPropagation();
63
+ const vertical = imagesStackVertically(previews.children);
64
+ const after = dropAfterTarget(event, item, vertical);
65
+ draggedId = "";
66
+ clearDropTargets();
67
+ onDrop({ sourceId, targetId: id, after });
68
+ });
69
+ }
70
+
71
+ return { bind, clearDropTargets, focus };
72
+ }
73
+
74
+ export function imagesStackVertically(items) {
75
+ const list = [...items];
76
+ return !list.some((item, index) =>
77
+ list.slice(index + 1).some((candidate) => Math.abs(item.offsetTop - candidate.offsetTop) < 2),
78
+ );
79
+ }
80
+
81
+ export function dropAfterTarget(event, item, vertical) {
82
+ const bounds = item.getBoundingClientRect();
83
+ return vertical
84
+ ? event.clientY >= bounds.top + bounds.height / 2
85
+ : event.clientX >= bounds.left + bounds.width / 2;
86
+ }
@@ -55,8 +55,15 @@
55
55
  rows="1"
56
56
  placeholder="Ask Pi to do something…"
57
57
  ></textarea>
58
+ <p id="attachment-summary" class="attachment-summary" hidden></p>
58
59
  <ul id="image-previews" class="image-previews" aria-label="Attached images"></ul>
59
- <p id="attachment-status" class="attachment-status" role="status" aria-live="polite" hidden></p>
60
+ <p
61
+ id="attachment-announcement"
62
+ class="visually-hidden"
63
+ role="status"
64
+ aria-live="polite"
65
+ aria-atomic="true"
66
+ ></p>
60
67
  <p id="composer-error" class="composer-error" role="alert" hidden></p>
61
68
  <div class="composer-bottom">
62
69
  <div class="composer-support">
@@ -64,11 +71,14 @@
64
71
  <input
65
72
  id="image-input"
66
73
  type="file"
67
- accept="image/png,image/jpeg,image/webp,image/gif"
74
+ accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/tiff,image/heic,image/heif,image/avif,.bmp,.tif,.tiff,.heic,.heif,.avif"
68
75
  multiple
69
76
  hidden
70
77
  />
71
78
  <span class="image-hint">Paste, drop, or choose images.</span>
79
+ <button id="clear-attachments" class="button secondary" type="button" hidden>
80
+ Clear attachments
81
+ </button>
72
82
  </div>
73
83
  <div class="send-actions">
74
84
  <button id="steer" class="button secondary" type="button" hidden>Steer</button>
@@ -91,6 +101,19 @@
91
101
  </details>
92
102
  </footer>
93
103
 
104
+ <dialog id="clear-attachments-dialog" class="confirmation-dialog" aria-labelledby="clear-attachments-title">
105
+ <form method="dialog">
106
+ <h2 id="clear-attachments-title">Clear attachments?</h2>
107
+ <p id="clear-attachments-message">Remove all unsent image attachments? Message text will be kept.</p>
108
+ <div class="dialog-actions">
109
+ <button type="submit" value="cancel">Cancel</button>
110
+ <button id="confirm-clear-attachments" class="danger" type="submit" value="confirm">
111
+ Clear attachments
112
+ </button>
113
+ </div>
114
+ </form>
115
+ </dialog>
116
+
94
117
  <dialog id="image-preview-dialog" class="image-preview-dialog" aria-labelledby="image-preview-title">
95
118
  <div class="image-preview-content">
96
119
  <header>
package/src/web/state.js CHANGED
@@ -13,6 +13,20 @@ export function initialState() {
13
13
  readingImages: 0,
14
14
  leaseClaimed: false,
15
15
  leaseGeneration: 0,
16
+ draftRevision: 0,
17
+ authoritativeText: "",
18
+ textDirty: false,
19
+ attachmentRevision: 0,
20
+ attachmentPhase: "empty",
21
+ imageLimits: undefined,
22
+ sentImages: {
23
+ revision: 0,
24
+ enabled: false,
25
+ items: [],
26
+ totalBytes: 0,
27
+ maxImages: 0,
28
+ maxBytes: 0,
29
+ },
16
30
  following: true,
17
31
  unseenUpdateIds: [],
18
32
  text: "",
@@ -22,17 +36,130 @@ export function initialState() {
22
36
  }
23
37
 
24
38
  export function applySnapshot(current, snapshot) {
25
- if (!Number.isSafeInteger(snapshot?.sequence) || snapshot.sequence < current.sequence)
39
+ let next = current;
40
+ if (Number.isSafeInteger(snapshot?.sequence) && snapshot.sequence >= current.sequence) {
41
+ next = {
42
+ ...current,
43
+ sequence: snapshot.sequence,
44
+ session: snapshot.session,
45
+ messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
46
+ tools: Array.isArray(snapshot.tools) ? snapshot.tools : [],
47
+ activity: snapshot.activity ?? "idle",
48
+ closed: Boolean(snapshot.closed),
49
+ needsSnapshot: false,
50
+ };
51
+ }
52
+ return applySentImages(
53
+ applyImageLimits(
54
+ applyAttachments(applyDraft(next, snapshot?.draft), snapshot?.attachments),
55
+ snapshot?.imageLimits,
56
+ ),
57
+ snapshot?.sentImages,
58
+ );
59
+ }
60
+
61
+ export function applyDraft(current, draft) {
62
+ if (
63
+ !Number.isSafeInteger(draft?.revision) ||
64
+ draft.revision < current.draftRevision ||
65
+ typeof draft.text !== "string"
66
+ ) {
26
67
  return current;
68
+ }
69
+ const preserveLocal = current.textDirty && current.text !== draft.text;
27
70
  return {
28
71
  ...current,
29
- sequence: snapshot.sequence,
30
- session: snapshot.session,
31
- messages: Array.isArray(snapshot.messages) ? snapshot.messages : [],
32
- tools: Array.isArray(snapshot.tools) ? snapshot.tools : [],
33
- activity: snapshot.activity ?? "idle",
34
- closed: Boolean(snapshot.closed),
35
- needsSnapshot: false,
72
+ draftRevision: draft.revision,
73
+ authoritativeText: draft.text,
74
+ text: preserveLocal ? current.text : draft.text,
75
+ textDirty: preserveLocal,
76
+ };
77
+ }
78
+
79
+ export function editDraftText(current, text) {
80
+ return invalidateSendAttempt({
81
+ ...current,
82
+ text,
83
+ textDirty: text !== current.authoritativeText,
84
+ error: "",
85
+ });
86
+ }
87
+
88
+ export function acknowledgeDraftText(current, draft, submittedText) {
89
+ if (current.text === submittedText) {
90
+ return applyDraft({ ...current, textDirty: false }, draft);
91
+ }
92
+ return applyDraft(current, draft);
93
+ }
94
+
95
+ export function applyImageLimits(current, limits) {
96
+ if (
97
+ !limits ||
98
+ !["maxImages", "maxImageBytes", "maxBatchBytes", "maxImagePixels"].every(
99
+ (key) => Number.isSafeInteger(limits[key]) && limits[key] > 0,
100
+ )
101
+ ) {
102
+ return current;
103
+ }
104
+ return {
105
+ ...current,
106
+ imageLimits: {
107
+ maxImages: limits.maxImages,
108
+ maxImageBytes: limits.maxImageBytes,
109
+ maxBatchBytes: limits.maxBatchBytes,
110
+ maxImagePixels: limits.maxImagePixels,
111
+ },
112
+ };
113
+ }
114
+
115
+ export function applySentImages(current, sentImages) {
116
+ if (
117
+ !Number.isSafeInteger(sentImages?.revision) ||
118
+ sentImages.revision < current.sentImages.revision ||
119
+ !Array.isArray(sentImages.items)
120
+ ) {
121
+ return current;
122
+ }
123
+ return {
124
+ ...current,
125
+ sentImages: {
126
+ revision: sentImages.revision,
127
+ enabled: Boolean(sentImages.enabled),
128
+ items: sentImages.items
129
+ .filter((item) => item && typeof item.id === "string")
130
+ .map((item) => ({ ...item })),
131
+ totalBytes: Number.isSafeInteger(sentImages.totalBytes) ? sentImages.totalBytes : 0,
132
+ maxImages: Number.isSafeInteger(sentImages.maxImages) ? sentImages.maxImages : 0,
133
+ maxBytes: Number.isSafeInteger(sentImages.maxBytes) ? sentImages.maxBytes : 0,
134
+ },
135
+ };
136
+ }
137
+
138
+ export function applyAttachments(current, attachments) {
139
+ if (
140
+ !Number.isSafeInteger(attachments?.revision) ||
141
+ attachments.revision < current.attachmentRevision ||
142
+ !Array.isArray(attachments.items)
143
+ ) {
144
+ return current;
145
+ }
146
+ const revisionChanged = attachments.revision !== current.attachmentRevision;
147
+ const images = attachments.items
148
+ .filter((item) => item && typeof item.id === "string" && typeof item.status === "string")
149
+ .map((item) => ({
150
+ ...item,
151
+ notes: Array.isArray(item.notes) ? [...item.notes] : [],
152
+ }));
153
+ return {
154
+ ...current,
155
+ attachmentRevision: attachments.revision,
156
+ attachmentPhase: typeof attachments.phase === "string" ? attachments.phase : "blocked",
157
+ images,
158
+ readingImages: images.filter(
159
+ (image) => image.status === "uploading" || image.status === "processing",
160
+ ).length,
161
+ outbox: revisionChanged ? undefined : current.outbox,
162
+ lastDelivery: revisionChanged ? undefined : current.lastDelivery,
36
163
  };
37
164
  }
38
165
 
@@ -82,6 +209,9 @@ export function prepareSend(current, requestId, delivery = "next") {
82
209
  const attempt = current.outbox ?? {
83
210
  requestId,
84
211
  text: current.text,
212
+ draftRevision: current.draftRevision,
213
+ attachmentRevision: current.attachmentRevision,
214
+ attachmentIds: current.images.map((image) => image.id),
85
215
  images: [...current.images],
86
216
  delivery,
87
217
  };
@@ -117,6 +247,10 @@ export function invalidateSendAttempt(current) {
117
247
  return { ...current, outbox: undefined, lastDelivery: undefined };
118
248
  }
119
249
 
250
+ export function clearDraftImages(current) {
251
+ return invalidateSendAttempt({ ...current, images: [], error: "" });
252
+ }
253
+
120
254
  export function setNearBottom(current, nearBottom) {
121
255
  if (!nearBottom) return current.following ? { ...current, following: false } : current;
122
256
  if (current.following && current.unseenUpdateIds.length === 0) return current;
@@ -132,6 +266,41 @@ export function followLatest(current) {
132
266
  return { ...current, following: true, unseenUpdateIds: [] };
133
267
  }
134
268
 
269
+ export function moveImage(images, id, direction) {
270
+ const from = images.findIndex((image) => image.id === id);
271
+ if (from === -1) return [...images];
272
+ const to = Math.max(0, Math.min(images.length - 1, from + direction));
273
+ if (to === from) return [...images];
274
+ const next = [...images];
275
+ const [image] = next.splice(from, 1);
276
+ if (image) next.splice(to, 0, image);
277
+ return next;
278
+ }
279
+
280
+ export function moveImageBefore(images, id, targetId) {
281
+ if (id === targetId) return [...images];
282
+ const from = images.findIndex((image) => image.id === id);
283
+ const target = images.findIndex((image) => image.id === targetId);
284
+ if (from === -1 || target === -1) return [...images];
285
+ const next = images.filter((image) => image.id !== id);
286
+ const targetIndex = next.findIndex((image) => image.id === targetId);
287
+ if (targetIndex === -1) return [...images];
288
+ next.splice(targetIndex, 0, images[from]);
289
+ return next;
290
+ }
291
+
292
+ export function moveImageAfter(images, id, targetId) {
293
+ if (id === targetId) return [...images];
294
+ const from = images.findIndex((image) => image.id === id);
295
+ const target = images.findIndex((image) => image.id === targetId);
296
+ if (from === -1 || target === -1) return [...images];
297
+ const next = images.filter((image) => image.id !== id);
298
+ const targetIndex = next.findIndex((image) => image.id === targetId);
299
+ if (targetIndex === -1) return [...images];
300
+ next.splice(targetIndex + 1, 0, images[from]);
301
+ return next;
302
+ }
303
+
135
304
  export function upsertById(items, value) {
136
305
  const index = items.findIndex((item) => item.id === value.id);
137
306
  if (index < 0) return [...items, value];
@@ -147,6 +316,7 @@ export function canSend(current) {
147
316
  !current.stale &&
148
317
  !current.pending &&
149
318
  current.readingImages === 0 &&
319
+ (current.images.length === 0 || current.attachmentPhase === "ready") &&
150
320
  (current.text.trim() || current.images.length > 0),
151
321
  );
152
322
  }
@@ -301,14 +301,33 @@ main {
301
301
  display: inline-flex;
302
302
  min-height: 32px;
303
303
  align-items: center;
304
+ flex-wrap: wrap;
305
+ gap: 0.35rem;
304
306
  margin-top: 0.65rem;
305
307
  padding: 0.25rem 0.55rem;
306
308
  border: 1px solid var(--border);
307
- border-radius: 999px;
309
+ border-radius: 0.75rem;
308
310
  color: var(--muted);
309
311
  font-size: 0.82rem;
310
312
  }
311
313
 
314
+ .message-image-action {
315
+ min-height: 44px;
316
+ padding-inline: 0.7rem;
317
+ border-color: var(--border);
318
+ background: var(--surface);
319
+ color: var(--text);
320
+ }
321
+
322
+ .message-image-action.subtle,
323
+ .message-image-expired {
324
+ color: var(--muted);
325
+ }
326
+
327
+ .message-image-action.subtle {
328
+ background: transparent;
329
+ }
330
+
312
331
  details {
313
332
  max-width: 100%;
314
333
  }
@@ -463,6 +482,12 @@ textarea {
463
482
  overflow-y: auto;
464
483
  }
465
484
 
485
+ textarea:focus-visible {
486
+ border-color: transparent;
487
+ outline-width: 2px;
488
+ outline-offset: 1px;
489
+ }
490
+
466
491
  textarea:disabled {
467
492
  opacity: 0.68;
468
493
  }
@@ -490,22 +515,22 @@ textarea:disabled {
490
515
 
491
516
  .composer-status,
492
517
  .composer-error,
493
- .attachment-status {
518
+ .attachment-summary {
494
519
  margin: 0;
495
520
  font-size: 0.84rem;
496
521
  }
497
522
 
498
523
  .composer-status,
499
- .attachment-status {
524
+ .attachment-summary {
500
525
  color: var(--muted);
501
526
  }
502
527
 
503
528
  .image-previews {
504
529
  display: flex;
530
+ flex-wrap: wrap;
505
531
  gap: 0.6rem;
506
532
  margin: 0;
507
533
  padding: 0;
508
- overflow-x: auto;
509
534
  list-style: none;
510
535
  }
511
536
 
@@ -515,14 +540,85 @@ textarea:disabled {
515
540
 
516
541
  .image-preview-item {
517
542
  display: grid;
518
- min-width: 270px;
519
- grid-template-columns: 68px minmax(6rem, 1fr) auto;
543
+ width: fit-content;
544
+ max-width: 100%;
545
+ min-width: 0;
546
+ flex: 0 1 auto;
547
+ grid-template-columns: 68px fit-content(14rem) auto;
520
548
  align-items: center;
521
549
  gap: 0.5rem;
522
550
  padding: 0.4rem;
523
551
  border: 1px solid var(--border);
524
552
  border-radius: 10px;
525
553
  background: var(--surface-secondary);
554
+ transition:
555
+ border-color 0.12s ease,
556
+ box-shadow 0.12s ease,
557
+ transform 0.12s ease;
558
+ }
559
+
560
+ .image-preview-item[draggable="true"] {
561
+ cursor: grab;
562
+ }
563
+
564
+ .image-preview-item[draggable="true"]:active {
565
+ cursor: grabbing;
566
+ }
567
+
568
+ .image-preview-item.dragging {
569
+ opacity: 0.5;
570
+ transform: scale(0.985);
571
+ }
572
+
573
+ .image-preview-item.drag-before {
574
+ border-color: var(--accent);
575
+ box-shadow: inset 3px 0 var(--accent);
576
+ }
577
+
578
+ .image-preview-item.drag-after {
579
+ border-color: var(--accent);
580
+ box-shadow: inset -3px 0 var(--accent);
581
+ }
582
+
583
+ .image-previews.vertical-drop .image-preview-item.drag-before {
584
+ box-shadow: inset 0 3px var(--accent);
585
+ }
586
+
587
+ .image-previews.vertical-drop .image-preview-item.drag-after {
588
+ box-shadow: inset 0 -3px var(--accent);
589
+ }
590
+
591
+ .attachment-details {
592
+ display: grid;
593
+ gap: 0.2rem;
594
+ min-width: 0;
595
+ }
596
+
597
+ .attachment-item-status {
598
+ color: var(--muted);
599
+ font-size: 0.78rem;
600
+ }
601
+
602
+ .attachment-item-status.error {
603
+ color: var(--danger);
604
+ }
605
+
606
+ .attachment-conversion-summary,
607
+ .attachment-order-context {
608
+ color: var(--muted);
609
+ font-size: 0.74rem;
610
+ line-height: 1.35;
611
+ }
612
+
613
+ .attachment-order-context {
614
+ font-weight: 650;
615
+ }
616
+
617
+ .image-actions {
618
+ display: flex;
619
+ align-items: center;
620
+ justify-content: flex-end;
621
+ gap: 0.35rem;
526
622
  }
527
623
 
528
624
  .attachment-preview {
@@ -538,6 +634,12 @@ textarea:disabled {
538
634
  cursor: zoom-in;
539
635
  }
540
636
 
637
+ .attachment-preview:empty::after {
638
+ content: "…";
639
+ color: var(--muted);
640
+ font-size: 1.4rem;
641
+ }
642
+
541
643
  .image-previews img {
542
644
  width: 100%;
543
645
  height: 100%;
@@ -579,12 +681,38 @@ button {
579
681
  }
580
682
 
581
683
  .button.secondary,
582
- .remove-image {
684
+ .remove-image,
685
+ .retry-image {
583
686
  border-color: var(--border);
584
687
  color: var(--text);
585
688
  background: var(--surface);
586
689
  }
587
690
 
691
+ .remove-image {
692
+ display: grid;
693
+ width: 44px;
694
+ padding: 0;
695
+ place-items: center;
696
+ color: var(--muted);
697
+ background: transparent;
698
+ }
699
+
700
+ .remove-image:hover:not(:disabled) {
701
+ border-color: var(--danger);
702
+ color: var(--danger);
703
+ background: var(--danger-soft);
704
+ }
705
+
706
+ .remove-image svg {
707
+ width: 20px;
708
+ height: 20px;
709
+ fill: none;
710
+ stroke: currentcolor;
711
+ stroke-linecap: round;
712
+ stroke-linejoin: round;
713
+ stroke-width: 1.8;
714
+ }
715
+
588
716
  button:disabled,
589
717
  .button.disabled,
590
718
  .button.primary:disabled {
@@ -595,6 +723,11 @@ button:disabled,
595
723
  border-color: var(--border);
596
724
  }
597
725
 
726
+ .remove-image:disabled {
727
+ border-color: transparent;
728
+ background: transparent;
729
+ }
730
+
598
731
  .button.disabled {
599
732
  pointer-events: none;
600
733
  }
@@ -609,6 +742,42 @@ footer p {
609
742
  margin-top: 0;
610
743
  }
611
744
 
745
+ .confirmation-dialog {
746
+ width: min(92vw, 30rem);
747
+ border: 1px solid var(--border);
748
+ border-radius: 14px;
749
+ padding: 1.25rem;
750
+ color: var(--text);
751
+ background: var(--surface);
752
+ box-shadow: var(--shadow);
753
+ }
754
+
755
+ .confirmation-dialog::backdrop {
756
+ background: rgb(11 17 30 / 72%);
757
+ }
758
+
759
+ .confirmation-dialog h2 {
760
+ margin-block: 0 0.5rem;
761
+ font-size: 1.15rem;
762
+ }
763
+
764
+ .confirmation-dialog p {
765
+ margin-block: 0 1rem;
766
+ color: var(--muted);
767
+ }
768
+
769
+ .dialog-actions {
770
+ display: flex;
771
+ justify-content: flex-end;
772
+ gap: 0.6rem;
773
+ }
774
+
775
+ .danger {
776
+ border-color: var(--danger);
777
+ color: var(--danger);
778
+ background: var(--danger-soft);
779
+ }
780
+
612
781
  .image-preview-dialog {
613
782
  width: min(96vw, 1400px);
614
783
  max-width: none;
@@ -740,10 +909,6 @@ footer p {
740
909
  .send-actions {
741
910
  flex: 0 1 auto;
742
911
  }
743
-
744
- .image-preview-item {
745
- min-width: min(100%, 270px);
746
- }
747
912
  }
748
913
 
749
914
  @media (prefers-color-scheme: dark) {