@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
@@ -0,0 +1,253 @@
1
+ // Pinned messages: the per-conversation pin list, the 📍 header button with
2
+ // its count, and the list modal (the shared summary modal, kind
3
+ // 'pinned-messages'). Fed by the conversation payload's `pins` on load and by
4
+ // the `conversation_pins_updated` socket event (REPLACE semantics per
5
+ // conversation), so a pin set on another device shows here without a reload.
6
+ // This list is also what a bubble's Pin / Unpin label is drawn from.
7
+
8
+ import {
9
+ IS_SHARED_VIEW,
10
+ closeSummaryModal,
11
+ escHtml,
12
+ fmtDate,
13
+ openSummaryModal,
14
+ renderSummaryModalContent,
15
+ showTransientRelayNotice,
16
+ summaryModalState,
17
+ } from './store.js';
18
+ import { updateMessagePin } from './api-client.js';
19
+
20
+ export const PINNED_MESSAGES_MODAL_KIND = 'pinned-messages';
21
+
22
+ const pinsByConversation = new Map();
23
+ // The relay numbers its pin lists. A conversation load that left before a pin
24
+ // change can arrive after the change's own socket event: the older list must
25
+ // not win, or the page would stay wrong until the next load.
26
+ const pinRevisionByConversation = new Map();
27
+ const pinTogglesInFlight = new Set();
28
+ let currentConversationId = null;
29
+ let modalBodyBound = false;
30
+ // The transcript lives in conversation-view.js and journal-view.js, which
31
+ // import this module; they hand their side in through initPinnedMessagesView.
32
+ let handlers = {
33
+ openConversation: null,
34
+ focusMessage: null,
35
+ syncRenderedPinState: null,
36
+ };
37
+
38
+ function normalizePins(pins) {
39
+ return (Array.isArray(pins) ? pins : [])
40
+ .map((pin) => ({ ...pin, messageId: String(pin?.messageId || '').trim() }))
41
+ .filter((pin) => pin.messageId);
42
+ }
43
+
44
+ export function getConversationPins(conversationId) {
45
+ return pinsByConversation.get(String(conversationId || '').trim()) || [];
46
+ }
47
+
48
+ export function isMessagePinned(conversationId, messageId) {
49
+ const id = String(messageId || '').trim();
50
+ return !!id && getConversationPins(conversationId).some((pin) => pin.messageId === id);
51
+ }
52
+
53
+ export function pinnedCountLabel(count) {
54
+ const total = Number(count) || 0;
55
+ return total ? `${total} pinned` : '';
56
+ }
57
+
58
+ function pinRoleLabel(role) {
59
+ return String(role || '').trim().toLowerCase() === 'user' ? 'You' : 'Agent';
60
+ }
61
+
62
+ export function renderPinnedListHtml(pins) {
63
+ const list = normalizePins(pins);
64
+ if (!list.length) {
65
+ return '<div class="pinned-empty">No pinned messages. Use Pin on a message to add one.</div>';
66
+ }
67
+ return `<div class="pinned-list">${list.map((pin) => {
68
+ const id = escHtml(pin.messageId);
69
+ const when = fmtDate(pin.timestamp);
70
+ const attachmentCount = Number(pin.attachmentCount) || 0;
71
+ const meta = [
72
+ escHtml(pinRoleLabel(pin.role)),
73
+ when ? escHtml(when) : '',
74
+ attachmentCount ? `📎 ${attachmentCount}` : '',
75
+ pin.hiddenFromShares === true ? '<span class="pinned-row-chip">hidden from shared</span>' : '',
76
+ ].filter(Boolean).join(' · ');
77
+ const preview = String(pin.preview || '').trim();
78
+ const previewHtml = preview
79
+ ? escHtml(preview)
80
+ : '<span class="pinned-row-no-text">(no text)</span>';
81
+ return `
82
+ <div class="pinned-row">
83
+ <button type="button" class="pinned-row-jump" data-pin-jump="${id}">
84
+ <span class="pinned-row-meta">${meta}</span>
85
+ <span class="pinned-row-preview">${previewHtml}</span>
86
+ </button>
87
+ <button type="button" class="pinned-row-unpin" data-pin-unpin="${id}" title="Unpin" aria-label="Unpin">🗑</button>
88
+ </div>`;
89
+ }).join('')}</div>`;
90
+ }
91
+
92
+ function syncPinnedHeaderButton() {
93
+ const button = document.getElementById('pinned-messages-btn');
94
+ if (!button) return;
95
+ const count = getConversationPins(currentConversationId).length;
96
+ button.hidden = IS_SHARED_VIEW || !count;
97
+ const countEl = button.querySelector('.header-icon-count');
98
+ if (countEl) countEl.textContent = count ? String(count) : '';
99
+ const label = count ? `Pinned messages (${count})` : 'Pinned messages';
100
+ button.setAttribute('title', label);
101
+ button.setAttribute('aria-label', label);
102
+ }
103
+
104
+ function pinnedModalContent() {
105
+ const pins = getConversationPins(currentConversationId);
106
+ return {
107
+ title: '📍 Pinned messages',
108
+ subtitle: pinnedCountLabel(pins.length),
109
+ bodyHtml: renderPinnedListHtml(pins),
110
+ kind: PINNED_MESSAGES_MODAL_KIND,
111
+ };
112
+ }
113
+
114
+ function syncCurrentConversationPins() {
115
+ syncPinnedHeaderButton();
116
+ if (summaryModalState.kind === PINNED_MESSAGES_MODAL_KIND) {
117
+ renderSummaryModalContent(pinnedModalContent());
118
+ }
119
+ handlers.syncRenderedPinState?.(
120
+ new Set(getConversationPins(currentConversationId).map((pin) => pin.messageId)),
121
+ );
122
+ }
123
+
124
+ export function setConversationPins(conversationId, pins, revision = 0) {
125
+ const id = String(conversationId || '').trim();
126
+ if (!id) return;
127
+ const nextRevision = Number(revision) || 0;
128
+ if (nextRevision) {
129
+ if (nextRevision < (pinRevisionByConversation.get(id) || 0)) return;
130
+ pinRevisionByConversation.set(id, nextRevision);
131
+ }
132
+ const normalized = normalizePins(pins);
133
+ // The list arrives with every conversation load, the live poll included: an
134
+ // unchanged one must not redraw the open modal under the user's finger.
135
+ if (JSON.stringify(normalized) === JSON.stringify(getConversationPins(id))) return;
136
+ if (normalized.length) pinsByConversation.set(id, normalized);
137
+ else pinsByConversation.delete(id);
138
+ if (id === currentConversationId) syncCurrentConversationPins();
139
+ }
140
+
141
+ export function setPinsConversation(conversationId) {
142
+ const id = String(conversationId || '').trim() || null;
143
+ if (id === currentConversationId) return;
144
+ currentConversationId = id;
145
+ // The conversation was closed (deleted, new chat): its list goes with it.
146
+ if (!id && summaryModalState.kind === PINNED_MESSAGES_MODAL_KIND) closeSummaryModal();
147
+ syncCurrentConversationPins();
148
+ }
149
+
150
+ export function openPinnedMessagesModal() {
151
+ if (IS_SHARED_VIEW) return;
152
+ openSummaryModal(pinnedModalContent());
153
+ }
154
+
155
+ function setPinButtonBusy(messageId, busy, pinned) {
156
+ const button = document.querySelector(`.msg-pin-btn[data-message-id="${CSS.escape(messageId)}"]`);
157
+ if (!button) return;
158
+ button.disabled = busy;
159
+ button.textContent = busy
160
+ ? (pinned ? 'Unpinning…' : 'Pinning…')
161
+ : (pinned ? 'Unpin' : 'Pin');
162
+ }
163
+
164
+ // `pinned` is the state the control showed when it was pressed; the request
165
+ // asks for the opposite. The answer carries the conversation's whole pin list,
166
+ // stored under that conversation's id: if the user has switched conversations
167
+ // meanwhile, the open one is not touched.
168
+ export async function toggleMessagePin(conversationId, messageId, pinned) {
169
+ const conversationKey = String(conversationId || '').trim();
170
+ const targetMessageId = String(messageId || '').trim();
171
+ if (!conversationKey || !targetMessageId) return;
172
+ const flightKey = `${conversationKey}:${targetMessageId}`;
173
+ if (pinTogglesInFlight.has(flightKey)) return;
174
+
175
+ pinTogglesInFlight.add(flightKey);
176
+ setPinButtonBusy(targetMessageId, true, pinned);
177
+ const result = await updateMessagePin(conversationKey, targetMessageId, !pinned);
178
+ pinTogglesInFlight.delete(flightKey);
179
+ if (!result?.ok) {
180
+ if (conversationKey === currentConversationId) setPinButtonBusy(targetMessageId, false, pinned);
181
+ showTransientRelayNotice(String(result?.error || '').trim() || (pinned
182
+ ? 'Could not unpin the message.'
183
+ : 'Could not pin the message.'));
184
+ return;
185
+ }
186
+
187
+ showTransientRelayNotice(result.pinned ? 'Message pinned.' : 'Message unpinned.');
188
+ if (conversationKey === currentConversationId) setPinButtonBusy(targetMessageId, false, result.pinned === true);
189
+ setConversationPins(conversationKey, result.pins, result.pinsRevision);
190
+ }
191
+
192
+ function isMessageOnPage(messageId) {
193
+ return !!document.querySelector(`#messages .msg[data-message-id="${CSS.escape(messageId)}"]`);
194
+ }
195
+
196
+ // On the page already: scroll there. Otherwise the path message search takes:
197
+ // the relay answers with the messages around the target and the history
198
+ // loaders page on from that window in both directions.
199
+ export async function jumpToPinnedMessage(messageId) {
200
+ const targetMessageId = String(messageId || '').trim();
201
+ const conversationId = currentConversationId;
202
+ if (!targetMessageId || !conversationId) return;
203
+ closeSummaryModal();
204
+ if (handlers.focusMessage?.(targetMessageId)) return;
205
+
206
+ await handlers.openConversation?.(conversationId, {
207
+ aroundMessageId: targetMessageId,
208
+ focusMessageId: targetMessageId,
209
+ });
210
+ if (currentConversationId !== conversationId || isMessageOnPage(targetMessageId)) return;
211
+ showTransientRelayNotice('That message is no longer in this conversation.');
212
+ // A message the relay no longer has answers with an empty window; put the
213
+ // end of the conversation back instead of leaving the page blank.
214
+ if (!document.querySelector('#messages .msg[data-message-id]')) {
215
+ await handlers.openConversation?.(conversationId);
216
+ }
217
+ }
218
+
219
+ function handlePinnedModalClick(event) {
220
+ if (summaryModalState.kind !== PINNED_MESSAGES_MODAL_KIND) return;
221
+ const target = event.target instanceof Element ? event.target : null;
222
+ const unpinBtn = target?.closest('[data-pin-unpin]');
223
+ if (unpinBtn) {
224
+ event.preventDefault();
225
+ void toggleMessagePin(currentConversationId, unpinBtn.getAttribute('data-pin-unpin'), true);
226
+ return;
227
+ }
228
+ const jumpBtn = target?.closest('[data-pin-jump]');
229
+ if (jumpBtn) {
230
+ event.preventDefault();
231
+ void jumpToPinnedMessage(jumpBtn.getAttribute('data-pin-jump'));
232
+ }
233
+ }
234
+
235
+ export function initPinnedMessagesView(nextHandlers = {}) {
236
+ handlers = { ...handlers, ...nextHandlers };
237
+ const button = document.getElementById('pinned-messages-btn');
238
+ if (button && button.dataset.bound !== '1') {
239
+ button.dataset.bound = '1';
240
+ button.addEventListener('click', (event) => {
241
+ event.preventDefault();
242
+ openPinnedMessagesModal();
243
+ });
244
+ }
245
+ // #summary-modal-body outlives every modal, so it is bound exactly once; the
246
+ // handler no-ops unless the pin list is the modal currently mounted.
247
+ const modalBody = document.getElementById('summary-modal-body');
248
+ if (modalBody && !modalBodyBound) {
249
+ modalBodyBound = true;
250
+ modalBody.addEventListener('click', handlePinnedModalClick);
251
+ }
252
+ syncPinnedHeaderButton();
253
+ }
@@ -25,7 +25,7 @@ function matchesDisplayMode(query) {
25
25
  }
26
26
  }
27
27
 
28
- function isInstalledAppMode() {
28
+ export function isInstalledAppMode() {
29
29
  const standalone = matchesDisplayMode('(display-mode: standalone)');
30
30
  const minimalUi = matchesDisplayMode('(display-mode: minimal-ui)');
31
31
  const launchedFromAndroidApp = String(document.referrer || '').startsWith('android-app://');
@@ -52,6 +52,11 @@ function shouldUseImmersiveTopLayout() {
52
52
  function syncThemeColor(immersive) {
53
53
  const meta = document.querySelector('meta[name="theme-color"]');
54
54
  if (!meta) return;
55
+ // Day mode keeps the white the head script (and a Theme change) set.
56
+ if (document.documentElement.getAttribute('data-theme') === 'light') {
57
+ meta.setAttribute('content', '#ffffff');
58
+ return;
59
+ }
55
60
  meta.setAttribute('content', immersive ? THEME_COLOR_IMMERSIVE : THEME_COLOR_BASE);
56
61
  }
57
62
 
@@ -9,7 +9,9 @@
9
9
  * A managed-but-disconnected tunnel stays green rather than going grey: the
10
10
  * relay itself is still reachable, and grey would claim otherwise. The tooltip
11
11
  * carries the bad news instead. Note this state is only ever visible locally —
12
- * a remote browser whose tunnel is down cannot load the page at all.
12
+ * a remote browser whose tunnel is down cannot load the page at all. A tunnel
13
+ * that never started because cloudflared is not installed reads "off" there,
14
+ * followed by the relay's sentence on how to install it.
13
15
  *
14
16
  * `tone` is the same state as `className`, minus the dot's CSS baggage. The
15
17
  * mobile burger toggle dyes its bars from it, because the dot itself sits in
@@ -24,6 +26,7 @@ function normalizeTunnel(tunnel) {
24
26
  if (!managed) return null;
25
27
  return {
26
28
  connected: tunnel.connected === true,
29
+ binaryMissing: tunnel.binaryMissing === true,
27
30
  lastError: String(tunnel.lastError || '').trim(),
28
31
  };
29
32
  }
@@ -56,6 +59,10 @@ export function resolveRelayDotState({
56
59
  let reach = 'Web relay reachable';
57
60
  if (tunnelled) {
58
61
  reach = 'Web relay reachable via Cloudflare Tunnel';
62
+ } else if (tunnel?.binaryMissing) {
63
+ reach = tunnel.lastError
64
+ ? `Web relay reachable; Cloudflare tunnel off: ${tunnel.lastError}`
65
+ : 'Web relay reachable; Cloudflare tunnel off: cloudflared is not installed';
59
66
  } else if (tunnel) {
60
67
  reach = tunnel.lastError
61
68
  ? `Web relay reachable; Cloudflare tunnel disconnected (${tunnel.lastError})`
@@ -1,3 +1,4 @@
1
+ import { versionedFileHref } from './file-version.mjs';
1
2
  import {
2
3
  BASE,
3
4
  escHtml,
@@ -116,10 +117,15 @@ export function normalizeDriveBrowserPath(rawPath) {
116
117
  return rest ? `${drive}/${rest}` : drive;
117
118
  }
118
119
 
119
- export function driveFileHrefFromPath(rawPath) {
120
+ /**
121
+ * `version` is the file's version token when the caller knows it (a preview
122
+ * carries one): the address then changes with the file, so an older copy
123
+ * cannot be served for it. The server ignores the parameter.
124
+ */
125
+ export function driveFileHrefFromPath(rawPath, version = '') {
120
126
  const normalized = normalizeDriveBrowserPath(rawPath);
121
127
  if (!normalized) return '';
122
- return `${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}`;
128
+ return versionedFileHref(`${BASE}/api/drives/file?path=${encodeURIComponent(normalized)}`, version);
123
129
  }
124
130
 
125
131
  /**
@@ -0,0 +1,78 @@
1
+ // Desktop scrollbars are thin and hard to grab. With a mouse, the scrollbar of
2
+ // the scroll area under the pointer widens to twice its width while the
3
+ // pointer is on it, and goes back 2.5 s after the pointer left it. The track
4
+ // always has the wide width (index.html), so nothing in the page moves when
5
+ // the thumb grows.
6
+
7
+ export const SCROLLBAR_WIDE_CLASS = 'scrollbar-wide';
8
+ export const SCROLLBAR_LINGER_MS = 2500;
9
+ // How far left of the scrollbar the pointer already counts as on it.
10
+ const REACH_PX = 4;
11
+
12
+ /**
13
+ * Whether a pointer at `clientX` is on the vertical scrollbar of a scroll
14
+ * area: `rect` is its border box, `barWidth` the room its scrollbar takes
15
+ * (offsetWidth - clientWidth, borders included, which errs on the wide side).
16
+ */
17
+ export function pointerOnScrollbar({ clientX, rect, barWidth, reach = REACH_PX } = {}) {
18
+ if (!rect || !(barWidth > 0)) return false;
19
+ return clientX >= rect.right - barWidth - reach && clientX <= rect.right;
20
+ }
21
+
22
+ function scrollAreaUnderPointer(target, clientX) {
23
+ for (let node = target; node && node.nodeType === 1; node = node.parentElement) {
24
+ if (node.scrollHeight <= node.clientHeight) continue;
25
+ const barWidth = node.offsetWidth - node.clientWidth;
26
+ if (pointerOnScrollbar({ clientX, rect: node.getBoundingClientRect(), barWidth })) return node;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ export function initScrollbarHover({ doc = document, win = window, lingerMs = SCROLLBAR_LINGER_MS } = {}) {
32
+ let canHover = false;
33
+ try { canHover = win.matchMedia('(hover: hover) and (pointer: fine)').matches; } catch {}
34
+ if (!canHover) return () => {};
35
+ const timers = new Map();
36
+ const widen = (node) => {
37
+ clearTimeout(timers.get(node));
38
+ timers.delete(node);
39
+ node.classList.add(SCROLLBAR_WIDE_CLASS);
40
+ };
41
+ const release = (node) => {
42
+ if (timers.has(node)) return;
43
+ timers.set(node, setTimeout(() => {
44
+ timers.delete(node);
45
+ node.classList.remove(SCROLLBAR_WIDE_CLASS);
46
+ }, lingerMs));
47
+ };
48
+ const onMove = (event) => {
49
+ const hovered = scrollAreaUnderPointer(event.target, event.clientX);
50
+ if (hovered) widen(hovered);
51
+ for (const node of doc.querySelectorAll(`.${SCROLLBAR_WIDE_CLASS}`)) {
52
+ if (node !== hovered) release(node);
53
+ }
54
+ };
55
+ // Dragging the thumb sends no pointer moves to the page; the scrolling it
56
+ // causes keeps the scrollbar wide for as long as it lasts.
57
+ const onScroll = (event) => {
58
+ const node = event.target;
59
+ if (node?.classList?.contains(SCROLLBAR_WIDE_CLASS) && timers.has(node)) {
60
+ clearTimeout(timers.get(node));
61
+ timers.delete(node);
62
+ release(node);
63
+ }
64
+ };
65
+ const onLeave = () => {
66
+ for (const node of doc.querySelectorAll(`.${SCROLLBAR_WIDE_CLASS}`)) release(node);
67
+ };
68
+ doc.addEventListener('mousemove', onMove, { passive: true });
69
+ doc.addEventListener('scroll', onScroll, { passive: true, capture: true });
70
+ doc.documentElement.addEventListener('mouseleave', onLeave, { passive: true });
71
+ return () => {
72
+ doc.removeEventListener('mousemove', onMove);
73
+ doc.removeEventListener('scroll', onScroll, { capture: true });
74
+ doc.documentElement.removeEventListener('mouseleave', onLeave);
75
+ for (const timer of timers.values()) clearTimeout(timer);
76
+ timers.clear();
77
+ };
78
+ }
@@ -35,7 +35,7 @@ import {
35
35
  } from './api-client.js';
36
36
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
37
37
  import { syncFontScaleSelect } from './font-scaling.js';
38
- import { syncPwaAppNameInput } from './pwa-install.js';
38
+ import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
39
39
  import { normalizeKnownCwdPath } from './cwd-picker.js';
40
40
  import { refreshPushSettingsSection } from './push-settings.js';
41
41
  import { selectSettingsTab } from './settings-tabs.js';
@@ -1589,6 +1589,9 @@ export function updateTheme(theme) {
1589
1589
  document.documentElement.removeAttribute('data-theme');
1590
1590
  writeLocalStorage(THEME_STORAGE_KEY, 'dark');
1591
1591
  }
1592
+ // The installed app's manifest, the tab icons and the toolbar colour follow.
1593
+ window.__applyThemeAssets?.(theme === 'light' ? 'light' : 'dark');
1594
+ syncInstalledAppUiState();
1592
1595
  }
1593
1596
 
1594
1597
  export function updateShowSuspendHostSetting(next) {
@@ -34,6 +34,7 @@ import {
34
34
  summaryModalState,
35
35
  } from './store.js';
36
36
  import { scheduleContextUsageRefresh } from './api-client.js';
37
+ import { capLiveActivityEntries } from './activity-replay-state.mjs';
37
38
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
38
39
  import { renderConvList, refreshConversations, openConversation } from './journal-view.js';
39
40
  import {
@@ -43,6 +44,7 @@ import {
43
44
  } from './ask-user-view.js';
44
45
  import { upsertRelayBoard, loadRelayBoards, renderRelayBoards } from './relay-board-view.js';
45
46
  import { setConversationBackgroundTasks } from './background-tasks-view.mjs';
47
+ import { setConversationPins } from './pinned-messages-view.mjs';
46
48
  import { setPreviews } from './preview-cards.mjs';
47
49
  import { setRemoteRelaysSnapshot } from './remote-relays-store.mjs';
48
50
  import { REMOTE_RELAY_SOCKET_EVENT } from './remote-relay-shared.mjs';
@@ -544,6 +546,11 @@ export async function connectSocket(overrideDeps) {
544
546
  socket.on('background_tasks', ({ conversationId, tasks }) => {
545
547
  setConversationBackgroundTasks(conversationId, tasks);
546
548
  });
549
+ // A message was pinned or unpinned (any client): the conversation's whole
550
+ // pin list, replacing what this page had.
551
+ socket.on('conversation_pins_updated', ({ conversationId, pins, revision }) => {
552
+ setConversationPins(conversationId, pins, revision);
553
+ });
547
554
  // REPLACE semantics for the whole set: the preview registry is relay-owned
548
555
  // and global, so every change ships the full list.
549
556
  socket.on('previews', ({ previews }) => {
@@ -574,7 +581,7 @@ export async function connectSocket(overrideDeps) {
574
581
  const lastText = typeof last === 'string' ? last : String(last?.text || '');
575
582
  const lastSubagentRunId = typeof last === 'object' && last ? (last.subagentRunId || null) : null;
576
583
  if (lastText !== entry.text || lastSubagentRunId !== entry.subagentRunId) {
577
- relayActivities.set(messageId, items.concat(entry).slice(-24));
584
+ relayActivities.set(messageId, capLiveActivityEntries(items.concat(entry)));
578
585
  }
579
586
  if (entry.subagentRunId) {
580
587
  upsertSubagentRun({ subagentRunId: entry.subagentRunId, messageId, conversationId });
@@ -133,6 +133,8 @@ export let filePreviewState = {
133
133
  preload: 'metadata',
134
134
  autoplay: false,
135
135
  },
136
+ // The files the viewer can step through (see viewer-gallery.mjs), or null.
137
+ gallery: null,
136
138
  };
137
139
  // Toolbar filters are sticky per browser (all default off); the toggles in
138
140
  // attachments-view.js are the only writers.
@@ -1390,9 +1392,37 @@ export function setSummaryModalLoading(loading) {
1390
1392
  }
1391
1393
  }
1392
1394
 
1395
+ // A modal kind can hold an unsaved draft (the context modal's compaction
1396
+ // window slider) that is written when the user leaves it, whichever way: the
1397
+ // close button, the backdrop, Escape, or another modal replacing it. A
1398
+ // re-render of the same kind (Refresh, a socket-driven refresh) is not a leave.
1399
+ let summaryModalLeave = null;
1400
+
1401
+ export function setSummaryModalLeaveHandler(kind, handler) {
1402
+ summaryModalLeave = typeof handler === 'function'
1403
+ ? { kind: String(kind || '').trim(), handler }
1404
+ : null;
1405
+ }
1406
+
1407
+ function runSummaryModalLeave(nextKind = null) {
1408
+ const leave = summaryModalLeave;
1409
+ if (!leave) return;
1410
+ if (nextKind !== null && nextKind === leave.kind) return;
1411
+ summaryModalLeave = null;
1412
+ try {
1413
+ leave.handler();
1414
+ } catch (error) {
1415
+ console.warn('Summary modal leave handler failed:', error);
1416
+ }
1417
+ }
1418
+
1393
1419
  export function renderSummaryModalContent({ title, subtitle = '', bodyHtml = '', refresh = null, kind = '' }) {
1420
+ runSummaryModalLeave(String(kind || '').trim());
1394
1421
  summaryModalState.kind = String(kind || '').trim();
1395
1422
  summaryModalState.refresh = typeof refresh === 'function' ? refresh : null;
1423
+ // Lets the stylesheet tell one kind of this shared modal from another.
1424
+ const modalEl = document.getElementById('summary-modal');
1425
+ if (modalEl?.dataset) modalEl.dataset.kind = summaryModalState.kind;
1396
1426
 
1397
1427
  const titleEl = document.getElementById('summary-modal-title');
1398
1428
  const subtitleEl = document.getElementById('summary-modal-subtitle');
@@ -1413,12 +1443,14 @@ export function openSummaryModal({ title, subtitle = '', bodyHtml = '', refresh
1413
1443
  }
1414
1444
 
1415
1445
  export function closeSummaryModal() {
1446
+ runSummaryModalLeave();
1416
1447
  summaryModalState.kind = '';
1417
1448
  summaryModalState.refresh = null;
1418
1449
  summaryModalState.loading = false;
1419
1450
  const modal = document.getElementById('summary-modal');
1420
1451
  modal?.classList.remove('visible');
1421
1452
  modal?.setAttribute('aria-hidden', 'true');
1453
+ if (modal?.dataset) delete modal.dataset.kind;
1422
1454
  const titleEl = document.getElementById('summary-modal-title');
1423
1455
  const subtitleEl = document.getElementById('summary-modal-subtitle');
1424
1456
  const bodyEl = document.getElementById('summary-modal-body');
@@ -62,13 +62,18 @@ function formatCompactTokens(value) {
62
62
  return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}k`;
63
63
  }
64
64
 
65
+ // Stamp of a compaction that is still running (the live bubble's
66
+ // `data-compact-boundary`).
67
+ export const COMPACT_PENDING_STAMP = 'pending';
68
+
65
69
  /**
66
70
  * Parses the `data-compact-boundary` stamp (`"<pre>|<post>"`, either side
67
- * possibly empty) into token counts.
71
+ * possibly empty, or `pending`) into token counts.
68
72
  */
69
73
  export function parseCompactBoundaryValue(value) {
70
74
  const text = String(value == null ? '' : value).trim();
71
75
  if (!text) return null;
76
+ if (text === COMPACT_PENDING_STAMP) return { pending: true, preTokens: null, postTokens: null };
72
77
  const [rawPre, rawPost] = text.split('|');
73
78
  const toCount = (raw) => {
74
79
  const digits = String(raw || '').trim();
@@ -80,6 +85,7 @@ export function parseCompactBoundaryValue(value) {
80
85
  }
81
86
 
82
87
  export function formatCompactBoundaryLabel(boundary) {
88
+ if (boundary?.pending) return 'Compacting context…';
83
89
  const preTokens = formatCompactTokens(boundary?.preTokens);
84
90
  const postTokens = formatCompactTokens(boundary?.postTokens);
85
91
  if (preTokens && postTokens) return `Context compacted · ${preTokens} → ${postTokens} tokens`;
@@ -97,6 +103,11 @@ export function formatCompactBoundaryLabel(boundary) {
97
103
  * every local-day rollover; a compaction separator sits immediately before
98
104
  * the message whose activities recorded the boundary (after that message's
99
105
  * day separator, when both land on the same row).
106
+ *
107
+ * The compaction key names the turn (`sourceMessageId`, else the row's own
108
+ * id), not the token counts: the live bubble and the reply that replaces it
109
+ * share it, so a running compaction's line is relabelled in place when the
110
+ * boundary lands instead of being swapped for a second row.
100
111
  */
101
112
  export function buildSeparatorPlan(rows, nowMs = Date.now()) {
102
113
  const list = Array.isArray(rows) ? rows : [];
@@ -123,11 +134,13 @@ export function buildSeparatorPlan(rows, nowMs = Date.now()) {
123
134
  const boundary = row?.compactBoundary && typeof row.compactBoundary === 'object'
124
135
  ? row.compactBoundary
125
136
  : parseCompactBoundaryValue(row?.compactBoundary);
126
- if (boundary && (boundary.preTokens != null || boundary.postTokens != null || row?.compactBoundary)) {
137
+ if (boundary && (boundary.pending || boundary.preTokens != null || boundary.postTokens != null || row?.compactBoundary)) {
138
+ const turnId = String(row?.sourceMessageId || '').trim() || messageId || index;
127
139
  plan.push({
128
- key: `compact:${messageId || index}:${boundary.preTokens ?? ''}-${boundary.postTokens ?? ''}`,
140
+ key: `compact:${turnId}`,
129
141
  kind: 'compact',
130
142
  label: formatCompactBoundaryLabel(boundary),
143
+ pending: boundary.pending === true,
131
144
  beforeIndex: index,
132
145
  messageId,
133
146
  });
@@ -140,11 +153,31 @@ function readRowFromNode(node) {
140
153
  const dataset = node?.dataset || {};
141
154
  return {
142
155
  messageId: String(dataset.messageId || '').trim(),
156
+ sourceMessageId: String(dataset.sourceMessageId || '').trim(),
143
157
  timestamp: String(dataset.messageTimestamp || '').trim(),
144
158
  compactBoundary: String(dataset.compactBoundary || '').trim(),
145
159
  };
146
160
  }
147
161
 
162
+ // The animated dots of a running compaction sit after the label text.
163
+ function setSeparatorLabel(doc, node, separator) {
164
+ const label = node.querySelector?.('.transcript-separator-label');
165
+ if (!label) return;
166
+ // textContent, never innerHTML: labels are generated here but the row sits
167
+ // in the same container as user content.
168
+ label.replaceChildren?.();
169
+ label.textContent = separator.label;
170
+ if (separator.pending) {
171
+ const dots = doc.createElement('span');
172
+ dots.className = 'dots';
173
+ for (let i = 0; i < 3; i += 1) dots.appendChild(doc.createElement('span'));
174
+ label.appendChild(dots);
175
+ }
176
+ node.classList.toggle('is-pending', !!separator.pending);
177
+ node.setAttribute('aria-label', separator.label);
178
+ node.dataset.separatorLabel = separator.label;
179
+ }
180
+
148
181
  function createSeparatorNode(doc, separator) {
149
182
  const node = doc.createElement('div');
150
183
  node.className = separator.kind === 'compact'
@@ -153,13 +186,10 @@ function createSeparatorNode(doc, separator) {
153
186
  node.dataset.separatorKey = separator.key;
154
187
  node.dataset.separatorKind = separator.kind;
155
188
  node.setAttribute('role', 'separator');
156
- node.setAttribute('aria-label', separator.label);
157
189
  const label = doc.createElement('span');
158
190
  label.className = 'transcript-separator-label';
159
- // textContent, never innerHTML: labels are generated here but the row sits
160
- // in the same container as user content.
161
- label.textContent = separator.label;
162
191
  node.appendChild(label);
192
+ setSeparatorLabel(doc, node, separator);
163
193
  return node;
164
194
  }
165
195
 
@@ -176,7 +206,9 @@ export function syncTranscriptSeparators(container, nowMs = Date.now()) {
176
206
  if (!container || typeof container.querySelectorAll !== 'function') return 0;
177
207
  const doc = container.ownerDocument || (typeof document !== 'undefined' ? document : null);
178
208
  if (!doc || typeof doc.createElement !== 'function') return 0;
179
- const nodes = Array.from(container.querySelectorAll('.msg[data-message-timestamp]'))
209
+ // The live bubble carries no timestamp; it joins the plan only while it
210
+ // stamps its turn's compaction.
211
+ const nodes = Array.from(container.querySelectorAll('.msg[data-message-timestamp], .msg[data-compact-boundary]'))
180
212
  .filter((node) => node.parentNode === container);
181
213
  const plan = buildSeparatorPlan(nodes.map(readRowFromNode), nowMs);
182
214
  const planByIndex = new Map();
@@ -196,16 +228,21 @@ export function syncTranscriptSeparators(container, nowMs = Date.now()) {
196
228
  cursor = cursor.previousSibling;
197
229
  }
198
230
  const matches = existing.length === desired.length
199
- && existing.every((el, i) => el.dataset.separatorKey === desired[i].key
200
- && el.dataset.separatorLabel === desired[i].label);
231
+ && existing.every((el, i) => el.dataset.separatorKey === desired[i].key);
201
232
  if (matches) {
233
+ // Same rows, maybe a new label (a running compaction that finished):
234
+ // relabel in place.
235
+ existing.forEach((el, i) => {
236
+ if (el.dataset.separatorLabel === desired[i].label) return;
237
+ setSeparatorLabel(doc, el, desired[i]);
238
+ changed += 1;
239
+ });
202
240
  for (const el of existing) kept.add(el);
203
241
  return;
204
242
  }
205
243
  for (const el of existing) el.remove();
206
244
  for (const separator of desired) {
207
245
  const created = createSeparatorNode(doc, separator);
208
- created.dataset.separatorLabel = separator.label;
209
246
  container.insertBefore(created, node);
210
247
  kept.add(created);
211
248
  changed += 1;