@narumitw/pi-image-drop 0.20.1 → 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/README.md CHANGED
@@ -1,12 +1,22 @@
1
1
  # 🖼️ pi-image-drop — Browser Image Staging for Pi
2
2
 
3
- [![npm](https://img.shields.io/npm/v/@narumitw/pi-image-drop)](https://www.npmjs.com/package/@narumitw/pi-image-drop) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-image-drop)](https://www.npmjs.com/package/@narumitw/pi-image-drop) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
5
  `@narumitw/pi-image-drop` adds one `/image-drop` command to the latest [Pi Coding Agent](https://pi.dev). It serves a private loopback page where you can paste, drop, choose, preview, reorder, retry, and remove local images. The ordered batch is attached to your next non-empty interactive Pi message.
6
6
 
7
7
  The page never contains a prompt or Attach button: Pi remains the only place where messages are written and sent.
8
8
 
9
- ## Install
9
+ ## ✨ Features
10
+
11
+ - Stages pasted, dropped, or selected images on a private loopback page.
12
+ - Preserves an ordered batch for the next non-empty interactive Pi message.
13
+ - Previews, reorders, retries, and removes images before submission.
14
+ - Supports PNG, JPEG, WebP, GIF, BMP, TIFF, HEIC/HEIF, and AVIF input.
15
+ - Applies orientation, strips private metadata, and enforces Pi-compatible image limits.
16
+ - Keeps bounded sent-image history for explicit re-attachment during the live session.
17
+ - Reports batch state above Pi's editor and can start automatically with each session.
18
+
19
+ ## 📦 Install
10
20
 
11
21
  ```bash
12
22
  pi install npm:@narumitw/pi-image-drop
@@ -22,19 +32,20 @@ just try image-drop
22
32
 
23
33
  This package targets the latest Pi release and uses its `agent_settled` lifecycle event. Older Pi releases are not supported.
24
34
 
25
- ## Workflow
35
+ ## 🚀 Workflow
26
36
 
27
- 1. Run `/image-drop` in an interactive Pi session.
28
- 2. Pi prints and displays a clickable one-time `http://127.0.0.1:<port>/...` link. The extension does **not** open a browser.
37
+ 1. Run `/image-drop` in an interactive Pi session. You can instead set `startOnSessionStart: true` to start the service with every Pi session.
38
+ 2. Pi prints and displays a clickable one-time `http://127.0.0.1:<port>/...` link. The extension does **not** open a browser, including when session startup is enabled.
29
39
  3. Open the link. Paste images anywhere, drop files, or select **Choose images**.
30
40
  4. Review previews and processing details. Drag to reorder, use the keyboard-accessible arrow buttons, retry failures, delete individual items, or use confirmed **Clear all**.
31
41
  5. Write and submit a non-empty message in Pi. The ready images are appended after any attachments already on that message, in browser order.
42
+ 6. After Pi records that user message, the sent images move to **Previously sent**. They are not attached to later prompts unless you explicitly choose **Add again**. You can preview, re-add, delete, or clear retained images from the browser page.
32
43
 
33
44
  The `🖼️` widget above Pi's editor reports ready, uploading, error, and queued counts. Uploading or failed items block the whole batch and preserve the Pi editor text. Image-only messages are not supported.
34
45
 
35
- Each `/image-drop` invocation rotates the unused one-time link. A browser refresh keeps the current in-memory batch. Opening the authenticated page in another tab gives the new tab the editing lease and makes the old tab stale.
46
+ By default, the loopback service starts lazily when you run `/image-drop`. With `startOnSessionStart: true`, it starts after each Pi session initializes and displays the link in Pi automatically. Each later `/image-drop` invocation reuses the service and rotates the unused one-time link. A browser refresh keeps the current in-memory batch and sent-image history. Opening the authenticated page in another tab gives the new tab the editing lease and makes the old tab stale. Reloading, replacing, forking, or shutting down the Pi session releases both the draft and all retained history.
36
47
 
37
- ## Supported images
48
+ ## 🖼️ Supported images
38
49
 
39
50
  | Input | Provider-ready output |
40
51
  | --- | --- |
@@ -51,7 +62,7 @@ Detection uses file signatures, not filenames or browser MIME types. SVG, HTML,
51
62
 
52
63
  The processor applies orientation and removes EXIF (including GPS), XMP, IPTC, comments, and other sensitive metadata. It retains an ICC color profile and animated GIF timing where the output format supports them. With Pi's `images.autoResize` enabled (the default), output is reduced to fit Pi's 2,000-pixel and approximately 4.5 MiB Base64 inline limits. With it disabled, output that exceeds either limit fails visibly instead of being resized.
53
64
 
54
- ## Configuration
65
+ ## ⚙️ Configuration
55
66
 
56
67
  Image Drop has one optional **global-only** JSON file:
57
68
 
@@ -63,36 +74,48 @@ Example:
63
74
 
64
75
  ```json
65
76
  {
77
+ "startOnSessionStart": true,
66
78
  "maxImages": 8,
67
79
  "maxImageBytes": 10485760,
68
80
  "maxBatchBytes": 41943040,
69
- "maxImagePixels": 50000000
81
+ "maxImagePixels": 50000000,
82
+ "maxRetainedImages": 128,
83
+ "maxRetainedBytes": 536870912
70
84
  }
71
85
  ```
72
86
 
73
- | Setting | Safe default | Hard ceiling |
87
+ | Setting | Default | Behavior |
88
+ | --- | --- | --- |
89
+ | `startOnSessionStart` | `false` | Start the loopback service and display a link after each Pi session initializes. This never opens a browser. |
90
+
91
+ | Limit setting | Safe default | Hard ceiling |
74
92
  | --- | ---: | ---: |
75
93
  | `maxImages` | 8 | 32 |
76
94
  | `maxImageBytes` | 10 MiB | 50 MiB |
77
95
  | `maxBatchBytes` | 40 MiB | 200 MiB |
78
96
  | `maxImagePixels` | 50 megapixels | 100 megapixels |
97
+ | `maxRetainedImages` | 128 | 256 |
98
+ | `maxRetainedBytes` | 512 MiB | 1 GiB |
99
+
100
+ `maxRetainedImages` and `maxRetainedBytes` govern how much sent history can coexist with the current draft, using combined image-count and resident-byte accounting. When either limit is reached, Image Drop removes the oldest sent-history entries first until the new draft fits. It never automatically removes the active or queued draft and does not reject a new image merely because retained history is full; the draft remains independently bounded by the batch limits above.
79
101
 
80
- Values are positive integer counts/bytes/pixels. `maxImageBytes` cannot exceed `maxBatchBytes`. Unknown fields, malformed JSON, invalid values, symlinks, or values above a hard ceiling cause the **whole file** to be ignored with one warning and safe defaults to be used. Values above a safe default but within a hard ceiling produce a memory/provider-limit warning.
102
+ Limit values are positive integer counts/bytes/pixels, and `startOnSessionStart` must be a boolean. `maxImageBytes` cannot exceed `maxBatchBytes`. Unknown fields, malformed JSON, invalid values, symlinks, or values above a hard ceiling cause the **whole file** to be ignored with one warning and safe defaults to be used. Limit values above a safe default but within a hard ceiling produce a memory/provider-limit warning.
81
103
 
82
104
  At upload and submission time, the extension also re-reads Pi's documented global and trusted-project `images.autoResize` and `images.blockImages` settings. `blockImages: true` or a text-only current model blocks processing/submission without discarding the draft.
83
105
 
84
- ## Security and privacy
106
+ ## 🔐 Security and privacy
85
107
 
86
108
  - The HTTP listener binds only to a random `127.0.0.1` port.
87
109
  - A rotating bootstrap token is exchanged once for an HttpOnly, `SameSite=Strict` session cookie, then removed from the URL.
88
110
  - Exact Host, mutation Origin, session-cookie, and active-client checks are enforced. No permissive CORS headers are sent.
89
111
  - Pages use a restrictive Content Security Policy plus no-store, no-referrer, MIME-sniffing, and frame-denial headers.
90
- - Raw request bodies, decoded pixels, source bytes, previews, and provider-ready bytes are bounded and stay in the Pi process memory. The extension creates no image cache or temporary image files.
91
- - Bytes are released after Pi records the matching user message, after Delete/Clear all, and on reload, session replacement/fork, or shutdown. Once Pi records a message, normal Pi/provider retention rules apply.
112
+ - Raw request bodies, decoded pixels, source bytes, previews, and provider-ready bytes are bounded and stay in the Pi process memory. The extension creates no image cache, temporary image files, browser storage, or session-file entries.
113
+ - After Pi records the matching user message, only sanitized provider-ready bytes and display metadata move to sent history; original uploaded source bytes are released. History remains available for preview and explicit re-adding until you delete it, FIFO retention limits remove it, or the Pi session reaches reload, replacement/fork, or shutdown.
114
+ - Once Pi records a message, normal Pi/provider retention rules apply independently of deleting Image Drop's in-process history.
92
115
 
93
116
  A loopback page is local to your operating-system network namespace. Do not expose the port to a LAN or public interface.
94
117
 
95
- ## Platforms, browsers, and remote environments
118
+ ## 🖥️ Platforms, browsers, and remote environments
96
119
 
97
120
  The supported local targets are current macOS, Windows, desktop Linux, and WSL with current stable Chrome, Edge, Firefox, or Safari where those browsers are available. Native `sharp` packages are installed for the current platform; HEVC-backed HEIC and BMP use bounded portable decoders because the patent-safe prebuilt `sharp`/libvips bundle omits them.
98
121
 
@@ -104,20 +127,21 @@ ssh -L 45678:127.0.0.1:45678 user@remote-host
104
127
 
105
128
  Then open the unchanged `http://127.0.0.1:45678/...` link locally. Image Drop does not provide a cloud relay or remote upload endpoint.
106
129
 
107
- ## Limitations
130
+ ## 🚧 Limitations
108
131
 
109
132
  - Only `/image-drop` is registered; there is no `/image-drop clear` command.
110
133
  - A non-empty interactive Pi message is required. RPC, extension-generated, slash-command, and image-only inputs do not consume the batch.
111
134
  - All items must be ready. One uploading or failed item blocks submission until it is retried or deleted.
112
135
  - Provider aggregate request limits vary. Raising the defaults to the hard ceilings does not guarantee that a provider accepts the final multi-image request.
113
- - Batches are intentionally not persisted or shown as recent history.
136
+ - Sent history exists only for the current live Pi session. It is not reconstructed from the transcript, persisted across sessions, or shared with another Pi process.
137
+ - FIFO retention can remove the oldest sent images automatically at the configured count or memory limit; the browser displays the current usage and limit.
114
138
 
115
- ## Package layout
139
+ ## 🗂️ Package layout
116
140
 
117
141
  ```text
118
142
  src/image-drop.ts Pi extension entrypoint
119
143
  src/runtime.ts Pi lifecycle and message orchestration
120
- src/batch.ts in-memory batch state machine
144
+ src/batch.ts in-memory draft and sent-history state machine
121
145
  src/images.ts bounded image processing
122
146
  src/server.ts authenticated loopback HTTP/SSE server
123
147
  src/settings.ts extension settings
@@ -125,7 +149,7 @@ src/pi-settings.ts effective Pi image settings adapter
125
149
  src/web/ framework-free browser page
126
150
  ```
127
151
 
128
- ## Development
152
+ ## 🧪 Development
129
153
 
130
154
  From the repository root:
131
155
 
@@ -138,7 +162,7 @@ just pack image-drop
138
162
 
139
163
  The dry-run package must contain the manifest, license, README, TypeScript sources, and static web assets, but no tests, fixtures, image bytes, or `node_modules`.
140
164
 
141
- ## Publishing
165
+ ## 🚀 Publishing
142
166
 
143
167
  The first publication is intentionally a maintainer action:
144
168
 
@@ -147,3 +171,11 @@ npm publish --workspace @narumitw/pi-image-drop --access public
147
171
  ```
148
172
 
149
173
  `just npm-public` only changes visibility after a scoped package already exists. Do not publish from an implementation or verification run.
174
+
175
+ ## 🔎 Keywords
176
+
177
+ Pi extension, Pi Coding Agent, browser image staging, image prompt, local image upload, metadata removal, local-first AI coding agent.
178
+
179
+ ## 📄 License
180
+
181
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-image-drop",
3
- "version": "0.20.1",
3
+ "version": "0.25.0",
4
4
  "description": "Pi extension for staging browser images and attaching them to the next message.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/batch.ts CHANGED
@@ -34,6 +34,13 @@ interface BatchItem extends ItemReservation {
34
34
  error?: string;
35
35
  }
36
36
 
37
+ interface HistoryItem {
38
+ id: string;
39
+ name: string;
40
+ processed: ProcessedImage;
41
+ processedAutoResize?: boolean;
42
+ }
43
+
37
44
  export interface PublicBatchItem extends ItemReservation {
38
45
  status: ItemStatus;
39
46
  error?: string;
@@ -55,6 +62,39 @@ export interface PublicBatchState {
55
62
  totalSourceBytes: number;
56
63
  }
57
64
 
65
+ export interface PublicHistoryItem {
66
+ id: string;
67
+ name: string;
68
+ size: number;
69
+ mimeType: string;
70
+ width: number;
71
+ height: number;
72
+ originalWidth: number;
73
+ originalHeight: number;
74
+ sourceFormat: string;
75
+ outputFormat: string;
76
+ resized: boolean;
77
+ notes: string[];
78
+ }
79
+
80
+ export interface PublicHistoryState {
81
+ revision: number;
82
+ items: PublicHistoryItem[];
83
+ totalBytes: number;
84
+ maxImages: number;
85
+ maxBytes: number;
86
+ }
87
+
88
+ export interface HistoryRestageInput {
89
+ historyId: string;
90
+ id: string;
91
+ }
92
+
93
+ export interface HistoryRestageResult {
94
+ addedIds: string[];
95
+ duplicates: Array<{ historyId: string; existingId: string }>;
96
+ }
97
+
58
98
  export interface MessageReservation {
59
99
  id: string;
60
100
  text: string;
@@ -77,6 +117,7 @@ export class BatchError extends Error {
77
117
 
78
118
  export class BatchStore {
79
119
  private items: BatchItem[] = [];
120
+ private history: HistoryItem[] = [];
80
121
  private revision = 0;
81
122
  private reservation?: MessageReservation;
82
123
  private closed = false;
@@ -107,6 +148,29 @@ export class BatchStore {
107
148
  };
108
149
  }
109
150
 
151
+ publicHistoryState(): PublicHistoryState {
152
+ return {
153
+ revision: this.revision,
154
+ items: this.history.map((item) => ({
155
+ id: item.id,
156
+ name: item.name,
157
+ size: item.processed.bytes.byteLength,
158
+ mimeType: item.processed.mimeType,
159
+ width: item.processed.width,
160
+ height: item.processed.height,
161
+ originalWidth: item.processed.originalWidth,
162
+ originalHeight: item.processed.originalHeight,
163
+ sourceFormat: item.processed.sourceFormat,
164
+ outputFormat: item.processed.outputFormat,
165
+ resized: item.processed.resized,
166
+ notes: [...item.processed.notes],
167
+ })),
168
+ totalBytes: this.historyBytes(),
169
+ maxImages: this.settings.maxRetainedImages,
170
+ maxBytes: this.settings.maxRetainedBytes,
171
+ };
172
+ }
173
+
110
174
  reserveItems(inputs: readonly ItemReservation[], expectedRevision = this.revision): number {
111
175
  this.assertMutable(expectedRevision);
112
176
  if (inputs.length === 0) throw new BatchError("No images supplied", "invalid");
@@ -136,6 +200,7 @@ export class BatchStore {
136
200
  throw new BatchError("Batch exceeds the byte limit", "limit");
137
201
  }
138
202
  this.items.push(...inputs.map((input) => ({ ...input, status: "uploading" as const })));
203
+ this.evictHistoryToBudget();
139
204
  return this.bump();
140
205
  }
141
206
 
@@ -172,6 +237,7 @@ export class BatchStore {
172
237
  const earlier = duplicates.find(({ index }) => index < itemIndex);
173
238
  if (earlier) {
174
239
  this.items = this.items.filter((candidate) => candidate.id !== id);
240
+ this.evictHistoryToBudget();
175
241
  this.bump();
176
242
  return { kind: "duplicate", existingId: earlier.candidate.id };
177
243
  }
@@ -183,9 +249,11 @@ export class BatchStore {
183
249
  if (duplicates.length > 0) {
184
250
  const laterIds = new Set(duplicates.map(({ candidate }) => candidate.id));
185
251
  this.items = this.items.filter((candidate) => !laterIds.has(candidate.id));
252
+ this.evictHistoryToBudget();
186
253
  this.bump();
187
254
  return { kind: "duplicate", existingId: item.id };
188
255
  }
256
+ this.evictHistoryToBudget();
189
257
  this.bump();
190
258
  return { kind: "ready" };
191
259
  }
@@ -323,6 +391,7 @@ export class BatchStore {
323
391
  preflightStarted: false,
324
392
  };
325
393
  this.reservation = reservation;
394
+ this.evictHistoryToBudget();
326
395
  this.bump();
327
396
  return cloneReservation(reservation);
328
397
  }
@@ -342,10 +411,101 @@ export class BatchStore {
342
411
  return { bytes: Buffer.from(processed.bytes), mimeType: processed.mimeType };
343
412
  }
344
413
 
414
+ historyPreview(id: string): { bytes: Buffer; mimeType: string } {
415
+ this.assertOpen();
416
+ const processed = this.historyItem(id).processed;
417
+ return { bytes: Buffer.from(processed.bytes), mimeType: processed.mimeType };
418
+ }
419
+
420
+ restageHistory(
421
+ inputs: readonly HistoryRestageInput[],
422
+ expectedRevision = this.revision,
423
+ ): HistoryRestageResult {
424
+ this.assertMutable(expectedRevision);
425
+ if (inputs.length === 0) throw new BatchError("No history images supplied", "invalid");
426
+ if (new Set(inputs.map((input) => input.historyId)).size !== inputs.length) {
427
+ throw new BatchError("History selection contains duplicates", "invalid");
428
+ }
429
+ const draftIds = new Set(this.items.map((item) => item.id));
430
+ const draftHashes = new Map(
431
+ this.items.flatMap((item) =>
432
+ item.processed ? [[item.processed.hash, item.id] as const] : [],
433
+ ),
434
+ );
435
+ const additions: BatchItem[] = [];
436
+ const duplicates: HistoryRestageResult["duplicates"] = [];
437
+ for (const input of inputs) {
438
+ if (!isSafeIdentifier(input.id) || draftIds.has(input.id)) {
439
+ throw new BatchError("Image id is invalid or duplicated", "invalid");
440
+ }
441
+ draftIds.add(input.id);
442
+ const history = this.historyItem(input.historyId);
443
+ const existingId = draftHashes.get(history.processed.hash);
444
+ if (existingId) {
445
+ duplicates.push({ historyId: input.historyId, existingId });
446
+ continue;
447
+ }
448
+ const processed = cloneProcessed(history.processed);
449
+ additions.push({
450
+ id: input.id,
451
+ name: history.name,
452
+ size: processed.bytes.byteLength,
453
+ status: "ready",
454
+ source: Buffer.from(processed.bytes),
455
+ processed,
456
+ processedAutoResize: history.processedAutoResize,
457
+ });
458
+ draftHashes.set(processed.hash, input.id);
459
+ }
460
+ if (this.items.length + additions.length > this.settings.maxImages) {
461
+ throw new BatchError("Batch exceeds the image-count limit", "limit");
462
+ }
463
+ const currentBytes = this.items.reduce((sum, item) => sum + item.size, 0);
464
+ const addedBytes = additions.reduce((sum, item) => sum + item.size, 0);
465
+ if (currentBytes + addedBytes > this.settings.maxBatchBytes) {
466
+ throw new BatchError("Batch exceeds the byte limit", "limit");
467
+ }
468
+ if (additions.length > 0) {
469
+ this.items.push(...additions);
470
+ this.evictHistoryToBudget();
471
+ this.bump();
472
+ }
473
+ return { addedIds: additions.map((item) => item.id), duplicates };
474
+ }
475
+
476
+ deleteHistory(id: string, expectedRevision = this.revision): number {
477
+ this.assertHistoryMutation(expectedRevision);
478
+ const before = this.history.length;
479
+ this.history = this.history.filter((item) => item.id !== id);
480
+ if (this.history.length === before)
481
+ throw new BatchError("History image not found", "not-found");
482
+ return this.bump();
483
+ }
484
+
485
+ clearHistory(expectedRevision = this.revision): number {
486
+ this.assertHistoryMutation(expectedRevision);
487
+ if (this.history.length === 0) return this.revision;
488
+ this.history = [];
489
+ return this.bump();
490
+ }
491
+
345
492
  commitReservation(digest: string): boolean {
346
493
  if (!this.reservation || this.reservation.digest !== digest) return false;
494
+ for (const [index, item] of this.items.entries()) {
495
+ if (!item.processed) continue;
496
+ this.history = this.history.filter(
497
+ (history) => history.processed.hash !== item.processed?.hash,
498
+ );
499
+ this.history.push({
500
+ id: historyId(this.revision, index, item.processed.hash),
501
+ name: item.name,
502
+ processed: cloneProcessed(item.processed),
503
+ processedAutoResize: item.processedAutoResize,
504
+ });
505
+ }
347
506
  this.reservation = undefined;
348
507
  this.items = [];
508
+ this.evictHistoryToBudget();
349
509
  this.bump();
350
510
  return true;
351
511
  }
@@ -363,6 +523,7 @@ export class BatchStore {
363
523
  this.closed = true;
364
524
  this.reservation = undefined;
365
525
  this.items = [];
526
+ this.history = [];
366
527
  this.bump();
367
528
  }
368
529
 
@@ -380,8 +541,12 @@ export class BatchStore {
380
541
  }
381
542
 
382
543
  private assertMutable(expectedRevision: number): void {
383
- this.assertOpen();
544
+ this.assertHistoryMutation(expectedRevision);
384
545
  if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
546
+ }
547
+
548
+ private assertHistoryMutation(expectedRevision: number): void {
549
+ this.assertOpen();
385
550
  if (expectedRevision !== this.revision)
386
551
  throw new BatchError("Batch revision is stale", "stale");
387
552
  }
@@ -392,6 +557,37 @@ export class BatchStore {
392
557
  return item;
393
558
  }
394
559
 
560
+ private historyItem(id: string): HistoryItem {
561
+ const item = this.history.find((candidate) => candidate.id === id);
562
+ if (!item) throw new BatchError("History image not found", "not-found");
563
+ return item;
564
+ }
565
+
566
+ private historyBytes(): number {
567
+ return this.history.reduce((sum, item) => sum + item.processed.bytes.byteLength, 0);
568
+ }
569
+
570
+ private draftResidentBytes(): number {
571
+ const itemBytes = this.items.reduce(
572
+ (sum, item) =>
573
+ sum + (item.source?.byteLength ?? item.size) + (item.processed?.bytes.byteLength ?? 0),
574
+ 0,
575
+ );
576
+ const queuedBase64Bytes =
577
+ this.reservation?.images.reduce((sum, image) => sum + Buffer.byteLength(image.data), 0) ?? 0;
578
+ return itemBytes + queuedBase64Bytes;
579
+ }
580
+
581
+ private evictHistoryToBudget(): void {
582
+ while (
583
+ this.history.length > 0 &&
584
+ (this.history.length + this.items.length > this.settings.maxRetainedImages ||
585
+ this.historyBytes() + this.draftResidentBytes() > this.settings.maxRetainedBytes)
586
+ ) {
587
+ this.history.shift();
588
+ }
589
+ }
590
+
395
591
  private bump(): number {
396
592
  this.revision += 1;
397
593
  return this.revision;
@@ -439,6 +635,13 @@ function isSafeIdentifier(id: string): boolean {
439
635
  return /^[A-Za-z0-9_-]{1,80}$/.test(id);
440
636
  }
441
637
 
638
+ function historyId(revision: number, index: number, hash: string): string {
639
+ return createHash("sha256")
640
+ .update(`history\0${revision}\0${index}\0${hash}`)
641
+ .digest("hex")
642
+ .slice(0, 24);
643
+ }
644
+
442
645
  function cryptoId(revision: number, images: readonly ImageContent[]): string {
443
646
  return createHash("sha256")
444
647
  .update(`${revision}\0${Date.now()}\0${digestImages(images)}`)
package/src/runtime.ts CHANGED
@@ -63,14 +63,7 @@ export class ImageDropRuntime {
63
63
  this.context = ctx;
64
64
  await this.recoverOrphanedReservation(ctx);
65
65
  try {
66
- const server = await this.ensureServer(ctx);
67
- const link = server.issueLink();
68
- if (this.batch?.publicState().phase === "empty") {
69
- ctx.ui.setWidget(WIDGET_KEY, [`🖼️ Image Drop: ${link}`]);
70
- } else {
71
- this.updateWidget(ctx);
72
- }
73
- ctx.ui.notify(`Image Drop: ${link}`, "info");
66
+ await this.presentLink(ctx);
74
67
  } catch (error) {
75
68
  ctx.ui.notify(`Image Drop could not start: ${formatError(error)}`, "error");
76
69
  }
@@ -110,6 +103,13 @@ export class ImageDropRuntime {
110
103
  ctx.ui.notify(warning ?? "Image Drop settings ignored.", "warning");
111
104
  }
112
105
  this.updateWidget(ctx);
106
+ if (!result.settings.startOnSessionStart) return;
107
+ try {
108
+ await this.presentLink(ctx);
109
+ } catch (error) {
110
+ if (generation !== this.generation || this.closed) return;
111
+ ctx.ui.notify(`Image Drop could not start: ${formatError(error)}`, "error");
112
+ }
113
113
  }
114
114
 
115
115
  async shutdown(ctx: ExtensionContext): Promise<void> {
@@ -259,6 +259,17 @@ export class ImageDropRuntime {
259
259
  return true;
260
260
  }
261
261
 
262
+ private async presentLink(ctx: ExtensionContext): Promise<void> {
263
+ const server = await this.ensureServer(ctx);
264
+ const link = server.issueLink();
265
+ if (this.batch?.publicState().phase === "empty") {
266
+ ctx.ui.setWidget(WIDGET_KEY, [`🖼️ Image Drop: ${link}`]);
267
+ } else {
268
+ this.updateWidget(ctx);
269
+ }
270
+ ctx.ui.notify(`Image Drop: ${link}`, "info");
271
+ }
272
+
262
273
  private async ensureServer(ctx: ExtensionContext): Promise<ServerControl> {
263
274
  if (this.closed || !this.batch || !this.settings || !this.processor) {
264
275
  throw new Error("the Pi session is not ready");
package/src/server.ts CHANGED
@@ -176,9 +176,15 @@ export class ImageDropServer {
176
176
  const previewMatch = /^\/api\/items\/([A-Za-z0-9_-]{1,80})\/preview$/.exec(url.pathname);
177
177
  if (request.method === "GET" && previewMatch) {
178
178
  const preview = this.options.batch.preview(previewMatch[1] as string);
179
- response.setHeader("Content-Type", preview.mimeType);
180
- response.setHeader("Content-Length", preview.bytes.byteLength);
181
- response.writeHead(200).end(preview.bytes);
179
+ this.image(response, preview);
180
+ return;
181
+ }
182
+ const historyPreviewMatch = /^\/api\/history\/([A-Za-z0-9_-]{1,80})\/preview$/.exec(
183
+ url.pathname,
184
+ );
185
+ if (request.method === "GET" && historyPreviewMatch) {
186
+ const preview = this.options.batch.historyPreview(historyPreviewMatch[1] as string);
187
+ this.image(response, preview);
182
188
  return;
183
189
  }
184
190
  if (request.method === "POST" && url.pathname === "/api/lease") {
@@ -195,6 +201,31 @@ export class ImageDropServer {
195
201
  }
196
202
  this.assertMutation(request);
197
203
  const lease = this.assertActiveClient(request);
204
+ if (request.method === "POST" && url.pathname === "/api/history/restage") {
205
+ const body = await readJson(request, JSON_LIMIT);
206
+ this.assertLease(lease);
207
+ const items = arrayField(body, "items").map((item) => {
208
+ if (!isRecord(item)) throw new HttpError(400, "Invalid history restage item");
209
+ return { historyId: stringField(item, "historyId"), id: stringField(item, "id") };
210
+ });
211
+ const restage = this.options.batch.restageHistory(items, integerField(body, "revision"));
212
+ this.broadcastState();
213
+ this.json(response, 200, { ...this.statePayload(), restage });
214
+ return;
215
+ }
216
+ const historyMatch = /^\/api\/history\/([A-Za-z0-9_-]{1,80})$/.exec(url.pathname);
217
+ if (request.method === "DELETE" && historyMatch) {
218
+ this.options.batch.deleteHistory(historyMatch[1] as string, queryInteger(url, "revision"));
219
+ this.respondWithState(response);
220
+ return;
221
+ }
222
+ if (request.method === "POST" && url.pathname === "/api/history/clear") {
223
+ const body = await readJson(request, JSON_LIMIT);
224
+ this.assertLease(lease);
225
+ this.options.batch.clearHistory(integerField(body, "revision"));
226
+ this.respondWithState(response);
227
+ return;
228
+ }
198
229
  if (request.method === "POST" && url.pathname === "/api/items") {
199
230
  const body = await readJson(request, JSON_LIMIT);
200
231
  this.assertLease(lease);
@@ -406,9 +437,16 @@ export class ImageDropServer {
406
437
  cwd: this.options.cwd,
407
438
  activeClientId: this.activeClientId,
408
439
  batch: this.options.batch.publicState(),
440
+ history: this.options.batch.publicHistoryState(),
409
441
  };
410
442
  }
411
443
 
444
+ private image(response: ServerResponse, image: { bytes: Buffer; mimeType: string }): void {
445
+ response.setHeader("Content-Type", image.mimeType);
446
+ response.setHeader("Content-Length", image.bytes.byteLength);
447
+ response.writeHead(200).end(image.bytes);
448
+ }
449
+
412
450
  private respondWithState(response: ServerResponse): void {
413
451
  this.broadcastState();
414
452
  this.json(response, 200, this.statePayload());
package/src/settings.ts CHANGED
@@ -5,11 +5,17 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  export const SETTINGS_FILE = "pi-image-drop.json";
6
6
  const MIB = 1024 * 1024;
7
7
 
8
- export interface ImageDropSettings {
8
+ export interface ImageDropLimits {
9
9
  maxImages: number;
10
10
  maxImageBytes: number;
11
11
  maxBatchBytes: number;
12
12
  maxImagePixels: number;
13
+ maxRetainedImages: number;
14
+ maxRetainedBytes: number;
15
+ }
16
+
17
+ export interface ImageDropSettings extends ImageDropLimits {
18
+ startOnSessionStart: boolean;
13
19
  }
14
20
 
15
21
  export const DEFAULT_SETTINGS: Readonly<ImageDropSettings> = Object.freeze({
@@ -17,21 +23,29 @@ export const DEFAULT_SETTINGS: Readonly<ImageDropSettings> = Object.freeze({
17
23
  maxImageBytes: 10 * MIB,
18
24
  maxBatchBytes: 40 * MIB,
19
25
  maxImagePixels: 50_000_000,
26
+ maxRetainedImages: 128,
27
+ maxRetainedBytes: 512 * MIB,
28
+ startOnSessionStart: false,
20
29
  });
21
30
 
22
- export const HARD_LIMITS: Readonly<ImageDropSettings> = Object.freeze({
31
+ export const HARD_LIMITS: Readonly<ImageDropLimits> = Object.freeze({
23
32
  maxImages: 32,
24
33
  maxImageBytes: 50 * MIB,
25
34
  maxBatchBytes: 200 * MIB,
26
35
  maxImagePixels: 100_000_000,
36
+ maxRetainedImages: 256,
37
+ maxRetainedBytes: 1024 * MIB,
27
38
  });
28
39
 
29
- const SETTING_KEYS = new Set<keyof ImageDropSettings>([
40
+ const LIMIT_KEYS = new Set<keyof ImageDropLimits>([
30
41
  "maxImages",
31
42
  "maxImageBytes",
32
43
  "maxBatchBytes",
33
44
  "maxImagePixels",
45
+ "maxRetainedImages",
46
+ "maxRetainedBytes",
34
47
  ]);
48
+ const SETTING_KEYS = new Set<keyof ImageDropSettings>([...LIMIT_KEYS, "startOnSessionStart"]);
35
49
 
36
50
  export type SettingsLoadResult =
37
51
  | { kind: "missing"; settings: ImageDropSettings }
@@ -43,7 +57,7 @@ export function normalizeSettings(value: unknown): ImageDropSettings | undefined
43
57
  return undefined;
44
58
  }
45
59
  const settings: ImageDropSettings = { ...DEFAULT_SETTINGS };
46
- for (const key of SETTING_KEYS) {
60
+ for (const key of LIMIT_KEYS) {
47
61
  if (!Object.hasOwn(value, key)) continue;
48
62
  const candidate = Reflect.get(value, key);
49
63
  if (
@@ -56,6 +70,10 @@ export function normalizeSettings(value: unknown): ImageDropSettings | undefined
56
70
  }
57
71
  settings[key] = candidate;
58
72
  }
73
+ if (Object.hasOwn(value, "startOnSessionStart")) {
74
+ if (typeof value.startOnSessionStart !== "boolean") return undefined;
75
+ settings.startOnSessionStart = value.startOnSessionStart;
76
+ }
59
77
  if (settings.maxImageBytes > settings.maxBatchBytes) return undefined;
60
78
  return settings;
61
79
  }
@@ -79,15 +97,13 @@ export async function loadSettings(
79
97
  try {
80
98
  const settings = normalizeSettings(JSON.parse(text) as unknown);
81
99
  if (!settings) return invalid(path, "invalid settings shape or value");
82
- const raised = (Object.keys(DEFAULT_SETTINGS) as Array<keyof ImageDropSettings>).filter(
83
- (key) => settings[key] > DEFAULT_SETTINGS[key],
84
- );
100
+ const raised = [...LIMIT_KEYS].filter((key) => settings[key] > DEFAULT_SETTINGS[key]);
85
101
  return {
86
102
  kind: "loaded",
87
103
  settings,
88
104
  warning:
89
105
  raised.length > 0
90
- ? `${SETTINGS_FILE} raises ${raised.join(", ")} above the safe defaults; large provider requests may fail.`
106
+ ? `${SETTINGS_FILE} raises ${raised.join(", ")} above the safe defaults; memory use or provider request size may increase.`
91
107
  : undefined,
92
108
  };
93
109
  } catch (error) {
package/src/web/app.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import {
2
2
  attemptMutation,
3
3
  canMutate,
4
+ draftPresentation,
4
5
  formatBytes,
5
6
  moveItem,
6
7
  moveItemBefore,
7
8
  preferNewestState,
8
9
  summarizeBatch,
10
+ summarizeHistory,
11
+ visibleItemNotes,
9
12
  } from "/state.js";
10
13
 
11
14
  const $ = (selector) => document.querySelector(selector);
@@ -16,13 +19,23 @@ const ui = {
16
19
  choose: $("#choose-files"),
17
20
  input: $("#file-input"),
18
21
  status: $("#status"),
22
+ nextStep: $("#next-step"),
19
23
  clear: $("#clear-all"),
20
24
  error: $("#error-banner"),
21
25
  grid: $("#grid"),
26
+ historyStatus: $("#history-status"),
27
+ historyRetention: $("#history-retention"),
28
+ historyClear: $("#clear-history"),
29
+ historyGrid: $("#history-grid"),
22
30
  overlay: $("#connection-overlay"),
23
31
  connectionTitle: $("#connection-title"),
24
32
  connectionMessage: $("#connection-message"),
25
33
  dialog: $("#clear-dialog"),
34
+ historyDialog: $("#clear-history-dialog"),
35
+ previewDialog: $("#image-preview-dialog"),
36
+ previewTitle: $("#image-preview-title"),
37
+ previewDismiss: $("#image-preview-dismiss"),
38
+ previewImage: $("#image-preview"),
26
39
  };
27
40
  const clientId = crypto.randomUUID();
28
41
  const pendingFiles = new Map();
@@ -79,6 +92,20 @@ function wire() {
79
92
  ui.dialog.addEventListener("close", () => {
80
93
  if (ui.dialog.returnValue === "confirm") void clearAll();
81
94
  });
95
+ ui.historyClear.addEventListener("click", () => {
96
+ ui.historyDialog.returnValue = "cancel";
97
+ ui.historyDialog.showModal();
98
+ });
99
+ ui.historyDialog.addEventListener("close", () => {
100
+ if (ui.historyDialog.returnValue === "confirm") void clearHistory();
101
+ });
102
+ ui.previewDismiss.addEventListener("click", closePreview);
103
+ ui.previewDialog.addEventListener("click", (event) => {
104
+ if (event.target === ui.previewDialog) closePreview();
105
+ });
106
+ ui.previewDialog.addEventListener("close", () => {
107
+ ui.previewImage.removeAttribute("src");
108
+ });
82
109
  }
83
110
 
84
111
  function connectEvents() {
@@ -215,6 +242,36 @@ async function clearAll() {
215
242
  }
216
243
  }
217
244
 
245
+ async function restageHistory(historyId) {
246
+ try {
247
+ const response = await request("/api/history/restage", {
248
+ method: "POST",
249
+ json: {
250
+ revision: state.batch.revision,
251
+ items: [{ historyId, id: crypto.randomUUID() }],
252
+ },
253
+ });
254
+ applyState(response);
255
+ clearError();
256
+ render();
257
+ const target = response.restage.addedIds[0] ?? response.restage.duplicates[0]?.existingId;
258
+ if (target) highlight(target);
259
+ } catch (error) {
260
+ showError(errorMessage(error));
261
+ }
262
+ }
263
+
264
+ async function deleteHistory(id) {
265
+ await mutate(`/api/history/${id}?revision=${state.batch.revision}`, { method: "DELETE" });
266
+ }
267
+
268
+ async function clearHistory() {
269
+ await mutate("/api/history/clear", {
270
+ method: "POST",
271
+ json: { revision: state.batch.revision },
272
+ });
273
+ }
274
+
218
275
  async function mutate(path, options) {
219
276
  const result = await attemptMutation(() => request(path, options));
220
277
  if (!result.ok) {
@@ -235,14 +292,26 @@ function render() {
235
292
  : state.projectName;
236
293
  ui.cwd.textContent = state.cwd;
237
294
  const summary = summarizeBatch(state.batch);
238
- ui.status.textContent = `${summary.label} · ${formatBytes(summary.bytes)}`;
295
+ const presentation = draftPresentation(state.batch);
296
+ ui.status.textContent = presentation.status;
297
+ ui.status.hidden = presentation.status === "";
298
+ ui.nextStep.textContent = presentation.guidance;
239
299
  const mutable = canMutate(state.batch);
300
+ ui.clear.hidden = summary.total === 0;
240
301
  ui.clear.disabled = !mutable || summary.total === 0;
241
302
  ui.choose.disabled = !mutable;
242
303
  ui.input.disabled = !mutable;
243
304
  ui.drop.classList.toggle("disabled", !mutable);
244
305
  ui.drop.setAttribute("aria-disabled", String(!mutable));
245
306
  ui.grid.replaceChildren(...state.batch.items.map((item, index) => card(item, index, mutable)));
307
+ const history = summarizeHistory(state.history);
308
+ ui.historyStatus.textContent = history.label;
309
+ ui.historyRetention.textContent = history.usage;
310
+ ui.historyClear.hidden = history.total === 0;
311
+ ui.historyClear.disabled = history.total === 0;
312
+ ui.historyGrid.replaceChildren(
313
+ ...(state.history?.items ?? []).map((item, index) => historyCard(item, index, mutable)),
314
+ );
246
315
  }
247
316
 
248
317
  function card(item, index, mutable) {
@@ -264,14 +333,19 @@ function card(item, index, mutable) {
264
333
  draggedId = undefined;
265
334
  });
266
335
 
267
- const preview = element("div", "preview");
336
+ let preview;
268
337
  if (item.status === "ready") {
338
+ preview = element("button", "preview preview-button");
339
+ preview.type = "button";
340
+ preview.setAttribute("aria-label", `Enlarge preview of ${item.name}`);
341
+ preview.addEventListener("click", () => openPreview(item, "items"));
269
342
  const image = document.createElement("img");
270
343
  image.src = `/api/items/${item.id}/preview?revision=${state.batch.revision}`;
271
344
  image.alt = `Preview of ${item.name}`;
272
345
  image.loading = "lazy";
273
346
  preview.append(image);
274
347
  } else {
348
+ preview = element("div", "preview");
275
349
  const placeholder = element("span", "placeholder", item.status === "error" ? "!" : "…");
276
350
  placeholder.setAttribute("aria-hidden", "true");
277
351
  preview.append(placeholder);
@@ -292,7 +366,7 @@ function card(item, index, mutable) {
292
366
  `${item.sourceFormat.toUpperCase()} → ${item.outputFormat.toUpperCase()}`,
293
367
  ),
294
368
  );
295
- for (const note of item.notes ?? []) body.append(element("p", "note", note));
369
+ for (const note of visibleItemNotes(item.notes)) body.append(element("p", "note", note));
296
370
  if (item.error) body.append(element("p", "item-error", item.error));
297
371
  article.append(body);
298
372
 
@@ -307,11 +381,66 @@ function card(item, index, mutable) {
307
381
  );
308
382
  if (item.status === "error")
309
383
  actions.append(button("Retry", "Retry", !mutable, () => retry(item.id)));
310
- actions.append(button("Delete", "Delete", !mutable, () => remove(item.id), "delete"));
384
+ actions.append(
385
+ button("Delete", "Delete", !mutable, () => remove(item.id), "danger-secondary"),
386
+ );
387
+ article.append(actions);
388
+ return article;
389
+ }
390
+
391
+ function historyCard(item, index, mutable) {
392
+ const article = document.createElement("article");
393
+ article.className = "image-card history-card";
394
+ article.tabIndex = 0;
395
+ article.setAttribute("aria-label", `${index + 1}. ${item.name}, sent this session`);
396
+
397
+ const preview = element("button", "preview preview-button");
398
+ preview.type = "button";
399
+ preview.setAttribute("aria-label", `Enlarge preview of ${item.name}`);
400
+ preview.addEventListener("click", () => openPreview(item, "history"));
401
+ const image = document.createElement("img");
402
+ image.src = `/api/history/${item.id}/preview?revision=${state.batch.revision}`;
403
+ image.alt = `Preview of sent image ${item.name}`;
404
+ image.loading = "lazy";
405
+ preview.append(image);
406
+ article.append(preview);
407
+
408
+ const body = element("div", "card-body");
409
+ body.append(element("h3", "", item.name));
410
+ body.append(
411
+ element("p", "meta", `${index + 1} · ${formatBytes(item.size)} · ${item.width}×${item.height}`),
412
+ );
413
+ for (const note of visibleItemNotes(item.notes)) body.append(element("p", "note", note));
414
+ article.append(body);
415
+
416
+ const actions = element("div", "card-actions");
417
+ actions.append(
418
+ button("Add sent image to the next Pi message", "Add again", !mutable, () =>
419
+ restageHistory(item.id),
420
+ ),
421
+ button(
422
+ "Delete sent image from session history",
423
+ "Delete",
424
+ false,
425
+ () => deleteHistory(item.id),
426
+ "danger-secondary",
427
+ ),
428
+ );
311
429
  article.append(actions);
312
430
  return article;
313
431
  }
314
432
 
433
+ function openPreview(item, collection) {
434
+ ui.previewTitle.textContent = item.name;
435
+ ui.previewImage.src = `/api/${collection}/${item.id}/preview?revision=${state.batch.revision}`;
436
+ ui.previewImage.alt = `Enlarged preview of ${item.name}`;
437
+ ui.previewDialog.showModal();
438
+ }
439
+
440
+ function closePreview() {
441
+ ui.previewDialog.close();
442
+ }
443
+
315
444
  function button(label, text, disabled, action, className = "") {
316
445
  const result = element("button", className, text);
317
446
  result.type = "button";
@@ -14,8 +14,8 @@
14
14
  <h1>Image Drop</h1>
15
15
  <p id="session-label" class="session-label">Connecting to Pi…</p>
16
16
  </div>
17
- <details>
18
- <summary>Details</summary>
17
+ <details class="session-details">
18
+ <summary>Session details</summary>
19
19
  <dl>
20
20
  <dt>Working directory</dt>
21
21
  <dd id="cwd">—</dd>
@@ -27,27 +27,44 @@
27
27
  <section id="drop-zone" class="drop-zone" aria-labelledby="drop-title">
28
28
  <div class="drop-copy">
29
29
  <span class="drop-icon" aria-hidden="true">🖼️</span>
30
- <h2 id="drop-title">Paste or drop images anywhere</h2>
31
- <p>Use Ctrl+V or Command+V, drag files here, or choose files.</p>
30
+ <div>
31
+ <h2 id="drop-title">Add images</h2>
32
+ <p>Paste anywhere, drop files here, or choose images.</p>
33
+ </div>
32
34
  <button id="choose-files" class="primary" type="button">Choose images</button>
33
35
  <input id="file-input" type="file" multiple 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" hidden />
34
36
  </div>
35
37
  </section>
36
38
 
37
- <section class="batch" aria-labelledby="batch-title">
39
+ <p class="collection-note">Sensitive image metadata removed from processed images.</p>
40
+
41
+ <section class="batch draft" aria-labelledby="batch-title">
38
42
  <div class="batch-toolbar">
39
43
  <div>
40
- <h2 id="batch-title">Next Pi message</h2>
41
- <p id="status" role="status" aria-live="polite">No images staged</p>
44
+ <h2 id="batch-title">Ready for next message</h2>
45
+ <p id="status" hidden></p>
46
+ <p id="next-step" class="next-step" role="status" aria-live="polite">Choose images to add them to your next Pi message.</p>
42
47
  </div>
43
- <button id="clear-all" class="danger-secondary" type="button" disabled>Clear all</button>
48
+ <button id="clear-all" class="danger-secondary" type="button" hidden disabled>Clear all</button>
44
49
  </div>
45
50
  <div id="error-banner" class="banner error" role="alert" hidden></div>
46
51
  <div id="grid" class="image-grid" aria-live="polite"></div>
47
52
  </section>
48
53
 
54
+ <section class="history" aria-labelledby="history-title">
55
+ <header class="history-toolbar">
56
+ <div class="history-summary">
57
+ <h2 id="history-title">Previously sent</h2>
58
+ <p id="history-status" role="status" aria-live="polite">No images sent yet</p>
59
+ <p class="history-note"><span id="history-retention">0 images retained</span>. Images here are not attached again unless you choose <strong>Add again</strong>. The oldest are removed at the configured memory limit, and all are cleared when this Pi session ends.</p>
60
+ </div>
61
+ <button id="clear-history" class="danger-secondary" type="button" hidden disabled>Clear history</button>
62
+ </header>
63
+ <div id="history-grid" class="image-grid" aria-live="polite"></div>
64
+ </section>
65
+
49
66
  <p class="privacy">
50
- Images stay in this Pi process until you submit a Pi message. They are then sent to your configured model provider with that message.
67
+ Draft and previously sent images stay in this Pi process until removed, evicted at the retention limit, or the session ends. Sending a Pi message sends its staged images to your configured model provider; deleting local history does not retract images already sent.
51
68
  </p>
52
69
  </main>
53
70
 
@@ -69,6 +86,30 @@
69
86
  </form>
70
87
  </dialog>
71
88
 
89
+ <dialog id="clear-history-dialog">
90
+ <form method="dialog">
91
+ <h2>Clear sent image history?</h2>
92
+ <p>This releases every retained image from this Pi session. Images already sent to Pi or a model provider are unaffected.</p>
93
+ <div class="dialog-actions">
94
+ <button type="submit" value="cancel">Cancel</button>
95
+ <button type="submit" class="danger" value="confirm">Clear history</button>
96
+ </div>
97
+ </form>
98
+ </dialog>
99
+
100
+ <dialog id="image-preview-dialog" class="image-preview-dialog" aria-labelledby="image-preview-title">
101
+ <div class="image-preview-content">
102
+ <header>
103
+ <h2 id="image-preview-title">Image preview</h2>
104
+ </header>
105
+ <div class="image-preview-stage">
106
+ <button id="image-preview-dismiss" class="image-preview-dismiss" type="button" aria-label="Close enlarged image">
107
+ <img id="image-preview" alt="" />
108
+ </button>
109
+ </div>
110
+ </div>
111
+ </dialog>
112
+
72
113
  <script type="module" src="/app.js"></script>
73
114
  </body>
74
115
  </html>
package/src/web/state.js CHANGED
@@ -13,6 +13,47 @@ export function summarizeBatch(batch) {
13
13
  };
14
14
  }
15
15
 
16
+ export function summarizeHistory(history) {
17
+ const total = (history?.items ?? []).length;
18
+ const bytes = Number(history?.totalBytes ?? 0);
19
+ const maxImages = Number(history?.maxImages ?? 0);
20
+ const maxBytes = Number(history?.maxBytes ?? 0);
21
+ return {
22
+ total,
23
+ bytes,
24
+ maxImages,
25
+ maxBytes,
26
+ label: total === 0 ? "No images sent yet" : `${total} ${plural(total, "image")} · ${formatBytes(bytes)}`,
27
+ usage: `${total}/${maxImages} images · ${formatBytes(bytes)} of ${formatBytes(maxBytes)}`,
28
+ };
29
+ }
30
+
31
+ const SHARED_METADATA_NOTE = "Sensitive image metadata removed";
32
+
33
+ export function draftPresentation(batch) {
34
+ const summary = summarizeBatch(batch);
35
+ return {
36
+ status: summary.total === 0 ? "" : `${summary.label} · ${formatBytes(summary.bytes)}`,
37
+ guidance: draftGuidance(batch),
38
+ };
39
+ }
40
+
41
+ export function draftGuidance(batch) {
42
+ const summary = summarizeBatch(batch);
43
+ if (batch.phase === "closed") return "This Pi session is no longer accepting images.";
44
+ if (batch.phase === "reserved") {
45
+ return "Queued with Pi. These images will be attached when Pi sends this message.";
46
+ }
47
+ if (summary.total === 0) return "Choose images to add them to your next Pi message.";
48
+ if (summary.error > 0) {
49
+ return `Fix or delete ${summary.error} ${plural(summary.error, "image")} that ${summary.error === 1 ? "needs" : "need"} attention before sending from Pi.`;
50
+ }
51
+ if (summary.uploading > 0) {
52
+ return `Wait for ${summary.uploading} ${plural(summary.uploading, "image")} to finish processing before sending from Pi.`;
53
+ }
54
+ return `Return to Pi and send a non-empty message. ${summary.ready} ready ${plural(summary.ready, "image")} will be attached automatically.`;
55
+ }
56
+
16
57
  export function statusLabel(phase, counts, total) {
17
58
  if (phase === "empty" || total === 0) return "No images staged";
18
59
  if (phase === "reserved") return `${total} ${plural(total, "image")} queued with Pi`;
@@ -22,6 +63,10 @@ export function statusLabel(phase, counts, total) {
22
63
  return parts.join(" · ");
23
64
  }
24
65
 
66
+ export function visibleItemNotes(notes) {
67
+ return Array.isArray(notes) ? notes.filter((note) => note !== SHARED_METADATA_NOTE) : [];
68
+ }
69
+
25
70
  export function moveItem(ids, id, direction) {
26
71
  const from = ids.indexOf(id);
27
72
  if (from === -1) return [...ids];
@@ -61,14 +61,17 @@ button.primary {
61
61
  button.primary:hover:not(:disabled) {
62
62
  background: var(--accent-strong);
63
63
  }
64
- button.danger,
65
- button.delete {
64
+ button.danger {
66
65
  border-color: var(--danger);
67
66
  color: white;
68
67
  background: var(--danger);
69
68
  }
70
69
  button.danger-secondary {
70
+ border-color: transparent;
71
+ background: transparent;
71
72
  color: var(--danger);
73
+ }
74
+ button.danger-secondary:hover:not(:disabled) {
72
75
  border-color: color-mix(in srgb, var(--danger) 45%, var(--line));
73
76
  }
74
77
 
@@ -104,6 +107,9 @@ h1 {
104
107
  details {
105
108
  max-width: min(28rem, 100%);
106
109
  }
110
+ .session-details {
111
+ align-self: center;
112
+ }
107
113
  summary {
108
114
  min-height: 44px;
109
115
  padding: 0.6rem;
@@ -129,14 +135,13 @@ main {
129
135
  }
130
136
  .drop-zone {
131
137
  display: grid;
132
- min-height: 260px;
138
+ min-height: 160px;
133
139
  place-items: center;
134
140
  border: 2px dashed #9da7ba;
135
141
  border-radius: 18px;
136
- padding: 2rem;
142
+ padding: 1.5rem 2rem;
137
143
  background: var(--surface);
138
144
  box-shadow: var(--shadow);
139
- text-align: center;
140
145
  transition:
141
146
  border-color 0.15s,
142
147
  background 0.15s,
@@ -151,21 +156,38 @@ main {
151
156
  opacity: 0.66;
152
157
  }
153
158
  .drop-copy {
154
- max-width: 36rem;
159
+ display: grid;
160
+ width: min(48rem, 100%);
161
+ grid-template-columns: auto minmax(0, 1fr) auto;
162
+ align-items: center;
163
+ gap: 1rem;
155
164
  }
156
165
  .drop-copy h2 {
157
- margin: 0.4rem 0;
166
+ margin: 0;
158
167
  }
159
168
  .drop-copy p {
160
- margin: 0.25rem 0 1.25rem;
169
+ margin: 0.15rem 0 0;
161
170
  color: var(--muted);
162
171
  }
163
172
  .drop-icon {
164
- font-size: 2.6rem;
173
+ font-size: 2.2rem;
165
174
  }
166
175
 
176
+ .collection-note {
177
+ margin: 1rem 0 0;
178
+ color: var(--muted);
179
+ font-size: 0.9rem;
180
+ }
167
181
  .batch {
168
- margin-top: 1.5rem;
182
+ margin-top: 0.75rem;
183
+ }
184
+ .history {
185
+ margin-top: 2rem;
186
+ border-top: 1px solid var(--line);
187
+ padding-top: 1.5rem;
188
+ }
189
+ .history h2 {
190
+ font-size: 1.25rem;
169
191
  }
170
192
  .batch-toolbar {
171
193
  display: flex;
@@ -181,6 +203,37 @@ main {
181
203
  .batch-toolbar p {
182
204
  color: var(--muted);
183
205
  }
206
+ .batch-toolbar .next-step {
207
+ max-width: 48rem;
208
+ margin-top: 0.2rem;
209
+ color: var(--text);
210
+ font-weight: 600;
211
+ }
212
+ .history-toolbar {
213
+ display: flex;
214
+ align-items: start;
215
+ justify-content: flex-start;
216
+ gap: 1rem;
217
+ margin-bottom: 0.8rem;
218
+ }
219
+ .history-summary {
220
+ min-width: 0;
221
+ max-width: 58rem;
222
+ }
223
+ .history-summary h2,
224
+ .history-summary p {
225
+ margin: 0;
226
+ }
227
+ .history-summary > p {
228
+ color: var(--muted);
229
+ }
230
+ .history-note {
231
+ margin-top: 0.35rem;
232
+ font-size: 0.9rem;
233
+ }
234
+ .history .image-card {
235
+ box-shadow: none;
236
+ }
184
237
  .banner {
185
238
  margin: 0.75rem 0;
186
239
  border-radius: 10px;
@@ -193,7 +246,7 @@ main {
193
246
  }
194
247
  .image-grid {
195
248
  display: grid;
196
- grid-template-columns: repeat(auto-fill, minmax(min(100%, 245px), 1fr));
249
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 245px), 1fr));
197
250
  gap: 1rem;
198
251
  }
199
252
  .image-card {
@@ -206,6 +259,9 @@ main {
206
259
  background: var(--surface);
207
260
  box-shadow: 0 4px 16px rgb(19 33 68 / 7%);
208
261
  }
262
+ .image-card:only-child {
263
+ max-width: 360px;
264
+ }
209
265
  .image-card.status-error {
210
266
  border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
211
267
  }
@@ -223,6 +279,18 @@ main {
223
279
  height: 100%;
224
280
  object-fit: contain;
225
281
  }
282
+ .preview-button {
283
+ width: 100%;
284
+ min-height: 0;
285
+ border: 0;
286
+ border-radius: 0;
287
+ padding: 0;
288
+ background: var(--surface-alt);
289
+ cursor: zoom-in;
290
+ }
291
+ .preview-button:hover:not(:disabled) {
292
+ border-color: transparent;
293
+ }
226
294
  .placeholder {
227
295
  display: grid;
228
296
  width: 3rem;
@@ -312,6 +380,67 @@ dialog {
312
380
  dialog::backdrop {
313
381
  background: rgb(11 17 30 / 60%);
314
382
  }
383
+ .image-preview-dialog {
384
+ width: min(96vw, 1400px);
385
+ max-width: none;
386
+ height: 94dvh;
387
+ max-height: none;
388
+ overflow: hidden;
389
+ padding: 0;
390
+ }
391
+ .image-preview-dialog::backdrop {
392
+ background: rgb(11 17 30 / 88%);
393
+ }
394
+ .image-preview-content {
395
+ display: grid;
396
+ height: 100%;
397
+ grid-template-rows: auto minmax(0, 1fr);
398
+ }
399
+ .image-preview-content header {
400
+ display: flex;
401
+ align-items: center;
402
+ justify-content: space-between;
403
+ gap: 1rem;
404
+ border-bottom: 1px solid var(--line);
405
+ padding: 0.75rem 1rem;
406
+ }
407
+ .image-preview-content h2 {
408
+ margin: 0;
409
+ overflow: hidden;
410
+ text-overflow: ellipsis;
411
+ white-space: nowrap;
412
+ font-size: 1rem;
413
+ }
414
+ .image-preview-stage {
415
+ display: grid;
416
+ min-height: 0;
417
+ place-items: center;
418
+ padding: 1rem;
419
+ background: var(--surface-alt);
420
+ }
421
+ .image-preview-dismiss {
422
+ display: grid;
423
+ width: 100%;
424
+ height: 100%;
425
+ min-height: 0;
426
+ place-items: center;
427
+ border: 0;
428
+ border-radius: 0;
429
+ padding: 0;
430
+ background: transparent;
431
+ pointer-events: none;
432
+ }
433
+ .image-preview-dismiss:hover:not(:disabled) {
434
+ border-color: transparent;
435
+ }
436
+ .image-preview-dismiss img {
437
+ width: 100%;
438
+ height: 100%;
439
+ min-height: 0;
440
+ cursor: zoom-out;
441
+ object-fit: contain;
442
+ pointer-events: auto;
443
+ }
315
444
  .dialog-actions {
316
445
  display: flex;
317
446
  justify-content: flex-end;
@@ -365,9 +494,21 @@ dialog::backdrop {
365
494
  .drop-zone {
366
495
  min-height: 210px;
367
496
  padding: 1rem;
497
+ text-align: center;
498
+ }
499
+ .drop-copy {
500
+ grid-template-columns: 1fr;
501
+ justify-items: center;
502
+ gap: 0.65rem;
368
503
  }
369
504
  .batch-toolbar {
370
505
  align-items: start;
506
+ flex-direction: column;
507
+ gap: 0.5rem;
508
+ }
509
+ .history-toolbar {
510
+ flex-direction: column;
511
+ gap: 0.5rem;
371
512
  }
372
513
  .image-grid {
373
514
  grid-template-columns: 1fr;