@oar-sh/oar 0.9.8 → 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 (41) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +53 -32
  3. package/bin/oar.js +814 -228
  4. package/package.json +4 -5
  5. package/server/README.md +20 -1
  6. package/server/db-schema.mjs +8 -0
  7. package/server/migrations/0008-message-pins.mjs +85 -0
  8. package/server/public/app/api-client.js +30 -0
  9. package/server/public/app/attachments-view.js +508 -125
  10. package/server/public/app/back-guard.mjs +58 -0
  11. package/server/public/app/bootstrap.js +46 -2
  12. package/server/public/app/conversation-view.js +110 -25
  13. package/server/public/app/external-link-policy.mjs +4 -1
  14. package/server/public/app/file-version.mjs +58 -0
  15. package/server/public/app/journal-view.js +16 -2
  16. package/server/public/app/pinned-messages-view.mjs +253 -0
  17. package/server/public/app/pwa-install.js +1 -1
  18. package/server/public/app/relay-dot-state.mjs +8 -1
  19. package/server/public/app/router.js +8 -2
  20. package/server/public/app/socket-handlers.js +6 -0
  21. package/server/public/app/store.js +6 -0
  22. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  23. package/server/public/app/viewer-controls-state.mjs +59 -0
  24. package/server/public/app/viewer-gallery.mjs +46 -0
  25. package/server/public/app/viewer-gestures.mjs +105 -0
  26. package/server/public/app/viewer-history.mjs +43 -0
  27. package/server/public/app/viewer-tap.mjs +112 -0
  28. package/server/public/index.html +304 -54
  29. package/server/repositories/message-pin-statements.mjs +39 -0
  30. package/server/repositories/message-repository.mjs +5 -0
  31. package/server/routes/messages-routes.mjs +33 -61
  32. package/server/routes/sessions-routes.mjs +92 -24
  33. package/server/server-runtime.mjs +29 -0
  34. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  35. package/server/services/file-serving.mjs +155 -0
  36. package/server/services/message-pin-list.mjs +52 -0
  37. package/server/services/oar-cli-helpers.mjs +139 -4
  38. package/server/services/oar-cli-menu.mjs +240 -0
  39. package/server/services/oar-cli-wsl.mjs +129 -0
  40. package/server/services/safe-served-content.mjs +61 -11
  41. package/server/services/session-history-refresh-service.mjs +24 -0
@@ -0,0 +1,112 @@
1
+ // Telling a tap from everything else that happens on the viewer's content.
2
+ //
3
+ // A tap is one pointer that goes down and comes up in place, quickly. A drag
4
+ // (panning a zoomed image, selecting text), a pinch (a second pointer) and a
5
+ // long press are not taps. Neither is the first half of a double tap: the
6
+ // tap is reported only once the double-tap window has passed without a
7
+ // second pointer going down, so a double tap zooms without first flipping
8
+ // the controls. Taps on things that act by themselves (links, buttons, a
9
+ // player's own controls) are left to them.
10
+
11
+ export const TAP_MAX_MOVE_PX = 10;
12
+ export const TAP_MAX_DURATION_MS = 500;
13
+ export const DOUBLE_TAP_WINDOW_MS = 300;
14
+
15
+ const INTERACTIVE_SELECTOR = 'a, button, input, textarea, select, summary, video, audio, [contenteditable], [data-no-viewer-tap]';
16
+
17
+ /**
18
+ * Pure tap recognizer fed with pointer events of the shape
19
+ * `{ type, pointerId, clientX, clientY, timeStamp, target }`. Calls `onTap`
20
+ * with the final pointer position, `isInteractive(target)` says whether a
21
+ * target handles taps itself (defaults to the usual controls), and
22
+ * `hasSelection()` lets a text selection that ended under the pointer count
23
+ * as a drag rather than a tap.
24
+ */
25
+ export function createTapRecognizer({
26
+ onTap,
27
+ isInteractive = defaultIsInteractive,
28
+ hasSelection = () => false,
29
+ setTimeoutImpl = setTimeout,
30
+ clearTimeoutImpl = clearTimeout,
31
+ } = {}) {
32
+ const state = {
33
+ active: null, // the pointer being watched: { id, x, y, at }
34
+ pointers: 0, // pointers currently down
35
+ pending: null, // timer holding a tap until the double-tap window passes
36
+ cancelled: false, // a second pointer joined or the pointer moved too far
37
+ };
38
+
39
+ const dropPending = () => {
40
+ if (state.pending === null) return;
41
+ clearTimeoutImpl(state.pending);
42
+ state.pending = null;
43
+ };
44
+
45
+ return {
46
+ handle(event) {
47
+ const type = String(event?.type || '');
48
+ if (type === 'pointerdown') {
49
+ state.pointers += 1;
50
+ // A second pointer down while a tap waits to be reported: the start
51
+ // of a double tap (or a pinch); either way not a single tap.
52
+ if (state.pending !== null) {
53
+ dropPending();
54
+ state.cancelled = true;
55
+ state.active = null;
56
+ return;
57
+ }
58
+ if (state.pointers > 1) {
59
+ state.cancelled = true;
60
+ state.active = null;
61
+ return;
62
+ }
63
+ if (isInteractive(event.target)) {
64
+ state.active = null;
65
+ return;
66
+ }
67
+ state.cancelled = false;
68
+ state.active = { id: event.pointerId, x: event.clientX, y: event.clientY, at: event.timeStamp };
69
+ return;
70
+ }
71
+ if (type === 'pointermove') {
72
+ if (!state.active || event.pointerId !== state.active.id) return;
73
+ if (Math.hypot(event.clientX - state.active.x, event.clientY - state.active.y) > TAP_MAX_MOVE_PX) {
74
+ state.cancelled = true;
75
+ state.active = null;
76
+ }
77
+ return;
78
+ }
79
+ if (type === 'pointerup' || type === 'pointercancel') {
80
+ state.pointers = Math.max(0, state.pointers - 1);
81
+ const active = state.active;
82
+ if (!active || event.pointerId !== active.id) {
83
+ if (state.pointers === 0) state.cancelled = false;
84
+ return;
85
+ }
86
+ state.active = null;
87
+ if (type === 'pointercancel' || state.cancelled) { state.cancelled = false; return; }
88
+ if (event.timeStamp - active.at > TAP_MAX_DURATION_MS) return;
89
+ if (Math.hypot(event.clientX - active.x, event.clientY - active.y) > TAP_MAX_MOVE_PX) return;
90
+ if (hasSelection()) return;
91
+ const point = { clientX: event.clientX, clientY: event.clientY };
92
+ dropPending();
93
+ state.pending = setTimeoutImpl(() => {
94
+ state.pending = null;
95
+ onTap?.(point);
96
+ }, DOUBLE_TAP_WINDOW_MS);
97
+ }
98
+ },
99
+ /** Forget a tap in flight, e.g. when the content is replaced. */
100
+ cancel() {
101
+ dropPending();
102
+ state.active = null;
103
+ state.cancelled = false;
104
+ state.pointers = 0;
105
+ },
106
+ };
107
+ }
108
+
109
+ export function defaultIsInteractive(target) {
110
+ if (!target || typeof target.closest !== 'function') return false;
111
+ return Boolean(target.closest(INTERACTIVE_SELECTOR));
112
+ }
@@ -572,6 +572,21 @@
572
572
  opacity: 0.5;
573
573
  cursor: not-allowed;
574
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; }
575
590
  #cli-dot {
576
591
  width: 12px;
577
592
  height: 12px;
@@ -1771,8 +1786,13 @@
1771
1786
  opacity: 0;
1772
1787
  pointer-events: none;
1773
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. */
1774
1793
  .msg-bubble:hover .msg-share-visibility-btn[data-hidden-from-shares="false"],
1775
- .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"] {
1776
1796
  opacity: 1;
1777
1797
  pointer-events: auto;
1778
1798
  }
@@ -1780,7 +1800,46 @@
1780
1800
  .msg.user .msg-share-visibility-btn { color: var(--user-bubble-fg); opacity: 0.8; }
1781
1801
  .msg.user .msg-share-visibility-btn[data-hidden-from-shares="false"] { opacity: 0; }
1782
1802
  .msg.user .msg-bubble:hover .msg-share-visibility-btn[data-hidden-from-shares="false"],
1783
- .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; }
1784
1843
  .thinking-thoughts-panel {
1785
1844
  margin-bottom: 8px;
1786
1845
  min-width: 0;
@@ -3226,28 +3285,62 @@
3226
3285
  .workspace-file-link { text-decoration: underline; }
3227
3286
  .workspace-file-link:hover { opacity: 0.9; }
3228
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. */
3229
3292
  #file-preview-modal {
3230
3293
  position: fixed; inset: 0; z-index: 11000;
3231
- display: none; align-items: center; justify-content: center;
3232
- background: rgba(0, 0, 0, 0.65);
3233
- padding: 18px;
3294
+ display: none;
3295
+ background: var(--bg2);
3296
+ --file-viewer-chrome-top: 0px;
3234
3297
  }
3235
- #file-preview-modal.visible { display: flex; }
3298
+ #file-preview-modal.visible { display: block; }
3299
+ #file-preview-modal.media-mode { background: #000; }
3236
3300
  .file-preview-dialog {
3237
- width: min(1100px, 100%);
3238
- max-height: min(90vh, 900px);
3239
- display: flex;
3240
- flex-direction: column;
3241
- background: var(--bg2);
3242
- border: 1px solid var(--border);
3243
- border-radius: 14px;
3301
+ position: absolute; inset: 0;
3244
3302
  overflow: hidden;
3245
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; }
3246
3341
  .file-preview-header {
3247
3342
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
3248
3343
  padding: 12px 14px;
3249
- border-bottom: 1px solid var(--border);
3250
- background: var(--bg3);
3251
3344
  }
3252
3345
  .file-preview-title {
3253
3346
  min-width: 0;
@@ -3272,23 +3365,35 @@
3272
3365
  }
3273
3366
  .file-preview-toolbar {
3274
3367
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
3275
- padding: 10px 14px;
3276
- border-bottom: 1px solid var(--border);
3368
+ padding: 0 14px 10px;
3277
3369
  }
3278
3370
  .file-preview-btn {
3279
3371
  border: 1px solid var(--border);
3280
- background: var(--bg3);
3372
+ background: var(--bg2);
3281
3373
  color: var(--text);
3282
3374
  border-radius: 8px;
3283
3375
  padding: 6px 10px;
3284
3376
  font-size: var(--fs-meta-md);
3285
3377
  cursor: pointer;
3378
+ text-decoration: none;
3286
3379
  }
3287
3380
  .file-preview-btn.active {
3288
3381
  border-color: var(--accent);
3289
3382
  color: var(--accent);
3290
3383
  }
3291
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); }
3292
3397
  .file-preview-warning {
3293
3398
  display: none;
3294
3399
  margin-left: auto;
@@ -3297,16 +3402,30 @@
3297
3402
  }
3298
3403
  .file-preview-warning.visible { display: inline; }
3299
3404
  .file-preview-meta {
3300
- padding: 8px 14px;
3405
+ padding: 0 14px 8px;
3301
3406
  font-size: var(--fs-meta-sm);
3302
3407
  color: var(--muted);
3303
- border-bottom: 1px dashed var(--border);
3304
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. */
3305
3412
  .file-preview-body {
3306
- flex: 1;
3307
- min-height: 0; /* prevents flex child from overflowing the dialog's max-height */
3413
+ position: absolute; inset: 0;
3308
3414
  overflow: auto;
3309
- 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;
3310
3429
  }
3311
3430
  .file-preview-markdown {
3312
3431
  line-height: 1.5;
@@ -3369,42 +3488,71 @@
3369
3488
  user-select: none;
3370
3489
  -webkit-user-drag: none;
3371
3490
  }
3372
- .file-preview-video-shell {
3491
+ .file-preview-video-shell,
3492
+ .file-preview-audio-shell {
3373
3493
  display: flex;
3374
3494
  flex-direction: column;
3495
+ align-items: center;
3496
+ justify-content: center;
3375
3497
  gap: 10px;
3376
3498
  width: 100%;
3377
3499
  height: 100%;
3378
3500
  }
3379
3501
  .file-preview-video {
3380
- width: 100%;
3381
3502
  max-width: 100%;
3382
- max-height: min(72vh, 100%);
3503
+ max-height: 100%;
3504
+ width: auto;
3505
+ height: auto;
3383
3506
  background: #000;
3384
- border-radius: 10px;
3385
3507
  }
3386
- /* 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. */
3387
3514
  .file-preview-body.image-zoom-mode,
3388
- .file-preview-body.video-preview-mode {
3389
- flex: 1;
3390
- 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 {
3391
3518
  overflow: hidden;
3392
3519
  padding: 0;
3393
3520
  display: flex;
3394
3521
  flex-direction: column;
3395
- background: #0b0f14;
3522
+ touch-action: none;
3396
3523
  }
3397
3524
  .file-preview-body.image-zoom-mode .file-preview-image {
3398
3525
  flex: 1;
3399
- min-height: 0; /* same reason — lets the img's max-height:100% resolve */
3400
- 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);
3401
3540
  }
3402
- .file-preview-body.video-preview-mode .file-preview-video-shell {
3403
- flex: 1;
3404
- min-height: 0;
3405
- padding: 12px 14px;
3406
- 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;
3407
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;
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; }
3408
3556
  .msg-attachment-video-chip {
3409
3557
  display: inline-flex;
3410
3558
  align-items: center;
@@ -3421,6 +3569,11 @@
3421
3569
  color: var(--muted);
3422
3570
  margin-top: 10px;
3423
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
+ }
3424
3577
 
3425
3578
  /* Annotation editor. Clones the file-preview shell, but sits above it
3426
3579
  (z-index 11500) so annotating a sent image can layer over the lightbox. */
@@ -4224,6 +4377,84 @@
4224
4377
  font-size: var(--fs-meta-sm);
4225
4378
  padding: 8px 6px;
4226
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
+ }
4227
4458
 
4228
4459
  #git-changes-modal,
4229
4460
  #git-diff-modal {
@@ -5304,6 +5535,7 @@
5304
5535
  }
5305
5536
  #pending-question-banner { font-size: var(--fs-ui-chip); padding: 3px 8px; }
5306
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; }
5307
5539
  #chat-actions-menu { min-width: 200px; }
5308
5540
  /* Slightly taller than desktop so the tap target barely changes; the row
5309
5541
  pitch still drops from 36px to 32px thanks to the tighter menu gap. */
@@ -5541,6 +5773,15 @@
5541
5773
  flex: 1 1 calc(50% - 4px);
5542
5774
  min-height: 40px;
5543
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
+ }
5544
5785
  .model-variant-row {
5545
5786
  min-height: 48px;
5546
5787
  padding: 10px 4px;
@@ -5813,6 +6054,7 @@
5813
6054
  </form>
5814
6055
  </div>
5815
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>
5816
6058
  <button id="message-search-btn" class="header-icon-btn" onclick="openMessageSearchModal()" title="Search messages" aria-label="Search messages">🔍</button>
5817
6059
  <button id="context-btn" class="header-icon-btn" onclick="showContext()" title="Check current context" aria-label="Check current context">🧠</button>
5818
6060
  <div id="chat-actions-menu-wrap">
@@ -7025,25 +7267,33 @@
7025
7267
  </div>
7026
7268
  </div>
7027
7269
 
7028
- <!-- 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. -->
7029
7272
  <div id="file-preview-modal" aria-hidden="true">
7030
7273
  <div class="file-preview-dialog" role="dialog" aria-modal="true" aria-label="File preview">
7031
- <div class="file-preview-header">
7032
- <div id="file-preview-title" class="file-preview-title">File preview</div>
7033
- <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>
7034
7291
  </div>
7035
- <div class="file-preview-toolbar">
7036
- <button id="file-preview-back" class="file-preview-btn" type="button" onclick="goBackFilePreview()" hidden disabled>← Back</button>
7037
- <button id="file-preview-mode-preview" class="file-preview-btn active" onclick="setFilePreviewMode('preview')">Preview</button>
7038
- <button id="file-preview-mode-raw" class="file-preview-btn" onclick="setFilePreviewMode('raw')">Raw</button>
7039
- <button id="file-preview-html-toggle" class="file-preview-btn" onclick="toggleFilePreviewHtml()">Show literal HTML</button>
7040
- <a id="file-preview-open-raw" class="file-preview-btn" download>Download</a>
7041
- <button id="file-preview-copy-btn" class="file-preview-btn" type="button" onclick="copyFilePreviewImage()" hidden>Copy</button>
7042
- <button id="file-preview-annotate-btn" class="file-preview-btn" type="button" onclick="openAnnotateEditorForPreview()" hidden>🖍️ Annotate</button>
7043
- <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>
7044
7296
  </div>
7045
- <div id="file-preview-meta" class="file-preview-meta"></div>
7046
- <div id="file-preview-body" class="file-preview-body"></div>
7047
7297
  </div>
7048
7298
  </div>
7049
7299
 
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // Pinned messages: the pinned_at column detection and its SQL in one place.
4
+ // The statements are null on a database the 0008 migration has not reached
5
+ // yet; every caller treats that as "no pins".
6
+ export function createMessagePinStatements(db) {
7
+ const messageColumns = new Set(
8
+ db.prepare(`PRAGMA table_info(messages)`).all().map((column) => String(column?.name || '').trim()),
9
+ );
10
+ const messagesSupportPins = messageColumns.has('pinned_at');
11
+ if (!messagesSupportPins) {
12
+ return {
13
+ messagesSupportPins,
14
+ setMessagePinnedAt: null,
15
+ listPinnedMessages: null,
16
+ countPinnedMessages: null,
17
+ };
18
+ }
19
+ return {
20
+ messagesSupportPins,
21
+ setMessagePinnedAt: db.prepare(`
22
+ UPDATE messages
23
+ SET pinned_at = ?
24
+ WHERE id = ? AND conversation_id = ?
25
+ `),
26
+ // Conversation order, not pin order: the list reads like a table of contents.
27
+ listPinnedMessages: db.prepare(`
28
+ SELECT id, role, text, mode, attachments, timestamp, pinned_at, hidden_from_shares
29
+ FROM messages
30
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
31
+ ORDER BY timestamp ASC
32
+ `),
33
+ countPinnedMessages: db.prepare(`
34
+ SELECT COUNT(*) AS cnt
35
+ FROM messages
36
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
37
+ `),
38
+ };
39
+ }
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
+ import { createMessagePinStatements } from './message-pin-statements.mjs';
4
5
  import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
5
6
 
6
7
  export function createMessageRepository(db) {
7
8
  const shareVisibility = createShareVisibilityStatements(db);
9
+ const messagePins = createMessagePinStatements(db);
8
10
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
11
  .some((column) => column.name === 'image_operation_id');
10
12
  // A turn paused at the Claude usage limit (usage-limit-pause-service): a
@@ -27,6 +29,9 @@ export function createMessageRepository(db) {
27
29
  getSharedMessages: shareVisibility.getSharedMessages,
28
30
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
29
31
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
32
+ setMessagePinnedAt: messagePins.setMessagePinnedAt,
33
+ listPinnedMessages: messagePins.listPinnedMessages,
34
+ countPinnedMessages: messagePins.countPinnedMessages,
30
35
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
31
36
  getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
32
37
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),