@oar-sh/oar 0.9.8 → 0.9.10
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 +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -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 +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- 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/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -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/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- 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/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- 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 +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -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 +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -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/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/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- package/shared/subagent-timeline.mjs +79 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Path arithmetic for the explorer's edits (new folder, rename, delete), on
|
|
2
|
+
// the path forms the explorer already uses: workspace paths are relative and
|
|
3
|
+
// `/`-joined with `''` for the root; drive paths are absolute (`/x/y` or
|
|
4
|
+
// `C:/x/y`, `C:` for a drive root). Pure, so it runs under node in tests.
|
|
5
|
+
|
|
6
|
+
/** The name part of a path. */
|
|
7
|
+
export function entryNameOf(pathValue) {
|
|
8
|
+
const value = String(pathValue || '');
|
|
9
|
+
const trimmed = value.endsWith('/') && value.length > 1 ? value.slice(0, -1) : value;
|
|
10
|
+
const index = trimmed.lastIndexOf('/');
|
|
11
|
+
return index === -1 ? trimmed : trimmed.slice(index + 1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The parent of a path: `''` above a top-level workspace entry or a drive root, `/` above `/x`. */
|
|
15
|
+
export function parentPathOf(pathValue) {
|
|
16
|
+
const value = String(pathValue || '');
|
|
17
|
+
const index = value.lastIndexOf('/');
|
|
18
|
+
if (index === -1) return '';
|
|
19
|
+
if (index === 0) return '/';
|
|
20
|
+
return value.slice(0, index);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The path of `name` inside `parentPath`. */
|
|
24
|
+
export function childPathOf(parentPath, name) {
|
|
25
|
+
const parent = String(parentPath || '');
|
|
26
|
+
const child = String(name || '');
|
|
27
|
+
if (!parent) return child;
|
|
28
|
+
return parent.endsWith('/') ? `${parent}${child}` : `${parent}/${child}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when `pathValue` is `prefix` or lies below it. */
|
|
32
|
+
export function isSameOrBelow(pathValue, prefix) {
|
|
33
|
+
const value = String(pathValue || '');
|
|
34
|
+
const base = String(prefix || '');
|
|
35
|
+
if (value === base) return true;
|
|
36
|
+
if (!base) return value !== '';
|
|
37
|
+
return value.startsWith(base.endsWith('/') ? base : `${base}/`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `pathValue` with `oldPrefix` replaced by `newPrefix` when it lies at or below it; otherwise unchanged. */
|
|
41
|
+
export function rewritePathPrefix(pathValue, oldPrefix, newPrefix) {
|
|
42
|
+
const value = String(pathValue || '');
|
|
43
|
+
if (!isSameOrBelow(value, oldPrefix) || oldPrefix === '') return value;
|
|
44
|
+
return `${newPrefix}${value.slice(String(oldPrefix).length)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A new Set with every member rewritten through `rewritePathPrefix`. */
|
|
48
|
+
export function rewritePathSet(paths, oldPrefix, newPrefix) {
|
|
49
|
+
const out = new Set();
|
|
50
|
+
for (const value of paths || []) out.add(rewritePathPrefix(value, oldPrefix, newPrefix));
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A new Set without the members at or below `prefix`. */
|
|
55
|
+
export function dropPathsBelow(paths, prefix) {
|
|
56
|
+
const out = new Set();
|
|
57
|
+
for (const value of paths || []) {
|
|
58
|
+
if (prefix === '' || !isSameOrBelow(value, prefix)) out.add(value);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The descendants of `dirPath` among `paths`, shallowest first, for a re-expansion walk. */
|
|
64
|
+
export function descendantsOf(paths, dirPath) {
|
|
65
|
+
return [...(paths || [])]
|
|
66
|
+
.filter((value) => value && value !== dirPath && isSameOrBelow(value, dirPath))
|
|
67
|
+
.sort((a, b) => a.length - b.length);
|
|
68
|
+
}
|
|
@@ -57,7 +57,10 @@ export function installExternalLinkPolicy({ documentRef = document, onFallback =
|
|
|
57
57
|
documentRef.addEventListener('click', (event) => {
|
|
58
58
|
const target = event.target instanceof Element ? event.target : event.target?.parentElement;
|
|
59
59
|
const anchor = target?.closest?.('a[href]');
|
|
60
|
-
|
|
60
|
+
// A download link is left to the browser: opened in a new tab instead, it
|
|
61
|
+
// lost its `download` name, and a phone showed or saved the file under
|
|
62
|
+
// the plain name, next to (or instead of) an older copy.
|
|
63
|
+
if (!anchor || anchor.hasAttribute('download') || isInAppLink(anchor)) return;
|
|
61
64
|
secureExternalLink(anchor, documentRef.baseURI, onFallback);
|
|
62
65
|
if (!isExternalNavigationHref(anchor.getAttribute('href'), documentRef.baseURI)) return;
|
|
63
66
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// A download link has to change when the file behind it changes. The address
|
|
2
|
+
// and the name a file is saved under both carry the file's version, so nothing
|
|
3
|
+
// between the relay and the Downloads folder can hand back an older copy:
|
|
4
|
+
// not a download manager or a proxy keyed on the URL, and not a phone that
|
|
5
|
+
// keeps `name.pdf`, `name (1).pdf`, `name (2).pdf` side by side and opens the
|
|
6
|
+
// first.
|
|
7
|
+
|
|
8
|
+
export function appendUrlParam(url, key, value) {
|
|
9
|
+
const base = String(url || '');
|
|
10
|
+
const text = String(value ?? '');
|
|
11
|
+
if (!base || !text) return base;
|
|
12
|
+
return `${base}${base.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(text)}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `href` with the file's version token (`v=…`) appended; unchanged when there is none or it has one. */
|
|
16
|
+
export function versionedFileHref(href, version) {
|
|
17
|
+
const base = String(href || '');
|
|
18
|
+
if (/[?&]v=/.test(base)) return base;
|
|
19
|
+
return appendUrlParam(base, 'v', version);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function twoDigits(value) {
|
|
23
|
+
return String(value).padStart(2, '0');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The name to save a download under: the file's own name with the time it was
|
|
28
|
+
* last changed, `report.pdf` → `report (1005-0719).pdf` (month, day, hour,
|
|
29
|
+
* minute, local time). Two versions of a file get two names, and the name
|
|
30
|
+
* says which one is open. Without a known time the name stays as it is.
|
|
31
|
+
*/
|
|
32
|
+
export function stampedDownloadName(name, mtimeMs) {
|
|
33
|
+
const plain = String(name || '').trim();
|
|
34
|
+
const time = Number(mtimeMs);
|
|
35
|
+
if (!plain || !Number.isFinite(time) || time <= 0) return plain;
|
|
36
|
+
const date = new Date(time);
|
|
37
|
+
if (Number.isNaN(date.getTime())) return plain;
|
|
38
|
+
const stamp = `${twoDigits(date.getMonth() + 1)}${twoDigits(date.getDate())}-${twoDigits(date.getHours())}${twoDigits(date.getMinutes())}`;
|
|
39
|
+
const dot = plain.lastIndexOf('.');
|
|
40
|
+
// A leading dot is part of the name (`.env`), not the start of an extension.
|
|
41
|
+
if (dot <= 0) return `${plain} (${stamp})`;
|
|
42
|
+
return `${plain.slice(0, dot)} (${stamp})${plain.slice(dot)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The download link of a previewed file: versioned address, and the stamped
|
|
47
|
+
* name both as the link's `download` name and as `as=…`, which the relay puts
|
|
48
|
+
* into the `Content-Disposition` header (a browser prefers that header over
|
|
49
|
+
* the link's own name).
|
|
50
|
+
*/
|
|
51
|
+
export function buildDownloadLink({ href, name, version, mtimeMs } = {}) {
|
|
52
|
+
const downloadName = stampedDownloadName(name, mtimeMs);
|
|
53
|
+
let url = versionedFileHref(href, version);
|
|
54
|
+
if (url && downloadName && downloadName !== String(name || '').trim()) {
|
|
55
|
+
url = appendUrlParam(url, 'as', downloadName);
|
|
56
|
+
}
|
|
57
|
+
return { href: url, downloadName };
|
|
58
|
+
}
|
|
@@ -103,10 +103,13 @@ export function relayShutdownBannerText(state) {
|
|
|
103
103
|
if (String(state.status || '') === 'shutting_down') {
|
|
104
104
|
return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
|
|
105
105
|
}
|
|
106
|
+
// Only the processing turns hold it; queued messages wait for the restart.
|
|
106
107
|
const queue = state.queue || {};
|
|
107
|
-
const turns = Number(queue.
|
|
108
|
-
const
|
|
109
|
-
|
|
108
|
+
const turns = Number(queue.processingCount || 0);
|
|
109
|
+
const held = Number(queue.pendingCount || 0) + Number(queue.parkedCount || 0);
|
|
110
|
+
const waiting = turns > 0 ? `waiting for ${turns} running turn${turns === 1 ? '' : 's'} to finish` : 'nothing is running, going down now';
|
|
111
|
+
const queued = held > 0 ? `; ${held} queued message${held === 1 ? '' : 's'} wait${held === 1 ? 's' : ''} for it` : '';
|
|
112
|
+
return `${icon} Relay ${action} queued — ${waiting}${queued}`;
|
|
110
113
|
}
|
|
111
114
|
|
|
112
115
|
function ensureBanner() {
|
|
@@ -23,7 +23,11 @@ import {
|
|
|
23
23
|
loadConversationLoadedMessageCount,
|
|
24
24
|
saveConversationScrollTop,
|
|
25
25
|
IS_SHARED_VIEW,
|
|
26
|
+
openSummaryModal,
|
|
27
|
+
closeSummaryModal,
|
|
28
|
+
setSummaryModalLeaveHandler,
|
|
26
29
|
} from './store.js';
|
|
30
|
+
import { archiveConfirmationFor } from './archive-confirm.mjs';
|
|
27
31
|
import {
|
|
28
32
|
loadConversations as loadConversationsApi,
|
|
29
33
|
loadConversation,
|
|
@@ -42,9 +46,13 @@ import {
|
|
|
42
46
|
loadGrokSettings,
|
|
43
47
|
loadOpenAISettings,
|
|
44
48
|
} from './api-client.js';
|
|
45
|
-
import {
|
|
49
|
+
import { isSetupRequired, updateProviderReadinessInputs } from './provider-readiness.mjs';
|
|
50
|
+
import { SETUP_TEXT } from './no-conversation-state.mjs';
|
|
51
|
+
import { openSettingsModal } from './settings-modal.js';
|
|
52
|
+
import { renderMessages, renderNoConversationState, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
|
|
46
53
|
import { MENU_SEPARATOR, bindLongPress, closeContextMenu, openContextMenu } from './context-menu.mjs';
|
|
47
54
|
import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
|
|
55
|
+
import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
|
|
48
56
|
import { setUsageLimitConversation } from './usage-limit-ui.js';
|
|
49
57
|
import { mergeConversationPreviews } from './preview-cards.mjs';
|
|
50
58
|
import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
|
|
@@ -84,6 +92,7 @@ import {
|
|
|
84
92
|
validateCloudSourceInputs,
|
|
85
93
|
} from './claude-cloud-ui.mjs';
|
|
86
94
|
import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
|
|
95
|
+
import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
|
|
87
96
|
import {
|
|
88
97
|
normalizeConversationFilter,
|
|
89
98
|
filterConversations,
|
|
@@ -336,6 +345,8 @@ export async function refreshConversations(options = {}) {
|
|
|
336
345
|
preserveProgress: preservePagination,
|
|
337
346
|
});
|
|
338
347
|
renderConvList();
|
|
348
|
+
// With nothing open the transcript's screen depends on whether chats exist.
|
|
349
|
+
if (!currentConvId) renderNoConversationState();
|
|
339
350
|
updateCompactButton();
|
|
340
351
|
// A reset bumps the loader version, which aborts any in-flight filter drain;
|
|
341
352
|
// restart it so an active filter keeps covering the unloaded pages.
|
|
@@ -344,6 +355,19 @@ export async function refreshConversations(options = {}) {
|
|
|
344
355
|
}
|
|
345
356
|
}
|
|
346
357
|
|
|
358
|
+
/** A sidebar row's state: its colour (green is 'ready') and whether its dots run. */
|
|
359
|
+
function conversationRowState(conversation, pendingByConversation = getPendingQuestionCountsByConversation()) {
|
|
360
|
+
const pendingCount = Number(pendingByConversation[conversation?.id] || 0);
|
|
361
|
+
const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
|
|
362
|
+
const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
|
|
363
|
+
const visualState = resolveConversationUiState({
|
|
364
|
+
conversation,
|
|
365
|
+
workerState,
|
|
366
|
+
hasPendingQuestion: pendingCount > 0,
|
|
367
|
+
});
|
|
368
|
+
return { visualState, processing: isConversationProcessing(conversation, workerState) };
|
|
369
|
+
}
|
|
370
|
+
|
|
347
371
|
export function renderConvList() {
|
|
348
372
|
const list = document.getElementById('conv-list');
|
|
349
373
|
const sorted = Object.values(conversations).sort((a, b) => {
|
|
@@ -385,17 +409,9 @@ export function renderConvList() {
|
|
|
385
409
|
return;
|
|
386
410
|
}
|
|
387
411
|
const conversationView = (conversation) => {
|
|
388
|
-
const
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const visualState = resolveConversationUiState({
|
|
392
|
-
conversation,
|
|
393
|
-
workerState,
|
|
394
|
-
hasPendingQuestion: pendingCount > 0,
|
|
395
|
-
});
|
|
396
|
-
const processing = isConversationProcessing(conversation, workerState);
|
|
397
|
-
if (processing) hasProcessingConversation = true;
|
|
398
|
-
return { visualState, processing };
|
|
412
|
+
const view = conversationRowState(conversation, pendingByConversation);
|
|
413
|
+
if (view.processing) hasProcessingConversation = true;
|
|
414
|
+
return view;
|
|
399
415
|
};
|
|
400
416
|
const listHtml = `${visible.map((c) => {
|
|
401
417
|
const view = conversationView(c);
|
|
@@ -408,10 +424,12 @@ export function renderConvList() {
|
|
|
408
424
|
// "↗ win-test": another relay's agent created this conversation.
|
|
409
425
|
// "via agent · “title”": an agent in that conversation on this relay did.
|
|
410
426
|
const originMarkerHtml = renderConversationOriginMarkerHtml(c, { conversations });
|
|
427
|
+
// "watched": a session another conversation watches by id (relay events).
|
|
428
|
+
const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
|
|
411
429
|
return `
|
|
412
430
|
<div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}${c.archived ? ' archived' : ''}" data-conversation-id="${c.id}" onclick="openConversation('${c.id}')">
|
|
413
431
|
<div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
|
|
414
|
-
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
|
|
432
|
+
<div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${watchedMarkerHtml}${providerIndicatorHtml}</div>
|
|
415
433
|
${c.archived
|
|
416
434
|
? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
|
|
417
435
|
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
|
|
@@ -428,10 +446,14 @@ export function renderConvList() {
|
|
|
428
446
|
scheduleConversationListBoundaryCheck();
|
|
429
447
|
}
|
|
430
448
|
|
|
449
|
+
// keepTranscript: the page shows a window this payload must not replace (the
|
|
450
|
+
// user jumped into the history; the payload is the end of the conversation).
|
|
451
|
+
// Everything but the messages still applies.
|
|
431
452
|
export function applyLoadedConversationState(id, response, {
|
|
432
453
|
restoreScroll = false,
|
|
433
454
|
savedScrollTop = null,
|
|
434
455
|
followLiveUpdates = !restoreScroll,
|
|
456
|
+
keepTranscript = false,
|
|
435
457
|
} = {}) {
|
|
436
458
|
if (!response) {
|
|
437
459
|
setRepoBrowserSessionInfo('', '');
|
|
@@ -478,6 +500,9 @@ export function applyLoadedConversationState(id, response, {
|
|
|
478
500
|
messageCount: Array.isArray(response.messages)
|
|
479
501
|
? Math.max(existingConversation.messageCount || 0, response.messages.length)
|
|
480
502
|
: (existingConversation.messageCount || 0),
|
|
503
|
+
// This payload's verdict only: the record's copy can predate the turn the
|
|
504
|
+
// socket has since reported running, and would end it on the page.
|
|
505
|
+
activeTurn: response.activeTurn,
|
|
481
506
|
});
|
|
482
507
|
renderConvList();
|
|
483
508
|
window.applyConversationPreferences?.(id, {
|
|
@@ -490,7 +515,13 @@ export function applyLoadedConversationState(id, response, {
|
|
|
490
515
|
// live poll, and the bare loadRepoBrowserTree path resets loaded folders and
|
|
491
516
|
// deep selections. The end-of-turn message_status handler refreshes the tree
|
|
492
517
|
// through the restoring path instead.
|
|
493
|
-
|
|
518
|
+
// Before the messages: their bubbles take Pin / Unpin from this list.
|
|
519
|
+
// Absent from an older relay's payload: keep what the socket said.
|
|
520
|
+
if ('pins' in response) setConversationPins(id, response.pins, response.pinsRevision);
|
|
521
|
+
setPinsConversation(id);
|
|
522
|
+
const didRenderMessages = keepTranscript
|
|
523
|
+
? false
|
|
524
|
+
: renderMessages(response.messages, !restoreScroll, response);
|
|
494
525
|
hydrateConversationDraft(id, {
|
|
495
526
|
draftText: response.draftText,
|
|
496
527
|
draftAttachments: response.draftAttachments,
|
|
@@ -544,7 +575,10 @@ export async function openConversation(id, options = {}) {
|
|
|
544
575
|
const previousConversationId = String(currentConvId || '').trim();
|
|
545
576
|
const nextConversationId = String(id || '').trim();
|
|
546
577
|
if (previousConversationId && nextConversationId && previousConversationId !== nextConversationId) {
|
|
547
|
-
|
|
578
|
+
// Reads the composer now, before it changes hands below, and saves in the
|
|
579
|
+
// background: a switch that waited for the save would take over the view
|
|
580
|
+
// when the save returns, after any click the user made meanwhile.
|
|
581
|
+
void flushConversationDraft(previousConversationId);
|
|
548
582
|
window.clearImageEditTarget?.();
|
|
549
583
|
}
|
|
550
584
|
const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
|
|
@@ -605,6 +639,9 @@ export async function openConversation(id, options = {}) {
|
|
|
605
639
|
return;
|
|
606
640
|
}
|
|
607
641
|
if (r) {
|
|
642
|
+
// Counted when the window is put on the page, not when it is asked for:
|
|
643
|
+
// a reload of the end that is still on its way must see the change.
|
|
644
|
+
if (forceFreshWindow) noteTranscriptJump();
|
|
608
645
|
applyLoadedConversationState(id, r, { restoreScroll, savedScrollTop });
|
|
609
646
|
if (focusMessageId) {
|
|
610
647
|
requestAnimationFrame(() => {
|
|
@@ -1475,6 +1512,31 @@ function populateNewConversationCwdSelect() {
|
|
|
1475
1512
|
syncNewConversationCwdControls();
|
|
1476
1513
|
}
|
|
1477
1514
|
|
|
1515
|
+
// The New Chat modal with no provider known to be ready: a note above the
|
|
1516
|
+
// buttons, Open Settings → Providers as the main button, and the usual Start
|
|
1517
|
+
// (same id, same handler) as a secondary "Start a Copilot chat anyway".
|
|
1518
|
+
function applyNewConversationSetupState(setupRequired) {
|
|
1519
|
+
const note = document.getElementById('new-conversation-setup-note');
|
|
1520
|
+
const openSettings = document.getElementById('new-conversation-open-settings');
|
|
1521
|
+
const confirm = document.getElementById('new-conversation-model-confirm');
|
|
1522
|
+
if (note) {
|
|
1523
|
+
note.hidden = !setupRequired;
|
|
1524
|
+
note.textContent = setupRequired ? SETUP_TEXT : '';
|
|
1525
|
+
}
|
|
1526
|
+
if (openSettings) {
|
|
1527
|
+
openSettings.hidden = !setupRequired;
|
|
1528
|
+
if (openSettings.dataset.bound !== '1') {
|
|
1529
|
+
openSettings.dataset.bound = '1';
|
|
1530
|
+
openSettings.addEventListener('click', (event) => {
|
|
1531
|
+
event.preventDefault();
|
|
1532
|
+
closeNewConversationModelModal();
|
|
1533
|
+
openSettingsModal('providers');
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
if (confirm) confirm.textContent = setupRequired ? 'Start a Copilot chat anyway' : 'Start chat';
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1478
1540
|
async function openNewConversationModelModal() {
|
|
1479
1541
|
const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
|
|
1480
1542
|
loadModelCatalog(),
|
|
@@ -1491,6 +1553,18 @@ async function openNewConversationModelModal() {
|
|
|
1491
1553
|
newConversationCursorSettingsCache = cursorSettings || null;
|
|
1492
1554
|
newConversationGrokSettingsCache = grokSettings || null;
|
|
1493
1555
|
newConversationClaudeCloudSettingsCache = claudeCloudSettings || null;
|
|
1556
|
+
// Fresh answers for the readiness question, shared with the empty screen
|
|
1557
|
+
// and the composer. With no provider known to be ready the modal opens in
|
|
1558
|
+
// its setup state: Settings is the main button, Start stays as "anyway".
|
|
1559
|
+
const readiness = updateProviderReadinessInputs({
|
|
1560
|
+
openai: settings || null,
|
|
1561
|
+
claude: claudeSettings || null,
|
|
1562
|
+
claudeCloud: claudeCloudSettings || null,
|
|
1563
|
+
cursor: cursorSettings || null,
|
|
1564
|
+
grok: grokSettings || null,
|
|
1565
|
+
catalog: catalog || null,
|
|
1566
|
+
});
|
|
1567
|
+
applyNewConversationSetupState(isSetupRequired(readiness));
|
|
1494
1568
|
// Every open starts from empty cloud fields; the folder lookup refills them.
|
|
1495
1569
|
resetNewConversationCloudState();
|
|
1496
1570
|
bindNewConversationCloudInputs();
|
|
@@ -1903,9 +1977,61 @@ async function setConversationArchived(id, archived) {
|
|
|
1903
1977
|
return true;
|
|
1904
1978
|
}
|
|
1905
1979
|
|
|
1980
|
+
const ARCHIVE_CONFIRM_KIND = 'archive-confirm';
|
|
1981
|
+
|
|
1982
|
+
/** Resolves true when the user confirms; Cancel, Escape, the backdrop or × keep the chat. */
|
|
1983
|
+
function askBeforeArchive(id, confirmation) {
|
|
1984
|
+
const title = String(conversations[id]?.title || '').trim();
|
|
1985
|
+
return new Promise((resolve) => {
|
|
1986
|
+
let settled = false;
|
|
1987
|
+
const settle = (value) => {
|
|
1988
|
+
if (settled) return;
|
|
1989
|
+
settled = true;
|
|
1990
|
+
resolve(value);
|
|
1991
|
+
};
|
|
1992
|
+
openSummaryModal({
|
|
1993
|
+
title: 'Archive session',
|
|
1994
|
+
subtitle: title,
|
|
1995
|
+
kind: ARCHIVE_CONFIRM_KIND,
|
|
1996
|
+
bodyHtml: `
|
|
1997
|
+
<p>${escHtml(confirmation.message)}</p>
|
|
1998
|
+
<p>${escHtml(confirmation.detail)}</p>
|
|
1999
|
+
<div class="summary-modal-actions">
|
|
2000
|
+
<button class="chat-title-action-btn" type="button" data-archive-confirm="1">🗄 Archive</button>
|
|
2001
|
+
<button class="chat-title-action-btn" type="button" data-archive-cancel="1">Cancel</button>
|
|
2002
|
+
</div>
|
|
2003
|
+
`,
|
|
2004
|
+
});
|
|
2005
|
+
// Runs on every way out of the modal, another modal replacing it included.
|
|
2006
|
+
setSummaryModalLeaveHandler(ARCHIVE_CONFIRM_KIND, () => settle(false));
|
|
2007
|
+
const body = document.getElementById('summary-modal-body');
|
|
2008
|
+
body?.querySelector('[data-archive-confirm]')?.addEventListener('click', (event) => {
|
|
2009
|
+
event.preventDefault();
|
|
2010
|
+
settle(true);
|
|
2011
|
+
closeSummaryModal();
|
|
2012
|
+
}, { once: true });
|
|
2013
|
+
const cancel = body?.querySelector('[data-archive-cancel]');
|
|
2014
|
+
cancel?.addEventListener('click', (event) => {
|
|
2015
|
+
event.preventDefault();
|
|
2016
|
+
closeSummaryModal();
|
|
2017
|
+
}, { once: true });
|
|
2018
|
+
cancel?.focus?.();
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
|
|
1906
2022
|
export async function archiveConv(e, id) {
|
|
1907
2023
|
e?.stopPropagation?.();
|
|
1908
2024
|
closeContextMenu();
|
|
2025
|
+
// A chat whose process is live (green, idle or not) asks first: archiving
|
|
2026
|
+
// stops that process. The relay still refuses a chat with a running turn.
|
|
2027
|
+
const conversation = conversations[id];
|
|
2028
|
+
const view = conversation ? conversationRowState(conversation) : null;
|
|
2029
|
+
const confirmation = archiveConfirmationFor({
|
|
2030
|
+
archived: conversation?.archived === true,
|
|
2031
|
+
visualState: view?.visualState,
|
|
2032
|
+
running: conversationHasActiveTurn(id) || conversation?.activeTurn === true || view?.processing === true,
|
|
2033
|
+
});
|
|
2034
|
+
if (confirmation && !(await askBeforeArchive(id, confirmation))) return;
|
|
1909
2035
|
await setConversationArchived(id, true);
|
|
1910
2036
|
}
|
|
1911
2037
|
|
|
@@ -1971,16 +2097,24 @@ export async function deleteConv(e, id) {
|
|
|
1971
2097
|
}
|
|
1972
2098
|
delete conversations[id];
|
|
1973
2099
|
renderConvList();
|
|
1974
|
-
if (currentConvId === id)
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
2100
|
+
if (currentConvId === id) clearOpenConversation();
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
/**
|
|
2104
|
+
* The conversation on screen is gone (deleted here or from another device):
|
|
2105
|
+
* nothing is open, the header says so, the transcript shows the empty state
|
|
2106
|
+
* and the ⋯ menu keeps its relay-wide entries. One place for both callers.
|
|
2107
|
+
*/
|
|
2108
|
+
export function clearOpenConversation() {
|
|
2109
|
+
setCurrentConv(null);
|
|
2110
|
+
clearAttachments();
|
|
2111
|
+
setRepoBrowserSessionInfo('', '');
|
|
2112
|
+
restoreInFlightThinking(null);
|
|
2113
|
+
renderMessages([]);
|
|
2114
|
+
const title = document.getElementById('chat-title');
|
|
2115
|
+
if (title) title.textContent = 'Select or start a conversation';
|
|
2116
|
+
window.syncChatTitleControls?.();
|
|
2117
|
+
updateSessionPill(null, null);
|
|
2118
|
+
updateCompactButton();
|
|
2119
|
+
scheduleContextUsageRefresh(null);
|
|
1986
2120
|
}
|
|
@@ -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
|
+
}
|