@oar-sh/oar 0.9.10 → 0.9.11

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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
@@ -6,8 +6,9 @@
6
6
  // The same row builder serves both surfaces: the composer's background-task
7
7
  // panel (this conversation's previews, beside the Bash task running the dev
8
8
  // server) and the settings modal's "Live previews" list (every session's).
9
- // Previews never expire, so the settings list is the one place that can always
10
- // close a link left behind in a conversation you have moved on from.
9
+ // Previews never expire and survive a relay restart, so the settings list is
10
+ // the one place that can always close a link left behind in a conversation you
11
+ // have moved on from; it shows each preview's age and last use to help.
11
12
 
12
13
  import { apiFetch } from './api-client.js';
13
14
 
@@ -21,10 +22,35 @@ export function subscribePreviews(callback) {
21
22
  return () => subscribers.delete(callback);
22
23
  }
23
24
 
25
+ // Transcript cards on screen, with the snapshot each was built from. A card's
26
+ // open/closed state is read from the registry when it is built, and a page that
27
+ // renders a transcript before the registry arrived (the conversation payload
28
+ // carries it after the messages) would otherwise keep showing a live preview
29
+ // as closed.
30
+ const transcriptCards = new Map();
31
+
32
+ function refreshTranscriptCards() {
33
+ for (const [card, snapshot] of [...transcriptCards]) {
34
+ if (!card.isConnected) {
35
+ transcriptCards.delete(card);
36
+ continue;
37
+ }
38
+ const liveEntry = previews.find((entry) => entry.token === snapshot.token && !entry.unverified);
39
+ const shownLive = !card.classList.contains('msg-preview-card-closed');
40
+ // Rebuilt when it opened or closed, and when its link moved (a changed
41
+ // public address: the live entry carries the current one).
42
+ if (!!liveEntry === shownLive && (!liveEntry || card.dataset.previewUrl === (liveEntry.url || snapshot.url))) continue;
43
+ const next = buildTranscriptPreviewCard(snapshot);
44
+ transcriptCards.delete(card);
45
+ if (next) card.replaceWith(next);
46
+ }
47
+ }
48
+
24
49
  function notify() {
25
50
  for (const callback of subscribers) {
26
51
  try { callback(previews); } catch {}
27
52
  }
53
+ try { refreshTranscriptCards(); } catch {}
28
54
  }
29
55
 
30
56
  function normalizePreview(entry) {
@@ -40,8 +66,12 @@ function normalizePreview(entry) {
40
66
  url: String(entry.url || '').trim(),
41
67
  basePath: String(entry.basePath || '').trim(),
42
68
  createdAt: Number(entry.createdAt) || null,
69
+ lastHitAt: Number(entry.lastHitAt) || null,
43
70
  // null = not probed yet; the badge stays absent rather than guessing.
44
71
  online: entry.online === true ? true : (entry.online === false ? false : null),
72
+ // Stored, but not served: the relay could not check it after a restart
73
+ // and tries again. It can be closed like any other.
74
+ unverified: entry.unverified === true,
45
75
  };
46
76
  }
47
77
 
@@ -110,13 +140,31 @@ async function copyPreviewUrl(url, button) {
110
140
  }
111
141
  }
112
142
 
113
- function previewDetail(preview, { withConversation = false } = {}) {
143
+ export function formatPreviewAge(timestamp, now = Date.now()) {
144
+ const at = Number(timestamp);
145
+ if (!Number.isFinite(at) || at <= 0) return '';
146
+ const minutes = Math.floor(Math.max(0, now - at) / 60_000);
147
+ if (minutes < 1) return 'just now';
148
+ if (minutes < 60) return `${minutes} min ago`;
149
+ const hours = Math.floor(minutes / 60);
150
+ if (hours < 48) return `${hours} h ago`;
151
+ return `${Math.floor(hours / 24)} d ago`;
152
+ }
153
+
154
+ function previewDetail(preview, { withConversation = false, withAge = false, now = Date.now() } = {}) {
114
155
  const parts = [];
115
156
  if (preview.targetPort) parts.push(`:${preview.targetPort}`);
116
- if (preview.online === false) parts.push('offline — dev server not responding');
157
+ if (preview.unverified) parts.push('not served — could not be checked after the restart, trying again');
158
+ else if (preview.online === false) parts.push('offline — dev server not responding');
117
159
  if (withConversation && preview.conversationId) {
118
160
  parts.push(`session ${preview.conversationId.slice(0, 8)}`);
119
161
  }
162
+ if (withAge) {
163
+ const published = formatPreviewAge(preview.createdAt, now);
164
+ if (published) parts.push(`published ${published}`);
165
+ const opened = formatPreviewAge(preview.lastHitAt, now);
166
+ parts.push(opened ? `last opened ${opened}` : 'never opened');
167
+ }
120
168
  return parts.join(' · ');
121
169
  }
122
170
 
@@ -124,7 +172,7 @@ function previewDetail(preview, { withConversation = false } = {}) {
124
172
  * One preview row. Built with createElement/textContent throughout: a label is
125
173
  * agent-supplied text and must never be able to execute as markup.
126
174
  */
127
- export function buildPreviewRow(preview, { withConversation = false } = {}) {
175
+ export function buildPreviewRow(preview, { withConversation = false, withAge = false, now } = {}) {
128
176
  const row = document.createElement('div');
129
177
  row.className = 'preview-row';
130
178
  row.dataset.token = preview.token;
@@ -145,7 +193,7 @@ export function buildPreviewRow(preview, { withConversation = false } = {}) {
145
193
  label.textContent = preview.label;
146
194
  main.appendChild(label);
147
195
 
148
- const detailText = previewDetail(preview, { withConversation });
196
+ const detailText = previewDetail(preview, { withConversation, withAge, now });
149
197
  if (detailText) {
150
198
  const detail = document.createElement('span');
151
199
  detail.className = 'preview-detail';
@@ -177,25 +225,28 @@ export function buildPreviewRow(preview, { withConversation = false } = {}) {
177
225
  actions.className = 'preview-actions';
178
226
  side.appendChild(actions);
179
227
 
180
- const open = document.createElement('a');
181
- open.className = 'preview-open';
182
- open.href = preview.url;
183
- open.target = '_blank';
184
- // noopener/noreferrer: the preview is a different origin running app code we
185
- // do not control, and it has no business holding a handle on this window.
186
- open.rel = 'noopener noreferrer';
187
- open.textContent = 'Open';
188
- actions.appendChild(open);
189
-
190
- const copy = document.createElement('button');
191
- copy.type = 'button';
192
- copy.className = 'preview-copy';
193
- copy.textContent = 'Copy link';
194
- copy.addEventListener('click', (event) => {
195
- event.preventDefault();
196
- void copyPreviewUrl(preview.url, copy);
197
- });
198
- actions.appendChild(copy);
228
+ // An unverified preview answers 404 for now: no link to open or copy.
229
+ if (!preview.unverified) {
230
+ const open = document.createElement('a');
231
+ open.className = 'preview-open';
232
+ open.href = preview.url;
233
+ open.target = '_blank';
234
+ // noopener/noreferrer: the preview is a different origin running app code we
235
+ // do not control, and it has no business holding a handle on this window.
236
+ open.rel = 'noopener noreferrer';
237
+ open.textContent = 'Open';
238
+ actions.appendChild(open);
239
+
240
+ const copy = document.createElement('button');
241
+ copy.type = 'button';
242
+ copy.className = 'preview-copy';
243
+ copy.textContent = 'Copy link';
244
+ copy.addEventListener('click', (event) => {
245
+ event.preventDefault();
246
+ void copyPreviewUrl(preview.url, copy);
247
+ });
248
+ actions.appendChild(copy);
249
+ }
199
250
 
200
251
  const closing = closesInFlight.has(preview.token);
201
252
  const close = document.createElement('button');
@@ -214,17 +265,29 @@ export function buildPreviewRow(preview, { withConversation = false } = {}) {
214
265
 
215
266
  /**
216
267
  * Transcript card for a preview published during a turn. Renders from the
217
- * persisted snapshot (the registry may be long gone), overlaying live state
218
- * from the current store: a token still registered gets a working row, a
219
- * vanished one keeps the label but drops the dead link for a `closed` badge.
268
+ * persisted snapshot, overlaying live state from the current store: a token
269
+ * still registered (previews survive a restart) gets a working row, a closed
270
+ * one keeps the label but drops the dead link for a `closed` badge.
271
+ *
272
+ * A public share carries no token or link (the link would open the preview to
273
+ * every reader of the transcript): its cards come as `{ label, redacted }` and
274
+ * render as a plain note.
220
275
  */
221
276
  export function buildTranscriptPreviewCard(snapshot) {
277
+ if (snapshot && typeof snapshot === 'object' && snapshot.redacted === true) {
278
+ return buildRedactedPreviewCard(snapshot);
279
+ }
222
280
  const preview = normalizePreview(snapshot);
223
281
  if (!preview) return null;
224
- const live = previews.some((entry) => entry.token === preview.token);
282
+ const liveEntry = previews.find((entry) => entry.token === preview.token && !entry.unverified);
283
+ const live = !!liveEntry;
284
+ // The snapshot's link was built from the public address at publish time;
285
+ // the live entry's follows the current settings.
286
+ const url = liveEntry?.url || preview.url;
225
287
 
226
288
  const card = document.createElement('div');
227
289
  card.className = live ? 'msg-preview-card' : 'msg-preview-card msg-preview-card-closed';
290
+ if (live) card.dataset.previewUrl = url;
228
291
 
229
292
  const icon = document.createElement('span');
230
293
  icon.className = 'preview-icon';
@@ -236,10 +299,12 @@ export function buildTranscriptPreviewCard(snapshot) {
236
299
  label.textContent = preview.label;
237
300
  card.appendChild(label);
238
301
 
302
+ transcriptCards.set(card, preview);
303
+
239
304
  if (live) {
240
305
  const open = document.createElement('a');
241
306
  open.className = 'preview-open';
242
- open.href = preview.url;
307
+ open.href = url;
243
308
  open.target = '_blank';
244
309
  open.rel = 'noopener noreferrer';
245
310
  open.textContent = 'Open';
@@ -251,7 +316,7 @@ export function buildTranscriptPreviewCard(snapshot) {
251
316
  copy.textContent = 'Copy link';
252
317
  copy.addEventListener('click', (event) => {
253
318
  event.preventDefault();
254
- void copyPreviewUrl(preview.url, copy);
319
+ void copyPreviewUrl(url, copy);
255
320
  });
256
321
  card.appendChild(copy);
257
322
  } else {
@@ -263,6 +328,27 @@ export function buildTranscriptPreviewCard(snapshot) {
263
328
  return card;
264
329
  }
265
330
 
331
+ function buildRedactedPreviewCard(snapshot) {
332
+ const card = document.createElement('div');
333
+ card.className = 'msg-preview-card msg-preview-card-redacted';
334
+
335
+ const icon = document.createElement('span');
336
+ icon.className = 'preview-icon';
337
+ icon.textContent = '🌐';
338
+ card.appendChild(icon);
339
+
340
+ const label = document.createElement('span');
341
+ label.className = 'preview-label';
342
+ label.textContent = String(snapshot.label || '').trim() || 'Preview';
343
+ card.appendChild(label);
344
+
345
+ const note = document.createElement('span');
346
+ note.className = 'preview-detail';
347
+ note.textContent = 'preview link not shared';
348
+ card.appendChild(note);
349
+ return card;
350
+ }
351
+
266
352
  export function renderPreviewRowsInto(container, rows, options = {}) {
267
353
  if (!container) return 0;
268
354
  container.textContent = '';
@@ -74,6 +74,10 @@ export async function runPreviewCommand(parsed, { conversationId = '' } = {}) {
74
74
  if (!previews.length) return { ok: true, notice: 'No live previews.' };
75
75
  const lines = previews.map((entry) => {
76
76
  const target = entry.mode === 'static' ? entry.rootDir : `:${entry.targetPort}`;
77
+ // Stored but not served until the relay could check it: no link.
78
+ if (entry.unverified === true) {
79
+ return `${entry.token.slice(0, 8)} · ${entry.label} (${target}) — not served, could not be checked after the restart`;
80
+ }
77
81
  const state = entry.online === false ? ' — offline' : '';
78
82
  return `${entry.token.slice(0, 8)} · ${entry.label} (${target})${state} → ${entry.url}`;
79
83
  });
@@ -96,7 +100,12 @@ export async function runPreviewCommand(parsed, { conversationId = '' } = {}) {
96
100
  // point at the panel where the states are visible instead.
97
101
  return { ok: false, notice: 'Preview failed — check the port/path, or whether the preview lane is enabled (Settings → Live previews).' };
98
102
  }
99
- return { ok: true, notice: `Preview published: ${created.url}` };
103
+ return {
104
+ ok: true,
105
+ notice: created.existing === true
106
+ ? `Preview already published (same link): ${created.url}`
107
+ : `Preview published: ${created.url}`,
108
+ };
100
109
  } catch {
101
110
  return { ok: false, notice: 'Preview command failed — could not reach the relay.' };
102
111
  }
@@ -3,6 +3,7 @@ import {
3
3
  currentConvId,
4
4
  escHtml,
5
5
  relayBoards,
6
+ scrollBottom,
6
7
  } from './store.js';
7
8
  import {
8
9
  boardBodyShownInReply,
@@ -16,6 +17,7 @@ import {
16
17
  } from './api-client.js';
17
18
  import { renderMarkdownPreview } from './router.js';
18
19
  import { attachCodeCopyButtons } from './code-copy.mjs';
20
+ import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
19
21
 
20
22
  // The conversation whose settled boards (acted on, dismissed) are loaded. They
21
23
  // are asked for per conversation: only the pending ones matter everywhere.
@@ -26,7 +28,18 @@ export function upsertRelayBoard(board) {
26
28
  if (!board || !board.id) return;
27
29
  relayBoards.set(board.id, board);
28
30
  window.renderConvList?.();
29
- renderRelayBoards();
31
+ showNewRelayBoards();
32
+ }
33
+
34
+ // A board that arrives is shown to a reader who is at the end. Anyone further
35
+ // up stays where they are; the jump-to-bottom button says that it waits.
36
+ // "At the end" is read here, once, before the board is on the page: the
37
+ // render itself reads no layout, it also runs inside a rebuild of the
38
+ // transcript, where a read would clamp the scroll position to the few rows
39
+ // that are on the page at that moment.
40
+ function showNewRelayBoards() {
41
+ const atEnd = followsTranscriptEnd();
42
+ if (renderRelayBoards() && atEnd) scrollBottom();
30
43
  }
31
44
 
32
45
  export async function loadRelayBoards() {
@@ -40,7 +53,7 @@ export async function loadRelayBoards() {
40
53
  }
41
54
  for (const board of pending) relayBoards.set(board.id, board);
42
55
  window.renderConvList?.();
43
- renderRelayBoards();
56
+ showNewRelayBoards();
44
57
  }
45
58
 
46
59
  async function loadBoardHistory(conversationId) {
@@ -186,10 +199,8 @@ export function renderRelayBoards() {
186
199
  // the live bubble into the reply, or one of another conversation.
187
200
  el.querySelectorAll('.relay-board-inline').forEach((node) => { if (!placed.has(node)) node.remove(); });
188
201
  el.querySelectorAll('.relay-board-container').forEach((node) => { if (!node.querySelector('.relay-board-inline')) node.remove(); });
189
- // A folded agent exchange whose turn waits on a board opens to show it
190
- // (agent-exchange-fold-view.mjs).
191
- window.syncAgentExchangeFolds?.();
192
- if (added) window.scrollBottom?.();
202
+ refreshJumpButton();
203
+ return added;
193
204
  }
194
205
 
195
206
  export async function submitRelayBoardAction(boardId, actionId) {
@@ -199,6 +210,7 @@ export async function submitRelayBoardAction(boardId, actionId) {
199
210
  const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
200
211
  const controls = card ? card.querySelectorAll('button') : [];
201
212
  controls.forEach((control) => { control.disabled = true; });
213
+ const send = beginOwnSend();
202
214
 
203
215
  try {
204
216
  const r = await submitRelayBoardActionApi(id, nextActionId);
@@ -208,6 +220,7 @@ export async function submitRelayBoardAction(boardId, actionId) {
208
220
  relayBoards.set(id, r.board);
209
221
  window.renderConvList?.();
210
222
  renderRelayBoards();
223
+ scrollAfterOwnSend(send);
211
224
  } catch (error) {
212
225
  controls.forEach((control) => { control.disabled = false; });
213
226
  alert(error.message || 'Failed to submit board action');
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What to tell the user when a restart request failed. An HTTP answer is a
3
+ * refusal and says so (`refused`, shown as an alert); no answer at all can
4
+ * mean the relay took the request and began restarting before it could
5
+ * reply (`unanswered`, shown as a notice).
6
+ */
7
+ export function describeRelayRestartError(error) {
8
+ const message = String(error?.message || '').trim();
9
+ if (Number(error?.status) > 0) {
10
+ return { kind: 'refused', text: `The relay refused the restart: ${message || `HTTP ${Number(error.status)}`}` };
11
+ }
12
+ return {
13
+ kind: 'unanswered',
14
+ text: `${message || 'The relay did not answer.'} If it took the restart, the connection comes back once it has restarted.`,
15
+ };
16
+ }
@@ -36,6 +36,7 @@ import {
36
36
  requestRelayRestart,
37
37
  } from './api-client.js';
38
38
  import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
39
+ import { describeRelayRestartError } from './relay-restart-error.mjs';
39
40
  import { syncFontScaleSelect } from './font-scaling.js';
40
41
  import { syncInstalledAppUiState, syncPwaAppNameInput } from './pwa-install.js';
41
42
  import { normalizeKnownCwdPath } from './cwd-picker.js';
@@ -48,6 +49,8 @@ import { refreshCliInstallSections } from './cli-install-ui.js';
48
49
  import { refreshUpdateSection } from './update-ui.js';
49
50
  import { refreshRemoteRelaysSection } from './remote-relays-settings.js';
50
51
  import { refreshAgentSessionsSection } from './agent-sessions-settings-ui.js';
52
+ import { refreshSharedConversationsSection } from './shared-conversations-settings.js';
53
+ import { syncScrollOnSendToggle } from './transcript-jump-bottom.mjs';
51
54
  import {
52
55
  getPreviews,
53
56
  renderPreviewRowsInto,
@@ -1337,15 +1340,33 @@ export async function removeOpenAISettings() {
1337
1340
  }
1338
1341
  }
1339
1342
 
1343
+ const WINDOWS_BOOT_CUSTOMISATION_LABELS = Object.freeze({
1344
+ action: 'what it runs',
1345
+ 'working-directory': 'working folder',
1346
+ 'logon-type': 'logon type',
1347
+ trigger: 'trigger',
1348
+ 'boot-delay': 'boot delay',
1349
+ launcher: 'launcher edited or from an older OAR',
1350
+ 'launcher-missing': 'launcher missing',
1351
+ });
1352
+
1340
1353
  function windowsAutostartStatusText() {
1341
1354
  const boot = windowsAutostartState?.boot;
1342
1355
  if (boot?.pendingElevation) {
1343
1356
  return 'Confirm the admin prompt on the PC… (it only appears on the physical desktop)';
1344
1357
  }
1345
1358
  if (boot?.lastError) return boot.lastError;
1359
+ if (boot?.readError) return `The boot task could not be read: ${boot.readError}`;
1346
1360
  if (windowsAutostartState?.mode === 'boot' && windowsAutostartState?.signinEnabled) {
1347
1361
  return 'A sign-in Startup entry also exists and can double-start the relay. Re-select "At system startup" to clean it up.';
1348
1362
  }
1363
+ if (windowsAutostartState?.mode === 'boot' && boot?.customised) {
1364
+ const labels = (boot.customisations || []).map((name) => WINDOWS_BOOT_CUSTOMISATION_LABELS[name] || name);
1365
+ return `The boot task or its launcher differs from what OAR generates (${labels.join(', ')}). Re-selecting "At system startup" regenerates both and keeps a changed launcher as a .bak copy.`;
1366
+ }
1367
+ if (windowsAutostartState?.mode === 'boot' && boot?.launcherBackupPath) {
1368
+ return `The previous launcher was replaced and kept as ${boot.launcherBackupPath}.`;
1369
+ }
1349
1370
  if (boot?.taskStatus === 'foreign') {
1350
1371
  return `A task named "${boot.taskName}" exists but was not created by OAR. Selecting "At system startup" replaces it.`;
1351
1372
  }
@@ -1425,7 +1446,7 @@ export async function refreshWindowsAutostartSetting() {
1425
1446
  }
1426
1447
  windowsAutostartState = result;
1427
1448
  } catch (error) {
1428
- alert(error?.message || 'Failed to read the Windows autostart setting.');
1449
+ alert(error?.message ? `Failed to read the Windows autostart setting: ${error.message}` : 'Failed to read the Windows autostart setting.');
1429
1450
  } finally {
1430
1451
  windowsAutostartUpdateInFlight = false;
1431
1452
  syncWindowsAutostartSetting();
@@ -1459,7 +1480,7 @@ export async function updateWindowsAutostartMode(mode) {
1459
1480
  );
1460
1481
  }
1461
1482
  } catch (error) {
1462
- alert(error?.message || 'Failed to update the Windows autostart setting.');
1483
+ alert(error?.message ? `Failed to update the Windows autostart setting: ${error.message}` : 'Failed to update the Windows autostart setting.');
1463
1484
  } finally {
1464
1485
  windowsAutostartUpdateInFlight = false;
1465
1486
  syncWindowsAutostartSetting();
@@ -1579,9 +1600,17 @@ async function restartRelayForFeatureFlags() {
1579
1600
  featureFlagsUpdateInFlight = true;
1580
1601
  syncFeatureFlagsSection();
1581
1602
  try {
1582
- const result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1583
- if (!result) {
1584
- alert('Failed to request a relay restart. Restarts can only be requested from the relay host itself.');
1603
+ let result = null;
1604
+ try {
1605
+ result = await requestRelayRestart({ reason: 'feature-flags-changed' });
1606
+ } catch (error) {
1607
+ const { kind, text } = describeRelayRestartError(error);
1608
+ if (kind === 'refused') alert(text);
1609
+ else showTransientRelayNotice(text, 9000);
1610
+ return;
1611
+ }
1612
+ if (!result?.ok) {
1613
+ alert('Failed to request a relay restart.');
1585
1614
  return;
1586
1615
  }
1587
1616
  showTransientRelayNotice('Relay restart requested. It will restart once the running turns finish; queued messages wait for it.');
@@ -1684,14 +1713,15 @@ export async function updateDefaultSessionWorkspaceRootSetting(rawValue) {
1684
1713
  }
1685
1714
 
1686
1715
  // "Live previews": every preview across every session, not just this
1687
- // conversation's. Previews never expire, so this list is the one place that can
1688
- // close a link left behind in a conversation you have moved on from.
1716
+ // conversation's. Previews never expire and survive a relay restart, so this
1717
+ // list is the one place that can close a link left behind in a conversation
1718
+ // you have moved on from; age and last use show which ones are stale.
1689
1719
  export function renderPreviewsSection() {
1690
1720
  const list = document.getElementById('settings-previews-list');
1691
1721
  const empty = document.getElementById('settings-previews-empty');
1692
1722
  if (!list) return;
1693
1723
  const previews = getPreviews();
1694
- renderPreviewRowsInto(list, previews, { withConversation: true });
1724
+ renderPreviewRowsInto(list, previews, { withConversation: true, withAge: true });
1695
1725
  if (empty) {
1696
1726
  empty.hidden = previews.length > 0;
1697
1727
  empty.textContent = 'No previews are published right now.';
@@ -1726,6 +1756,7 @@ export function openSettingsModal(tab, providerTab) {
1726
1756
  themeSelect.value = readLocalStorage(THEME_STORAGE_KEY) === 'light' ? 'light' : 'dark';
1727
1757
  }
1728
1758
  syncSuspendHostVisibility();
1759
+ syncScrollOnSendToggle();
1729
1760
  syncFontScaleSelect();
1730
1761
  syncPwaAppNameInput();
1731
1762
  syncDefaultSessionWorkspaceRootInput();
@@ -1762,6 +1793,7 @@ export function openSettingsModal(tab, providerTab) {
1762
1793
  void refreshSubagentRecordRetentionSetting();
1763
1794
  void refreshPushSettingsSection();
1764
1795
  void refreshPreviewsSection();
1796
+ void refreshSharedConversationsSection();
1765
1797
  void refreshRemoteRelaysSection();
1766
1798
  void refreshAgentSessionsSection();
1767
1799
  void refreshFeatureFlagsSection();
@@ -1,7 +1,7 @@
1
1
  const SETTINGS_TAB_STORAGE_KEY = 'copilot_settings_tab';
2
2
  const SETTINGS_PROVIDER_TAB_STORAGE_KEY = 'copilot_settings_provider_tab';
3
3
 
4
- const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'notifications', 'features'];
4
+ const SETTINGS_TABS = ['general', 'providers', 'relays', 'previews', 'shares', 'notifications', 'features'];
5
5
  // Copilot leads: it is the default provider, and `SETTINGS_PROVIDER_TABS[0]`
6
6
  // is also the sub-tab a first-time visitor lands on.
7
7
  const SETTINGS_PROVIDER_TABS = ['copilot', 'openai', 'claude', 'claude-cloud', 'grok', 'cursor'];
@@ -0,0 +1,49 @@
1
+ // Text for Settings → Sharing: a share row's detail line and an access log
2
+ // entry. DOM-free so it is unit-testable.
3
+
4
+ // The same steps as a preview's age (preview-cards.mjs), which this module
5
+ // cannot import without the API client.
6
+ function age(iso, now) {
7
+ const at = Date.parse(String(iso || ''));
8
+ if (!Number.isFinite(at) || at <= 0) return '';
9
+ const minutes = Math.floor(Math.max(0, now - at) / 60_000);
10
+ if (minutes < 1) return 'just now';
11
+ if (minutes < 60) return `${minutes} min ago`;
12
+ const hours = Math.floor(minutes / 60);
13
+ if (hours < 48) return `${hours} h ago`;
14
+ return `${Math.floor(hours / 24)} d ago`;
15
+ }
16
+
17
+ function plural(count, one, many) {
18
+ return `${count} ${count === 1 ? one : many}`;
19
+ }
20
+
21
+ /** "shared 3 d ago · new link 2 h ago · last opened 5 min ago · 4 opens · 2 viewers" */
22
+ export function describeShare(share, now = Date.now()) {
23
+ const parts = [];
24
+ const since = age(share?.sharedSince, now);
25
+ if (since) parts.push(`shared ${since}`);
26
+ const renewed = age(share?.renewedAt, now);
27
+ if (renewed) parts.push(`new link ${renewed}`);
28
+ const opened = age(share?.lastOpenedAt, now);
29
+ parts.push(opened ? `last opened ${opened}` : 'never opened');
30
+ const opens = Math.max(0, Math.trunc(Number(share?.openCount) || 0));
31
+ const viewers = Math.max(0, Math.trunc(Number(share?.viewerCount) || 0));
32
+ parts.push(plural(opens, 'open', 'opens'));
33
+ parts.push(plural(viewers, 'viewer', 'viewers'));
34
+ if (share?.archived) parts.push('archived');
35
+ return parts.join(' · ');
36
+ }
37
+
38
+ /** "16 Oct 2026, 14:05 · DE · 203.0.113.7 · viewer 1a2b3c4d" */
39
+ export function describeShareOpen(open, { locale } = {}) {
40
+ const parts = [];
41
+ const at = Date.parse(String(open?.openedAt || ''));
42
+ if (Number.isFinite(at)) {
43
+ parts.push(new Date(at).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }));
44
+ }
45
+ if (open?.country) parts.push(String(open.country));
46
+ parts.push(open?.ip ? String(open.ip) : 'IP not stored');
47
+ if (open?.viewer) parts.push(`viewer ${open.viewer}`);
48
+ return parts.join(' · ');
49
+ }