@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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// What the transcript shows while no conversation is open. No DOM here;
|
|
2
|
+
// conversation-view.js draws the result into the `.empty-state` node.
|
|
3
|
+
//
|
|
4
|
+
// Three variants: `setup` when the relay has no provider known to be ready
|
|
5
|
+
// (the fresh-install case: point to Settings → Providers, do not pretend
|
|
6
|
+
// Copilot is set up), `first-chat` when a provider is ready and there are no
|
|
7
|
+
// conversations yet, and `pick` when conversations exist but none is open.
|
|
8
|
+
// A readiness answer that is unknown counts as ready, as everywhere else.
|
|
9
|
+
|
|
10
|
+
import { isSetupRequired } from './provider-readiness.mjs';
|
|
11
|
+
|
|
12
|
+
export const SETUP_HEADING = 'Set up a provider';
|
|
13
|
+
export const SETUP_TEXT = 'Turn on Claude, Cursor, OpenAI or Grok in Settings → Providers, or sign in to the Copilot CLI on the relay host.';
|
|
14
|
+
|
|
15
|
+
const ACTIONS = Object.freeze({
|
|
16
|
+
openSettings: (primary) => ({ id: 'empty-state-open-settings', action: 'open-settings', label: primary ? 'Open Settings' : 'Settings', primary, link: !primary }),
|
|
17
|
+
newChat: (primary) => ({ id: 'empty-state-new-chat', action: 'new-chat', label: '+ New chat', primary, link: false }),
|
|
18
|
+
showConversations: () => ({ id: 'empty-state-show-conversations', action: 'show-conversations', label: 'Show conversations', primary: false, link: false }),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} input
|
|
23
|
+
* @param {object|null} [input.readiness] resolveProviderReadiness()'s answer
|
|
24
|
+
* @param {number} [input.conversationCount] live conversations the client knows
|
|
25
|
+
* @param {boolean} [input.mobile] the sidebar is an off-canvas drawer
|
|
26
|
+
* @returns {{ variant: 'setup' | 'first-chat' | 'pick', icon: string, heading: string, text: string, actions: Array<{ id: string, action: string, label: string, primary: boolean, link: boolean }> }}
|
|
27
|
+
*/
|
|
28
|
+
export function resolveNoConversationState({ readiness = null, conversationCount = 0, mobile = false } = {}) {
|
|
29
|
+
if (isSetupRequired(readiness)) {
|
|
30
|
+
return {
|
|
31
|
+
variant: 'setup',
|
|
32
|
+
icon: '⚙️',
|
|
33
|
+
heading: SETUP_HEADING,
|
|
34
|
+
text: SETUP_TEXT,
|
|
35
|
+
actions: [ACTIONS.openSettings(true), ACTIONS.newChat(false)],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const count = Math.max(0, Number(conversationCount) || 0);
|
|
39
|
+
if (count === 0) {
|
|
40
|
+
return {
|
|
41
|
+
variant: 'first-chat',
|
|
42
|
+
icon: '🚀',
|
|
43
|
+
heading: 'Start your first chat',
|
|
44
|
+
text: 'Pick a model and a folder, and go.',
|
|
45
|
+
actions: [ACTIONS.newChat(true), ACTIONS.openSettings(false)],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const actions = [ACTIONS.newChat(true)];
|
|
49
|
+
if (mobile) actions.push(ACTIONS.showConversations());
|
|
50
|
+
actions.push(ACTIONS.openSettings(false));
|
|
51
|
+
return {
|
|
52
|
+
variant: 'pick',
|
|
53
|
+
icon: '💬',
|
|
54
|
+
heading: 'No conversation open',
|
|
55
|
+
text: 'Pick one from the list or start a new one.',
|
|
56
|
+
actions,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -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
|
|
10
|
-
// close a link left behind in a conversation you
|
|
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
|
|
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.
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
218
|
-
*
|
|
219
|
-
*
|
|
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
|
|
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 =
|
|
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(
|
|
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 {
|
|
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
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Which providers are known to be ready to run a chat, from what the client
|
|
2
|
+
// already loads: the provider settings payloads and the model catalog. No DOM.
|
|
3
|
+
//
|
|
4
|
+
// OpenAI, Claude, Claude Cloud, Cursor and Grok say so themselves: their
|
|
5
|
+
// settings carry `enabled`. Copilot has no such flag and the relay never asks
|
|
6
|
+
// whether the Copilot CLI is signed in, so the best signal is indirect: has a
|
|
7
|
+
// real Copilot model list ever reached the catalog? A fresh database is seeded
|
|
8
|
+
// with a curated list under the source `bootstrap-seed`; a server discovery,
|
|
9
|
+
// an extension snapshot or a worker snapshot replaces that source. Until one
|
|
10
|
+
// has, Copilot is `unknown`, never "not ready": the signal can be wrong (a
|
|
11
|
+
// signed-in host whose first discovery timed out), so callers only point to
|
|
12
|
+
// Settings and never block.
|
|
13
|
+
//
|
|
14
|
+
// A payload that failed to load is `null`, and makes the whole answer
|
|
15
|
+
// `known: false`. Callers treat an unknown answer as ready, which is today's
|
|
16
|
+
// behaviour: a network blip must never show the setup wall.
|
|
17
|
+
|
|
18
|
+
export const SEED_CATALOG_SOURCE = 'bootstrap-seed';
|
|
19
|
+
|
|
20
|
+
const SETTINGS_PROVIDERS = Object.freeze([
|
|
21
|
+
['openai', 'openai'],
|
|
22
|
+
['claude', 'claude'],
|
|
23
|
+
['claudeCloud', 'claude-cloud'],
|
|
24
|
+
['cursor', 'cursor'],
|
|
25
|
+
['grok', 'grok'],
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Payloads whose absence means "could not be loaded" rather than "not offered". */
|
|
29
|
+
const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'catalog']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {object} input the settings payloads New Chat loads, plus GET /api/models
|
|
33
|
+
* @param {object|null} [input.openai]
|
|
34
|
+
* @param {object|null} [input.claude]
|
|
35
|
+
* @param {object|null} [input.claudeCloud] null on a relay without Claude Cloud: not offered, not unknown
|
|
36
|
+
* @param {object|null} [input.cursor]
|
|
37
|
+
* @param {object|null} [input.grok]
|
|
38
|
+
* @param {object|null} [input.catalog]
|
|
39
|
+
* @returns {{ known: boolean, anyReady: boolean, ready: string[], copilot: 'ready' | 'unknown' }}
|
|
40
|
+
*/
|
|
41
|
+
export function resolveProviderReadiness(input = {}) {
|
|
42
|
+
const payloads = input && typeof input === 'object' ? input : {};
|
|
43
|
+
const known = REQUIRED_INPUTS.every((key) => payloads[key] && typeof payloads[key] === 'object');
|
|
44
|
+
const ready = [];
|
|
45
|
+
for (const [key, providerId] of SETTINGS_PROVIDERS) {
|
|
46
|
+
if (payloads[key]?.enabled === true) ready.push(providerId);
|
|
47
|
+
}
|
|
48
|
+
const source = String(payloads.catalog?.source || '').trim();
|
|
49
|
+
const copilot = payloads.catalog && typeof payloads.catalog === 'object' && source && source !== SEED_CATALOG_SOURCE
|
|
50
|
+
? 'ready'
|
|
51
|
+
: 'unknown';
|
|
52
|
+
if (copilot === 'ready') ready.push('github');
|
|
53
|
+
return { known, anyReady: ready.length > 0, ready, copilot };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The one question the empty screen, New Chat and the composer ask. */
|
|
57
|
+
export function isSetupRequired(readiness) {
|
|
58
|
+
return !!readiness && readiness.known === true && readiness.anyReady !== true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── The client's live copy ────────────────────────────────────────────────
|
|
62
|
+
// Fed from the initial loads, the socket events that follow a Settings save
|
|
63
|
+
// or a catalog change, and New Chat's own fresh loads. Subscribers redraw the
|
|
64
|
+
// empty screen and the composer when the answer changes.
|
|
65
|
+
|
|
66
|
+
const inputs = {
|
|
67
|
+
openai: undefined,
|
|
68
|
+
claude: undefined,
|
|
69
|
+
claudeCloud: undefined,
|
|
70
|
+
cursor: undefined,
|
|
71
|
+
grok: undefined,
|
|
72
|
+
catalog: undefined,
|
|
73
|
+
};
|
|
74
|
+
let current = resolveProviderReadiness(inputs);
|
|
75
|
+
const listeners = new Set();
|
|
76
|
+
|
|
77
|
+
function readinessKey(readiness) {
|
|
78
|
+
return `${readiness.known ? 1 : 0}|${readiness.anyReady ? 1 : 0}|${readiness.ready.join(',')}|${readiness.copilot}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Records fresh payloads; `null` for a failed load, `undefined` keys are left
|
|
83
|
+
* as they were. Listeners run only when the resolved answer changed.
|
|
84
|
+
*/
|
|
85
|
+
export function updateProviderReadinessInputs(partial = {}) {
|
|
86
|
+
if (!partial || typeof partial !== 'object') return current;
|
|
87
|
+
for (const key of Object.keys(inputs)) {
|
|
88
|
+
if (partial[key] !== undefined) inputs[key] = partial[key];
|
|
89
|
+
}
|
|
90
|
+
const next = resolveProviderReadiness(inputs);
|
|
91
|
+
const changed = readinessKey(next) !== readinessKey(current);
|
|
92
|
+
current = next;
|
|
93
|
+
if (changed) {
|
|
94
|
+
for (const listener of Array.from(listeners)) {
|
|
95
|
+
try { listener(current); } catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getProviderReadiness() {
|
|
102
|
+
return current;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function isProviderSetupRequired() {
|
|
106
|
+
return isSetupRequired(current);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function subscribeProviderReadiness(listener) {
|
|
110
|
+
if (typeof listener !== 'function') return () => {};
|
|
111
|
+
listeners.add(listener);
|
|
112
|
+
return () => { listeners.delete(listener); };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Tests only: back to "nothing loaded yet". */
|
|
116
|
+
export function resetProviderReadinessForTests() {
|
|
117
|
+
for (const key of Object.keys(inputs)) inputs[key] = undefined;
|
|
118
|
+
current = resolveProviderReadiness(inputs);
|
|
119
|
+
listeners.clear();
|
|
120
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
56
|
+
showNewRelayBoards();
|
|
44
57
|
}
|
|
45
58
|
|
|
46
59
|
async function loadBoardHistory(conversationId) {
|
|
@@ -186,7 +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
|
-
|
|
202
|
+
refreshJumpButton();
|
|
203
|
+
return added;
|
|
190
204
|
}
|
|
191
205
|
|
|
192
206
|
export async function submitRelayBoardAction(boardId, actionId) {
|
|
@@ -196,6 +210,7 @@ export async function submitRelayBoardAction(boardId, actionId) {
|
|
|
196
210
|
const card = document.querySelector(`.relay-board-inline[data-board-id="${CSS.escape(id)}"]`);
|
|
197
211
|
const controls = card ? card.querySelectorAll('button') : [];
|
|
198
212
|
controls.forEach((control) => { control.disabled = true; });
|
|
213
|
+
const send = beginOwnSend();
|
|
199
214
|
|
|
200
215
|
try {
|
|
201
216
|
const r = await submitRelayBoardActionApi(id, nextActionId);
|
|
@@ -205,6 +220,7 @@ export async function submitRelayBoardAction(boardId, actionId) {
|
|
|
205
220
|
relayBoards.set(id, r.board);
|
|
206
221
|
window.renderConvList?.();
|
|
207
222
|
renderRelayBoards();
|
|
223
|
+
scrollAfterOwnSend(send);
|
|
208
224
|
} catch (error) {
|
|
209
225
|
controls.forEach((control) => { control.disabled = false; });
|
|
210
226
|
alert(error.message || 'Failed to submit board action');
|