@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.
- package/CHANGELOG.md +344 -0
- package/README.md +89 -40
- package/bin/oar.js +1023 -135
- package/package.json +4 -5
- package/server/README.md +20 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +0 -11
- package/server/claude-worker/claude-session-process.mjs +196 -28
- package/server/claude-worker/sdk-message-normalizer.mjs +28 -0
- package/server/db-schema.mjs +8 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/public/app/activity-replay-state.mjs +68 -5
- package/server/public/app/api-client.js +36 -2
- package/server/public/app/attachments-view.js +508 -125
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/bootstrap.js +170 -59
- package/server/public/app/context-usage-view.mjs +127 -8
- package/server/public/app/conversation-view.js +172 -32
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/journal-view.js +16 -2
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/pwa-install.js +6 -1
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/router.js +8 -2
- package/server/public/app/scrollbar-hover.mjs +78 -0
- package/server/public/app/settings-modal.js +4 -1
- package/server/public/app/socket-handlers.js +8 -1
- package/server/public/app/store.js +32 -0
- package/server/public/app/transcript-separators.mjs +48 -11
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +43 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon-light-192.png +0 -0
- package/server/public/app-icon-light-512.png +0 -0
- package/server/public/app-icon-light-maskable-512.png +0 -0
- package/server/public/app-icon-light.svg +4 -0
- package/server/public/app-icon-maskable-512.png +0 -0
- package/server/public/app-icon.svg +3 -3
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +487 -67
- package/server/public/manifest.webmanifest +9 -3
- package/server/public/sw.js +6 -1
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +5 -0
- package/server/routes/messages-routes.mjs +36 -62
- package/server/routes/sessions-routes.mjs +107 -26
- package/server/server-runtime.mjs +39 -8
- package/server/services/claude-context-usage.mjs +51 -3
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +205 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/pwa-manifest-theme.mjs +30 -0
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-history-refresh-service.mjs +24 -0
- package/server/services/session-worker-registry-service.mjs +18 -1
- 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
|
-
|
|
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)
|
|
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:${
|
|
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
|
-
|
|
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;
|