@oar-sh/oar 0.9.7 → 0.9.9

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 (64) hide show
  1. package/CHANGELOG.md +344 -0
  2. package/README.md +89 -40
  3. package/bin/oar.js +1023 -135
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
  7. package/server/claude-worker/claude-session-process.mjs +196 -28
  8. package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
  9. package/server/db-schema.mjs +8 -0
  10. package/server/migrations/0008-message-pins.mjs +85 -0
  11. package/server/public/app/activity-replay-state.mjs +68 -5
  12. package/server/public/app/api-client.js +36 -2
  13. package/server/public/app/attachments-view.js +508 -125
  14. package/server/public/app/back-guard.mjs +58 -0
  15. package/server/public/app/bootstrap.js +170 -59
  16. package/server/public/app/context-usage-view.mjs +127 -8
  17. package/server/public/app/conversation-view.js +172 -32
  18. package/server/public/app/external-link-policy.mjs +4 -1
  19. package/server/public/app/file-version.mjs +58 -0
  20. package/server/public/app/journal-view.js +16 -2
  21. package/server/public/app/pinned-messages-view.mjs +253 -0
  22. package/server/public/app/pwa-install.js +6 -1
  23. package/server/public/app/relay-dot-state.mjs +8 -1
  24. package/server/public/app/router.js +8 -2
  25. package/server/public/app/scrollbar-hover.mjs +78 -0
  26. package/server/public/app/settings-modal.js +4 -1
  27. package/server/public/app/socket-handlers.js +8 -1
  28. package/server/public/app/store.js +32 -0
  29. package/server/public/app/transcript-separators.mjs +48 -11
  30. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  31. package/server/public/app/viewer-controls-state.mjs +59 -0
  32. package/server/public/app/viewer-gallery.mjs +46 -0
  33. package/server/public/app/viewer-gestures.mjs +105 -0
  34. package/server/public/app/viewer-history.mjs +43 -0
  35. package/server/public/app/viewer-tap.mjs +112 -0
  36. package/server/public/app-icon-192.png +0 -0
  37. package/server/public/app-icon-512.png +0 -0
  38. package/server/public/app-icon-light-192.png +0 -0
  39. package/server/public/app-icon-light-512.png +0 -0
  40. package/server/public/app-icon-light-maskable-512.png +0 -0
  41. package/server/public/app-icon-light.svg +4 -0
  42. package/server/public/app-icon-maskable-512.png +0 -0
  43. package/server/public/app-icon.svg +3 -3
  44. package/server/public/favicon.ico +0 -0
  45. package/server/public/index.html +487 -67
  46. package/server/public/manifest.webmanifest +9 -3
  47. package/server/public/sw.js +6 -1
  48. package/server/repositories/message-pin-statements.mjs +39 -0
  49. package/server/repositories/message-repository.mjs +5 -0
  50. package/server/routes/messages-routes.mjs +36 -62
  51. package/server/routes/sessions-routes.mjs +107 -26
  52. package/server/server-runtime.mjs +39 -8
  53. package/server/services/claude-context-usage.mjs +51 -3
  54. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  55. package/server/services/file-serving.mjs +155 -0
  56. package/server/services/message-pin-list.mjs +52 -0
  57. package/server/services/oar-cli-helpers.mjs +205 -4
  58. package/server/services/oar-cli-menu.mjs +240 -0
  59. package/server/services/oar-cli-wsl.mjs +129 -0
  60. package/server/services/pwa-manifest-theme.mjs +30 -0
  61. package/server/services/safe-served-content.mjs +61 -11
  62. package/server/services/session-history-refresh-service.mjs +24 -0
  63. package/server/services/session-worker-registry-service.mjs +18 -1
  64. package/server/services/update-install-service.mjs +9 -3
@@ -3,12 +3,23 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content">
6
- <meta name="theme-color" content="#161b22">
6
+ <meta name="theme-color" content="#0d1117">
7
7
  <meta name="application-name" content="OAR">
8
8
  <meta name="apple-mobile-web-app-title" content="OAR">
9
9
  <meta name="mobile-web-app-capable" content="yes">
10
10
  <meta name="apple-mobile-web-app-capable" content="yes">
11
11
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
12
+ <script>
13
+ // The stored Theme setting, applied before the first paint so the start
14
+ // screen (and its logo) already match it.
15
+ const __STORED_THEME = (() => {
16
+ try { return window.localStorage.getItem('copilot_theme') === 'light' ? 'light' : 'dark'; } catch { return 'dark'; }
17
+ })();
18
+ if (__STORED_THEME === 'light') {
19
+ document.documentElement.setAttribute('data-theme', 'light');
20
+ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '#ffffff');
21
+ }
22
+ </script>
12
23
  <script>
13
24
  window.__COPILOT_APP_CONFIG = { basePath: '' };
14
25
  const __APP_CONFIG = window.__COPILOT_APP_CONFIG;
@@ -163,13 +174,35 @@
163
174
  if (errorEl) errorEl.textContent = 'Could not reach the relay';
164
175
  }
165
176
  };
166
- if (!__IS_SHARED_ROUTE) {
167
- document.write(`<link rel="manifest" href="${__APP_BASE}/manifest.webmanifest?${__PWA_BUST}">`);
168
- }
169
- document.write(`<link rel="icon" type="image/png" sizes="192x192" href="${__APP_BASE}/app-icon-192.png?${__PWA_BUST}">`);
170
- document.write(`<link rel="icon" type="image/png" sizes="512x512" href="${__APP_BASE}/app-icon-512.png?${__PWA_BUST}">`);
171
- document.write(`<link rel="icon" type="image/svg+xml" href="${__APP_BASE}/app-icon.svg?${__PWA_BUST}">`);
172
- document.write(`<link rel="apple-touch-icon" href="${__APP_BASE}/app-icon-192.png?${__PWA_BUST}">`);
177
+ // Manifest and tab icons for one theme: the dark icon set, or its
178
+ // `app-icon-light*` twin with a white launch background.
179
+ const __themeAssetLinks = (theme) => {
180
+ const light = theme === 'light';
181
+ const icon = light ? 'app-icon-light' : 'app-icon';
182
+ return [
183
+ ...(__IS_SHARED_ROUTE ? [] : [
184
+ { rel: 'manifest', href: `${__APP_BASE}/manifest.webmanifest?${__PWA_BUST}${light ? '&theme=light' : ''}` },
185
+ ]),
186
+ { rel: 'icon', type: 'image/png', sizes: '192x192', href: `${__APP_BASE}/${icon}-192.png?${__PWA_BUST}` },
187
+ { rel: 'icon', type: 'image/png', sizes: '512x512', href: `${__APP_BASE}/${icon}-512.png?${__PWA_BUST}` },
188
+ { rel: 'icon', type: 'image/svg+xml', href: `${__APP_BASE}/${icon}.svg?${__PWA_BUST}` },
189
+ { rel: 'apple-touch-icon', href: `${__APP_BASE}/${icon}-192.png?${__PWA_BUST}` },
190
+ ];
191
+ };
192
+ const __themeAssetLinkHtml = (theme) => __themeAssetLinks(theme)
193
+ .map((link) => `<link data-theme-asset="1" ${Object.entries(link).map(([key, value]) => `${key}="${value}"`).join(' ')}>`)
194
+ .join('');
195
+ document.write(__themeAssetLinkHtml(__STORED_THEME));
196
+ document.write(`<style>
197
+ .brand-logo { background-image: url("${__APP_BASE}/app-icon.svg?${__PWA_BUST}"); }
198
+ :root[data-theme="light"] .brand-logo { background-image: url("${__APP_BASE}/app-icon-light.svg?${__PWA_BUST}"); }
199
+ </style>`);
200
+ // A Theme change swaps them in place (settings-modal's updateTheme).
201
+ window.__applyThemeAssets = (theme) => {
202
+ const next = theme === 'light' ? 'light' : 'dark';
203
+ for (const node of Array.from(document.head.querySelectorAll('link[data-theme-asset]'))) node.remove();
204
+ document.head.insertAdjacentHTML('beforeend', __themeAssetLinkHtml(next));
205
+ };
173
206
  </script>
174
207
  <title>OAR — Open Agent Relay</title>
175
208
  <script>document.write('<scr'+'ipt src="'+__APP_BASE+'/socket.io/socket.io.js"><\/scr'+'ipt>');</script>
@@ -384,12 +417,44 @@
384
417
  display: none; align-items: center; justify-content: center;
385
418
  height: var(--app-height); padding: 24px;
386
419
  }
420
+ /* The start screen lies over the page until the app (or the sign-in box)
421
+ is ready. In the installed app it takes over from the system's launch
422
+ screen: the logo starts where and as large as the launch screen showed
423
+ it, then settles into this layout while the label fades in. */
387
424
  #startup-loading {
388
- display: flex; align-items: center; justify-content: center;
389
- height: var(--app-height); padding: 24px;
425
+ position: fixed; inset: 0; z-index: 10000;
426
+ display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px;
427
+ padding: 24px 24px 124px;
428
+ background: var(--bg);
390
429
  color: var(--muted);
391
430
  font-size: var(--fs-core-subtitle);
431
+ transition: opacity 150ms ease;
432
+ }
433
+ #startup-loading.is-leaving { opacity: 0; }
434
+ #startup-loading .brand-logo { width: 148px; height: 148px; }
435
+ #startup-loading.is-animated .brand-logo {
436
+ animation: startup-logo-settle 500ms cubic-bezier(0.22, 1, 0.36, 1) 200ms both;
437
+ }
438
+ #startup-loading.is-animated .startup-label {
439
+ animation: startup-label-in 400ms ease 450ms both;
440
+ }
441
+ #startup-loading .startup-label { transition: opacity 150ms ease; }
442
+ @keyframes startup-logo-settle {
443
+ from { transform: translate(var(--startup-dx, 0px), var(--startup-dy, 0px)) scale(var(--startup-scale, 1)); }
444
+ to { transform: none; }
392
445
  }
446
+ @keyframes startup-label-in {
447
+ from { opacity: 0; }
448
+ to { opacity: 1; }
449
+ }
450
+ /* The logo for the active theme. A background, not an <img>: the browser
451
+ fetches only the one the matching rule names (the rules with the URLs are
452
+ written by the head script, which knows the relay's base path). */
453
+ .brand-logo {
454
+ width: 128px; height: 128px; flex: none;
455
+ background-position: center; background-size: contain; background-repeat: no-repeat;
456
+ }
457
+ .auth-box .brand-logo { width: 96px; height: 96px; margin: 0 auto 16px; }
393
458
  .auth-box {
394
459
  background: var(--bg2); border: 1px solid var(--border); border-radius: 12px;
395
460
  padding: 40px 32px; width: 100%; max-width: 400px; text-align: center;
@@ -507,6 +572,21 @@
507
572
  opacity: 0.5;
508
573
  cursor: not-allowed;
509
574
  }
575
+ /* An icon with a number beside it (📍 3) grows with the number. */
576
+ .header-icon-btn.has-count {
577
+ width: auto;
578
+ min-width: 32px;
579
+ padding: 0 7px;
580
+ gap: 4px;
581
+ }
582
+ .header-icon-count {
583
+ font-size: var(--fs-meta-sm);
584
+ font-variant-numeric: tabular-nums;
585
+ }
586
+ .header-icon-count:empty { display: none; }
587
+ /* The display rule above outranks the UA [hidden] rule, same trap as the
588
+ other [hidden] overrides in this file. */
589
+ #pinned-messages-btn[hidden] { display: none; }
510
590
  #cli-dot {
511
591
  width: 12px;
512
592
  height: 12px;
@@ -1352,6 +1432,9 @@
1352
1432
  padding: 2px 10px;
1353
1433
  background: var(--bg2);
1354
1434
  }
1435
+ /* A compaction that is still running: the label trails the bouncing dots. */
1436
+ .transcript-separator.is-pending .dots { margin-left: 6px; }
1437
+ .transcript-separator.is-pending .dots span { width: 4px; height: 4px; }
1355
1438
 
1356
1439
  .empty-state {
1357
1440
  flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
@@ -1703,8 +1786,13 @@
1703
1786
  opacity: 0;
1704
1787
  pointer-events: none;
1705
1788
  }
1789
+ /* `.msg-actions-revealed`: a tap on the bubble on a touch screen. The tap
1790
+ also leaves an emulated :hover behind, but Chromium drops that hover at
1791
+ the next scroll or layout change (seen on Linux), which left the row
1792
+ untappable; the class holds the reveal until a tap lands elsewhere. */
1706
1793
  .msg-bubble:hover .msg-share-visibility-btn[data-hidden-from-shares="false"],
1707
- .msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"] {
1794
+ .msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"],
1795
+ .msg-bubble.msg-actions-revealed .msg-share-visibility-btn[data-hidden-from-shares="false"] {
1708
1796
  opacity: 1;
1709
1797
  pointer-events: auto;
1710
1798
  }
@@ -1712,7 +1800,46 @@
1712
1800
  .msg.user .msg-share-visibility-btn { color: var(--user-bubble-fg); opacity: 0.8; }
1713
1801
  .msg.user .msg-share-visibility-btn[data-hidden-from-shares="false"] { opacity: 0; }
1714
1802
  .msg.user .msg-bubble:hover .msg-share-visibility-btn[data-hidden-from-shares="false"],
1715
- .msg.user .msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"] { opacity: 0.8; }
1803
+ .msg.user .msg-bubble:focus-within .msg-share-visibility-btn[data-hidden-from-shares="false"],
1804
+ .msg.user .msg-bubble.msg-actions-revealed .msg-share-visibility-btn[data-hidden-from-shares="false"] { opacity: 0.8; }
1805
+ /* The pin half of the same corner row: `Pin` is revealed like `Hide`, a
1806
+ pinned message keeps its label and `Unpin` in view. */
1807
+ .msg-share-visibility { flex-wrap: wrap; }
1808
+ .msg-pinned-label {
1809
+ color: var(--muted);
1810
+ font-size: var(--fs-ui-chip);
1811
+ white-space: nowrap;
1812
+ }
1813
+ .msg-pin-btn {
1814
+ appearance: none;
1815
+ border: 0;
1816
+ background: transparent;
1817
+ color: var(--muted);
1818
+ cursor: pointer;
1819
+ font-size: var(--fs-ui-chip);
1820
+ line-height: 1;
1821
+ padding: 4px;
1822
+ text-decoration: underline;
1823
+ text-underline-offset: 2px;
1824
+ }
1825
+ .msg-pin-btn:hover:not(:disabled) { color: var(--accent); }
1826
+ .msg-pin-btn:disabled { cursor: wait; opacity: 0.55; }
1827
+ .msg-pin-btn[data-pinned="false"] {
1828
+ opacity: 0;
1829
+ pointer-events: none;
1830
+ }
1831
+ .msg-bubble:hover .msg-pin-btn[data-pinned="false"],
1832
+ .msg-bubble:focus-within .msg-pin-btn[data-pinned="false"],
1833
+ .msg-bubble.msg-actions-revealed .msg-pin-btn[data-pinned="false"] {
1834
+ opacity: 1;
1835
+ pointer-events: auto;
1836
+ }
1837
+ .msg.user .msg-pinned-label,
1838
+ .msg.user .msg-pin-btn { color: var(--user-bubble-fg); opacity: 0.8; }
1839
+ .msg.user .msg-pin-btn[data-pinned="false"] { opacity: 0; }
1840
+ .msg.user .msg-bubble:hover .msg-pin-btn[data-pinned="false"],
1841
+ .msg.user .msg-bubble:focus-within .msg-pin-btn[data-pinned="false"],
1842
+ .msg.user .msg-bubble.msg-actions-revealed .msg-pin-btn[data-pinned="false"] { opacity: 0.8; }
1716
1843
  .thinking-thoughts-panel {
1717
1844
  margin-bottom: 8px;
1718
1845
  min-width: 0;
@@ -1744,6 +1871,12 @@
1744
1871
  word-break: break-word;
1745
1872
  }
1746
1873
  .thinking-stream[hidden] { display: none; }
1874
+ .thinking-compacting-note {
1875
+ color: var(--muted);
1876
+ font-size: var(--fs-meta-sm);
1877
+ font-style: italic;
1878
+ }
1879
+ .thinking-compacting-note[hidden] { display: none; }
1747
1880
  .thinking-stream > :first-child { margin-top: 0; }
1748
1881
  .thinking-stream > :last-child { margin-bottom: 0; }
1749
1882
  .thinking-activity {
@@ -2097,6 +2230,9 @@
2097
2230
  font-size: 0.76rem;
2098
2231
  color: var(--muted);
2099
2232
  }
2233
+ .ctx-autocompact-notes { display: flex; flex-direction: column; gap: 4px; }
2234
+ .ctx-autocompact-notes:empty { display: none; }
2235
+ .ctx-autocompact-warning { color: var(--warn); }
2100
2236
  /* Claude-only thinking control (on/off + text visibility), under the
2101
2237
  auto-compact slider. */
2102
2238
  /* The folder's commit attribution (attribution-control.mjs), styled like
@@ -3149,28 +3285,62 @@
3149
3285
  .workspace-file-link { text-decoration: underline; }
3150
3286
  .workspace-file-link:hover { opacity: 0.9; }
3151
3287
 
3288
+ /* The file viewer fills the screen. Its chrome (title, toolbar, meta line
3289
+ above; position and prev/next below) floats over the content and slides
3290
+ away when the controls are hidden. Text kinds keep the app background and
3291
+ a readable column; media kinds go black edge to edge. */
3152
3292
  #file-preview-modal {
3153
3293
  position: fixed; inset: 0; z-index: 11000;
3154
- display: none; align-items: center; justify-content: center;
3155
- background: rgba(0, 0, 0, 0.65);
3156
- padding: 18px;
3294
+ display: none;
3295
+ background: var(--bg2);
3296
+ --file-viewer-chrome-top: 0px;
3157
3297
  }
3158
- #file-preview-modal.visible { display: flex; }
3298
+ #file-preview-modal.visible { display: block; }
3299
+ #file-preview-modal.media-mode { background: #000; }
3159
3300
  .file-preview-dialog {
3160
- width: min(1100px, 100%);
3161
- max-height: min(90vh, 900px);
3162
- display: flex;
3163
- flex-direction: column;
3164
- background: var(--bg2);
3165
- border: 1px solid var(--border);
3166
- border-radius: 14px;
3301
+ position: absolute; inset: 0;
3167
3302
  overflow: hidden;
3168
3303
  }
3304
+ .file-preview-chrome {
3305
+ position: absolute; left: 0; right: 0; z-index: 2;
3306
+ transition: opacity 0.18s ease, transform 0.18s ease;
3307
+ }
3308
+ .file-preview-chrome-top {
3309
+ top: 0;
3310
+ padding-top: env(safe-area-inset-top);
3311
+ background: color-mix(in srgb, var(--bg3) 92%, transparent);
3312
+ -webkit-backdrop-filter: blur(10px);
3313
+ backdrop-filter: blur(10px);
3314
+ border-bottom: 1px solid var(--border);
3315
+ }
3316
+ #file-preview-modal.media-mode .file-preview-chrome-top {
3317
+ background: linear-gradient(rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.62) 70%, rgba(0, 0, 0, 0));
3318
+ border-bottom: 0;
3319
+ -webkit-backdrop-filter: none;
3320
+ backdrop-filter: none;
3321
+ color: #fff;
3322
+ padding-bottom: 18px;
3323
+ }
3324
+ .file-preview-chrome-bottom {
3325
+ bottom: 0;
3326
+ display: flex; align-items: center; justify-content: center; gap: 14px;
3327
+ padding: 12px 14px calc(12px + env(safe-area-inset-bottom));
3328
+ background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.62));
3329
+ color: #fff;
3330
+ }
3331
+ .file-preview-chrome-bottom[hidden] { display: none; }
3332
+ #file-preview-modal.controls-hidden .file-preview-chrome { opacity: 0; pointer-events: none; }
3333
+ #file-preview-modal.controls-hidden .file-preview-chrome-top { transform: translateY(-12px); }
3334
+ #file-preview-modal.controls-hidden .file-preview-chrome-bottom { transform: translateY(12px); }
3335
+ .file-preview-position {
3336
+ min-width: 4ch; text-align: center;
3337
+ font-size: var(--fs-meta-md);
3338
+ font-variant-numeric: tabular-nums;
3339
+ }
3340
+ .file-preview-nav { font-size: 1.3rem; line-height: 1; padding: 4px 14px; }
3169
3341
  .file-preview-header {
3170
3342
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
3171
3343
  padding: 12px 14px;
3172
- border-bottom: 1px solid var(--border);
3173
- background: var(--bg3);
3174
3344
  }
3175
3345
  .file-preview-title {
3176
3346
  min-width: 0;
@@ -3195,23 +3365,35 @@
3195
3365
  }
3196
3366
  .file-preview-toolbar {
3197
3367
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
3198
- padding: 10px 14px;
3199
- border-bottom: 1px solid var(--border);
3368
+ padding: 0 14px 10px;
3200
3369
  }
3201
3370
  .file-preview-btn {
3202
3371
  border: 1px solid var(--border);
3203
- background: var(--bg3);
3372
+ background: var(--bg2);
3204
3373
  color: var(--text);
3205
3374
  border-radius: 8px;
3206
3375
  padding: 6px 10px;
3207
3376
  font-size: var(--fs-meta-md);
3208
3377
  cursor: pointer;
3378
+ text-decoration: none;
3209
3379
  }
3210
3380
  .file-preview-btn.active {
3211
3381
  border-color: var(--accent);
3212
3382
  color: var(--accent);
3213
3383
  }
3214
3384
  .file-preview-btn[hidden] { display: none; }
3385
+ #file-preview-modal.media-mode .file-preview-btn,
3386
+ #file-preview-modal.media-mode .file-preview-close {
3387
+ background: rgba(255, 255, 255, 0.14);
3388
+ border-color: rgba(255, 255, 255, 0.22);
3389
+ color: #fff;
3390
+ }
3391
+ #file-preview-modal.media-mode .file-preview-btn.active {
3392
+ border-color: var(--accent);
3393
+ color: #fff;
3394
+ background: color-mix(in srgb, var(--accent) 45%, transparent);
3395
+ }
3396
+ #file-preview-modal.media-mode .file-preview-meta { color: rgba(255, 255, 255, 0.72); }
3215
3397
  .file-preview-warning {
3216
3398
  display: none;
3217
3399
  margin-left: auto;
@@ -3220,16 +3402,30 @@
3220
3402
  }
3221
3403
  .file-preview-warning.visible { display: inline; }
3222
3404
  .file-preview-meta {
3223
- padding: 8px 14px;
3405
+ padding: 0 14px 8px;
3224
3406
  font-size: var(--fs-meta-sm);
3225
3407
  color: var(--muted);
3226
- border-bottom: 1px dashed var(--border);
3227
3408
  }
3409
+ .file-preview-meta:empty { display: none; }
3410
+ /* Text starts below the chrome so the first line is readable with the
3411
+ controls up; the chrome's height is measured into the variable. */
3228
3412
  .file-preview-body {
3229
- flex: 1;
3230
- min-height: 0; /* prevents flex child from overflowing the dialog's max-height */
3413
+ position: absolute; inset: 0;
3231
3414
  overflow: auto;
3232
- padding: 12px 14px;
3415
+ -webkit-overflow-scrolling: touch;
3416
+ padding: calc(var(--file-viewer-chrome-top) + 12px) 14px calc(28px + env(safe-area-inset-bottom));
3417
+ /* Up and down scroll the text; sideways is left to the viewer (a swipe
3418
+ to the next file). A block that scrolls sideways itself keeps that. */
3419
+ touch-action: pan-y pinch-zoom;
3420
+ }
3421
+ .file-preview-markdown pre,
3422
+ .file-preview-code pre,
3423
+ .file-preview-markdown table { touch-action: pan-x pan-y; }
3424
+ .file-preview-nav:disabled { opacity: 0.35; cursor: default; }
3425
+ .file-preview-markdown,
3426
+ .file-preview-body > .file-preview-code {
3427
+ max-width: 56rem;
3428
+ margin: 0 auto;
3233
3429
  }
3234
3430
  .file-preview-markdown {
3235
3431
  line-height: 1.5;
@@ -3292,42 +3488,71 @@
3292
3488
  user-select: none;
3293
3489
  -webkit-user-drag: none;
3294
3490
  }
3295
- .file-preview-video-shell {
3491
+ .file-preview-video-shell,
3492
+ .file-preview-audio-shell {
3296
3493
  display: flex;
3297
3494
  flex-direction: column;
3495
+ align-items: center;
3496
+ justify-content: center;
3298
3497
  gap: 10px;
3299
3498
  width: 100%;
3300
3499
  height: 100%;
3301
3500
  }
3302
3501
  .file-preview-video {
3303
- width: 100%;
3304
3502
  max-width: 100%;
3305
- max-height: min(72vh, 100%);
3503
+ max-height: 100%;
3504
+ width: auto;
3505
+ height: auto;
3306
3506
  background: #000;
3307
- border-radius: 10px;
3308
3507
  }
3309
- /* Image body: no scrollbar, fill remaining dialog space, dark letterbox bg */
3508
+ /* The player takes the card's width; a percentage on the player alone
3509
+ would resolve against a content-sized flex row and come out as zero. */
3510
+ .file-preview-audio-shell .file-preview-card { width: min(560px, calc(100% - 28px)); }
3511
+ .file-preview-audio-shell .file-preview-card-actions { width: 100%; }
3512
+ .file-preview-audio-shell audio { width: 100%; }
3513
+ /* Media and cards: no scrolling, the content is centred on the whole screen. */
3310
3514
  .file-preview-body.image-zoom-mode,
3311
- .file-preview-body.video-preview-mode {
3312
- flex: 1;
3313
- min-height: 0; /* critical: prevents flex child from overflowing max-height */
3515
+ .file-preview-body.video-preview-mode,
3516
+ .file-preview-body.audio-preview-mode,
3517
+ .file-preview-body.card-preview-mode {
3314
3518
  overflow: hidden;
3315
3519
  padding: 0;
3316
3520
  display: flex;
3317
3521
  flex-direction: column;
3318
- background: #0b0f14;
3522
+ touch-action: none;
3319
3523
  }
3320
3524
  .file-preview-body.image-zoom-mode .file-preview-image {
3321
3525
  flex: 1;
3322
- min-height: 0; /* same reason — lets the img's max-height:100% resolve */
3323
- background: #0b0f14;
3526
+ min-height: 0; /* lets the img's max-height:100% resolve */
3527
+ }
3528
+ .file-preview-body.image-zoom-mode .file-preview-image img { border-radius: 0; }
3529
+ /* A file with nothing to show inline (a PDF, an unknown binary) or an audio
3530
+ file: a card in the middle with the name, the size and what can be done. */
3531
+ .file-preview-card {
3532
+ margin: auto;
3533
+ max-width: min(420px, calc(100% - 28px));
3534
+ display: flex; flex-direction: column; align-items: center; gap: 8px;
3535
+ text-align: center;
3536
+ padding: 24px;
3537
+ border: 1px solid var(--border);
3538
+ border-radius: 16px;
3539
+ background: var(--bg3);
3324
3540
  }
3325
- .file-preview-body.video-preview-mode .file-preview-video-shell {
3326
- flex: 1;
3327
- min-height: 0;
3328
- padding: 12px 14px;
3329
- background: #0b0f14;
3541
+ #file-preview-modal.media-mode .file-preview-card {
3542
+ background: rgba(255, 255, 255, 0.08);
3543
+ border-color: rgba(255, 255, 255, 0.16);
3544
+ color: #fff;
3545
+ }
3546
+ .file-preview-card-icon { font-size: 3rem; line-height: 1; }
3547
+ .file-preview-card-name {
3548
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
3549
+ font-size: var(--fs-meta-md);
3550
+ word-break: break-all;
3330
3551
  }
3552
+ .file-preview-card-meta { font-size: var(--fs-meta-sm); color: var(--muted); }
3553
+ #file-preview-modal.media-mode .file-preview-card-meta { color: rgba(255, 255, 255, 0.72); }
3554
+ .file-preview-card-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 8px; }
3555
+ .file-preview-card-actions .file-preview-btn { padding: 10px 16px; }
3331
3556
  .msg-attachment-video-chip {
3332
3557
  display: inline-flex;
3333
3558
  align-items: center;
@@ -3344,6 +3569,11 @@
3344
3569
  color: var(--muted);
3345
3570
  margin-top: 10px;
3346
3571
  }
3572
+ .file-preview-body.image-zoom-mode .file-preview-note,
3573
+ .file-preview-body.video-preview-mode .file-preview-note {
3574
+ margin: auto;
3575
+ padding: 14px;
3576
+ }
3347
3577
 
3348
3578
  /* Annotation editor. Clones the file-preview shell, but sits above it
3349
3579
  (z-index 11500) so annotating a sent image can layer over the lightbox. */
@@ -4147,6 +4377,84 @@
4147
4377
  font-size: var(--fs-meta-sm);
4148
4378
  padding: 8px 6px;
4149
4379
  }
4380
+ /* Pinned messages list (the summary modal, kind 'pinned-messages'). */
4381
+ .pinned-list {
4382
+ display: flex;
4383
+ flex-direction: column;
4384
+ gap: 8px;
4385
+ }
4386
+ .pinned-row {
4387
+ display: flex;
4388
+ align-items: stretch;
4389
+ border: 1px solid var(--border);
4390
+ background: var(--bg3);
4391
+ border-radius: 10px;
4392
+ overflow: hidden;
4393
+ }
4394
+ .pinned-row:hover,
4395
+ .pinned-row:focus-within {
4396
+ border-color: var(--accent);
4397
+ }
4398
+ .pinned-row-jump {
4399
+ appearance: none;
4400
+ border: 0;
4401
+ background: transparent;
4402
+ color: var(--text);
4403
+ font: inherit;
4404
+ text-align: left;
4405
+ cursor: pointer;
4406
+ flex: 1;
4407
+ min-width: 0;
4408
+ padding: 10px 12px;
4409
+ display: flex;
4410
+ flex-direction: column;
4411
+ gap: 4px;
4412
+ }
4413
+ .pinned-row-meta {
4414
+ font-size: var(--fs-meta-xs);
4415
+ color: var(--muted);
4416
+ }
4417
+ .pinned-row-chip {
4418
+ border: 1px solid var(--border);
4419
+ border-radius: 999px;
4420
+ padding: 0 6px;
4421
+ white-space: nowrap;
4422
+ }
4423
+ .pinned-row-preview {
4424
+ font-size: var(--fs-meta-sm);
4425
+ line-height: 1.4;
4426
+ display: -webkit-box;
4427
+ -webkit-line-clamp: 2;
4428
+ -webkit-box-orient: vertical;
4429
+ overflow: hidden;
4430
+ overflow-wrap: anywhere;
4431
+ }
4432
+ .pinned-row-no-text {
4433
+ color: var(--muted);
4434
+ font-style: italic;
4435
+ }
4436
+ /* 44px: a thumb-sized target, always in view (no hover on a phone). */
4437
+ .pinned-row-unpin {
4438
+ appearance: none;
4439
+ border: 0;
4440
+ border-left: 1px solid var(--border);
4441
+ background: transparent;
4442
+ color: var(--muted);
4443
+ cursor: pointer;
4444
+ flex: 0 0 44px;
4445
+ min-height: 44px;
4446
+ font-size: var(--fs-ui-icon);
4447
+ line-height: 1;
4448
+ }
4449
+ .pinned-row-unpin:hover {
4450
+ color: var(--danger);
4451
+ background: rgba(248, 81, 73, 0.08);
4452
+ }
4453
+ .pinned-empty {
4454
+ color: var(--muted);
4455
+ font-size: var(--fs-meta-sm);
4456
+ padding: 8px 6px;
4457
+ }
4150
4458
 
4151
4459
  #git-changes-modal,
4152
4460
  #git-diff-modal {
@@ -5227,6 +5535,7 @@
5227
5535
  }
5228
5536
  #pending-question-banner { font-size: var(--fs-ui-chip); padding: 3px 8px; }
5229
5537
  .header-icon-btn { width: 30px; height: 30px; border-radius: 8px; font-size: var(--fs-ui-icon); }
5538
+ .header-icon-btn.has-count { min-width: 30px; padding: 0 6px; }
5230
5539
  #chat-actions-menu { min-width: 200px; }
5231
5540
  /* Slightly taller than desktop so the tap target barely changes; the row
5232
5541
  pitch still drops from 36px to 32px thanks to the tighter menu gap. */
@@ -5464,6 +5773,15 @@
5464
5773
  flex: 1 1 calc(50% - 4px);
5465
5774
  min-height: 40px;
5466
5775
  }
5776
+ /* The pin list has one action, Close: the full-width action row above
5777
+ would leave its title no room. Title left, close button right. */
5778
+ #summary-modal[data-kind="pinned-messages"] .summary-header-actions {
5779
+ width: auto;
5780
+ }
5781
+ #summary-modal[data-kind="pinned-messages"] .summary-header-actions .summary-close {
5782
+ flex: 0 0 auto;
5783
+ min-width: 40px;
5784
+ }
5467
5785
  .model-variant-row {
5468
5786
  min-height: 48px;
5469
5787
  padding: 10px 4px;
@@ -5585,6 +5903,18 @@
5585
5903
  ::-webkit-scrollbar { width: 6px; }
5586
5904
  ::-webkit-scrollbar-track { background: transparent; }
5587
5905
  ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
5906
+ /* With a mouse the track is twice as wide and the thumb is drawn thin in
5907
+ its middle; the thumb fills the track while the pointer is on the
5908
+ scrollbar and for a moment after (scrollbar-hover.mjs). The track keeps
5909
+ its width, so the page does not move when the thumb grows. */
5910
+ @media (hover: hover) and (pointer: fine) {
5911
+ ::-webkit-scrollbar { width: 12px; }
5912
+ ::-webkit-scrollbar-thumb {
5913
+ border: 3px solid transparent; border-top-width: 0; border-bottom-width: 0;
5914
+ background-clip: padding-box; border-radius: 6px;
5915
+ }
5916
+ .scrollbar-wide::-webkit-scrollbar-thumb { border-width: 0; background: var(--muted); }
5917
+ }
5588
5918
  </style>
5589
5919
  </head>
5590
5920
  <body>
@@ -5592,7 +5922,8 @@
5592
5922
  <!-- Auth Gate -->
5593
5923
  <div id="auth-gate">
5594
5924
  <div class="auth-box">
5595
- <h1>🛶 OAR</h1>
5925
+ <div class="brand-logo" aria-hidden="true"></div>
5926
+ <h1>OAR</h1>
5596
5927
  <p>Enter your access token to connect</p>
5597
5928
  <input id="token-input" type="password" placeholder="Access token" autofocus onkeydown="if(event.key==='Enter')window.__copilotAuthConnect(event)">
5598
5929
  <button onclick="window.__copilotAuthConnect(event)">Connect</button>
@@ -5600,7 +5931,87 @@
5600
5931
  </div>
5601
5932
  </div>
5602
5933
 
5603
- <div id="startup-loading" role="status" aria-live="polite">Connecting to OAR…</div>
5934
+ <div id="startup-loading" role="status" aria-live="polite"><div class="brand-logo" aria-hidden="true"></div><div class="startup-label">Connecting to OAR…</div></div>
5935
+ <script>
5936
+ // The start screen's hand-over from the system launch screen, and its two
5937
+ // ways out: into the app (finish) or into the sign-in box (toAuth). Only the
5938
+ // installed app animates and makes its exits wait for the animation; a
5939
+ // browser tab has no launch screen to continue from and leaves at once.
5940
+ window.__startupSplash = (() => {
5941
+ const root = document.getElementById('startup-loading');
5942
+ const logo = root.querySelector('.brand-logo');
5943
+ const label = root.querySelector('.startup-label');
5944
+ const media = (query) => { try { return window.matchMedia(query).matches; } catch { return false; } };
5945
+ const installed = media('(display-mode: standalone)') || media('(display-mode: fullscreen)')
5946
+ || window.navigator.standalone === true;
5947
+ const animated = installed && !media('(prefers-reduced-motion: reduce)');
5948
+ const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
5949
+ let done = Promise.resolve();
5950
+ if (animated) {
5951
+ // Android draws its launch icon around the centre of the whole screen,
5952
+ // 275 px across for a maskable icon, which makes the otter 155 px wide.
5953
+ // The tile used here carries the otter at 373/512 of its width.
5954
+ const LAUNCH_TILE_PX = 155.2 / (373 / 512);
5955
+ // The page only knows the sum of the system bars; a three-button
5956
+ // navigation bar (48 px) is assumed for the split.
5957
+ const NAV_BAR_PX = 48;
5958
+ const rect = logo.getBoundingClientRect();
5959
+ const bars = Math.max(0, (window.screen?.height || window.innerHeight) - window.innerHeight);
5960
+ const topBar = Math.max(0, bars - NAV_BAR_PX);
5961
+ let targetY = (window.screen?.height || window.innerHeight) / 2 - topBar;
5962
+ // An implausible reading (split screen, a desktop window) falls back to
5963
+ // the middle of the page.
5964
+ if (!(Math.abs(targetY - window.innerHeight / 2) <= 60)) targetY = window.innerHeight / 2;
5965
+ root.style.setProperty('--startup-dx', `${window.innerWidth / 2 - (rect.left + rect.width / 2)}px`);
5966
+ root.style.setProperty('--startup-dy', `${targetY - (rect.top + rect.height / 2)}px`);
5967
+ root.style.setProperty('--startup-scale', String(LAUNCH_TILE_PX / rect.width));
5968
+ root.classList.add('is-animated');
5969
+ // Rest 200 ms, settle 500 ms; the label's fade ends at 850 ms.
5970
+ done = wait(850);
5971
+ }
5972
+ let left = null;
5973
+ const leave = (run) => {
5974
+ // Removed whatever happens on the way out: a start screen left over
5975
+ // the page would cover the whole app.
5976
+ if (!left) {
5977
+ left = (animated && root.isConnected ? done.then(run) : Promise.resolve())
5978
+ .catch(() => {})
5979
+ .then(() => { root.remove(); });
5980
+ }
5981
+ return left;
5982
+ };
5983
+ return {
5984
+ animated,
5985
+ // Into the app: fade the start screen away over the page below it.
5986
+ finish: () => leave(async () => {
5987
+ root.classList.add('is-leaving');
5988
+ await wait(150);
5989
+ }),
5990
+ // Into the sign-in box: the logo travels on into the box's own logo
5991
+ // place, shrinking to its size, and the box fades in around it.
5992
+ toAuth: () => leave(async () => {
5993
+ const box = document.querySelector('#auth-gate .auth-box');
5994
+ const target = box?.querySelector('.brand-logo')?.getBoundingClientRect();
5995
+ if (!box || !target || !target.width) return;
5996
+ box.style.opacity = '0';
5997
+ root.style.background = 'transparent';
5998
+ label.style.animation = 'none';
5999
+ label.style.opacity = '0';
6000
+ const from = logo.getBoundingClientRect();
6001
+ logo.style.animation = 'none';
6002
+ logo.style.transition = 'transform 450ms cubic-bezier(0.22, 1, 0.36, 1)';
6003
+ logo.style.transform = `translate(${target.left + target.width / 2 - (from.left + from.width / 2)}px, `
6004
+ + `${target.top + target.height / 2 - (from.top + from.height / 2)}px) scale(${target.width / from.width})`;
6005
+ await wait(450);
6006
+ box.style.transition = 'opacity 300ms ease';
6007
+ box.style.opacity = '1';
6008
+ await wait(300);
6009
+ box.style.transition = '';
6010
+ box.style.opacity = '';
6011
+ }),
6012
+ };
6013
+ })();
6014
+ </script>
5604
6015
 
5605
6016
  <!-- Sidebar overlay (mobile) -->
5606
6017
  <div id="sidebar-overlay" onclick="closeSidebar()"></div>
@@ -5643,6 +6054,7 @@
5643
6054
  </form>
5644
6055
  </div>
5645
6056
  <div id="chat-header-actions">
6057
+ <button id="pinned-messages-btn" class="header-icon-btn has-count" type="button" title="Pinned messages" aria-label="Pinned messages" hidden>📍<span class="header-icon-count"></span></button>
5646
6058
  <button id="message-search-btn" class="header-icon-btn" onclick="openMessageSearchModal()" title="Search messages" aria-label="Search messages">🔍</button>
5647
6059
  <button id="context-btn" class="header-icon-btn" onclick="showContext()" title="Check current context" aria-label="Check current context">🧠</button>
5648
6060
  <div id="chat-actions-menu-wrap">
@@ -6855,25 +7267,33 @@
6855
7267
  </div>
6856
7268
  </div>
6857
7269
 
6858
- <!-- Workspace file preview modal -->
7270
+ <!-- Fullscreen file viewer. The content fills the screen; the chrome bars
7271
+ float over it and a tap on the content shows or hides them. -->
6859
7272
  <div id="file-preview-modal" aria-hidden="true">
6860
7273
  <div class="file-preview-dialog" role="dialog" aria-modal="true" aria-label="File preview">
6861
- <div class="file-preview-header">
6862
- <div id="file-preview-title" class="file-preview-title">File preview</div>
6863
- <button class="file-preview-close" onclick="closeFilePreview()" aria-label="Close preview">×</button>
7274
+ <div id="file-preview-body" class="file-preview-body"></div>
7275
+ <div id="file-preview-chrome-top" class="file-preview-chrome file-preview-chrome-top">
7276
+ <div class="file-preview-header">
7277
+ <div id="file-preview-title" class="file-preview-title">File preview</div>
7278
+ <button class="file-preview-close" onclick="closeFilePreview()" aria-label="Close preview">×</button>
7279
+ </div>
7280
+ <div class="file-preview-toolbar">
7281
+ <button id="file-preview-back" class="file-preview-btn" type="button" onclick="goBackFilePreview()" hidden disabled>← Back</button>
7282
+ <button id="file-preview-mode-preview" class="file-preview-btn active" onclick="setFilePreviewMode('preview')">Preview</button>
7283
+ <button id="file-preview-mode-raw" class="file-preview-btn" onclick="setFilePreviewMode('raw')">Raw</button>
7284
+ <button id="file-preview-html-toggle" class="file-preview-btn" onclick="toggleFilePreviewHtml()">Show literal HTML</button>
7285
+ <a id="file-preview-open-raw" class="file-preview-btn" download>Download</a>
7286
+ <button id="file-preview-copy-btn" class="file-preview-btn" type="button" onclick="copyFilePreviewImage()" hidden>Copy</button>
7287
+ <button id="file-preview-annotate-btn" class="file-preview-btn" type="button" onclick="openAnnotateEditorForPreview()" hidden>🖍️ Annotate</button>
7288
+ <span id="file-preview-warning" class="file-preview-warning">⚠ Safe layout HTML enabled; active content is blocked</span>
7289
+ </div>
7290
+ <div id="file-preview-meta" class="file-preview-meta"></div>
6864
7291
  </div>
6865
- <div class="file-preview-toolbar">
6866
- <button id="file-preview-back" class="file-preview-btn" type="button" onclick="goBackFilePreview()" hidden disabled>← Back</button>
6867
- <button id="file-preview-mode-preview" class="file-preview-btn active" onclick="setFilePreviewMode('preview')">Preview</button>
6868
- <button id="file-preview-mode-raw" class="file-preview-btn" onclick="setFilePreviewMode('raw')">Raw</button>
6869
- <button id="file-preview-html-toggle" class="file-preview-btn" onclick="toggleFilePreviewHtml()">Show literal HTML</button>
6870
- <a id="file-preview-open-raw" class="file-preview-btn" download>Download</a>
6871
- <button id="file-preview-copy-btn" class="file-preview-btn" type="button" onclick="copyFilePreviewImage()" hidden>Copy</button>
6872
- <button id="file-preview-annotate-btn" class="file-preview-btn" type="button" onclick="openAnnotateEditorForPreview()" hidden>🖍️ Annotate</button>
6873
- <span id="file-preview-warning" class="file-preview-warning">⚠ Safe layout HTML enabled; active content is blocked</span>
7292
+ <div id="file-preview-chrome-bottom" class="file-preview-chrome file-preview-chrome-bottom" hidden>
7293
+ <button id="file-preview-prev" class="file-preview-btn file-preview-nav" type="button" aria-label="Previous file">‹</button>
7294
+ <span id="file-preview-position" class="file-preview-position"></span>
7295
+ <button id="file-preview-next" class="file-preview-btn file-preview-nav" type="button" aria-label="Next file">›</button>
6874
7296
  </div>
6875
- <div id="file-preview-meta" class="file-preview-meta"></div>
6876
- <div id="file-preview-body" class="file-preview-body"></div>
6877
7297
  </div>
6878
7298
  </div>
6879
7299
 
@@ -6908,8 +7328,8 @@
6908
7328
 
6909
7329
  <script>
6910
7330
  window.__loadCopilotApp().catch((error) => {
6911
- document.getElementById('startup-loading')?.remove();
6912
7331
  document.getElementById('auth-gate').style.display = 'flex';
7332
+ void window.__startupSplash.toAuth();
6913
7333
  const errorEl = document.getElementById('auth-error');
6914
7334
  if (errorEl) errorEl.textContent = `App failed to initialize: ${String(error?.message || error || 'unknown error')}`;
6915
7335
  });