@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. 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
- if (!anchor || isInAppLink(anchor)) return;
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.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,9 +46,13 @@ import {
42
46
  loadGrokSettings,
43
47
  loadOpenAISettings,
44
48
  } from './api-client.js';
45
- import { renderMessages, restoreInFlightThinking, focusConversationMessageById, 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';
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 pendingCount = Number(pendingByConversation[conversation?.id] || 0);
389
- const sdkSessionId = String(conversation?.sdkSessionId || '').trim();
390
- const workerState = sdkSessionId ? getSessionWorkerState(sdkSessionId) : null;
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
- const didRenderMessages = renderMessages(response.messages, !restoreScroll, response);
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
- 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);
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
- setCurrentConv(null);
1976
- clearAttachments();
1977
- setRepoBrowserSessionInfo('', '');
1978
- restoreInFlightThinking(null);
1979
- renderMessages([]);
1980
- document.getElementById('chat-title').textContent = 'Select or start a conversation';
1981
- window.syncChatTitleControls?.();
1982
- updateSessionPill(null, null);
1983
- updateCompactButton();
1984
- scheduleContextUsageRefresh(null);
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
+ }