@oar-sh/oar 0.9.9 → 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 +247 -0
- package/README.md +78 -9
- package/bin/bat/oar.bat +46 -46
- package/package.json +2 -2
- package/server/README.md +286 -3
- 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 +36 -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 +61 -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 +516 -6
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +186 -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 +361 -85
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +146 -26
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/relay-board-view.js +3 -0
- 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/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +59 -28
- package/server/public/app/store.js +19 -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-history.mjs +5 -3
- package/server/public/index.html +396 -5
- package/server/repositories/message-repository.mjs +29 -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 +515 -50
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +72 -0
- package/server/server-runtime.mjs +245 -62
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -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/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +7 -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
|
+
}
|
|
@@ -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,7 +46,10 @@ 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';
|
|
48
55
|
import { setConversationPins, setPinsConversation } from './pinned-messages-view.mjs';
|
|
@@ -85,6 +92,7 @@ import {
|
|
|
85
92
|
validateCloudSourceInputs,
|
|
86
93
|
} from './claude-cloud-ui.mjs';
|
|
87
94
|
import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
|
|
95
|
+
import { relayEventWatchedMarkerHtml } from './relay-event-subscriptions-ui.js';
|
|
88
96
|
import {
|
|
89
97
|
normalizeConversationFilter,
|
|
90
98
|
filterConversations,
|
|
@@ -337,6 +345,8 @@ export async function refreshConversations(options = {}) {
|
|
|
337
345
|
preserveProgress: preservePagination,
|
|
338
346
|
});
|
|
339
347
|
renderConvList();
|
|
348
|
+
// With nothing open the transcript's screen depends on whether chats exist.
|
|
349
|
+
if (!currentConvId) renderNoConversationState();
|
|
340
350
|
updateCompactButton();
|
|
341
351
|
// A reset bumps the loader version, which aborts any in-flight filter drain;
|
|
342
352
|
// restart it so an active filter keeps covering the unloaded pages.
|
|
@@ -345,6 +355,19 @@ export async function refreshConversations(options = {}) {
|
|
|
345
355
|
}
|
|
346
356
|
}
|
|
347
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
|
+
|
|
348
371
|
export function renderConvList() {
|
|
349
372
|
const list = document.getElementById('conv-list');
|
|
350
373
|
const sorted = Object.values(conversations).sort((a, b) => {
|
|
@@ -386,17 +409,9 @@ export function renderConvList() {
|
|
|
386
409
|
return;
|
|
387
410
|
}
|
|
388
411
|
const conversationView = (conversation) => {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const visualState = resolveConversationUiState({
|
|
393
|
-
conversation,
|
|
394
|
-
workerState,
|
|
395
|
-
hasPendingQuestion: pendingCount > 0,
|
|
396
|
-
});
|
|
397
|
-
const processing = isConversationProcessing(conversation, workerState);
|
|
398
|
-
if (processing) hasProcessingConversation = true;
|
|
399
|
-
return { visualState, processing };
|
|
412
|
+
const view = conversationRowState(conversation, pendingByConversation);
|
|
413
|
+
if (view.processing) hasProcessingConversation = true;
|
|
414
|
+
return view;
|
|
400
415
|
};
|
|
401
416
|
const listHtml = `${visible.map((c) => {
|
|
402
417
|
const view = conversationView(c);
|
|
@@ -409,10 +424,12 @@ export function renderConvList() {
|
|
|
409
424
|
// "↗ win-test": another relay's agent created this conversation.
|
|
410
425
|
// "via agent · “title”": an agent in that conversation on this relay did.
|
|
411
426
|
const originMarkerHtml = renderConversationOriginMarkerHtml(c, { conversations });
|
|
427
|
+
// "watched": a session another conversation watches by id (relay events).
|
|
428
|
+
const watchedMarkerHtml = relayEventWatchedMarkerHtml(c.id);
|
|
412
429
|
return `
|
|
413
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}')">
|
|
414
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>
|
|
415
|
-
<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>
|
|
416
433
|
${c.archived
|
|
417
434
|
? `<button class="conv-archive" onclick="unarchiveConv(event,'${c.id}')" title="Unarchive" aria-label="Unarchive">📂</button>`
|
|
418
435
|
: `<button class="conv-archive" onclick="archiveConv(event,'${c.id}')" title="Archive" aria-label="Archive">🗄</button>`}
|
|
@@ -483,6 +500,9 @@ export function applyLoadedConversationState(id, response, {
|
|
|
483
500
|
messageCount: Array.isArray(response.messages)
|
|
484
501
|
? Math.max(existingConversation.messageCount || 0, response.messages.length)
|
|
485
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,
|
|
486
506
|
});
|
|
487
507
|
renderConvList();
|
|
488
508
|
window.applyConversationPreferences?.(id, {
|
|
@@ -555,7 +575,10 @@ export async function openConversation(id, options = {}) {
|
|
|
555
575
|
const previousConversationId = String(currentConvId || '').trim();
|
|
556
576
|
const nextConversationId = String(id || '').trim();
|
|
557
577
|
if (previousConversationId && nextConversationId && previousConversationId !== nextConversationId) {
|
|
558
|
-
|
|
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);
|
|
559
582
|
window.clearImageEditTarget?.();
|
|
560
583
|
}
|
|
561
584
|
const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
|
|
@@ -1489,6 +1512,31 @@ function populateNewConversationCwdSelect() {
|
|
|
1489
1512
|
syncNewConversationCwdControls();
|
|
1490
1513
|
}
|
|
1491
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
|
+
|
|
1492
1540
|
async function openNewConversationModelModal() {
|
|
1493
1541
|
const [catalog, settings, claudeSettings, cursorSettings, grokSettings, claudeCloudSettings] = await Promise.all([
|
|
1494
1542
|
loadModelCatalog(),
|
|
@@ -1505,6 +1553,18 @@ async function openNewConversationModelModal() {
|
|
|
1505
1553
|
newConversationCursorSettingsCache = cursorSettings || null;
|
|
1506
1554
|
newConversationGrokSettingsCache = grokSettings || null;
|
|
1507
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));
|
|
1508
1568
|
// Every open starts from empty cloud fields; the folder lookup refills them.
|
|
1509
1569
|
resetNewConversationCloudState();
|
|
1510
1570
|
bindNewConversationCloudInputs();
|
|
@@ -1917,9 +1977,61 @@ async function setConversationArchived(id, archived) {
|
|
|
1917
1977
|
return true;
|
|
1918
1978
|
}
|
|
1919
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
|
+
|
|
1920
2022
|
export async function archiveConv(e, id) {
|
|
1921
2023
|
e?.stopPropagation?.();
|
|
1922
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;
|
|
1923
2035
|
await setConversationArchived(id, true);
|
|
1924
2036
|
}
|
|
1925
2037
|
|
|
@@ -1985,16 +2097,24 @@ export async function deleteConv(e, id) {
|
|
|
1985
2097
|
}
|
|
1986
2098
|
delete conversations[id];
|
|
1987
2099
|
renderConvList();
|
|
1988
|
-
if (currentConvId === id)
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
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);
|
|
2000
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -186,6 +186,9 @@ export function renderRelayBoards() {
|
|
|
186
186
|
// the live bubble into the reply, or one of another conversation.
|
|
187
187
|
el.querySelectorAll('.relay-board-inline').forEach((node) => { if (!placed.has(node)) node.remove(); });
|
|
188
188
|
el.querySelectorAll('.relay-board-container').forEach((node) => { if (!node.querySelector('.relay-board-inline')) node.remove(); });
|
|
189
|
+
// A folded agent exchange whose turn waits on a board opens to show it
|
|
190
|
+
// (agent-exchange-fold-view.mjs).
|
|
191
|
+
window.syncAgentExchangeFolds?.();
|
|
189
192
|
if (added) window.scrollBottom?.();
|
|
190
193
|
}
|
|
191
194
|
|