@narumitw/pi-image-drop 0.20.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,15 +24,16 @@ This package targets the latest Pi release and uses its `agent_settled` lifecycl
24
24
 
25
25
  ## Workflow
26
26
 
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.
27
+ 1. Run `/image-drop` in an interactive Pi session. You can instead set `startOnSessionStart: true` to start the service with every 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, including when session startup is enabled.
29
29
  3. Open the link. Paste images anywhere, drop files, or select **Choose images**.
30
30
  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
31
  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.
32
+ 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
33
 
33
34
  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
35
 
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.
36
+ 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
37
 
37
38
  ## Supported images
38
39
 
@@ -63,21 +64,32 @@ Example:
63
64
 
64
65
  ```json
65
66
  {
67
+ "startOnSessionStart": true,
66
68
  "maxImages": 8,
67
69
  "maxImageBytes": 10485760,
68
70
  "maxBatchBytes": 41943040,
69
- "maxImagePixels": 50000000
71
+ "maxImagePixels": 50000000,
72
+ "maxRetainedImages": 128,
73
+ "maxRetainedBytes": 536870912
70
74
  }
71
75
  ```
72
76
 
73
- | Setting | Safe default | Hard ceiling |
77
+ | Setting | Default | Behavior |
78
+ | --- | --- | --- |
79
+ | `startOnSessionStart` | `false` | Start the loopback service and display a link after each Pi session initializes. This never opens a browser. |
80
+
81
+ | Limit setting | Safe default | Hard ceiling |
74
82
  | --- | ---: | ---: |
75
83
  | `maxImages` | 8 | 32 |
76
84
  | `maxImageBytes` | 10 MiB | 50 MiB |
77
85
  | `maxBatchBytes` | 40 MiB | 200 MiB |
78
86
  | `maxImagePixels` | 50 megapixels | 100 megapixels |
87
+ | `maxRetainedImages` | 128 | 256 |
88
+ | `maxRetainedBytes` | 512 MiB | 1 GiB |
89
+
90
+ `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
91
 
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.
92
+ 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
93
 
82
94
  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
95
 
@@ -87,8 +99,9 @@ At upload and submission time, the extension also re-reads Pi's documented globa
87
99
  - A rotating bootstrap token is exchanged once for an HttpOnly, `SameSite=Strict` session cookie, then removed from the URL.
88
100
  - Exact Host, mutation Origin, session-cookie, and active-client checks are enforced. No permissive CORS headers are sent.
89
101
  - 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.
102
+ - 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.
103
+ - 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.
104
+ - Once Pi records a message, normal Pi/provider retention rules apply independently of deleting Image Drop's in-process history.
92
105
 
93
106
  A loopback page is local to your operating-system network namespace. Do not expose the port to a LAN or public interface.
94
107
 
@@ -110,14 +123,15 @@ Then open the unchanged `http://127.0.0.1:45678/...` link locally. Image Drop do
110
123
  - A non-empty interactive Pi message is required. RPC, extension-generated, slash-command, and image-only inputs do not consume the batch.
111
124
  - All items must be ready. One uploading or failed item blocks submission until it is retried or deleted.
112
125
  - 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.
126
+ - 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.
127
+ - 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
128
 
115
129
  ## Package layout
116
130
 
117
131
  ```text
118
132
  src/image-drop.ts Pi extension entrypoint
119
133
  src/runtime.ts Pi lifecycle and message orchestration
120
- src/batch.ts in-memory batch state machine
134
+ src/batch.ts in-memory draft and sent-history state machine
121
135
  src/images.ts bounded image processing
122
136
  src/server.ts authenticated loopback HTTP/SSE server
123
137
  src/settings.ts extension settings
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-image-drop",
3
- "version": "0.20.0",
3
+ "version": "0.20.2",
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
@@ -30,9 +30,17 @@ interface BatchItem extends ItemReservation {
30
30
  source?: Buffer;
31
31
  processed?: ProcessedImage;
32
32
  processedAutoResize?: boolean;
33
+ processingOwner?: "browser" | "runtime";
33
34
  error?: string;
34
35
  }
35
36
 
37
+ interface HistoryItem {
38
+ id: string;
39
+ name: string;
40
+ processed: ProcessedImage;
41
+ processedAutoResize?: boolean;
42
+ }
43
+
36
44
  export interface PublicBatchItem extends ItemReservation {
37
45
  status: ItemStatus;
38
46
  error?: string;
@@ -54,6 +62,39 @@ export interface PublicBatchState {
54
62
  totalSourceBytes: number;
55
63
  }
56
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
+
57
98
  export interface MessageReservation {
58
99
  id: string;
59
100
  text: string;
@@ -76,6 +117,7 @@ export class BatchError extends Error {
76
117
 
77
118
  export class BatchStore {
78
119
  private items: BatchItem[] = [];
120
+ private history: HistoryItem[] = [];
79
121
  private revision = 0;
80
122
  private reservation?: MessageReservation;
81
123
  private closed = false;
@@ -106,6 +148,29 @@ export class BatchStore {
106
148
  };
107
149
  }
108
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
+
109
174
  reserveItems(inputs: readonly ItemReservation[], expectedRevision = this.revision): number {
110
175
  this.assertMutable(expectedRevision);
111
176
  if (inputs.length === 0) throw new BatchError("No images supplied", "invalid");
@@ -135,6 +200,7 @@ export class BatchStore {
135
200
  throw new BatchError("Batch exceeds the byte limit", "limit");
136
201
  }
137
202
  this.items.push(...inputs.map((input) => ({ ...input, status: "uploading" as const })));
203
+ this.evictHistoryToBudget();
138
204
  return this.bump();
139
205
  }
140
206
 
@@ -153,6 +219,7 @@ export class BatchStore {
153
219
  item.processedAutoResize = undefined;
154
220
  item.error = undefined;
155
221
  item.status = "processing";
222
+ item.processingOwner = "browser";
156
223
  return this.bump();
157
224
  }
158
225
 
@@ -170,19 +237,23 @@ export class BatchStore {
170
237
  const earlier = duplicates.find(({ index }) => index < itemIndex);
171
238
  if (earlier) {
172
239
  this.items = this.items.filter((candidate) => candidate.id !== id);
240
+ this.evictHistoryToBudget();
173
241
  this.bump();
174
242
  return { kind: "duplicate", existingId: earlier.candidate.id };
175
243
  }
176
244
  item.processed = cloneProcessed(processed);
177
245
  item.processedAutoResize = autoResize;
178
246
  item.status = "ready";
247
+ item.processingOwner = undefined;
179
248
  item.error = undefined;
180
249
  if (duplicates.length > 0) {
181
250
  const laterIds = new Set(duplicates.map(({ candidate }) => candidate.id));
182
251
  this.items = this.items.filter((candidate) => !laterIds.has(candidate.id));
252
+ this.evictHistoryToBudget();
183
253
  this.bump();
184
254
  return { kind: "duplicate", existingId: item.id };
185
255
  }
256
+ this.evictHistoryToBudget();
186
257
  this.bump();
187
258
  return { kind: "ready" };
188
259
  }
@@ -195,6 +266,7 @@ export class BatchStore {
195
266
  item.error = sanitizeError(error);
196
267
  item.processed = undefined;
197
268
  item.processedAutoResize = undefined;
269
+ item.processingOwner = undefined;
198
270
  return this.bump();
199
271
  }
200
272
 
@@ -214,7 +286,9 @@ export class BatchStore {
214
286
  this.assertOpen();
215
287
  if (this.reservation) return false;
216
288
  const inFlight = this.items.filter(
217
- (item) => item.status === "uploading" || item.status === "processing",
289
+ (item) =>
290
+ item.status === "uploading" ||
291
+ (item.status === "processing" && item.processingOwner === "browser"),
218
292
  );
219
293
  if (inFlight.length === 0) return false;
220
294
  for (const item of inFlight) {
@@ -222,6 +296,7 @@ export class BatchStore {
222
296
  item.error = sanitizeError(error);
223
297
  item.processed = undefined;
224
298
  item.processedAutoResize = undefined;
299
+ item.processingOwner = undefined;
225
300
  }
226
301
  this.bump();
227
302
  return true;
@@ -245,6 +320,7 @@ export class BatchStore {
245
320
  item.status = "processing";
246
321
  item.processed = undefined;
247
322
  item.processedAutoResize = undefined;
323
+ item.processingOwner = "runtime";
248
324
  item.error = undefined;
249
325
  }
250
326
  this.bump();
@@ -259,6 +335,7 @@ export class BatchStore {
259
335
  throw new BatchError("Image cannot be retried without uploaded source bytes", "not-ready");
260
336
  }
261
337
  item.status = "processing";
338
+ item.processingOwner = "browser";
262
339
  item.error = undefined;
263
340
  item.processedAutoResize = undefined;
264
341
  this.bump();
@@ -314,6 +391,7 @@ export class BatchStore {
314
391
  preflightStarted: false,
315
392
  };
316
393
  this.reservation = reservation;
394
+ this.evictHistoryToBudget();
317
395
  this.bump();
318
396
  return cloneReservation(reservation);
319
397
  }
@@ -333,10 +411,101 @@ export class BatchStore {
333
411
  return { bytes: Buffer.from(processed.bytes), mimeType: processed.mimeType };
334
412
  }
335
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
+
336
492
  commitReservation(digest: string): boolean {
337
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
+ }
338
506
  this.reservation = undefined;
339
507
  this.items = [];
508
+ this.evictHistoryToBudget();
340
509
  this.bump();
341
510
  return true;
342
511
  }
@@ -354,6 +523,7 @@ export class BatchStore {
354
523
  this.closed = true;
355
524
  this.reservation = undefined;
356
525
  this.items = [];
526
+ this.history = [];
357
527
  this.bump();
358
528
  }
359
529
 
@@ -371,8 +541,12 @@ export class BatchStore {
371
541
  }
372
542
 
373
543
  private assertMutable(expectedRevision: number): void {
374
- this.assertOpen();
544
+ this.assertHistoryMutation(expectedRevision);
375
545
  if (this.reservation) throw new BatchError("Batch is frozen", "frozen");
546
+ }
547
+
548
+ private assertHistoryMutation(expectedRevision: number): void {
549
+ this.assertOpen();
376
550
  if (expectedRevision !== this.revision)
377
551
  throw new BatchError("Batch revision is stale", "stale");
378
552
  }
@@ -383,6 +557,37 @@ export class BatchStore {
383
557
  return item;
384
558
  }
385
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
+
386
591
  private bump(): number {
387
592
  this.revision += 1;
388
593
  return this.revision;
@@ -430,6 +635,13 @@ function isSafeIdentifier(id: string): boolean {
430
635
  return /^[A-Za-z0-9_-]{1,80}$/.test(id);
431
636
  }
432
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
+
433
645
  function cryptoId(revision: number, images: readonly ImageContent[]): string {
434
646
  return createHash("sha256")
435
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;