@oar-sh/oar 0.9.24 → 0.9.25

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.
Files changed (66) hide show
  1. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +3 -1
  2. package/CHANGELOG.md +142 -0
  3. package/README.md +12 -11
  4. package/package.json +3 -1
  5. package/server/claude-worker/claude-remote-relay-tool.mjs +15 -0
  6. package/server/claude-worker/sdk-message-normalizer.mjs +29 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +11 -9
  8. package/server/cursor-worker/sdk-message-normalizer.mjs +13 -0
  9. package/server/db-schema.mjs +19 -0
  10. package/server/grok-worker/sdk-message-normalizer.mjs +3 -0
  11. package/server/migrations/0039-media-index.mjs +167 -0
  12. package/server/migrations/0040-image-session-defaults.mjs +93 -0
  13. package/server/public/app/api-client.js +58 -0
  14. package/server/public/app/ask-user-view.js +2 -1
  15. package/server/public/app/attachments-view.js +66 -9
  16. package/server/public/app/bootstrap.js +85 -25
  17. package/server/public/app/chat-actions-menu-state.mjs +1 -0
  18. package/server/public/app/context-media-tab.js +143 -0
  19. package/server/public/app/conversation-view.js +90 -5
  20. package/server/public/app/image-composer-ui.js +518 -0
  21. package/server/public/app/image-turn-placeholder.mjs +233 -0
  22. package/server/public/app/journal-view.js +12 -2
  23. package/server/public/app/media-gallery-model.mjs +202 -0
  24. package/server/public/app/media-gallery-ui.js +524 -0
  25. package/server/public/app/media-view.js +79 -0
  26. package/server/public/app/openai-image-composer.mjs +492 -0
  27. package/server/public/app/openai-image-cost-ui.js +426 -0
  28. package/server/public/app/openai-image-spend.mjs +443 -0
  29. package/server/public/app/plan-usage-view.mjs +38 -6
  30. package/server/public/app/relay-error-ctas.mjs +5 -0
  31. package/server/public/app/session-history-ui.js +2 -0
  32. package/server/public/app/settings-modal.js +4 -0
  33. package/server/public/app/socket-handlers.js +26 -0
  34. package/server/public/app/tab-strip-fade.mjs +1 -1
  35. package/server/public/app/viewer-gallery.mjs +23 -1
  36. package/server/public/index.html +383 -7
  37. package/server/relay-tools.md +1 -0
  38. package/server/repositories/image-conversation-repository.mjs +69 -0
  39. package/server/repositories/media-repository.mjs +305 -0
  40. package/server/routes/media-routes.mjs +361 -0
  41. package/server/routes/messages-routes.mjs +1410 -360
  42. package/server/routes/sessions-routes.mjs +361 -14
  43. package/server/server-runtime.mjs +19 -0
  44. package/server/services/database-file.mjs +6 -4
  45. package/server/services/generated-image-cleanup-service.mjs +2 -1
  46. package/server/services/generated-image-files.mjs +110 -0
  47. package/server/services/image-input-paths.mjs +389 -0
  48. package/server/services/image-operation-service.mjs +6 -2
  49. package/server/services/media-extract.mjs +653 -0
  50. package/server/services/media-index-service.mjs +221 -0
  51. package/server/services/openai-image-preview-turns.mjs +330 -0
  52. package/server/services/openai-image-spend-service.mjs +516 -0
  53. package/server/services/openai-image-stream.mjs +170 -0
  54. package/server/services/plan-usage-openai-image.mjs +148 -0
  55. package/server/services/remote-relay-caller-context.mjs +6 -0
  56. package/server/services/remote-relay-dispatcher.mjs +386 -22
  57. package/server/services/remote-relay-registry-service.mjs +3 -1
  58. package/server/services/token-usage-service.mjs +15 -0
  59. package/shared/image-header.mjs +90 -0
  60. package/shared/openai-image-options.mjs +299 -0
  61. package/shared/openai-image-presets.mjs +175 -0
  62. package/shared/openai-image-previews.mjs +40 -0
  63. package/shared/openai-image-prices.mjs +291 -0
  64. package/shared/openai-image-spend.mjs +90 -0
  65. package/shared/remote-relay-contract.mjs +126 -4
  66. package/shared/worker-runtime/direct-openai-image.mjs +96 -50
@@ -32,7 +32,29 @@ export function galleryAt(gallery, index) {
32
32
 
33
33
  export function galleryPositionLabel(gallery) {
34
34
  if (!gallery || !Array.isArray(gallery.items) || !gallery.items.length) return '';
35
- return `${gallery.index + 1} / ${gallery.items.length}`;
35
+ return `${gallery.index + 1} / ${gallery.items.length}${galleryHasMore(gallery) ? '+' : ''}`;
36
+ }
37
+
38
+ // A paged gallery (the media gallery) knows only the pages loaded so far:
39
+ // `hasMore` says more items follow and `loadMore()` fetches the next page
40
+ // as `{ items, hasMore }`. Stepping past the last loaded item loads it.
41
+
42
+ export function galleryHasMore(gallery) {
43
+ return Boolean(gallery && gallery.hasMore && typeof gallery.loadMore === 'function');
44
+ }
45
+
46
+ /** Whether a step of `delta` can land somewhere: a neighbour, or a page still to load. */
47
+ export function galleryCanStep(gallery, delta) {
48
+ if (galleryNeighborIndex(gallery, delta) !== -1) return true;
49
+ return Number(delta) > 0 && galleryHasMore(gallery) && Number(gallery.index) === gallery.items.length - 1;
50
+ }
51
+
52
+ /** The gallery with a loaded page appended (items it already has are not added twice). */
53
+ export function extendGallery(gallery, page) {
54
+ if (!gallery) return null;
55
+ const known = new Set(gallery.items.map((item) => item.key).filter(Boolean));
56
+ const added = (Array.isArray(page?.items) ? page.items : []).filter((item) => !item?.key || !known.has(item.key));
57
+ return { ...gallery, items: [...gallery.items, ...added], hasMore: Boolean(page?.hasMore) && added.length > 0 };
36
58
  }
37
59
 
38
60
  /** The items on either side of the open one, nearest first. */
@@ -891,6 +891,8 @@
891
891
  .inbox-count.is-urgent { background: var(--danger); color: #fff; }
892
892
  body.inbox-view-active #input-area { display: none; }
893
893
  body.inbox-view-active #pending-question-banner { display: none; }
894
+ body.media-view-active #input-area { display: none; }
895
+ body.media-view-active #pending-question-banner { display: none; }
894
896
  .inbox-view { display: grid; gap: calc(7px * var(--ui-scale)); padding: calc(4px * var(--ui-scale)) 0 calc(12px * var(--ui-scale)); }
895
897
  .inbox-filter { display: flex; gap: calc(3px * var(--ui-scale)); flex-wrap: wrap; }
896
898
  .inbox-filter-option {
@@ -1837,6 +1839,31 @@
1837
1839
  /* Two classes: .settings-help, later in this sheet, would grey it out. */
1838
1840
  .settings-help.claude-api-field-error { color: var(--danger); }
1839
1841
  .settings-help.claude-api-field-error[hidden] { display: none; }
1842
+ /* OpenAI's image cap and presets (openai-image-cost-ui.js). */
1843
+ .settings-help.openai-image-field-error { color: var(--danger); }
1844
+ .settings-help.openai-image-field-error[hidden] { display: none; }
1845
+ #openai-image-cap-input {
1846
+ background: var(--bg);
1847
+ color: var(--text);
1848
+ border: 1px solid var(--border);
1849
+ border-radius: calc(6px * var(--ui-scale));
1850
+ padding: calc(3px * var(--ui-scale)) calc(7px * var(--ui-scale));
1851
+ outline: none;
1852
+ font-size: var(--fs-meta-md);
1853
+ width: min(calc(140px * var(--ui-scale)), 100%);
1854
+ }
1855
+ #openai-image-cap-input[aria-invalid="true"] { border-color: var(--danger); }
1856
+ .openai-image-cap-row { display: flex; flex-wrap: wrap; gap: calc(6px * var(--ui-scale)); align-items: center; }
1857
+ #openai-image-spend-line[data-state="warning"] { color: var(--warn); }
1858
+ #openai-image-spend-line[data-state="error"] { color: var(--danger); }
1859
+ #openai-image-allow-today-btn[hidden],
1860
+ #openai-image-stop-allowing-btn[hidden] { display: none; }
1861
+ .openai-image-presets-list { list-style: none; margin: 0; padding: 0; width: 100%; display: flex; flex-direction: column; gap: calc(4px * var(--ui-scale)); }
1862
+ .openai-image-preset-row { display: flex; align-items: center; gap: calc(6px * var(--ui-scale)); min-width: 0; }
1863
+ .openai-image-preset-text { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
1864
+ .openai-image-preset-text strong { font-weight: 600; overflow-wrap: anywhere; }
1865
+ .openai-image-preset-details { color: var(--muted); font-size: var(--fs-meta-sm); overflow-wrap: anywhere; }
1866
+ .openai-image-preset-tag { color: var(--muted); font-size: var(--fs-meta-xs); flex: 0 0 auto; }
1840
1867
  /* The native spin buttons of a number field follow the theme: light ones
1841
1868
  on a dark field read as a glitch. */
1842
1869
  :root:not([data-theme="light"]) input[type="number"] { color-scheme: dark; }
@@ -2499,6 +2526,27 @@
2499
2526
  width: 100%; max-width: 100%; box-sizing: border-box;
2500
2527
  min-width: 0;
2501
2528
  }
2529
+ /* A running image turn in the live bubble (image-turn-placeholder.mjs): the
2530
+ image area keeps the size's aspect ratio from the first second, so a
2531
+ partial arriving does not move the transcript. */
2532
+ .image-turn-placeholder { display: flex; flex-direction: column; gap: calc(4px * var(--ui-scale)); margin: calc(2px * var(--ui-scale)) 0 calc(6px * var(--ui-scale)); }
2533
+ .thinking-bubble:has(> .image-turn-placeholder) > .dots { display: none; }
2534
+ .image-turn-placeholder-meta { display: flex; align-items: baseline; flex-wrap: wrap; gap: calc(6px * var(--ui-scale)); color: var(--text); font-size: var(--fs-meta-md); }
2535
+ .image-turn-placeholder-timer { font-variant-numeric: tabular-nums; color: var(--muted); }
2536
+ .image-turn-placeholder-retry { color: var(--muted); font-style: italic; }
2537
+ .image-turn-placeholder-retry[hidden] { display: none; }
2538
+ .image-turn-placeholder-details { color: var(--muted); font-size: var(--fs-meta-sm); overflow-wrap: anywhere; }
2539
+ .image-turn-placeholder-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: calc(6px * var(--ui-scale)); width: min(100%, calc(360px * var(--ui-scale))); }
2540
+ .image-turn-placeholder-grid:not([data-count="1"]) { grid-template-columns: repeat(2, minmax(0, 1fr)); }
2541
+ .image-turn-placeholder-tile {
2542
+ position: relative; overflow: hidden; border-radius: calc(10px * var(--ui-scale));
2543
+ border: 1px solid var(--border); background: var(--bg3);
2544
+ animation: image-turn-pulse 1.8s ease-in-out infinite;
2545
+ }
2546
+ .image-turn-placeholder-tile.has-preview { animation: none; }
2547
+ .image-turn-placeholder-tile img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
2548
+ @keyframes image-turn-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
2549
+ @media (prefers-reduced-motion: reduce) { .image-turn-placeholder-tile { animation: none; } }
2502
2550
  /* Sticky inside the scroller: Stop on the live bubble is the only way to
2503
2551
  stop a turn, and a long turn must stay stoppable without scrolling back
2504
2552
  up. It sticks within its own bubble only, so it never rides over the
@@ -3315,6 +3363,190 @@
3315
3363
  border-color: var(--accent);
3316
3364
  color: var(--accent);
3317
3365
  }
3366
+ /* The media gallery (media-gallery-ui.js): the 🧠 Media tab and the global
3367
+ Media view. A messenger's shared media: square tiles under month
3368
+ headers, 3 columns on a phone; a Files tab as a list. */
3369
+ .media-gallery {
3370
+ display: flex;
3371
+ flex-direction: column;
3372
+ gap: calc(8px * var(--ui-scale));
3373
+ min-width: 0;
3374
+ }
3375
+ .media-gallery[data-scope="global"] {
3376
+ padding: calc(12px * var(--ui-scale));
3377
+ max-width: calc(1100px * var(--ui-scale));
3378
+ margin: 0 auto;
3379
+ width: 100%;
3380
+ box-sizing: border-box;
3381
+ }
3382
+ .media-gallery-tabs,
3383
+ .media-gallery-chips,
3384
+ .context-tab-strip {
3385
+ display: flex;
3386
+ align-items: center;
3387
+ gap: calc(4px * var(--ui-scale));
3388
+ flex-wrap: wrap;
3389
+ }
3390
+ .media-gallery-tab,
3391
+ .media-gallery-chip,
3392
+ .context-tab {
3393
+ flex: 0 0 auto;
3394
+ border: 1px solid var(--border);
3395
+ background: var(--bg2);
3396
+ color: var(--text);
3397
+ border-radius: calc(8px * var(--ui-scale));
3398
+ height: calc(28px * var(--ui-scale));
3399
+ padding: 0 calc(10px * var(--ui-scale));
3400
+ font-size: var(--fs-meta-md);
3401
+ white-space: nowrap;
3402
+ cursor: pointer;
3403
+ }
3404
+ .media-gallery-tab[aria-selected="true"],
3405
+ .media-gallery-chip[aria-pressed="true"],
3406
+ .context-tab[aria-selected="true"] {
3407
+ border-color: var(--accent);
3408
+ color: var(--accent);
3409
+ }
3410
+ .context-tab-count {
3411
+ margin-left: calc(4px * var(--ui-scale));
3412
+ color: var(--muted);
3413
+ }
3414
+ .context-tab-strip {
3415
+ margin-bottom: calc(10px * var(--ui-scale));
3416
+ }
3417
+ .media-gallery-filters {
3418
+ display: flex;
3419
+ flex-wrap: wrap;
3420
+ align-items: center;
3421
+ gap: calc(6px * var(--ui-scale));
3422
+ }
3423
+ .media-gallery-select,
3424
+ .media-gallery-search {
3425
+ height: calc(28px * var(--ui-scale));
3426
+ border: 1px solid var(--border);
3427
+ border-radius: calc(8px * var(--ui-scale));
3428
+ background: var(--bg2);
3429
+ color: var(--text);
3430
+ font-size: var(--fs-meta-md);
3431
+ padding: 0 calc(6px * var(--ui-scale));
3432
+ max-width: 100%;
3433
+ }
3434
+ .media-gallery-select[data-media-filter="session"] {
3435
+ max-width: calc(260px * var(--ui-scale));
3436
+ }
3437
+ .media-gallery-search {
3438
+ flex: 1 1 calc(180px * var(--ui-scale));
3439
+ min-width: 0;
3440
+ }
3441
+ .media-gallery-toggle {
3442
+ display: inline-flex;
3443
+ align-items: center;
3444
+ gap: calc(4px * var(--ui-scale));
3445
+ font-size: var(--fs-meta-md);
3446
+ color: var(--muted);
3447
+ }
3448
+ .media-gallery-status p {
3449
+ margin: calc(4px * var(--ui-scale)) 0;
3450
+ color: var(--muted);
3451
+ font-size: var(--fs-meta-md);
3452
+ }
3453
+ .media-month-label {
3454
+ position: sticky;
3455
+ top: 0;
3456
+ z-index: 1;
3457
+ margin: 0;
3458
+ padding: calc(6px * var(--ui-scale)) calc(2px * var(--ui-scale));
3459
+ background: var(--bg);
3460
+ color: var(--muted);
3461
+ font-size: var(--fs-meta-md);
3462
+ font-weight: 600;
3463
+ }
3464
+ .media-grid {
3465
+ display: grid;
3466
+ grid-template-columns: repeat(auto-fill, minmax(calc(140px * var(--ui-scale)), 1fr));
3467
+ gap: 2px;
3468
+ }
3469
+ .media-tile {
3470
+ position: relative;
3471
+ aspect-ratio: 1;
3472
+ padding: 0;
3473
+ border: 0;
3474
+ background: var(--bg3);
3475
+ overflow: hidden;
3476
+ cursor: pointer;
3477
+ content-visibility: auto;
3478
+ contain-intrinsic-size: calc(140px * var(--ui-scale));
3479
+ }
3480
+ .media-tile img,
3481
+ .media-tile video {
3482
+ width: 100%;
3483
+ height: 100%;
3484
+ object-fit: cover;
3485
+ display: block;
3486
+ pointer-events: none;
3487
+ }
3488
+ .media-tile:focus-visible {
3489
+ outline: 2px solid var(--accent);
3490
+ outline-offset: -2px;
3491
+ }
3492
+ .media-tile-archived img,
3493
+ .media-tile-archived video {
3494
+ opacity: 0.6;
3495
+ }
3496
+ .media-tile-badge,
3497
+ .media-tile-play {
3498
+ position: absolute;
3499
+ font-size: calc(12px * var(--ui-scale));
3500
+ line-height: 1;
3501
+ padding: calc(3px * var(--ui-scale)) calc(4px * var(--ui-scale));
3502
+ border-radius: calc(6px * var(--ui-scale));
3503
+ background: rgba(0, 0, 0, 0.55);
3504
+ color: #fff;
3505
+ }
3506
+ .media-tile-badge { right: calc(4px * var(--ui-scale)); bottom: calc(4px * var(--ui-scale)); }
3507
+ .media-tile-play { left: calc(4px * var(--ui-scale)); bottom: calc(4px * var(--ui-scale)); }
3508
+ .media-file-list {
3509
+ display: flex;
3510
+ flex-direction: column;
3511
+ }
3512
+ .media-file-row {
3513
+ display: flex;
3514
+ align-items: center;
3515
+ gap: calc(10px * var(--ui-scale));
3516
+ padding: calc(8px * var(--ui-scale)) calc(4px * var(--ui-scale));
3517
+ border: 0;
3518
+ border-bottom: 1px solid var(--border);
3519
+ background: transparent;
3520
+ color: var(--text);
3521
+ text-align: left;
3522
+ cursor: pointer;
3523
+ }
3524
+ .media-file-icon { font-size: calc(22px * var(--ui-scale)); }
3525
+ .media-file-text { display: flex; flex-direction: column; min-width: 0; }
3526
+ .media-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3527
+ .media-file-meta { color: var(--muted); font-size: var(--fs-meta-md); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3528
+ .media-gallery-more {
3529
+ align-self: center;
3530
+ border: 1px solid var(--border);
3531
+ background: var(--bg2);
3532
+ color: var(--text);
3533
+ border-radius: calc(8px * var(--ui-scale));
3534
+ padding: calc(4px * var(--ui-scale)) calc(12px * var(--ui-scale));
3535
+ cursor: pointer;
3536
+ }
3537
+ .media-gallery-sentinel { height: 1px; }
3538
+ @media (max-width: 680px) {
3539
+ .media-grid { grid-template-columns: repeat(3, 1fr); }
3540
+ .media-gallery[data-scope="global"] { padding: calc(8px * var(--ui-scale)) calc(4px * var(--ui-scale)); }
3541
+ .media-gallery-select[data-media-filter="session"] { max-width: 100%; flex: 1 1 100%; }
3542
+ .media-gallery-tabs,
3543
+ .media-gallery-chips,
3544
+ .context-tab-strip {
3545
+ flex-wrap: nowrap;
3546
+ overflow-x: auto;
3547
+ scrollbar-width: none;
3548
+ }
3549
+ }
3318
3550
  .plan-usage-card-titles {
3319
3551
  display: flex;
3320
3552
  flex-direction: column;
@@ -3411,6 +3643,17 @@
3411
3643
  background: var(--bg3);
3412
3644
  }
3413
3645
  .plan-usage-meter-secondary .plan-usage-bar { height: calc(6px * var(--ui-scale)); border-radius: calc(3px * var(--ui-scale)); }
3646
+ /* A bar with a mark on it (the OpenAI images card's 80 % warning line). */
3647
+ .plan-usage-bar-marked { position: relative; }
3648
+ .plan-usage-bar-mark {
3649
+ position: absolute;
3650
+ top: 0;
3651
+ bottom: 0;
3652
+ width: 2px;
3653
+ margin-left: -1px;
3654
+ background: var(--text);
3655
+ opacity: 0.55;
3656
+ }
3414
3657
  .plan-usage-bar-fill {
3415
3658
  display: block;
3416
3659
  height: 100%;
@@ -4294,7 +4537,12 @@
4294
4537
  #mode-select,
4295
4538
  #model-select,
4296
4539
  #reasoning-effort-select,
4297
- #context-tier-select {
4540
+ #context-tier-select,
4541
+ #image-size-custom-input,
4542
+ #image-preset-select,
4543
+ #image-count-select,
4544
+ #image-format-select,
4545
+ #image-background-select {
4298
4546
  background: var(--bg3); border: 1px solid var(--border); color: var(--text);
4299
4547
  border-radius: calc(8px * var(--ui-scale)); height: calc(30px * var(--ui-scale)); padding: 0 calc(8px * var(--ui-scale)); font-size: var(--fs-core-control);
4300
4548
  width: 100%; min-width: 0; outline: none;
@@ -4302,7 +4550,64 @@
4302
4550
  #mode-select:focus,
4303
4551
  #model-select:focus,
4304
4552
  #reasoning-effort-select:focus,
4305
- #context-tier-select:focus { border-color: var(--accent); }
4553
+ #context-tier-select:focus,
4554
+ #image-size-custom-input:focus,
4555
+ #image-preset-select:focus,
4556
+ #image-count-select:focus,
4557
+ #image-format-select:focus,
4558
+ #image-background-select:focus { border-color: var(--accent); }
4559
+ /* An image model's custom W×H under the Size select, and Count, Format and
4560
+ Background side by side: three narrow selects fit the 340px popover and
4561
+ the phone's bottom sheet alike. */
4562
+ #image-size-custom-input { box-sizing: border-box; font-family: var(--mono, monospace); }
4563
+ /* No iOS focus zoom (see #conv-filter-input). */
4564
+ @media (pointer: coarse), (max-width: 680px) {
4565
+ #image-size-custom-input { font-size: max(16px, 1rem); }
4566
+ }
4567
+ #image-size-custom-input[aria-invalid="true"] { border-color: var(--danger); }
4568
+ .image-size-custom-row { display: flex; flex-direction: column; gap: calc(3px * var(--ui-scale)); margin-top: calc(4px * var(--ui-scale)); }
4569
+ .image-size-custom-row[hidden],
4570
+ .image-size-custom-error[hidden],
4571
+ .composer-settings-image-row[hidden],
4572
+ .composer-settings-image-row .composer-settings-field[hidden] { display: none; }
4573
+ .image-size-custom-error { color: var(--danger); font-size: var(--fs-meta-sm); overflow-wrap: anywhere; }
4574
+ .composer-settings-image-row { display: flex; gap: calc(6px * var(--ui-scale)); min-width: 0; }
4575
+ /* An image model's Preset select with "Save as preset…" beside it, and
4576
+ what a pick skipped or a save answered under them. */
4577
+ #image-preset-field[hidden],
4578
+ .image-preset-note[hidden] { display: none; }
4579
+ .image-preset-row { display: flex; gap: calc(6px * var(--ui-scale)); min-width: 0; align-items: center; }
4580
+ .image-preset-row #image-preset-select { flex: 1 1 auto; }
4581
+ .image-preset-save-btn {
4582
+ flex: 0 0 auto;
4583
+ background: var(--bg3); border: 1px solid var(--border); color: var(--text);
4584
+ border-radius: calc(8px * var(--ui-scale)); height: calc(30px * var(--ui-scale)); padding: 0 calc(8px * var(--ui-scale));
4585
+ font-size: var(--fs-core-control); cursor: pointer; white-space: nowrap;
4586
+ }
4587
+ .image-preset-save-btn:hover { border-color: var(--accent); }
4588
+ .image-preset-save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
4589
+ .image-preset-note { color: var(--muted); font-size: var(--fs-meta-sm); overflow-wrap: anywhere; }
4590
+ .image-preset-note[data-state="error"] { color: var(--danger); }
4591
+ /* Today's OpenAI image spend in an image chat: amber from the warning
4592
+ mark, red once the cap stops image turns. */
4593
+ #openai-image-spend-banner {
4594
+ background: var(--bg2);
4595
+ background: color-mix(in srgb, var(--warn) 14%, var(--bg2));
4596
+ border: 1px solid var(--warn);
4597
+ color: var(--warn);
4598
+ border-radius: calc(8px * var(--ui-scale));
4599
+ padding: calc(3px * var(--ui-scale)) calc(8px * var(--ui-scale));
4600
+ margin-bottom: calc(5px * var(--ui-scale));
4601
+ font-size: var(--fs-meta-sm);
4602
+ overflow-wrap: anywhere;
4603
+ }
4604
+ #openai-image-spend-banner[hidden] { display: none; }
4605
+ #openai-image-spend-banner[data-kind="reached"] {
4606
+ background: color-mix(in srgb, var(--danger) 14%, var(--bg2));
4607
+ border-color: var(--danger);
4608
+ color: var(--danger);
4609
+ }
4610
+ .composer-settings-image-row .composer-settings-field { flex: 1 1 0; }
4306
4611
  /* Live background-task fold above the composer. The summary reuses the
4307
4612
  relay tones so its running state matches the sidebar dot / burger bars. */
4308
4613
  #background-tasks-panel {
@@ -6790,6 +7095,10 @@
6790
7095
  /* CWD-pick mode is launched from the summary / new-chat modals (z 12050),
6791
7096
  so the browser must stack above them; #relay-toast stays on top at 12600. */
6792
7097
  #repo-browser-modal.cwd-pick-mode { z-index: 12400; }
7098
+ /* A picture opened from the 🧠 Media tab shows above that modal, and its
7099
+ annotate editor above the picture. */
7100
+ #file-preview-modal.over-summary { z-index: 12200; }
7101
+ body:has(#file-preview-modal.over-summary) #image-annotate-modal { z-index: 12300; }
6793
7102
  .repo-cwd-pick-bar {
6794
7103
  display: flex;
6795
7104
  align-items: center;
@@ -7872,19 +8181,22 @@
7872
8181
  width is the scroll padding too, so a tab scrolled into view (the
7873
8182
  selected one, a focused one) clears it. */
7874
8183
  .settings-tab-strip,
7875
- .plan-usage-tab-strip {
8184
+ .plan-usage-tab-strip,
8185
+ .context-tab-strip,
8186
+ .media-gallery-tabs,
8187
+ .media-gallery-chips {
7876
8188
  --tab-strip-fade: calc(44px * var(--ui-scale));
7877
8189
  scroll-padding-inline: var(--tab-strip-fade);
7878
8190
  }
7879
- :is(.settings-tab-strip, .plan-usage-tab-strip)[data-fade="right"] {
8191
+ :is(.settings-tab-strip, .plan-usage-tab-strip, .context-tab-strip, .media-gallery-tabs, .media-gallery-chips)[data-fade="right"] {
7880
8192
  -webkit-mask-image: linear-gradient(to left, transparent, #000 var(--tab-strip-fade));
7881
8193
  mask-image: linear-gradient(to left, transparent, #000 var(--tab-strip-fade));
7882
8194
  }
7883
- :is(.settings-tab-strip, .plan-usage-tab-strip)[data-fade="both"] {
8195
+ :is(.settings-tab-strip, .plan-usage-tab-strip, .context-tab-strip, .media-gallery-tabs, .media-gallery-chips)[data-fade="both"] {
7884
8196
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--tab-strip-fade), #000 calc(100% - var(--tab-strip-fade)), transparent);
7885
8197
  mask-image: linear-gradient(to right, transparent, #000 var(--tab-strip-fade), #000 calc(100% - var(--tab-strip-fade)), transparent);
7886
8198
  }
7887
- :is(.settings-tab-strip, .plan-usage-tab-strip)[data-fade="left"] {
8199
+ :is(.settings-tab-strip, .plan-usage-tab-strip, .context-tab-strip, .media-gallery-tabs, .media-gallery-chips)[data-fade="left"] {
7888
8200
  -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--tab-strip-fade));
7889
8201
  mask-image: linear-gradient(to right, transparent, #000 var(--tab-strip-fade));
7890
8202
  }
@@ -8266,6 +8578,7 @@
8266
8578
  <button id="chat-menu-watching" class="chat-actions-menu-item chat-menu-meta-chip" type="button" role="menuitem" hidden>👁 Watching sessions…</button>
8267
8579
  <button id="chat-menu-schedules" class="chat-actions-menu-item chat-menu-meta-chip" type="button" role="menuitem" hidden>⏰ Schedules…</button>
8268
8580
  <button id="chat-menu-origin" class="chat-actions-menu-item chat-menu-meta-chip" type="button" role="menuitem" hidden>↗ Started by an agent</button>
8581
+ <div id="chat-menu-image-cost" class="chat-actions-menu-meta" data-testid="chat-menu-image-cost" hidden><span id="chat-menu-image-cost-value" class="chat-actions-menu-meta-secondary">OpenAI images: …</span></div>
8269
8582
  <button id="chat-menu-edit-title" class="chat-actions-menu-item" type="button" role="menuitem">✍️ Edit conversation title</button>
8270
8583
  <button id="chat-menu-questions" class="chat-actions-menu-item chat-menu-twin" type="button" role="menuitem" hidden>📓 Questions</button>
8271
8584
  <button id="chat-menu-pins" class="chat-actions-menu-item chat-menu-twin" type="button" role="menuitem" hidden>📍 Pinned messages</button>
@@ -8287,7 +8600,8 @@
8287
8600
  <button id="chat-menu-change-cwd" class="chat-actions-menu-item" type="button" role="menuitem">🗂️ Change CWD</button>
8288
8601
  <button id="chat-menu-git-changes" class="chat-actions-menu-item" type="button" role="menuitem">🌿 Git changes</button>
8289
8602
  <button id="chat-menu-open-cloud-session" class="chat-actions-menu-item" type="button" role="menuitem" hidden>☁️ Open on claude.ai</button>
8290
- <button id="chat-menu-settings" class="chat-actions-menu-item" type="button" role="menuitem">⚙️ Settings</button>
8603
+ <button id="chat-menu-media" class="chat-actions-menu-item" type="button" role="menuitem">🖼️ Media</button>
8604
+ <button id="chat-menu-settings" class="chat-actions-menu-item" type="button" role="menuitem">⚙️ Settings</button>
8291
8605
  <button id="chat-menu-about" class="chat-actions-menu-item" type="button" role="menuitem">ℹ️ About OAR</button>
8292
8606
  <button id="chat-menu-restart-relay" class="chat-actions-menu-item" type="button" role="menuitem">🌄 Restart web relay</button>
8293
8607
  <button id="chat-menu-suspend-host" class="chat-actions-menu-item" type="button" role="menuitem" onclick="openSuspendHostConfirmation()">💤 Suspend host</button>
@@ -8310,6 +8624,8 @@
8310
8624
  <div id="share-preview-banner" role="note"></div>
8311
8625
  <div id="model-banner"></div>
8312
8626
  <div id="usage-limit-banner" role="status" aria-live="polite" hidden></div>
8627
+ <!-- Today's OpenAI image spend in an image chat (openai-image-cost-ui.js). -->
8628
+ <div id="openai-image-spend-banner" data-testid="openai-image-spend-banner" role="status" aria-live="polite" hidden></div>
8313
8629
  <div id="cloud-session-line" aria-label="Cloud session" hidden></div>
8314
8630
  <div id="model-metadata-blocker" aria-live="polite">
8315
8631
  <span id="model-metadata-blocker-text">Model metadata is unavailable. Refresh to choose a model and reasoning effort.</span>
@@ -8367,6 +8683,15 @@
8367
8683
  </select>
8368
8684
  <div id="model-lock-note" hidden></div>
8369
8685
  </div>
8686
+ <!-- An image model's presets (image-composer-ui.js). -->
8687
+ <div id="image-preset-field" class="composer-settings-field" data-field="image-preset" hidden>
8688
+ <label class="composer-settings-field-label" for="image-preset-select">Preset</label>
8689
+ <div class="image-preset-row">
8690
+ <select id="image-preset-select" data-testid="image-preset-select" title="Image preset"><option value="">None</option></select>
8691
+ <button id="image-preset-save-btn" data-testid="image-preset-save" type="button" class="image-preset-save-btn" title="Save the image options below as a preset">Save as preset…</button>
8692
+ </div>
8693
+ <div id="image-preset-note" data-testid="image-preset-note" class="image-preset-note" role="status" aria-live="polite" hidden></div>
8694
+ </div>
8370
8695
  <div class="composer-settings-field" data-field="effort">
8371
8696
  <label id="reasoning-effort-label" class="composer-settings-field-label" for="reasoning-effort-select">Effort</label>
8372
8697
  <select id="reasoning-effort-select" title="Reasoning effort" disabled>
@@ -8378,6 +8703,26 @@
8378
8703
  <select id="context-tier-select" title="Context window">
8379
8704
  <option value="default">—</option>
8380
8705
  </select>
8706
+ <!-- An image model's "Custom…" size (image-composer-ui.js). -->
8707
+ <div id="image-size-custom-row" class="image-size-custom-row" hidden>
8708
+ <input id="image-size-custom-input" type="text" inputmode="text" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="2048x1152" aria-label="Custom image size, width x height" aria-describedby="image-size-custom-error">
8709
+ <div id="image-size-custom-error" class="image-size-custom-error" role="alert" hidden></div>
8710
+ </div>
8711
+ </div>
8712
+ <!-- An image model's Count, Format and Background (image-composer-ui.js). -->
8713
+ <div id="image-options-row" class="composer-settings-image-row" hidden>
8714
+ <div class="composer-settings-field" data-field="image-count">
8715
+ <label class="composer-settings-field-label" for="image-count-select">Count</label>
8716
+ <select id="image-count-select" title="Images per turn"></select>
8717
+ </div>
8718
+ <div class="composer-settings-field" data-field="image-format">
8719
+ <label class="composer-settings-field-label" for="image-format-select">Format</label>
8720
+ <select id="image-format-select" title="Image format"></select>
8721
+ </div>
8722
+ <div class="composer-settings-field" data-field="image-background">
8723
+ <label class="composer-settings-field-label" for="image-background-select">Background</label>
8724
+ <select id="image-background-select" title="Image background"></select>
8725
+ </div>
8381
8726
  </div>
8382
8727
  <!-- Subscription | API key in a Claude chat (claude-billing-toggle-ui.js). -->
8383
8728
  <div id="claude-billing-toggle" class="claude-billing-toggle composer-settings-field" data-field="billing" hidden></div>
@@ -9036,6 +9381,37 @@
9036
9381
  </div>
9037
9382
  <div class="settings-help">Leave the key blank to keep the saved key, or enter a new one to replace it. Provider changes also update conversations that have not sent a message yet. The Base URL is useful for OpenAI-compatible endpoints or for pointing to the capture proxy (<code>node server/tools/capture-proxy.mjs</code>) to inspect requests.</div>
9038
9383
  </div>
9384
+ <!-- Image turns: the daily cap, today's spend and the presets (openai-image-cost-ui.js). -->
9385
+ <div id="openai-image-spend-section" class="settings-row settings-row-column settings-gap" data-testid="openai-image-spend-section">
9386
+ <label for="openai-image-cap-input">Daily image cap (USD)</label>
9387
+ <div class="openai-image-cap-row">
9388
+ <input
9389
+ id="openai-image-cap-input"
9390
+ data-testid="openai-image-cap-input"
9391
+ type="number"
9392
+ inputmode="decimal"
9393
+ min="0"
9394
+ max="10000"
9395
+ step="0.01"
9396
+ value="5"
9397
+ aria-label="Daily image cap in US dollars, 0 for no cap"
9398
+ aria-describedby="openai-image-spend-error openai-image-cap-help"
9399
+ >
9400
+ <button id="openai-image-cap-save-btn" data-testid="openai-image-cap-save" type="button" class="summary-btn">Save cap</button>
9401
+ </div>
9402
+ <div id="openai-image-spend-error" data-testid="openai-image-spend-error" class="settings-help openai-image-field-error" role="alert" hidden></div>
9403
+ <div id="openai-image-cap-help" class="settings-help">What image turns may cost this relay per day (relay time), estimates included. Once today's spend reaches it, new image turns are refused until tomorrow or until you allow more for today; a running turn finishes. 0 means no cap.</div>
9404
+ <div id="openai-image-spend-line" data-testid="openai-image-spend-line" class="settings-help settings-provider-status" role="status">Today's image spend is not known yet.</div>
9405
+ <div class="settings-actions">
9406
+ <button id="openai-image-allow-today-btn" data-testid="openai-image-allow-today" type="button" class="summary-btn" hidden>Allow past the cap for today</button>
9407
+ <button id="openai-image-stop-allowing-btn" data-testid="openai-image-stop-allowing" type="button" class="summary-btn" hidden>Stop allowing</button>
9408
+ </div>
9409
+ <div id="openai-image-spend-status" class="settings-help" role="status" aria-live="polite"></div>
9410
+ <label class="settings-gap-sm" id="openai-image-presets-label">Image presets</label>
9411
+ <ul id="openai-image-presets-list" data-testid="openai-image-presets-list" class="openai-image-presets-list" aria-labelledby="openai-image-presets-label"></ul>
9412
+ <div id="openai-image-presets-error" class="settings-help openai-image-field-error" role="alert" hidden></div>
9413
+ <div id="openai-image-presets-note" class="settings-help"></div>
9414
+ </div>
9039
9415
  </div>
9040
9416
  <div
9041
9417
  id="settings-provider-panel-claude"
@@ -47,6 +47,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
47
47
  - "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
48
48
  - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
49
49
  - A "model" for `create_session` or `send` must be an id `relay_info` lists for the provider (Claude also takes "opus", "sonnet", "haiku"). An unknown id is refused with the close matches and the valid ids (`Unknown model 'gpt-5-4' for Copilot on relay "…"; did you mean 'gpt-5.4'? Valid ids: … Nothing was sent.`), and nothing is created or sent: pick from the list instead of guessing. Copilot ids use dots (`claude-opus-5.5`, not the Claude form `claude-opus-5-5`); a hyphen form is refused with the close match named, and a Copilot session never moves to another provider because of its model name. If the answer has a "modelNote" saying the model could not be checked, the relay had no model list yet.
50
+ - Image sessions (provider "openai" with a "gpt-image-*" or "dall-e-*" model; `create_session` with provider "openai", "images" or "image" and no "model" gets the relay's image default): `send` and `create_session` take "images" (up to 4 absolute paths of PNG, JPEG or WebP files on the target relay, each at most 20 MB: inside the session's working directory, or images any session there generated) and "image" ({"size":"1536x1024","quality":"high","count":2,"output_format":"webp","background":"transparent"}, for that turn). `update_session` with "image" sets the session's defaults. The replies list "images": [{"path","width","height","mime"}] with the files on that relay, which a later `send` can attach, and a text line such as "1 image · 1536x1024 · gpt-image-2". Sizes are "auto" or W×H with both edges multiples of 16, the longer at most 3840, at most 3840×2160 pixels, aspect 1:3 to 3:1 (dall-e keep their fixed sizes). A refused path, a file that is no PNG/JPEG/WebP, or a session whose model makes no images is refused with the reason and nothing is sent; a paired relay must run an OAR that takes images ("imageInputs" in `relay_info`). A failed image call is tried at most twice: the note says whether it may have been billed, so retry only when the user wants the cost. "image" may name a preset ("preset", from relay_info's "imagePresets"); its own fields win over it. Image turns count toward the relay's daily cap of OpenAI image spend; once reached, image sends are refused with OPENAI_IMAGE_DAILY_CAP_REACHED and nothing is sent: tell the user, who can allow more for the day (agents cannot). `usage` shows the spend.
50
51
  - A new session knows nothing of this conversation: write a self-contained prompt. A Claude Cloud session works on a fresh clone in a sandbox at Anthropic, so name the branch it should work on and push.
51
52
  - Follow each session with `wait` (the message id comes back from `create_session` and `send`) and `read_session`; "wait_seconds" is limited to the maximum set on the relay (default 600).
52
53
  - Sessions run on the relay owner's accounts and cost their usage: start only what the task needs.
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import crypto from 'crypto';
4
+
3
5
  const IMAGE_SCHEMA_VERSION = 1;
4
6
 
5
7
  export function migrateImageConversationSchema(db) {
@@ -200,6 +202,10 @@ export function createImageConversationRepository(db) {
200
202
  WHERE operation.queue_message_id = ?
201
203
  `),
202
204
  linkQueueOperation: db.prepare(`UPDATE queue SET image_operation_id = ? WHERE id = ? AND image_operation_id IS NULL`),
205
+ // queue.image_options_json comes with migration 0040: prepared on first
206
+ // use, and without the column (a failed migration) the options are not
207
+ // kept and the worker sends the composer's quality and size as before.
208
+ setQueueImageOptions: { run: (json, id) => queueImageOptionsStatement()?.run(json, id) },
203
209
  insertAsset: db.prepare(`
204
210
  INSERT INTO image_operation_assets (
205
211
  operation_id, ordinal, role, upload_id, content_sha256, media_type, created_at
@@ -309,9 +315,72 @@ export function createImageConversationRepository(db) {
309
315
  return statements.insertEdge.run(edge);
310
316
  }
311
317
 
318
+ // A conversation's image defaults (migration 0040) live on its image
319
+ // sessions: every one of them carries the same copy, and a conversation
320
+ // without one gets a session for them. Prepared on first use: without the
321
+ // column (a failed migration) a read finds none and a write throws.
322
+ let queueOptionsStatement;
323
+ function queueImageOptionsStatement() {
324
+ if (queueOptionsStatement !== undefined) return queueOptionsStatement;
325
+ const hasColumn = db.prepare(`PRAGMA table_info(queue)`).all().some((column) => column.name === 'image_options_json');
326
+ queueOptionsStatement = hasColumn ? db.prepare(`UPDATE queue SET image_options_json = ? WHERE id = ?`) : null;
327
+ return queueOptionsStatement;
328
+ }
329
+
330
+ let defaultsStatements = null;
331
+ function imageDefaultsStatements() {
332
+ if (defaultsStatements) return defaultsStatements;
333
+ const hasColumn = db.prepare(`PRAGMA table_info(image_sessions)`).all().some((column) => column.name === 'defaults_json');
334
+ if (!hasColumn) return null;
335
+ defaultsStatements = {
336
+ read: db.prepare(`
337
+ SELECT defaults_json FROM image_sessions
338
+ WHERE conversation_id = ? AND defaults_json IS NOT NULL
339
+ ORDER BY updated_at DESC
340
+ LIMIT 1
341
+ `),
342
+ write: db.prepare(`UPDATE image_sessions SET defaults_json = ? WHERE conversation_id = ?`),
343
+ };
344
+ return defaultsStatements;
345
+ }
346
+
347
+ /** A conversation's image defaults as stored (`{}` when it has none). */
348
+ function readConversationImageDefaults(conversationId) {
349
+ const row = imageDefaultsStatements()?.read.get(String(conversationId || '')) || null;
350
+ if (!row) return {};
351
+ try {
352
+ const parsed = JSON.parse(row.defaults_json);
353
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
354
+ } catch {
355
+ return {};
356
+ }
357
+ }
358
+
359
+ /** Stores a conversation's image defaults (checked by the caller). */
360
+ function writeConversationImageDefaults(conversationId, defaults, { sessionId, now = new Date().toISOString() } = {}) {
361
+ const statementsForDefaults = imageDefaultsStatements();
362
+ if (!statementsForDefaults) throw new Error('Image session defaults are unavailable until the relay restarts');
363
+ const json = JSON.stringify(defaults && typeof defaults === 'object' ? defaults : {});
364
+ const conversation = String(conversationId || '');
365
+ db.transaction(() => {
366
+ if (statementsForDefaults.write.run(json, conversation).changes > 0) return;
367
+ statements.insertSession.run({
368
+ id: sessionId || `img_session_${crypto.randomUUID()}`,
369
+ conversationId: conversation,
370
+ origin: 'native',
371
+ schemaVersion: IMAGE_SCHEMA_VERSION,
372
+ createdAt: now,
373
+ updatedAt: now,
374
+ });
375
+ statementsForDefaults.write.run(json, conversation);
376
+ })();
377
+ }
378
+
312
379
  return {
313
380
  ...statements,
314
381
  imageSchemaVersion: IMAGE_SCHEMA_VERSION,
315
382
  insertEdgeChecked,
383
+ readConversationImageDefaults,
384
+ writeConversationImageDefaults,
316
385
  };
317
386
  }