@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
@@ -1,4 +1,4 @@
1
- import { BASE, TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
1
+ import { BASE, TOKEN, CLIENT_ID, IS_SHARE_PREVIEW, SHARE_PREVIEW_CONVERSATION_ID, SHARED_CONVERSATION_TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
2
 
3
3
  // fetch() has no default timeout. Without one, a request issued while the app is
4
4
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -71,6 +71,58 @@ export async function apiFetch(url, opts = {}) {
71
71
  }
72
72
  }
73
73
 
74
+ const SERVER_ERROR_MAX_CHARS = 300;
75
+
76
+ /** A server's error text made fit for an alert or a status line. */
77
+ export function readableServerError(value) {
78
+ const text = String(value == null ? '' : value)
79
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
80
+ .replace(/\s+/g, ' ')
81
+ .trim();
82
+ return text.length > SERVER_ERROR_MAX_CHARS ? `${text.slice(0, SERVER_ERROR_MAX_CHARS - 1)}…` : text;
83
+ }
84
+
85
+ /**
86
+ * apiFetch for the few callers that show the user why a request failed:
87
+ * throws an Error carrying the server's `error` text (or the HTTP status, or
88
+ * the network failure) instead of returning null. The error's `status` is the
89
+ * HTTP status the relay answered with, or 0 when no answer arrived. Paused
90
+ * networking still returns null, as apiFetch does.
91
+ */
92
+ export async function apiFetchOrThrow(url, opts = {}) {
93
+ if (!networkRequestsEnabled && !opts.keepalive) return null;
94
+ let response;
95
+ try {
96
+ response = await fetch(`${BASE}${url}`, {
97
+ signal: requestTimeoutSignal(),
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ ...authHeaders(),
101
+ ...(opts.headers || {}),
102
+ },
103
+ ...opts,
104
+ });
105
+ } catch (error) {
106
+ noteFetchFailure(url, error);
107
+ const timedOut = error?.name === 'TimeoutError' || error?.name === 'AbortError';
108
+ throw Object.assign(
109
+ new Error(timedOut ? 'The relay did not answer in time.' : `The relay could not be reached (${readableServerError(toErrorMessage(error))}).`),
110
+ { status: 0 },
111
+ );
112
+ }
113
+ noteFetchSuccess();
114
+ if (!response.ok) {
115
+ console.error('API error', response.status, url);
116
+ let serverText = '';
117
+ try {
118
+ const body = await response.json();
119
+ serverText = readableServerError(body?.error || body?.message || '');
120
+ } catch {}
121
+ throw Object.assign(new Error(serverText || `The relay answered HTTP ${response.status}.`), { status: response.status });
122
+ }
123
+ return response.json();
124
+ }
125
+
74
126
  export async function verifyExistingSession(tokenCandidate = '') {
75
127
  if (!networkRequestsEnabled) {
76
128
  return {
@@ -195,7 +247,7 @@ export async function updateWorkspaceRoot(rootPath, conversationId = null) {
195
247
  const endpoint = convId
196
248
  ? `/api/conversation/${encodeURIComponent(convId)}/workspace-root`
197
249
  : '/api/workspace-root';
198
- const response = await apiFetch(endpoint, {
250
+ const response = await apiFetchOrThrow(endpoint, {
199
251
  method: 'POST',
200
252
  body: JSON.stringify({ rootPath: pathValue }),
201
253
  });
@@ -474,14 +526,14 @@ export async function updateCursorSettings({
474
526
  }
475
527
 
476
528
  export async function loadWindowsAutostartSetting() {
477
- return apiFetch('/api/settings/windows-autostart');
529
+ return apiFetchOrThrow('/api/settings/windows-autostart');
478
530
  }
479
531
 
480
532
  export async function updateWindowsAutostartSetting(mode) {
481
533
  // Mode string ('off' | 'signin' | 'boot'); a boolean still works for the
482
534
  // pre-mode call sites and maps onto signin/off server-side.
483
535
  const body = typeof mode === 'boolean' ? { enabled: mode } : { mode: String(mode) };
484
- return apiFetch('/api/settings/windows-autostart', {
536
+ return apiFetchOrThrow('/api/settings/windows-autostart', {
485
537
  method: 'POST',
486
538
  body: JSON.stringify(body),
487
539
  });
@@ -692,9 +744,12 @@ export async function relaunchSessionWorkerWithWorkspaceRoot(conversationId, roo
692
744
  // One key per user gesture, so a duplicate delivery (retried fetch, second tab,
693
745
  // stray touch event) is provably the same request and the relay replays it
694
746
  // instead of running a second stop/spawn cycle.
695
- return apiFetch(`/api/conversation/${encodeURIComponent(convId)}/relaunch-with-workspace-root`, {
747
+ // A relaunch stops the worker and starts a new one; on Windows that alone
748
+ // can take longer than the default ceiling.
749
+ return apiFetchOrThrow(`/api/conversation/${encodeURIComponent(convId)}/relaunch-with-workspace-root`, {
696
750
  method: 'POST',
697
751
  body: JSON.stringify({ rootPath: pathValue, idempotencyKey: String(idempotencyKey || '') }),
752
+ signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
698
753
  });
699
754
  }
700
755
 
@@ -878,6 +933,64 @@ async function conversationActionRequest(url) {
878
933
  }
879
934
  }
880
935
 
936
+ // Session groups: every call answers the relay's `{ ok, ... }`, or
937
+ // `{ ok: false, status, error }` when it refuses, or null when offline.
938
+ async function groupRequest(url, method, body = null) {
939
+ if (!areNetworkRequestsEnabled()) return null;
940
+ try {
941
+ const response = await fetch(`${BASE}${url}`, {
942
+ signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
943
+ method,
944
+ headers: { 'Content-Type': 'application/json', ...authHeaders() },
945
+ body: body === null ? undefined : JSON.stringify(body),
946
+ });
947
+ const payload = await response.json().catch(() => null);
948
+ if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
949
+ noteFetchSuccess();
950
+ return payload || { ok: true };
951
+ } catch (error) {
952
+ noteFetchFailure(url, error);
953
+ return null;
954
+ }
955
+ }
956
+
957
+ export function createConversationGroup({ name = null, conversationIds = [] } = {}) {
958
+ return groupRequest('/api/conversation-groups', 'POST', { name, conversationIds });
959
+ }
960
+
961
+ export function renameConversationGroup(groupId, name) {
962
+ return groupRequest(`/api/conversation-groups/${encodeURIComponent(String(groupId || ''))}`, 'PATCH', { name });
963
+ }
964
+
965
+ export function moveConversationToGroup(conversationId, groupId) {
966
+ return groupRequest(`/api/conversation/${encodeURIComponent(String(conversationId || ''))}/group`, 'PUT', { groupId });
967
+ }
968
+
969
+ /**
970
+ * A conversation into a group at a place in its hand order: before
971
+ * `beforeConversationId`, or at the end with null. `groupId: null` takes it
972
+ * out of its group.
973
+ */
974
+ export function placeConversationInGroup(conversationId, { groupId = null, beforeConversationId = null } = {}) {
975
+ return groupRequest(`/api/conversation/${encodeURIComponent(String(conversationId || ''))}/group`, 'PUT', { groupId, beforeConversationId });
976
+ }
977
+
978
+ export function removeConversationFromGroup(conversationId) {
979
+ return groupRequest(`/api/conversation/${encodeURIComponent(String(conversationId || ''))}/group`, 'DELETE');
980
+ }
981
+
982
+ export function dissolveConversationGroup(groupId) {
983
+ return groupRequest(`/api/conversation-groups/${encodeURIComponent(String(groupId || ''))}`, 'DELETE');
984
+ }
985
+
986
+ export function archiveConversationGroup(groupId) {
987
+ return groupRequest(`/api/conversation-groups/${encodeURIComponent(String(groupId || ''))}/archive`, 'POST', {});
988
+ }
989
+
990
+ export function stopConversationGroup(groupId) {
991
+ return groupRequest(`/api/conversation-groups/${encodeURIComponent(String(groupId || ''))}/stop`, 'POST', { clientId: CLIENT_ID });
992
+ }
993
+
881
994
  export async function deleteConversation(id) {
882
995
  const convId = String(id || '').trim();
883
996
  if (!convId || !areNetworkRequestsEnabled()) return null;
@@ -901,14 +1014,50 @@ export async function deleteConversation(id) {
901
1014
  }
902
1015
  }
903
1016
 
1017
+ // Keeps the relay's `error` text on a refusal (a turn still running), so the
1018
+ // notice can say why.
1019
+ async function shareVisibilityRequest(url, method, body = null) {
1020
+ if (!networkRequestsEnabled) return null;
1021
+ try {
1022
+ const response = await fetch(`${BASE}${url}`, {
1023
+ signal: requestTimeoutSignal(),
1024
+ method,
1025
+ headers: {
1026
+ 'Content-Type': 'application/json',
1027
+ ...authHeaders(),
1028
+ },
1029
+ body: body === null ? undefined : JSON.stringify(body),
1030
+ });
1031
+ const result = await response.json().catch(() => null);
1032
+ if (!response.ok) {
1033
+ console.error('API error', response.status, url);
1034
+ return { ok: false, status: response.status, error: String(result?.error || '').trim() };
1035
+ }
1036
+ noteFetchSuccess();
1037
+ return result;
1038
+ } catch (error) {
1039
+ noteFetchFailure(url, error);
1040
+ return null;
1041
+ }
1042
+ }
1043
+
904
1044
  export async function updateMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
905
1045
  const convId = String(conversationId || '').trim();
906
1046
  const msgId = String(messageId || '').trim();
907
1047
  if (!convId || !msgId) return null;
908
- return apiFetch(`/api/conversation/${encodeURIComponent(convId)}/message/${encodeURIComponent(msgId)}/share-visibility`, {
909
- method: 'PATCH',
910
- body: JSON.stringify({ hiddenFromShares: hiddenFromShares === true }),
911
- });
1048
+ return shareVisibilityRequest(
1049
+ `/api/conversation/${encodeURIComponent(convId)}/message/${encodeURIComponent(msgId)}/share-visibility`,
1050
+ 'PATCH',
1051
+ { hiddenFromShares: hiddenFromShares === true },
1052
+ );
1053
+ }
1054
+
1055
+ // Every hidden message of the conversation visible to shared viewers again,
1056
+ // except those of a turn still running (the answer's `skipped`).
1057
+ export async function unhideAllMessagesFromShares(conversationId) {
1058
+ const convId = String(conversationId || '').trim();
1059
+ if (!convId) return null;
1060
+ return shareVisibilityRequest(`/api/conversation/${encodeURIComponent(convId)}/share-visibility/unhide-all`, 'POST');
912
1061
  }
913
1062
 
914
1063
  // Pin or unpin one message. Unlike apiFetch this keeps the relay's `error`
@@ -986,6 +1135,57 @@ export async function createConversationShareLink(id) {
986
1135
  });
987
1136
  }
988
1137
 
1138
+ // Share management (Settings → Shared conversations, the ⋯ menu). These throw
1139
+ // with the relay's reason, so the caller can say why an action failed.
1140
+ export async function listConversationShares() {
1141
+ return apiFetchOrThrow('/api/shares');
1142
+ }
1143
+
1144
+ export async function renewConversationShareLink(id) {
1145
+ const convId = String(id || '').trim();
1146
+ if (!convId) return null;
1147
+ return apiFetchOrThrow(`/api/conversation/${encodeURIComponent(convId)}/share/renew`, { method: 'POST' });
1148
+ }
1149
+
1150
+ export async function stopSharingConversation(id) {
1151
+ const convId = String(id || '').trim();
1152
+ if (!convId) return null;
1153
+ return apiFetchOrThrow(`/api/conversation/${encodeURIComponent(convId)}/share`, { method: 'DELETE' });
1154
+ }
1155
+
1156
+ export async function loadConversationShareOpens(id, { limit = 200 } = {}) {
1157
+ const convId = String(id || '').trim();
1158
+ if (!convId) return null;
1159
+ const pageSize = Math.max(1, Math.trunc(Number(limit) || 200));
1160
+ return apiFetchOrThrow(`/api/conversation/${encodeURIComponent(convId)}/share/opens?limit=${pageSize}`);
1161
+ }
1162
+
1163
+ export async function updateShareSettings(settings = {}) {
1164
+ return apiFetchOrThrow('/api/settings/shares', {
1165
+ method: 'POST',
1166
+ body: JSON.stringify(settings || {}),
1167
+ });
1168
+ }
1169
+
1170
+ export async function countStoredViewerIps() {
1171
+ return apiFetchOrThrow('/api/settings/shares/stored-viewer-ips');
1172
+ }
1173
+
1174
+ export async function forgetStoredViewerIps() {
1175
+ return apiFetchOrThrow('/api/settings/shares/stored-viewer-ips', { method: 'DELETE' });
1176
+ }
1177
+
1178
+ export async function loadSharePathPrivacySetting() {
1179
+ return apiFetchOrThrow('/api/settings/share-path-privacy');
1180
+ }
1181
+
1182
+ export async function updateSharePathPrivacySetting(mode) {
1183
+ return apiFetchOrThrow('/api/settings/share-path-privacy', {
1184
+ method: 'POST',
1185
+ body: JSON.stringify({ mode }),
1186
+ });
1187
+ }
1188
+
989
1189
  export async function loadServerStatusEventPage({ before = null, limit = 40 } = {}) {
990
1190
  const params = new URLSearchParams();
991
1191
  const pageSize = Math.max(1, Math.min(100, Math.trunc(Number(limit) || 40)));
@@ -1004,9 +1204,23 @@ export async function loadServerStatusEventPage({ before = null, limit = 40 } =
1004
1204
  };
1005
1205
  }
1006
1206
 
1007
- export async function loadSharedConversation(shareToken, options = {}) {
1207
+ // The shared reader's source: the share link's route, or for "Preview as
1208
+ // shared" the owner's preview route (cookie-authed, marked redactions).
1209
+ function sharedReaderEndpoint(shareToken, suffix = '') {
1210
+ if (IS_SHARE_PREVIEW) return `${BASE}/api/conversation/${encodeURIComponent(SHARE_PREVIEW_CONVERSATION_ID)}/share-preview${suffix}`;
1008
1211
  const token = String(shareToken || '').trim().toLowerCase();
1009
- if (!token) return { ok: false, status: 400, error: 'Missing shared token' };
1212
+ return token ? `${BASE}/api/shared/${encodeURIComponent(token)}${suffix}` : '';
1213
+ }
1214
+
1215
+ function sharedReaderHeaders() {
1216
+ return IS_SHARE_PREVIEW
1217
+ ? { 'Content-Type': 'application/json', ...authHeaders() }
1218
+ : { 'Content-Type': 'application/json' };
1219
+ }
1220
+
1221
+ export async function loadSharedConversation(shareToken, options = {}) {
1222
+ const endpoint = sharedReaderEndpoint(shareToken);
1223
+ if (!endpoint) return { ok: false, status: 400, error: 'Missing shared token' };
1010
1224
  const params = new URLSearchParams();
1011
1225
  const limit = Math.max(1, Math.trunc(Number(options.limit) || 0));
1012
1226
  if (limit) params.set('limit', String(limit));
@@ -1020,11 +1234,13 @@ export async function loadSharedConversation(shareToken, options = {}) {
1020
1234
  if (afterTimestamp) params.set('afterTimestamp', afterTimestamp);
1021
1235
  const aroundMessageId = String(options.aroundMessageId || '').trim();
1022
1236
  if (aroundMessageId) params.set('aroundMessageId', aroundMessageId);
1237
+ // The page's first fetch: the relay logs it as an open (polls are not).
1238
+ if (options.open === true) params.set('open', '1');
1023
1239
  const query = params.toString();
1024
1240
  try {
1025
- const response = await fetch(`${BASE}/api/shared/${encodeURIComponent(token)}${query ? `?${query}` : ''}`, {
1241
+ const response = await fetch(`${endpoint}${query ? `?${query}` : ''}`, {
1026
1242
  signal: requestTimeoutSignal(),
1027
- headers: { 'Content-Type': 'application/json' },
1243
+ headers: sharedReaderHeaders(),
1028
1244
  });
1029
1245
  const payload = await response.json().catch(() => null);
1030
1246
  if (!response.ok) {
@@ -1048,6 +1264,23 @@ export async function loadSharedConversation(shareToken, options = {}) {
1048
1264
  }
1049
1265
  }
1050
1266
 
1267
+ /** One page of a sub-agent's record, as the shared view (or its preview) may read it. */
1268
+ export async function loadSharedSubagentRecord(runId, { after = 0, limit = 500 } = {}) {
1269
+ const id = String(runId || '').trim();
1270
+ const endpoint = id ? sharedReaderEndpoint(SHARED_CONVERSATION_TOKEN, `/subagent/${encodeURIComponent(id)}`) : '';
1271
+ if (!endpoint) return null;
1272
+ try {
1273
+ const response = await fetch(`${endpoint}?after=${Number(after) || 0}&limit=${Number(limit) || 500}`, {
1274
+ signal: requestTimeoutSignal(),
1275
+ headers: sharedReaderHeaders(),
1276
+ });
1277
+ if (!response.ok) return null;
1278
+ return await response.json().catch(() => null);
1279
+ } catch {
1280
+ return null;
1281
+ }
1282
+ }
1283
+
1051
1284
  export async function reportSharedViewerPresence(shareToken, viewerId) {
1052
1285
  const token = String(shareToken || '').trim().toLowerCase();
1053
1286
  const viewer = String(viewerId || '').trim();
@@ -1122,8 +1355,10 @@ export async function killSessionWorker(sdkSessionId, body = {}) {
1122
1355
  });
1123
1356
  }
1124
1357
 
1358
+ // The three relay maintenance calls throw on failure (apiFetchOrThrow), so
1359
+ // their callers can tell a refusal from a restart that cut the connection.
1125
1360
  export async function requestRelayRestart(body = {}) {
1126
- return apiFetch('/api/relay/shutdown', {
1361
+ return apiFetchOrThrow('/api/relay/shutdown', {
1127
1362
  method: 'POST',
1128
1363
  body: JSON.stringify({
1129
1364
  reason: 'manual-restart',
@@ -1171,14 +1406,14 @@ export async function cancelUsageLimitPause(conversationId) {
1171
1406
  }
1172
1407
 
1173
1408
  export async function cancelRelayRestart(body = {}) {
1174
- return apiFetch('/api/relay/shutdown/cancel', {
1409
+ return apiFetchOrThrow('/api/relay/shutdown/cancel', {
1175
1410
  method: 'POST',
1176
1411
  body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
1177
1412
  });
1178
1413
  }
1179
1414
 
1180
1415
  export async function requestQueueEmpty(body = {}) {
1181
- return apiFetch('/api/queue/empty', {
1416
+ return apiFetchOrThrow('/api/queue/empty', {
1182
1417
  method: 'POST',
1183
1418
  body: JSON.stringify({
1184
1419
  reason: 'manual-empty-queue',
@@ -5,6 +5,7 @@ import {
5
5
  escHtml,
6
6
  relayQuestions,
7
7
  relayQuestionDrafts,
8
+ scrollBottom,
8
9
  } from './store.js';
9
10
  import { loadRelayQuestions as loadRelayQuestionsApi, answerRelayQuestion, answerRelayQuestionStructured } from './api-client.js';
10
11
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
@@ -13,6 +14,7 @@ import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
13
14
  import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
14
15
  import { isChatInteractionHeld } from './selection-guard.mjs';
15
16
  import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
17
+ import { beginOwnSend, followsTranscriptEnd, refreshJumpButton, scrollAfterOwnSend } from './transcript-jump-bottom-view.mjs';
16
18
 
17
19
  let relayQuestionRenderHash = '';
18
20
  let renderedRelayQuestionIds = new Set();
@@ -27,12 +29,6 @@ const relayQuestionMultiMode = new Map();
27
29
  function multiModeFor(question) {
28
30
  return question ? relayQuestionMultiMode.get(question.id) : undefined;
29
31
  }
30
- const RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX = 80;
31
-
32
- function distanceFromBottom(el) {
33
- if (!el) return Number.POSITIVE_INFINITY;
34
- return Math.max(0, el.scrollHeight - el.clientHeight - el.scrollTop);
35
- }
36
32
 
37
33
  function captureFocusedQuestionControl(messagesEl) {
38
34
  const active = document.activeElement;
@@ -200,7 +196,8 @@ export async function openPendingQuestionFromBanner() {
200
196
  return;
201
197
  }
202
198
  renderRelayQuestions();
203
- window.scrollBottom?.();
199
+ // The reader asked for the question: it waits at the end.
200
+ scrollBottom();
204
201
  }
205
202
 
206
203
  // Scoped to #messages on purpose: scrollIntoView walks every ancestor
@@ -218,7 +215,9 @@ export function renderRelayQuestions() {
218
215
  const el = document.getElementById('messages');
219
216
  if (!el) return;
220
217
  const previousScrollTop = el.scrollTop;
221
- const shouldAutoScroll = distanceFromBottom(el) <= RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX;
218
+ // A card is shown to a reader who is at the end. Anyone further up stays
219
+ // where they are; the jump-to-bottom button says that a question waits.
220
+ const shouldAutoScroll = followsTranscriptEnd();
222
221
  const focusedControl = captureFocusedQuestionControl(el);
223
222
  const questions = Array.from(relayQuestions.values())
224
223
  .filter((q) => q && q.conversationId === currentConvId && q.status === 'pending')
@@ -254,6 +253,7 @@ export function renderRelayQuestions() {
254
253
  if (!questions.length) {
255
254
  renderedRelayQuestionIds = nextRenderedQuestionIds;
256
255
  if (!shouldAutoScroll) el.scrollTop = previousScrollTop;
256
+ refreshJumpButton();
257
257
  return;
258
258
  }
259
259
 
@@ -330,19 +330,17 @@ export function renderRelayQuestions() {
330
330
  el.appendChild(wrapper);
331
331
  }
332
332
 
333
- // An agent exchange whose turn asks this is shown whole, before anything
334
- // scrolls (agent-exchange-fold-view.mjs).
335
- window.syncAgentExchangeFolds?.();
336
333
  renderedRelayQuestionIds = nextRenderedQuestionIds;
337
334
  restoreFocusedQuestionControl(el, focusedControl);
338
- if (firstNewQuestionId) {
335
+ if (!shouldAutoScroll) {
336
+ el.scrollTop = previousScrollTop;
337
+ } else if (firstNewQuestionId) {
339
338
  const target = el.querySelector(`.relay-question-container[data-question-id="${CSS.escape(firstNewQuestionId)}"]`);
340
339
  scrollQuestionCardIntoMessages(el, target);
341
- } else if (shouldAutoScroll) {
342
- window.scrollBottom?.();
343
340
  } else {
344
- el.scrollTop = previousScrollTop;
341
+ scrollBottom();
345
342
  }
343
+ refreshJumpButton();
346
344
  }
347
345
 
348
346
  function fieldDomId(questionId, index) {
@@ -489,6 +487,7 @@ export async function submitRelayStructuredAnswer(questionId) {
489
487
  const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
490
488
  const controls = card ? card.querySelectorAll('button, input, select, textarea') : [];
491
489
  controls.forEach((el) => { el.disabled = true; });
490
+ const send = beginOwnSend();
492
491
 
493
492
  try {
494
493
  const sdkSessionId = String(question?.sdkSessionId || '').trim();
@@ -513,6 +512,7 @@ export async function submitRelayStructuredAnswer(questionId) {
513
512
  window.renderConvList?.();
514
513
  window.syncComposerButtonState?.();
515
514
  renderRelayQuestions();
515
+ scrollAfterOwnSend(send);
516
516
  window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
517
517
  } catch (e) {
518
518
  controls.forEach((el) => { el.disabled = false; });
@@ -594,6 +594,7 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
594
594
  const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
595
595
  const controls = card ? card.querySelectorAll('button, textarea') : [];
596
596
  controls.forEach((el) => { el.disabled = true; });
597
+ const send = beginOwnSend();
597
598
 
598
599
  try {
599
600
  const question = relayQuestions.get(questionId) || null;
@@ -618,6 +619,7 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
618
619
  window.renderConvList?.();
619
620
  window.syncComposerButtonState?.();
620
621
  renderRelayQuestions();
622
+ scrollAfterOwnSend(send);
621
623
  window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
622
624
  } catch (e) {
623
625
  controls.forEach((el) => { el.disabled = false; });