@oar-sh/oar 0.9.9 → 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.
- package/CHANGELOG.md +561 -1
- package/README.md +162 -909
- package/bin/bat/oar.bat +46 -46
- package/package.json +14 -14
- package/server/README.md +369 -13
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +121 -9
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +79 -12
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +95 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +187 -0
- package/server/public/app/api-client.js +312 -16
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +17 -12
- package/server/public/app/attachments-view.js +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +336 -59
- package/server/public/app/chat-actions-menu-state.mjs +126 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +436 -117
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +13 -4
- package/server/public/app/journal-view.js +930 -45
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +19 -3
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +93 -9
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +137 -40
- package/server/public/app/store.js +115 -32
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +558 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
- package/server/public/app/transcript-jump-bottom.mjs +210 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-history.mjs +5 -3
- package/server/public/index.html +774 -12
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +30 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +698 -114
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +843 -193
- package/server/server-runtime.mjs +559 -160
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +49 -27
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1952 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +551 -7
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +695 -0
- package/server/services/session-history-refresh-service.mjs +7 -0
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +160 -6
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- 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
|
|
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
|
|
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
|
|
536
|
+
return apiFetchOrThrow('/api/settings/windows-autostart', {
|
|
485
537
|
method: 'POST',
|
|
486
538
|
body: JSON.stringify(body),
|
|
487
539
|
});
|
|
@@ -498,6 +550,17 @@ export async function updateTurnCeilingSetting(ceilingMinutes) {
|
|
|
498
550
|
});
|
|
499
551
|
}
|
|
500
552
|
|
|
553
|
+
export async function loadSubagentRecordRetentionSetting() {
|
|
554
|
+
return apiFetch('/api/settings/subagent-record-retention');
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function updateSubagentRecordRetentionSetting(retentionDays) {
|
|
558
|
+
return apiFetch('/api/settings/subagent-record-retention', {
|
|
559
|
+
method: 'POST',
|
|
560
|
+
body: JSON.stringify({ retentionDays: Number(retentionDays) }),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
501
564
|
export async function loadBackgroundTaskTimeoutSetting() {
|
|
502
565
|
return apiFetch('/api/settings/background-task-timeout');
|
|
503
566
|
}
|
|
@@ -681,9 +744,12 @@ export async function relaunchSessionWorkerWithWorkspaceRoot(conversationId, roo
|
|
|
681
744
|
// One key per user gesture, so a duplicate delivery (retried fetch, second tab,
|
|
682
745
|
// stray touch event) is provably the same request and the relay replays it
|
|
683
746
|
// instead of running a second stop/spawn cycle.
|
|
684
|
-
|
|
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`, {
|
|
685
750
|
method: 'POST',
|
|
686
751
|
body: JSON.stringify({ rootPath: pathValue, idempotencyKey: String(idempotencyKey || '') }),
|
|
752
|
+
signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
|
|
687
753
|
});
|
|
688
754
|
}
|
|
689
755
|
|
|
@@ -867,6 +933,64 @@ async function conversationActionRequest(url) {
|
|
|
867
933
|
}
|
|
868
934
|
}
|
|
869
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
|
+
|
|
870
994
|
export async function deleteConversation(id) {
|
|
871
995
|
const convId = String(id || '').trim();
|
|
872
996
|
if (!convId || !areNetworkRequestsEnabled()) return null;
|
|
@@ -890,14 +1014,50 @@ export async function deleteConversation(id) {
|
|
|
890
1014
|
}
|
|
891
1015
|
}
|
|
892
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
|
+
|
|
893
1044
|
export async function updateMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
|
|
894
1045
|
const convId = String(conversationId || '').trim();
|
|
895
1046
|
const msgId = String(messageId || '').trim();
|
|
896
1047
|
if (!convId || !msgId) return null;
|
|
897
|
-
return
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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');
|
|
901
1061
|
}
|
|
902
1062
|
|
|
903
1063
|
// Pin or unpin one message. Unlike apiFetch this keeps the relay's `error`
|
|
@@ -975,6 +1135,57 @@ export async function createConversationShareLink(id) {
|
|
|
975
1135
|
});
|
|
976
1136
|
}
|
|
977
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
|
+
|
|
978
1189
|
export async function loadServerStatusEventPage({ before = null, limit = 40 } = {}) {
|
|
979
1190
|
const params = new URLSearchParams();
|
|
980
1191
|
const pageSize = Math.max(1, Math.min(100, Math.trunc(Number(limit) || 40)));
|
|
@@ -993,9 +1204,23 @@ export async function loadServerStatusEventPage({ before = null, limit = 40 } =
|
|
|
993
1204
|
};
|
|
994
1205
|
}
|
|
995
1206
|
|
|
996
|
-
|
|
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}`;
|
|
997
1211
|
const token = String(shareToken || '').trim().toLowerCase();
|
|
998
|
-
|
|
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' };
|
|
999
1224
|
const params = new URLSearchParams();
|
|
1000
1225
|
const limit = Math.max(1, Math.trunc(Number(options.limit) || 0));
|
|
1001
1226
|
if (limit) params.set('limit', String(limit));
|
|
@@ -1009,11 +1234,13 @@ export async function loadSharedConversation(shareToken, options = {}) {
|
|
|
1009
1234
|
if (afterTimestamp) params.set('afterTimestamp', afterTimestamp);
|
|
1010
1235
|
const aroundMessageId = String(options.aroundMessageId || '').trim();
|
|
1011
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');
|
|
1012
1239
|
const query = params.toString();
|
|
1013
1240
|
try {
|
|
1014
|
-
const response = await fetch(`${
|
|
1241
|
+
const response = await fetch(`${endpoint}${query ? `?${query}` : ''}`, {
|
|
1015
1242
|
signal: requestTimeoutSignal(),
|
|
1016
|
-
headers:
|
|
1243
|
+
headers: sharedReaderHeaders(),
|
|
1017
1244
|
});
|
|
1018
1245
|
const payload = await response.json().catch(() => null);
|
|
1019
1246
|
if (!response.ok) {
|
|
@@ -1037,6 +1264,23 @@ export async function loadSharedConversation(shareToken, options = {}) {
|
|
|
1037
1264
|
}
|
|
1038
1265
|
}
|
|
1039
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
|
+
|
|
1040
1284
|
export async function reportSharedViewerPresence(shareToken, viewerId) {
|
|
1041
1285
|
const token = String(shareToken || '').trim().toLowerCase();
|
|
1042
1286
|
const viewer = String(viewerId || '').trim();
|
|
@@ -1111,8 +1355,10 @@ export async function killSessionWorker(sdkSessionId, body = {}) {
|
|
|
1111
1355
|
});
|
|
1112
1356
|
}
|
|
1113
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.
|
|
1114
1360
|
export async function requestRelayRestart(body = {}) {
|
|
1115
|
-
return
|
|
1361
|
+
return apiFetchOrThrow('/api/relay/shutdown', {
|
|
1116
1362
|
method: 'POST',
|
|
1117
1363
|
body: JSON.stringify({
|
|
1118
1364
|
reason: 'manual-restart',
|
|
@@ -1160,14 +1406,14 @@ export async function cancelUsageLimitPause(conversationId) {
|
|
|
1160
1406
|
}
|
|
1161
1407
|
|
|
1162
1408
|
export async function cancelRelayRestart(body = {}) {
|
|
1163
|
-
return
|
|
1409
|
+
return apiFetchOrThrow('/api/relay/shutdown/cancel', {
|
|
1164
1410
|
method: 'POST',
|
|
1165
1411
|
body: JSON.stringify({ requestedBy: 'localhost-api', ...(body || {}) }),
|
|
1166
1412
|
});
|
|
1167
1413
|
}
|
|
1168
1414
|
|
|
1169
1415
|
export async function requestQueueEmpty(body = {}) {
|
|
1170
|
-
return
|
|
1416
|
+
return apiFetchOrThrow('/api/queue/empty', {
|
|
1171
1417
|
method: 'POST',
|
|
1172
1418
|
body: JSON.stringify({
|
|
1173
1419
|
reason: 'manual-empty-queue',
|
|
@@ -1318,6 +1564,56 @@ export async function uploadAttachment(item) {
|
|
|
1318
1564
|
return response.json();
|
|
1319
1565
|
}
|
|
1320
1566
|
|
|
1567
|
+
// A POST whose failure message matters: unlike apiFetch it throws with the
|
|
1568
|
+
// relay's `error` text, so the explorer can show "That name is already taken"
|
|
1569
|
+
// instead of a bare failure.
|
|
1570
|
+
export async function apiMutate(url, body = {}) {
|
|
1571
|
+
if (!networkRequestsEnabled) throw new Error('Network requests are paused');
|
|
1572
|
+
let response;
|
|
1573
|
+
try {
|
|
1574
|
+
response = await fetch(`${BASE}${url}`, {
|
|
1575
|
+
signal: requestTimeoutSignal(),
|
|
1576
|
+
method: 'POST',
|
|
1577
|
+
headers: {
|
|
1578
|
+
'Content-Type': 'application/json',
|
|
1579
|
+
...authHeaders(),
|
|
1580
|
+
},
|
|
1581
|
+
body: JSON.stringify(body),
|
|
1582
|
+
});
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
noteFetchFailure(url, error);
|
|
1585
|
+
throw new Error('The relay could not be reached');
|
|
1586
|
+
}
|
|
1587
|
+
const payload = await response.json().catch(() => null);
|
|
1588
|
+
if (!response.ok) throw new Error(String(payload?.error || '').trim() || `Request failed (${response.status})`);
|
|
1589
|
+
noteFetchSuccess();
|
|
1590
|
+
return payload;
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// The explorer's edits. `root` is the explorer root the path belongs to:
|
|
1594
|
+
// 'workspace' paths are relative to the conversation's root, everything else
|
|
1595
|
+
// ('drives', 'session') is an absolute path checked like a drives listing.
|
|
1596
|
+
function explorerMutationRoute(root, operation) {
|
|
1597
|
+
return root === 'workspace' ? `/api/repo/${operation}` : `/api/drives/${operation}`;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
function explorerScope(root, conversationId) {
|
|
1601
|
+
const convId = String(conversationId || '').trim();
|
|
1602
|
+
return root === 'workspace' && convId ? { conversationId: convId } : {};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
export function createExplorerFolder({ root, path: parentPath, name, conversationId = null }) {
|
|
1606
|
+
return apiMutate(explorerMutationRoute(root, 'mkdir'), { path: String(parentPath || ''), name, ...explorerScope(root, conversationId) });
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
export function renameExplorerEntry({ root, path: entryPath, newName, conversationId = null }) {
|
|
1610
|
+
return apiMutate(explorerMutationRoute(root, 'rename'), { path: String(entryPath || ''), newName, ...explorerScope(root, conversationId) });
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
export function deleteExplorerEntry({ root, path: entryPath, conversationId = null, dryRun = false }) {
|
|
1614
|
+
return apiMutate(explorerMutationRoute(root, 'delete'), { path: String(entryPath || ''), dryRun: dryRun === true, ...explorerScope(root, conversationId) });
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1321
1617
|
export async function loadRepoTree(includeHidden = false, includeHeavy = false, conversationId = null) {
|
|
1322
1618
|
const params = new URLSearchParams({
|
|
1323
1619
|
includeHidden: includeHidden ? '1' : '0',
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Whether archiving a conversation asks first. Archiving stops the chat's
|
|
2
|
+
// worker, so a chat whose worker is live (the sidebar's green state, idle or
|
|
3
|
+
// not) or whose turn is running asks before it goes; an offline chat archives
|
|
4
|
+
// at once. DOM-free so the decision is unit-testable.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The confirmation an archive needs, or null when it goes through at once.
|
|
8
|
+
* `visualState` is the sidebar row's state (resolveConversationUiState), and
|
|
9
|
+
* `running` whether a turn of the chat is running as far as the page knows.
|
|
10
|
+
*/
|
|
11
|
+
export function archiveConfirmationFor({ archived = false, visualState = '', running = false } = {}) {
|
|
12
|
+
if (archived === true) return null;
|
|
13
|
+
if (running === true) {
|
|
14
|
+
return {
|
|
15
|
+
reason: 'running',
|
|
16
|
+
message: 'Archive this session? It is still working on a reply.',
|
|
17
|
+
detail: 'The relay archives it only once the turn has stopped.',
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (String(visualState || '').trim().toLowerCase() === 'ready') {
|
|
21
|
+
return {
|
|
22
|
+
reason: 'active',
|
|
23
|
+
message: 'Archive this session? Its process is still active.',
|
|
24
|
+
detail: 'Archiving stops the idle process. The chat stays in the Archived view.',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -332,14 +332,15 @@ export function renderRelayQuestions() {
|
|
|
332
332
|
|
|
333
333
|
renderedRelayQuestionIds = nextRenderedQuestionIds;
|
|
334
334
|
restoreFocusedQuestionControl(el, focusedControl);
|
|
335
|
-
if (
|
|
335
|
+
if (!shouldAutoScroll) {
|
|
336
|
+
el.scrollTop = previousScrollTop;
|
|
337
|
+
} else if (firstNewQuestionId) {
|
|
336
338
|
const target = el.querySelector(`.relay-question-container[data-question-id="${CSS.escape(firstNewQuestionId)}"]`);
|
|
337
339
|
scrollQuestionCardIntoMessages(el, target);
|
|
338
|
-
} else if (shouldAutoScroll) {
|
|
339
|
-
window.scrollBottom?.();
|
|
340
340
|
} else {
|
|
341
|
-
|
|
341
|
+
scrollBottom();
|
|
342
342
|
}
|
|
343
|
+
refreshJumpButton();
|
|
343
344
|
}
|
|
344
345
|
|
|
345
346
|
function fieldDomId(questionId, index) {
|
|
@@ -486,6 +487,7 @@ export async function submitRelayStructuredAnswer(questionId) {
|
|
|
486
487
|
const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
|
|
487
488
|
const controls = card ? card.querySelectorAll('button, input, select, textarea') : [];
|
|
488
489
|
controls.forEach((el) => { el.disabled = true; });
|
|
490
|
+
const send = beginOwnSend();
|
|
489
491
|
|
|
490
492
|
try {
|
|
491
493
|
const sdkSessionId = String(question?.sdkSessionId || '').trim();
|
|
@@ -510,6 +512,7 @@ export async function submitRelayStructuredAnswer(questionId) {
|
|
|
510
512
|
window.renderConvList?.();
|
|
511
513
|
window.syncComposerButtonState?.();
|
|
512
514
|
renderRelayQuestions();
|
|
515
|
+
scrollAfterOwnSend(send);
|
|
513
516
|
window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
|
|
514
517
|
} catch (e) {
|
|
515
518
|
controls.forEach((el) => { el.disabled = false; });
|
|
@@ -591,6 +594,7 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
|
|
|
591
594
|
const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
|
|
592
595
|
const controls = card ? card.querySelectorAll('button, textarea') : [];
|
|
593
596
|
controls.forEach((el) => { el.disabled = true; });
|
|
597
|
+
const send = beginOwnSend();
|
|
594
598
|
|
|
595
599
|
try {
|
|
596
600
|
const question = relayQuestions.get(questionId) || null;
|
|
@@ -615,6 +619,7 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
|
|
|
615
619
|
window.renderConvList?.();
|
|
616
620
|
window.syncComposerButtonState?.();
|
|
617
621
|
renderRelayQuestions();
|
|
622
|
+
scrollAfterOwnSend(send);
|
|
618
623
|
window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
|
|
619
624
|
} catch (e) {
|
|
620
625
|
controls.forEach((el) => { el.disabled = false; });
|