@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +247 -0
  2. package/README.md +78 -9
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +2 -2
  5. package/server/README.md +286 -3
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +35 -1
  8. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  9. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  10. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  12. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  13. package/server/db-schema.mjs +36 -0
  14. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  15. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  16. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  17. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  18. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  19. package/server/public/app/api-client.js +61 -0
  20. package/server/public/app/archive-confirm.mjs +28 -0
  21. package/server/public/app/ask-user-view.js +3 -0
  22. package/server/public/app/attachments-view.js +516 -6
  23. package/server/public/app/background-tasks-view.mjs +18 -26
  24. package/server/public/app/bootstrap.js +186 -35
  25. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  26. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  27. package/server/public/app/composer-control-state.mjs +16 -0
  28. package/server/public/app/composer-keyboard.mjs +54 -0
  29. package/server/public/app/conversation-view.js +361 -85
  30. package/server/public/app/explorer-edit-state.mjs +68 -0
  31. package/server/public/app/host-suspend-ui.js +6 -3
  32. package/server/public/app/journal-view.js +146 -26
  33. package/server/public/app/no-conversation-state.mjs +58 -0
  34. package/server/public/app/provider-readiness.mjs +120 -0
  35. package/server/public/app/relay-board-view.js +3 -0
  36. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  37. package/server/public/app/relay-event-view.mjs +234 -0
  38. package/server/public/app/settings-modal.js +53 -1
  39. package/server/public/app/socket-handlers.js +59 -28
  40. package/server/public/app/store.js +19 -2
  41. package/server/public/app/subagent-row.mjs +138 -0
  42. package/server/public/app/subagent-timeline-view.mjs +186 -0
  43. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  44. package/server/public/app/subagent-viewer.mjs +534 -0
  45. package/server/public/app/task-row-format.mjs +29 -0
  46. package/server/public/app/thinking-follow.js +4 -0
  47. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  48. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  49. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  50. package/server/public/app/update-ui.js +1 -1
  51. package/server/public/app/viewer-history.mjs +5 -3
  52. package/server/public/index.html +396 -5
  53. package/server/repositories/message-repository.mjs +29 -3
  54. package/server/repositories/question-repository.mjs +9 -3
  55. package/server/repositories/remote-relay-repository.mjs +6 -2
  56. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  57. package/server/routes/ask-user-routes.mjs +3 -0
  58. package/server/routes/messages-routes.mjs +515 -50
  59. package/server/routes/relay-event-routes.mjs +157 -0
  60. package/server/routes/remote-relay-routes.mjs +15 -0
  61. package/server/routes/sessions-routes.mjs +72 -0
  62. package/server/server-runtime.mjs +245 -62
  63. package/server/services/explorer-entry-name.mjs +26 -0
  64. package/server/services/explorer-mutations.mjs +122 -0
  65. package/server/services/preview-proxy-server.mjs +41 -23
  66. package/server/services/push-dispatch-service.mjs +18 -0
  67. package/server/services/relay-event-link-service.mjs +289 -0
  68. package/server/services/relay-event-service.mjs +1935 -0
  69. package/server/services/relay-shutdown-gate.mjs +31 -0
  70. package/server/services/remote-relay-caller-context.mjs +52 -7
  71. package/server/services/remote-relay-dispatcher.mjs +494 -2
  72. package/server/services/remote-relay-registry-service.mjs +11 -1
  73. package/server/services/session-activity-tracker.mjs +487 -0
  74. package/server/services/session-history-refresh-service.mjs +7 -0
  75. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  76. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  77. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  78. package/server/services/subagent-record-retention.mjs +164 -0
  79. package/server/services/subagent-timeline-service.mjs +328 -0
  80. package/server/services/subagent-viewer-rooms.mjs +119 -0
  81. package/server/services/upgrade-socket-guard.mjs +16 -0
  82. package/shared/relay-events.mjs +371 -0
  83. package/shared/remote-relay-contract.mjs +157 -5
  84. package/shared/subagent-record-retention.mjs +31 -0
  85. 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.pendingCount || 0) + Number(queue.processingCount || 0) + Number(queue.parkedCount || 0);
108
- const waiting = turns > 0 ? `waiting for ${turns} turn${turns === 1 ? '' : 's'} to finish` : 'queue is idle, going down now';
109
- return `${icon} Relay ${action} queued — ${waiting}`;
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 { renderMessages, restoreInFlightThinking, focusConversationMessageById, noteTranscriptJump, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch, openStopTurnConfirmationForConversation, conversationHasActiveTurn } from './conversation-view.js';
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 pendingCount = Number(pendingByConversation[conversation?.id] || 0);
390
- const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
391
- const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
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
- await flushConversationDraft(previousConversationId);
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
- setCurrentConv(null);
1990
- clearAttachments();
1991
- setRepoBrowserSessionInfo('', '');
1992
- restoreInFlightThinking(null);
1993
- renderMessages([]);
1994
- document.getElementById('chat-title').textContent = 'Select or start a conversation';
1995
- window.syncChatTitleControls?.();
1996
- updateSessionPill(null, null);
1997
- updateCompactButton();
1998
- scheduleContextUsageRefresh(null);
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