@narumitw/pi-webui 0.21.0 → 0.25.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.
package/src/web/app.js CHANGED
@@ -1,15 +1,25 @@
1
+ import { createImageDragController } from "./image-drag.js";
1
2
  import {
3
+ acknowledgeDraftText,
4
+ applyAttachments,
2
5
  applyConversationEvent,
6
+ applyDraft,
7
+ applyImageLimits,
3
8
  applyLease,
9
+ applySentImages,
4
10
  applySnapshot,
5
11
  busyLabel,
6
12
  canSend,
7
13
  completeSend,
8
14
  deliveryNotice,
15
+ editDraftText,
9
16
  failSend,
10
17
  followLatest,
11
18
  initialState,
12
19
  invalidateSendAttempt,
20
+ moveImage,
21
+ moveImageAfter,
22
+ moveImageBefore,
13
23
  noteUnseenUpdate,
14
24
  prepareSend,
15
25
  setNearBottom,
@@ -25,8 +35,27 @@ let snapshotRefresh;
25
35
  let snapshotTarget = 0;
26
36
  let transcriptFrame;
27
37
  let transcriptAnnouncement = "";
38
+ let lastAttachmentAnnouncement = "";
28
39
  let dragDepth = 0;
29
40
  let previewReturnFocus;
41
+ let mutatingAttachments = false;
42
+ let draftSaveTimer;
43
+ let draftSaveQueue = Promise.resolve();
44
+ const retryFiles = new Map();
45
+ const uploadProgress = new Map();
46
+
47
+ const SUPPORTED_IMAGE_TYPES = new Set([
48
+ "image/png",
49
+ "image/jpeg",
50
+ "image/webp",
51
+ "image/gif",
52
+ "image/bmp",
53
+ "image/x-ms-bmp",
54
+ "image/tiff",
55
+ "image/heic",
56
+ "image/heif",
57
+ "image/avif",
58
+ ]);
30
59
 
31
60
  const ui = {
32
61
  project: document.querySelector("#project-name"),
@@ -44,8 +73,12 @@ const ui = {
44
73
  input: document.querySelector("#message-input"),
45
74
  imageInput: document.querySelector("#image-input"),
46
75
  addImages: document.querySelector('label[for="image-input"]'),
76
+ clearAttachments: document.querySelector("#clear-attachments"),
77
+ clearDialog: document.querySelector("#clear-attachments-dialog"),
78
+ clearMessage: document.querySelector("#clear-attachments-message"),
47
79
  previews: document.querySelector("#image-previews"),
48
- attachmentStatus: document.querySelector("#attachment-status"),
80
+ attachmentSummary: document.querySelector("#attachment-summary"),
81
+ attachmentAnnouncement: document.querySelector("#attachment-announcement"),
49
82
  status: document.querySelector("#composer-status"),
50
83
  error: document.querySelector("#composer-error"),
51
84
  send: document.querySelector("#send-next"),
@@ -57,10 +90,26 @@ const ui = {
57
90
  previewDismiss: document.querySelector("#image-preview-dismiss"),
58
91
  };
59
92
 
60
- const transcriptRenderer = createTranscriptRenderer({ documentRef: document, list: ui.transcript });
93
+ const imageDrag = createImageDragController(ui.previews, {
94
+ isLocked: composerLocked,
95
+ onDrop: ({ sourceId, targetId, after }) => {
96
+ const images = after
97
+ ? moveImageAfter(model.images, sourceId, targetId)
98
+ : moveImageBefore(model.images, sourceId, targetId);
99
+ void reorderImages(images, sourceId);
100
+ },
101
+ });
102
+
103
+ const transcriptRenderer = createTranscriptRenderer({
104
+ documentRef: document,
105
+ list: ui.transcript,
106
+ onReattach: (id) => void reattachSentImage(id),
107
+ onForget: (id) => void forgetSentImage(id),
108
+ });
61
109
 
62
110
  ui.input.addEventListener("input", () => {
63
- model = invalidateSendAttempt({ ...model, text: ui.input.value, error: "" });
111
+ model = editDraftText(model, ui.input.value);
112
+ scheduleDraftSave();
64
113
  resizeInput();
65
114
  renderComposer();
66
115
  });
@@ -73,6 +122,19 @@ ui.imageInput.addEventListener("change", () => {
73
122
  void addFiles(ui.imageInput.files);
74
123
  ui.imageInput.value = "";
75
124
  });
125
+ ui.clearAttachments.addEventListener("click", () => {
126
+ if (composerLocked() || model.images.length < 2) return;
127
+ ui.clearDialog.returnValue = "cancel";
128
+ ui.clearMessage.textContent = `Remove all ${model.images.length} unsent image attachments? Message text will be kept.`;
129
+ ui.clearDialog.showModal();
130
+ });
131
+ ui.clearDialog.addEventListener("close", () => {
132
+ if (ui.clearDialog.returnValue !== "confirm") {
133
+ requestAnimationFrame(() => ui.clearAttachments.focus());
134
+ return;
135
+ }
136
+ void clearAttachments();
137
+ });
76
138
  ui.input.addEventListener("keydown", (event) => {
77
139
  if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
78
140
  event.preventDefault();
@@ -95,9 +157,7 @@ window.addEventListener(
95
157
  { passive: true },
96
158
  );
97
159
  document.addEventListener("paste", (event) => {
98
- const files = [...(event.clipboardData?.files ?? [])].filter((file) =>
99
- file.type.startsWith("image/"),
100
- );
160
+ const files = [...(event.clipboardData?.files ?? [])].filter(isSupportedImageFile);
101
161
  if (files.length === 0 || composerLocked()) return;
102
162
  event.preventDefault();
103
163
  void addFiles(files);
@@ -119,9 +179,7 @@ ui.composer.addEventListener("dragleave", () => {
119
179
  ui.composer.addEventListener("drop", (event) => {
120
180
  dragDepth = 0;
121
181
  ui.composer.classList.remove("drag-active");
122
- const files = [...(event.dataTransfer?.files ?? [])].filter((file) =>
123
- file.type.startsWith("image/"),
124
- );
182
+ const files = [...(event.dataTransfer?.files ?? [])].filter(isSupportedImageFile);
125
183
  if (files.length === 0 || composerLocked()) return;
126
184
  event.preventDefault();
127
185
  void addFiles(files);
@@ -130,6 +188,7 @@ ui.previewClose.addEventListener("click", () => ui.previewDialog.close());
130
188
  ui.previewDismiss.addEventListener("click", () => ui.previewDialog.close());
131
189
  ui.previewDialog.addEventListener("close", () => {
132
190
  ui.previewImage.removeAttribute("src");
191
+ delete ui.previewImage.dataset.imageId;
133
192
  ui.previewImage.alt = "";
134
193
  if (previewReturnFocus?.isConnected) previewReturnFocus.focus();
135
194
  previewReturnFocus = undefined;
@@ -178,6 +237,7 @@ async function claimLease() {
178
237
  });
179
238
  if (!response.ok) throw new Error(await responseError(response));
180
239
  model = applyLease(model, await response.json(), clientId, true);
240
+ if (model.textDirty) scheduleDraftSave();
181
241
  render();
182
242
  }
183
243
 
@@ -188,6 +248,7 @@ function connectEvents() {
188
248
  events.addEventListener("open", () => {
189
249
  reconnectDelay = 500;
190
250
  model = { ...model, connected: true, error: "" };
251
+ if (model.textDirty) scheduleDraftSave();
191
252
  render();
192
253
  });
193
254
  events.addEventListener("conversation", (event) => {
@@ -210,6 +271,22 @@ function connectEvents() {
210
271
  model = applyLease(model, JSON.parse(event.data), clientId);
211
272
  render();
212
273
  });
274
+ events.addEventListener("draft", (event) => {
275
+ model = applyDraft(model, JSON.parse(event.data));
276
+ renderComposer();
277
+ });
278
+ events.addEventListener("attachments", (event) => {
279
+ model = applyAttachments(model, JSON.parse(event.data));
280
+ render();
281
+ });
282
+ events.addEventListener("image-limits", (event) => {
283
+ model = applyImageLimits(model, JSON.parse(event.data));
284
+ renderComposer();
285
+ });
286
+ events.addEventListener("sent-images", (event) => {
287
+ model = applySentImages(model, JSON.parse(event.data));
288
+ render({ updateKey: "sent-images" });
289
+ });
213
290
  events.addEventListener("session-ended", () => {
214
291
  model = { ...model, closed: true, activity: "ended", connected: false };
215
292
  events?.close();
@@ -246,15 +323,22 @@ function connectionFailure(error) {
246
323
  }
247
324
 
248
325
  async function send(steer) {
249
- if (!canSend(model)) return;
326
+ if (composerLocked() || !canSend(model)) return;
327
+ try {
328
+ await flushDraftText();
329
+ } catch (error) {
330
+ model = { ...model, error: errorMessage(error) };
331
+ render();
332
+ return;
333
+ }
334
+ if (composerLocked() || !canSend(model) || model.textDirty) return;
250
335
  const prepared = prepareSend(model, crypto.randomUUID(), steer ? "steer" : "next");
251
336
  const attempt = prepared.attempt;
252
337
  model = prepared.state;
253
338
  renderComposer();
254
339
  const payload = {
255
340
  requestId: attempt.requestId,
256
- text: attempt.text,
257
- images: attempt.images.map(({ name, mimeType, data }) => ({ name, mimeType, data })),
341
+ draftRevision: attempt.draftRevision,
258
342
  delivery: attempt.delivery,
259
343
  };
260
344
  try {
@@ -269,10 +353,13 @@ async function send(steer) {
269
353
  throw new Error(message);
270
354
  }
271
355
  const accepted = await response.json();
272
- for (const image of attempt.images) URL.revokeObjectURL(image.previewUrl);
273
- if (attempt.images.some((image) => image.previewUrl === ui.previewImage.src)) {
356
+ if (accepted.draft) model = applyDraft(model, accepted.draft);
357
+ if (accepted.attachments) model = applyAttachments(model, accepted.attachments);
358
+ if (accepted.sentImages) model = applySentImages(model, accepted.sentImages);
359
+ if (attempt.attachmentIds.includes(ui.previewImage.dataset.imageId)) {
274
360
  ui.previewDialog.close();
275
361
  }
362
+ for (const id of attempt.attachmentIds) retryFiles.delete(id);
276
363
  model = completeSend(model, attempt, accepted.delivery);
277
364
  render();
278
365
  ui.input.focus();
@@ -283,94 +370,350 @@ async function send(steer) {
283
370
  }
284
371
 
285
372
  async function addFiles(fileList) {
286
- if (composerLocked()) return;
287
- const supported = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
373
+ if (composerLocked() || model.readingImages > 0) return;
288
374
  const files = [...(fileList ?? [])];
289
- if (model.images.length + files.length > 8) {
290
- model = { ...model, error: "You can attach at most 8 images." };
375
+ if (files.length === 0) return;
376
+ const limits = model.imageLimits;
377
+ if (!limits) {
378
+ model = { ...model, error: "Effective image limits are still loading." };
379
+ render();
380
+ return;
381
+ }
382
+ if (model.images.length + files.length > limits.maxImages) {
383
+ model = { ...model, error: `You can attach at most ${limits.maxImages} images.` };
291
384
  render();
292
385
  return;
293
386
  }
294
- model = { ...model, readingImages: model.readingImages + 1 };
387
+ for (const file of files) {
388
+ if (!isSupportedImageFile(file)) {
389
+ model = { ...model, error: `${file.name || "Image"} is not a supported image.` };
390
+ render();
391
+ return;
392
+ }
393
+ if (file.size > limits.maxImageBytes) {
394
+ model = {
395
+ ...model,
396
+ error: `${file.name || "Image"} is larger than ${formatMib(limits.maxImageBytes)}.`,
397
+ };
398
+ render();
399
+ return;
400
+ }
401
+ }
402
+ const batchBytes =
403
+ model.images.reduce((total, image) => total + (image.size ?? 0), 0) +
404
+ files.reduce((total, file) => total + file.size, 0);
405
+ if (batchBytes > limits.maxBatchBytes) {
406
+ model = {
407
+ ...model,
408
+ error: `Combined image input is larger than ${formatMib(limits.maxBatchBytes)}.`,
409
+ };
410
+ render();
411
+ return;
412
+ }
413
+ const pending = files.map((file) => ({ id: crypto.randomUUID(), file }));
414
+ try {
415
+ mutatingAttachments = true;
416
+ renderComposer();
417
+ const response = await attachmentMutation("/api/attachments/reserve", {
418
+ method: "POST",
419
+ body: JSON.stringify({
420
+ revision: model.attachmentRevision,
421
+ items: pending.map(({ id, file }) => ({
422
+ id,
423
+ name: file.name || "Pasted image",
424
+ size: file.size,
425
+ mimeType: file.type,
426
+ })),
427
+ }),
428
+ });
429
+ model = applyAttachments(model, response);
430
+ for (const { id, file } of pending) retryFiles.set(id, file);
431
+ render();
432
+ for (const { id, file } of pending) await uploadFile(id, file);
433
+ } catch (error) {
434
+ model = { ...model, error: errorMessage(error) };
435
+ render();
436
+ } finally {
437
+ mutatingAttachments = false;
438
+ renderComposer();
439
+ }
440
+ }
441
+
442
+ async function uploadFile(id, file) {
443
+ uploadProgress.set(id, { loaded: 0, total: file.size });
295
444
  renderComposer();
296
445
  try {
297
- for (const file of files) {
298
- if (model.images.length >= 8) {
299
- model = { ...model, error: "You can attach at most 8 images." };
300
- break;
301
- }
302
- if (!supported.has(file.type)) {
303
- model = { ...model, error: `${file.name || "Image"} is not a supported image.` };
304
- continue;
305
- }
306
- if (file.size > 10 * 1024 * 1024) {
307
- model = { ...model, error: `${file.name || "Image"} is larger than 10 MB.` };
308
- continue;
309
- }
310
- try {
311
- const data = await readBase64(file);
312
- model = invalidateSendAttempt({
313
- ...model,
314
- images: [
315
- ...model.images,
316
- {
317
- id: crypto.randomUUID(),
318
- name: file.name || "Pasted image",
319
- mimeType: file.type,
320
- data,
321
- previewUrl: URL.createObjectURL(file),
322
- },
323
- ],
324
- error: "",
325
- });
326
- } catch (error) {
327
- model = { ...model, error: errorMessage(error) };
446
+ const state = await uploadAttachment(
447
+ `/api/attachments/${encodeURIComponent(id)}/upload?revision=${model.attachmentRevision}`,
448
+ file,
449
+ (progress) => {
450
+ uploadProgress.set(id, progress);
451
+ renderComposer();
452
+ },
453
+ );
454
+ retryFiles.delete(id);
455
+ model = applyAttachments(model, state);
456
+ render();
457
+ } finally {
458
+ uploadProgress.delete(id);
459
+ renderComposer();
460
+ }
461
+ }
462
+
463
+ function uploadAttachment(path, file, onProgress) {
464
+ return new Promise((resolve, reject) => {
465
+ const request = new XMLHttpRequest();
466
+ request.open("POST", path);
467
+ request.responseType = "json";
468
+ request.setRequestHeader("Content-Type", "application/octet-stream");
469
+ request.setRequestHeader("X-Pi-Web-Client", clientId);
470
+ request.upload.addEventListener("progress", (event) => {
471
+ if (event.lengthComputable) onProgress({ loaded: event.loaded, total: event.total });
472
+ });
473
+ request.addEventListener("load", () => {
474
+ if (request.status >= 200 && request.status < 300) {
475
+ resolve(request.response);
476
+ return;
328
477
  }
478
+ reject(new Error(request.response?.error || `Request failed (${request.status}).`));
479
+ });
480
+ request.addEventListener("error", () => reject(new Error("Image upload failed.")));
481
+ request.addEventListener("abort", () => reject(new Error("Image upload was cancelled.")));
482
+ request.send(file);
483
+ });
484
+ }
485
+
486
+ async function retryImage(id) {
487
+ if (composerLocked()) return;
488
+ mutatingAttachments = true;
489
+ renderComposer();
490
+ try {
491
+ const file = retryFiles.get(id);
492
+ if (file) {
493
+ await uploadFile(id, file);
494
+ } else {
495
+ const response = await attachmentMutation(
496
+ `/api/attachments/${encodeURIComponent(id)}/retry`,
497
+ {
498
+ method: "POST",
499
+ body: JSON.stringify({ revision: model.attachmentRevision }),
500
+ },
501
+ );
502
+ model = applyAttachments(model, response);
329
503
  render();
330
504
  }
505
+ } catch (error) {
506
+ model = { ...model, error: errorMessage(error) };
507
+ render();
508
+ } finally {
509
+ mutatingAttachments = false;
510
+ renderComposer();
511
+ }
512
+ }
513
+
514
+ function scheduleDraftSave() {
515
+ clearTimeout(draftSaveTimer);
516
+ if (!model.textDirty || model.closed || model.stale || !model.connected) return;
517
+ draftSaveTimer = setTimeout(() => {
518
+ draftSaveTimer = undefined;
519
+ void flushDraftText().catch((error) => {
520
+ model = { ...model, error: errorMessage(error) };
521
+ render();
522
+ });
523
+ }, 180);
524
+ }
525
+
526
+ function flushDraftText() {
527
+ clearTimeout(draftSaveTimer);
528
+ draftSaveTimer = undefined;
529
+ draftSaveQueue = draftSaveQueue
530
+ .catch(() => undefined)
531
+ .then(async () => {
532
+ while (model.textDirty) {
533
+ if (model.closed || model.stale || !model.connected) {
534
+ throw new Error("Reconnect the active tab to save this draft.");
535
+ }
536
+ const submittedText = model.text;
537
+ const response = await fetch("/api/draft", {
538
+ method: "POST",
539
+ headers: { "Content-Type": "application/json", "X-Pi-Web-Client": clientId },
540
+ body: JSON.stringify({
541
+ requestId: crypto.randomUUID(),
542
+ revision: model.draftRevision,
543
+ text: submittedText,
544
+ }),
545
+ });
546
+ if (response.status === 409) {
547
+ await refreshSnapshot();
548
+ continue;
549
+ }
550
+ if (!response.ok) throw new Error(await responseError(response));
551
+ model = acknowledgeDraftText(model, await response.json(), submittedText);
552
+ renderComposer();
553
+ }
554
+ });
555
+ return draftSaveQueue;
556
+ }
557
+
558
+ function isSupportedImageFile(file) {
559
+ return (
560
+ SUPPORTED_IMAGE_TYPES.has(file.type) ||
561
+ /\.(?:bmp|tif|tiff|heic|heif|avif)$/i.test(file.name || "")
562
+ );
563
+ }
564
+
565
+ async function reattachSentImage(retainedImageId) {
566
+ if (composerLocked() || !retainedImageId) return;
567
+ if (model.attachmentPhase !== "empty" && model.attachmentPhase !== "ready") {
568
+ model = { ...model, error: "Wait for current images to finish before attaching again." };
569
+ render();
570
+ return;
571
+ }
572
+ mutatingAttachments = true;
573
+ renderComposer();
574
+ try {
575
+ const response = await attachmentMutation("/api/sent-images/reattach", {
576
+ method: "POST",
577
+ body: JSON.stringify({
578
+ revision: model.attachmentRevision,
579
+ items: [{ retainedId: retainedImageId, id: crypto.randomUUID() }],
580
+ }),
581
+ });
582
+ model = applyDraft(model, response.draft);
583
+ model = applyAttachments(model, response.attachments);
584
+ model = applySentImages(model, response.sentImages);
585
+ model = { ...model, error: "" };
586
+ render();
587
+ } catch (error) {
588
+ model = { ...model, error: errorMessage(error) };
589
+ render();
331
590
  } finally {
332
- model = { ...model, readingImages: Math.max(0, model.readingImages - 1) };
591
+ mutatingAttachments = false;
333
592
  renderComposer();
334
593
  }
335
594
  }
336
595
 
337
- function removeImage(id) {
338
- if (model.pending) return;
339
- const removed = model.images.find((image) => image.id === id);
340
- if (removed) {
341
- if (ui.previewImage.src === removed.previewUrl) ui.previewDialog.close();
342
- URL.revokeObjectURL(removed.previewUrl);
596
+ async function forgetSentImage(retainedImageId) {
597
+ if (composerLocked() || !retainedImageId) return;
598
+ if (!window.confirm("Forget this retained image? This does not retract provider content."))
599
+ return;
600
+ try {
601
+ const response = await attachmentMutation(
602
+ `/api/sent-images/${encodeURIComponent(retainedImageId)}?revision=${model.sentImages.revision}`,
603
+ { method: "DELETE" },
604
+ );
605
+ model = applySentImages(model, response);
606
+ render({ updateKey: "sent-images" });
607
+ } catch (error) {
608
+ model = { ...model, error: errorMessage(error) };
609
+ render();
343
610
  }
344
- model = invalidateSendAttempt({
345
- ...model,
346
- images: model.images.filter((image) => image.id !== id),
611
+ }
612
+
613
+ async function clearAttachments() {
614
+ if (composerLocked() || model.images.length < 2) return;
615
+ const clearedIds = model.images.map((image) => image.id);
616
+ if (clearedIds.includes(ui.previewImage.dataset.imageId)) ui.previewDialog.close();
617
+ const state = await mutateAttachmentState("/api/attachments/clear", {
618
+ method: "POST",
619
+ body: JSON.stringify({ revision: model.attachmentRevision }),
347
620
  });
348
- render();
621
+ if (!state) return;
622
+ for (const id of clearedIds) retryFiles.delete(id);
623
+ ui.input.focus();
624
+ }
625
+
626
+ async function reorderImages(images, focusId) {
627
+ if (composerLocked()) return;
628
+ if (images.every((image, index) => image.id === model.images[index]?.id)) {
629
+ imageDrag.focus(focusId);
630
+ return;
631
+ }
632
+ const previousImages = model.images;
633
+ const previousRevision = model.attachmentRevision;
634
+ model = { ...model, images };
635
+ mutatingAttachments = true;
636
+ renderComposer();
637
+ imageDrag.focus(focusId);
638
+ ui.previews.setAttribute("aria-busy", "true");
639
+ try {
640
+ const state = await attachmentMutation("/api/attachments/reorder", {
641
+ method: "POST",
642
+ body: JSON.stringify({
643
+ revision: previousRevision,
644
+ ids: images.map((image) => image.id),
645
+ }),
646
+ });
647
+ model = applyAttachments(model, state);
648
+ model = { ...model, error: "" };
649
+ } catch (error) {
650
+ if (model.attachmentRevision === previousRevision) {
651
+ model = { ...model, images: previousImages };
652
+ }
653
+ model = { ...model, error: errorMessage(error) };
654
+ } finally {
655
+ mutatingAttachments = false;
656
+ ui.previews.removeAttribute("aria-busy");
657
+ render();
658
+ imageDrag.focus(focusId);
659
+ }
660
+ }
661
+
662
+ async function removeImage(id) {
663
+ if (composerLocked()) return;
664
+ if (ui.previewImage.dataset.imageId === id) ui.previewDialog.close();
665
+ const state = await mutateAttachmentState(
666
+ `/api/attachments/${encodeURIComponent(id)}?revision=${model.attachmentRevision}`,
667
+ { method: "DELETE", headers: { "Content-Type": "application/json" } },
668
+ );
669
+ if (state) {
670
+ retryFiles.delete(id);
671
+ uploadProgress.delete(id);
672
+ }
673
+ }
674
+
675
+ async function mutateAttachmentState(path, options) {
676
+ if (composerLocked()) return;
677
+ mutatingAttachments = true;
678
+ renderComposer();
679
+ try {
680
+ const state = await attachmentMutation(path, options);
681
+ model = applyAttachments(model, state);
682
+ model = { ...model, error: "" };
683
+ render();
684
+ return state;
685
+ } catch (error) {
686
+ model = { ...model, error: errorMessage(error) };
687
+ render();
688
+ } finally {
689
+ mutatingAttachments = false;
690
+ renderComposer();
691
+ }
692
+ }
693
+
694
+ async function attachmentMutation(path, options) {
695
+ const response = await fetch(path, {
696
+ ...options,
697
+ headers: {
698
+ "Content-Type": "application/json",
699
+ "X-Pi-Web-Client": clientId,
700
+ ...options.headers,
701
+ },
702
+ });
703
+ if (!response.ok) throw new Error(await responseError(response));
704
+ return response.json();
349
705
  }
350
706
 
351
707
  function openImagePreview(image) {
708
+ if (image.status !== "ready") return;
352
709
  previewReturnFocus = document.activeElement;
353
710
  ui.previewTitle.textContent = image.name;
354
- ui.previewImage.src = image.previewUrl;
711
+ ui.previewImage.src = `/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`;
712
+ ui.previewImage.dataset.imageId = image.id;
355
713
  ui.previewImage.alt = image.name;
356
714
  ui.previewDialog.showModal();
357
715
  }
358
716
 
359
- function readBase64(file) {
360
- return new Promise((resolve, reject) => {
361
- const reader = new FileReader();
362
- reader.addEventListener("error", () => reject(new Error(`Could not read ${file.name}.`)));
363
- reader.addEventListener("load", () => {
364
- if (typeof reader.result !== "string") {
365
- reject(new Error(`Could not read ${file.name}.`));
366
- return;
367
- }
368
- resolve(reader.result.slice(reader.result.indexOf(",") + 1));
369
- });
370
- reader.readAsDataURL(file);
371
- });
372
- }
373
-
374
717
  function render(options = {}) {
375
718
  renderHeader();
376
719
  queueTranscriptRender(options.updateKey, options.announcement);
@@ -395,7 +738,7 @@ function queueTranscriptRender(updateKey, announcement) {
395
738
  if (transcriptFrame) return;
396
739
  transcriptFrame = requestAnimationFrame(() => {
397
740
  transcriptFrame = undefined;
398
- transcriptRenderer.render(model.messages, model.tools);
741
+ transcriptRenderer.render(model.messages, model.tools, model.sentImages);
399
742
  ui.empty.hidden = model.messages.length > 0;
400
743
  if (transcriptAnnouncement) {
401
744
  ui.transcriptStatus.textContent = transcriptAnnouncement;
@@ -412,53 +755,135 @@ function renderComposer() {
412
755
  const locked = composerLocked();
413
756
  ui.composer.classList.toggle("locked", locked);
414
757
  ui.send.textContent = busyLabel(model);
415
- ui.send.disabled = !canSend(model);
758
+ ui.send.disabled = locked || !canSend(model);
416
759
  ui.steer.hidden = model.activity !== "running";
417
- ui.steer.disabled = !canSend(model);
418
- ui.input.disabled = locked;
419
- ui.imageInput.disabled = locked;
420
- ui.addImages.classList.toggle("disabled", locked);
421
- ui.addImages.setAttribute("aria-disabled", String(locked));
760
+ ui.steer.disabled = locked || !canSend(model);
761
+ ui.input.disabled = model.closed || model.stale;
762
+ const admissionLocked = locked || model.readingImages > 0;
763
+ ui.imageInput.disabled = admissionLocked;
764
+ ui.addImages.classList.toggle("disabled", admissionLocked);
765
+ ui.addImages.setAttribute("aria-disabled", String(admissionLocked));
766
+ ui.clearAttachments.hidden = model.images.length < 2;
767
+ ui.clearAttachments.disabled = locked;
422
768
  ui.status.textContent = composerStatus();
423
769
  ui.error.hidden = !model.error;
424
770
  ui.error.textContent = model.error;
425
- ui.attachmentStatus.hidden = model.images.length === 0;
426
- const attachmentStatus =
427
- model.images.length === 0
428
- ? ""
429
- : `${model.images.length} of 8 images attached · Sensitive metadata is removed before sending.`;
430
- if (ui.attachmentStatus.textContent !== attachmentStatus) {
431
- ui.attachmentStatus.textContent = attachmentStatus;
771
+ const maximum = model.imageLimits?.maxImages ?? model.images.length;
772
+ ui.attachmentSummary.hidden = model.images.length === 0;
773
+ ui.attachmentSummary.textContent = model.images.length
774
+ ? `${model.images.length}/${maximum} images attached · Sensitive metadata is removed before sending.`
775
+ : "";
776
+ const attachmentAnnouncement = model.images.length
777
+ ? `Attachment state: ${attachmentPhaseLabel(model.attachmentPhase)}.`
778
+ : "";
779
+ if (attachmentAnnouncement !== lastAttachmentAnnouncement) {
780
+ lastAttachmentAnnouncement = attachmentAnnouncement;
781
+ ui.attachmentAnnouncement.textContent = attachmentAnnouncement;
432
782
  }
433
783
  ui.previews.replaceChildren();
434
- for (const image of model.images) {
784
+ for (const [index, image] of model.images.entries()) {
435
785
  const item = document.createElement("li");
436
786
  item.className = "image-preview-item";
787
+ item.dataset.imageId = image.id;
788
+ const retryable = image.status === "error" && (image.retryable || retryFiles.has(image.id));
789
+ const orderingLocked = locked || model.attachmentPhase !== "ready";
790
+ const reorderable = model.images.length > 1 && image.status === "ready";
791
+ item.draggable = model.images.length > 1 && image.status === "ready" && !orderingLocked;
792
+ if (reorderable) {
793
+ item.tabIndex = 0;
794
+ item.setAttribute(
795
+ "aria-label",
796
+ `${image.name}, image ${index + 1} of ${model.images.length}. Drag to reorder or press Alt plus Up or Down Arrow.`,
797
+ );
798
+ item.setAttribute("aria-keyshortcuts", "Alt+ArrowUp Alt+ArrowDown");
799
+ }
800
+ item.addEventListener("keydown", (event) => {
801
+ if (
802
+ event.target !== item ||
803
+ composerLocked() ||
804
+ orderingLocked ||
805
+ !event.altKey ||
806
+ event.ctrlKey ||
807
+ event.metaKey
808
+ )
809
+ return;
810
+ const direction = event.key === "ArrowUp" ? -1 : event.key === "ArrowDown" ? 1 : 0;
811
+ if (direction === 0 || index + direction < 0 || index + direction >= model.images.length)
812
+ return;
813
+ event.preventDefault();
814
+ void reorderImages(moveImage(model.images, image.id, direction), image.id);
815
+ });
816
+ imageDrag.bind(item, { id: image.id, orderingLocked });
437
817
  const previewButton = document.createElement("button");
438
818
  previewButton.type = "button";
439
819
  previewButton.className = "attachment-preview";
440
820
  previewButton.setAttribute("aria-label", `Preview image ${image.name}`);
441
- previewButton.disabled = model.pending;
821
+ previewButton.disabled = locked || image.status !== "ready";
442
822
  previewButton.addEventListener("click", () => openImagePreview(image));
443
823
  const preview = document.createElement("img");
444
- preview.src = image.previewUrl;
824
+ if (image.status === "ready") {
825
+ preview.src = `/api/attachments/${encodeURIComponent(image.id)}/preview?v=${model.attachmentRevision}`;
826
+ }
445
827
  preview.alt = "";
828
+ preview.draggable = false;
446
829
  previewButton.append(preview);
830
+ const details = document.createElement("span");
831
+ details.className = "attachment-details";
447
832
  const name = document.createElement("span");
448
833
  name.textContent = image.name;
834
+ const itemStatus = document.createElement("span");
835
+ itemStatus.className = `attachment-item-status ${image.status}`;
836
+ itemStatus.textContent = attachmentItemLabel(image, uploadProgress.get(image.id));
837
+ details.append(name, itemStatus);
838
+ if (Array.isArray(image.notes) && image.notes.length > 0) {
839
+ const summary = document.createElement("span");
840
+ summary.className = "attachment-conversion-summary";
841
+ summary.textContent = image.notes.join(" · ");
842
+ details.append(summary);
843
+ }
844
+ if (model.images.length > 1) {
845
+ const order = document.createElement("span");
846
+ order.className = "attachment-order-context";
847
+ order.textContent = `Order ${index + 1} of ${model.images.length}`;
848
+ details.append(order);
849
+ }
850
+ const actions = document.createElement("div");
851
+ actions.className = "image-actions";
449
852
  const remove = document.createElement("button");
450
853
  remove.type = "button";
451
854
  remove.className = "remove-image";
452
- remove.textContent = "Remove";
453
- remove.disabled = model.pending;
855
+ remove.disabled = locked;
454
856
  remove.setAttribute("aria-label", `Remove image ${image.name}`);
455
- remove.addEventListener("click", () => removeImage(image.id));
456
- item.append(previewButton, name, remove);
857
+ remove.title = `Remove ${image.name}`;
858
+ remove.append(createTrashIcon());
859
+ remove.addEventListener("click", () => void removeImage(image.id));
860
+ if (retryable) {
861
+ const retry = document.createElement("button");
862
+ retry.type = "button";
863
+ retry.className = "retry-image";
864
+ retry.textContent = "Retry";
865
+ retry.disabled = locked;
866
+ retry.setAttribute("aria-label", `Retry image ${image.name}`);
867
+ retry.addEventListener("click", () => void retryImage(image.id));
868
+ actions.append(retry);
869
+ }
870
+ actions.append(remove);
871
+ item.append(previewButton, details, actions);
457
872
  ui.previews.append(item);
458
873
  }
459
874
  document.documentElement.style.setProperty("--composer-height", `${ui.composer.offsetHeight}px`);
460
875
  }
461
876
 
877
+ function createTrashIcon() {
878
+ const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
879
+ icon.setAttribute("viewBox", "0 0 24 24");
880
+ icon.setAttribute("aria-hidden", "true");
881
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
882
+ path.setAttribute("d", "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14M10 10v6m4-6v6");
883
+ icon.append(path);
884
+ return icon;
885
+ }
886
+
462
887
  function renderBlocking() {
463
888
  ui.blocking.hidden = !model.closed && !model.stale;
464
889
  if (model.closed) {
@@ -482,8 +907,31 @@ function resizeInput() {
482
907
  ui.input.style.height = `${Math.min(ui.input.scrollHeight, window.innerHeight * 0.32)}px`;
483
908
  }
484
909
 
910
+ function attachmentPhaseLabel(phase) {
911
+ if (phase === "uploading") return "Uploading";
912
+ if (phase === "processing") return "Processing";
913
+ if (phase === "blocked") return "Needs attention";
914
+ if (phase === "reserved") return "Submitting";
915
+ return "Ready";
916
+ }
917
+
918
+ function attachmentItemLabel(image, progress) {
919
+ if (image.status === "uploading" && progress?.total > 0) {
920
+ const percent = Math.min(100, Math.round((progress.loaded / progress.total) * 100));
921
+ return `Uploading · ${percent}%`;
922
+ }
923
+ if (image.status === "uploading") return "Uploading…";
924
+ if (image.status === "processing") return "Processing…";
925
+ if (image.status === "error") return image.error || "Needs attention";
926
+ const dimensions =
927
+ Number.isSafeInteger(image.width) && Number.isSafeInteger(image.height)
928
+ ? ` · ${image.width}×${image.height}`
929
+ : "";
930
+ return `Ready${dimensions}`;
931
+ }
932
+
485
933
  function composerStatus() {
486
- if (model.readingImages > 0) return "Preparing images…";
934
+ if (model.readingImages > 0) return "Staging images on this Pi session…";
487
935
  if (model.pending) return "Submitting message…";
488
936
  const notice = deliveryNotice(model);
489
937
  if (notice) return notice;
@@ -512,7 +960,13 @@ function conversationAnnouncement(event) {
512
960
  }
513
961
 
514
962
  function composerLocked() {
515
- return model.closed || model.stale || model.pending;
963
+ return (
964
+ model.closed ||
965
+ model.stale ||
966
+ model.pending ||
967
+ mutatingAttachments ||
968
+ model.attachmentPhase === "reserved"
969
+ );
516
970
  }
517
971
 
518
972
  function hasDraggedFile(event) {
@@ -532,6 +986,10 @@ async function responseError(response) {
532
986
  }
533
987
  }
534
988
 
989
+ function formatMib(bytes) {
990
+ return `${bytes / (1024 * 1024)} MiB`;
991
+ }
992
+
535
993
  function errorMessage(error) {
536
994
  return error instanceof Error ? error.message : String(error);
537
995
  }