@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -43,6 +43,8 @@ function insertEmojiAtCaret(emoji) {
43
43
  input.selectionEnd = nextCaret;
44
44
  autoResize(input);
45
45
  input.focus();
46
+ // Runs the composer's input pipeline, which saves the draft.
47
+ input.dispatchEvent(new Event('input', { bubbles: true }));
46
48
  }
47
49
 
48
50
  function handleEmojiChoice(emoji) {
@@ -1,3 +1,5 @@
1
+ import { captureTranscriptAnchor, restoreTranscriptAnchor } from './transcript-viewport-anchor.mjs';
2
+
1
3
  const FONT_SCALE_STORAGE_KEY = 'copilot_font_scale';
2
4
  const FONT_SCALE_MIN = 0.5;
3
5
  const FONT_SCALE_MAX = 1.5;
@@ -53,41 +55,6 @@ function normalizeFontScaleSelectValue(raw = '') {
53
55
  return clampFontScale(numeric);
54
56
  }
55
57
 
56
- function getMessageViewportAnchor() {
57
- const container = document.getElementById('messages');
58
- if (!container) return null;
59
- const containerRect = container.getBoundingClientRect();
60
- const messages = Array.from(container.querySelectorAll('.msg[data-message-id]'));
61
- for (const message of messages) {
62
- const rect = message.getBoundingClientRect();
63
- if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) continue;
64
- const messageId = String(message.dataset.messageId || '').trim();
65
- if (!messageId) continue;
66
- return {
67
- messageId,
68
- offsetTop: rect.top - containerRect.top,
69
- };
70
- }
71
- return null;
72
- }
73
-
74
- function restoreMessageViewportAnchor(anchor) {
75
- if (!anchor?.messageId) return;
76
- const container = document.getElementById('messages');
77
- if (!container) return;
78
- const messageId = String(anchor.messageId || '').trim();
79
- if (!messageId) return;
80
- const message = Array.from(container.querySelectorAll('.msg[data-message-id]'))
81
- .find((node) => String(node?.dataset?.messageId || '').trim() === messageId);
82
- if (!message) return;
83
- const containerRect = container.getBoundingClientRect();
84
- const rect = message.getBoundingClientRect();
85
- const delta = rect.top - containerRect.top - Number(anchor.offsetTop || 0);
86
- if (Math.abs(delta) > 0.5) {
87
- container.scrollTop += delta;
88
- }
89
- }
90
-
91
58
  export function syncFontScaleSelect() {
92
59
  const select = document.getElementById('font-scale-select');
93
60
  if (!select) return;
@@ -117,7 +84,7 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
117
84
  syncFontScaleSelect();
118
85
  return normalized;
119
86
  }
120
- const anchor = preserveMessageAnchor ? getMessageViewportAnchor() : null;
87
+ const anchor = preserveMessageAnchor ? captureTranscriptAnchor(document.getElementById('messages')) : null;
121
88
  fontScaleValue = normalized;
122
89
  document.documentElement.style.setProperty('--font-scale', normalized.toFixed(4));
123
90
  document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(normalized * 100)}%`);
@@ -125,7 +92,7 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
125
92
  syncFontScaleSelect();
126
93
  if (anchor) {
127
94
  requestAnimationFrame(() => {
128
- restoreMessageViewportAnchor(anchor);
95
+ restoreTranscriptAnchor(document.getElementById('messages'), anchor);
129
96
  });
130
97
  }
131
98
  return normalized;
@@ -0,0 +1,272 @@
1
+ // Pending host suspend / relay restart, as every device sees it: an app-wide
2
+ // banner with a countdown and Cancel, plus the chat-actions menu labels.
3
+ // State arrives over the socket (`host_suspend_state`, `relay_shutdown_state`)
4
+ // and is also re-read from GET /api/status polls, so a client that missed an
5
+ // event still catches up. The banner ignores the per-device "Show Suspend
6
+ // host action" setting on purpose: a device with the action hidden must still
7
+ // be able to see and cancel a queued suspend.
8
+
9
+ import { escHtml, showTransientRelayNotice } from './store.js';
10
+ import { cancelHostSuspend, cancelRelayRestart } from './api-client.js';
11
+
12
+ const BANNER_ID = 'pending-action-banner';
13
+ const SUSPEND_MENU_ID = 'chat-menu-suspend-host';
14
+ const RESTART_MENU_ID = 'chat-menu-restart-relay';
15
+ const SUSPEND_MENU_LABEL = '💤 Suspend host';
16
+ const SUSPEND_MENU_PENDING_LABEL = '💤 Suspend pending…';
17
+ const RESTART_MENU_LABEL = '🌄 Restart web relay';
18
+ const RESTART_MENU_PENDING_LABEL = '🌄 Restart pending…';
19
+
20
+ let hostSuspendState = null;
21
+ let relayShutdownState = null;
22
+ let countdownTimer = null;
23
+ let cancelInFlight = false;
24
+
25
+ export function getHostSuspendState() {
26
+ return hostSuspendState;
27
+ }
28
+
29
+ export function getRelayShutdownState() {
30
+ return relayShutdownState;
31
+ }
32
+
33
+ export function isHostSuspendPending(state = hostSuspendState) {
34
+ const status = String(state?.status || '').trim();
35
+ return status === 'queued' || status === 'countdown' || status === 'suspending';
36
+ }
37
+
38
+ export function isRelayShutdownPending(state = relayShutdownState) {
39
+ const status = String(state?.status || '').trim();
40
+ return status === 'queued' || status === 'shutting_down';
41
+ }
42
+
43
+ /** "0:27" style remaining time from a fireAt ISO string; null when not counting. */
44
+ export function formatCountdown(fireAt, nowMs = Date.now()) {
45
+ const target = Date.parse(String(fireAt || ''));
46
+ if (!Number.isFinite(target)) return null;
47
+ const remaining = Math.max(0, Math.ceil((target - nowMs) / 1000));
48
+ const minutes = Math.floor(remaining / 60);
49
+ const seconds = remaining % 60;
50
+ return `${minutes}:${String(seconds).padStart(2, '0')}`;
51
+ }
52
+
53
+ /** One human line per blocker, e.g. "sidebar polish — 2 background agents: …". */
54
+ export function describeHostSuspendBlocker(blocker) {
55
+ if (!blocker || typeof blocker !== 'object') return '';
56
+ const title = String(blocker.title || '').trim();
57
+ const detail = String(blocker.detail || '').trim();
58
+ switch (String(blocker.kind || '')) {
59
+ case 'turn':
60
+ return `${title || 'a session'} — ${detail || 'turn running'}`;
61
+ case 'background':
62
+ return `${title || 'a session'} — ${detail || 'background agents running'}`;
63
+ case 'ci':
64
+ return `CI ${title ? `in ${title}` : ''} — ${detail || 'runs open'}`.replace(/\s+—/, ' —');
65
+ case 'ci-unknown':
66
+ return detail || `CI state of ${title || 'a repository'} unknown`;
67
+ case 'ci-checking':
68
+ return detail || 'Checking CI state';
69
+ case 'error':
70
+ return detail || 'Activity check failed';
71
+ default:
72
+ return [title, detail].filter(Boolean).join(' — ');
73
+ }
74
+ }
75
+
76
+ /** Short banner summary: first two blockers, "+N more". */
77
+ export function summarizeHostSuspendBlockers(blockers) {
78
+ const list = (Array.isArray(blockers) ? blockers : []).map(describeHostSuspendBlocker).filter(Boolean);
79
+ if (!list.length) return '';
80
+ const shown = list.slice(0, 2);
81
+ const rest = list.length - shown.length;
82
+ return shown.join('; ') + (rest > 0 ? `; +${rest} more` : '');
83
+ }
84
+
85
+ export function hostSuspendBannerText(state, nowMs = Date.now()) {
86
+ if (!isHostSuspendPending(state)) return '';
87
+ const status = String(state.status || '');
88
+ if (status === 'suspending') return '💤 Suspending host now…';
89
+ if (status === 'countdown') {
90
+ const countdown = formatCountdown(state.fireAt, nowMs);
91
+ return countdown ? `💤 Everything is idle — suspending host in ${countdown}` : '💤 Everything is idle — suspending host shortly';
92
+ }
93
+ const summary = summarizeHostSuspendBlockers(state.blockers);
94
+ return summary
95
+ ? `💤 Suspend queued — waiting for: ${summary}`
96
+ : '💤 Suspend queued — waiting for running agents to finish';
97
+ }
98
+
99
+ export function relayShutdownBannerText(state) {
100
+ if (!isRelayShutdownPending(state)) return '';
101
+ const action = state.action === 'restart' || state.restart ? 'restart' : 'shutdown';
102
+ const icon = action === 'restart' ? '🌄' : '⏻';
103
+ if (String(state.status || '') === 'shutting_down') {
104
+ return `${icon} Relay ${action === 'restart' ? 'restarting' : 'shutting down'} now…`;
105
+ }
106
+ 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}`;
110
+ }
111
+
112
+ function ensureBanner() {
113
+ let banner = document.getElementById(BANNER_ID);
114
+ if (banner) return banner;
115
+ banner = document.createElement('div');
116
+ banner.id = BANNER_ID;
117
+ banner.setAttribute('role', 'status');
118
+ banner.setAttribute('aria-live', 'polite');
119
+ document.body?.appendChild(banner);
120
+ return banner;
121
+ }
122
+
123
+ function bannerRow({ text, cancelLabel, onCancel, key }) {
124
+ const row = document.createElement('div');
125
+ row.className = 'pending-action-row';
126
+ row.dataset.pending = key;
127
+ const label = document.createElement('span');
128
+ label.className = 'pending-action-text';
129
+ label.textContent = text;
130
+ row.appendChild(label);
131
+ if (cancelLabel && typeof onCancel === 'function') {
132
+ const btn = document.createElement('button');
133
+ btn.type = 'button';
134
+ btn.className = 'relay-toast-action pending-action-cancel';
135
+ btn.textContent = cancelLabel;
136
+ btn.disabled = cancelInFlight;
137
+ btn.addEventListener('click', (event) => {
138
+ event.preventDefault();
139
+ event.stopPropagation();
140
+ void onCancel();
141
+ });
142
+ row.appendChild(btn);
143
+ }
144
+ return row;
145
+ }
146
+
147
+ async function runCancelHostSuspend() {
148
+ if (cancelInFlight) return;
149
+ cancelInFlight = true;
150
+ renderPendingActionBanner();
151
+ try {
152
+ const result = await cancelHostSuspend();
153
+ if (!result?.ok) {
154
+ showTransientRelayNotice('Failed to cancel the queued suspend.', 5000);
155
+ return;
156
+ }
157
+ if (result.state) applyHostSuspendState(result.state);
158
+ showTransientRelayNotice(result.cancelled ? 'Queued host suspend cancelled.' : 'No host suspend was pending.', 4000);
159
+ } finally {
160
+ cancelInFlight = false;
161
+ renderPendingActionBanner();
162
+ }
163
+ }
164
+
165
+ async function runCancelRelayShutdown() {
166
+ if (cancelInFlight) return;
167
+ cancelInFlight = true;
168
+ renderPendingActionBanner();
169
+ try {
170
+ const result = await cancelRelayRestart();
171
+ if (!result?.ok) {
172
+ showTransientRelayNotice('Failed to cancel the queued relay restart.', 5000);
173
+ return;
174
+ }
175
+ applyRelayShutdownState(result);
176
+ showTransientRelayNotice(result.cancelled ? 'Queued relay restart cancelled.' : 'No relay restart was pending.', 4000);
177
+ } finally {
178
+ cancelInFlight = false;
179
+ renderPendingActionBanner();
180
+ }
181
+ }
182
+
183
+ function syncCountdownTimer() {
184
+ const needsTicker = isHostSuspendPending() && String(hostSuspendState?.status || '') === 'countdown' && hostSuspendState?.fireAt;
185
+ if (needsTicker && !countdownTimer) {
186
+ countdownTimer = setInterval(() => renderPendingActionBanner(), 1000);
187
+ } else if (!needsTicker && countdownTimer) {
188
+ clearInterval(countdownTimer);
189
+ countdownTimer = null;
190
+ }
191
+ }
192
+
193
+ export function renderPendingActionBanner() {
194
+ if (typeof document === 'undefined') return;
195
+ const rows = [];
196
+ if (isHostSuspendPending()) {
197
+ rows.push(bannerRow({
198
+ key: 'host-suspend',
199
+ text: hostSuspendBannerText(hostSuspendState),
200
+ cancelLabel: String(hostSuspendState?.status || '') === 'suspending' ? '' : 'Cancel',
201
+ onCancel: runCancelHostSuspend,
202
+ }));
203
+ }
204
+ if (isRelayShutdownPending()) {
205
+ const restarting = String(relayShutdownState?.status || '') === 'shutting_down';
206
+ rows.push(bannerRow({
207
+ key: 'relay-shutdown',
208
+ text: relayShutdownBannerText(relayShutdownState),
209
+ cancelLabel: restarting ? '' : 'Cancel',
210
+ onCancel: runCancelRelayShutdown,
211
+ }));
212
+ }
213
+ const banner = document.getElementById(BANNER_ID) || (rows.length ? ensureBanner() : null);
214
+ if (!banner) return;
215
+ banner.replaceChildren(...rows);
216
+ banner.classList.toggle('visible', rows.length > 0);
217
+ banner.hidden = rows.length === 0;
218
+ syncMenuLabels();
219
+ syncCountdownTimer();
220
+ }
221
+
222
+ function syncMenuLabels() {
223
+ const suspendBtn = document.getElementById(SUSPEND_MENU_ID);
224
+ if (suspendBtn) suspendBtn.textContent = isHostSuspendPending() ? SUSPEND_MENU_PENDING_LABEL : SUSPEND_MENU_LABEL;
225
+ const restartBtn = document.getElementById(RESTART_MENU_ID);
226
+ if (restartBtn) restartBtn.textContent = isRelayShutdownPending() ? RESTART_MENU_PENDING_LABEL : RESTART_MENU_LABEL;
227
+ }
228
+
229
+ export function applyHostSuspendState(state) {
230
+ const previous = hostSuspendState;
231
+ hostSuspendState = state && typeof state === 'object' ? state : null;
232
+ if (isHostSuspendPending(previous) && !isHostSuspendPending(hostSuspendState) && hostSuspendState?.lastError) {
233
+ showTransientRelayNotice(`Host suspend failed: ${hostSuspendState.lastError}`, 8000);
234
+ }
235
+ renderPendingActionBanner();
236
+ }
237
+
238
+ export function applyRelayShutdownState(state) {
239
+ relayShutdownState = state && typeof state === 'object' ? state : null;
240
+ renderPendingActionBanner();
241
+ }
242
+
243
+ /** GET /api/status carries both states; the poll keeps late clients honest. */
244
+ export function applyPendingActionsFromStatus(status) {
245
+ if (!status || typeof status !== 'object') return;
246
+ if ('hostSuspend' in status) hostSuspendState = status.hostSuspend || null;
247
+ if ('relayShutdown' in status) {
248
+ const next = status.relayShutdown ? { ...status.relayShutdown } : null;
249
+ if (next && !next.queue) {
250
+ next.queue = {
251
+ pendingCount: Number(status.pendingCount || 0),
252
+ processingCount: Number(status.processingCount || 0),
253
+ parkedCount: Number(status.parkedCount || 0),
254
+ };
255
+ }
256
+ relayShutdownState = next;
257
+ }
258
+ renderPendingActionBanner();
259
+ }
260
+
261
+ export function initHostSuspendUi() {
262
+ renderPendingActionBanner();
263
+ }
264
+
265
+ export function __resetHostSuspendUiForTests() {
266
+ hostSuspendState = null;
267
+ relayShutdownState = null;
268
+ cancelInFlight = false;
269
+ if (countdownTimer) { clearInterval(countdownTimer); countdownTimer = null; }
270
+ document.getElementById(BANNER_ID)?.remove();
271
+ syncMenuLabels();
272
+ }
@@ -36,7 +36,7 @@ import {
36
36
  loadGrokSettings,
37
37
  loadOpenAISettings,
38
38
  } from './api-client.js';
39
- import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft } from './conversation-view.js';
39
+ import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch } from './conversation-view.js';
40
40
  import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
41
41
  import { mergeConversationPreviews } from './preview-cards.mjs';
42
42
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
@@ -58,6 +58,13 @@ import {
58
58
  conversationProviderIndicatorLabel,
59
59
  } from './conversation-provider-indicator.mjs';
60
60
  import { leaveStatusView } from './status-view.mjs';
61
+ import { renderConversationOriginMarkerHtml } from './remote-relay-origin-view.mjs';
62
+ import {
63
+ normalizeConversationFilter,
64
+ filterConversations,
65
+ drainRemainingPages,
66
+ describeFilterMatchCount,
67
+ } from './conversation-list-filter.mjs';
61
68
 
62
69
  const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
63
70
  const PROCESSING_DOT_INTERVAL_MS = 1000;
@@ -74,9 +81,13 @@ const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
74
81
  const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
75
82
  const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
76
83
  const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
84
+ const CONV_FILTER_DEBOUNCE_MS = 220;
77
85
  let processingDotFrame = 0;
78
86
  let processingDotTimer = null;
79
87
  let lastConvListHtml = '';
88
+ let conversationListFilterText = '';
89
+ let conversationFilterDrainActive = false;
90
+ let conversationFilterGeneration = 0;
80
91
  let openConversationVersion = 0;
81
92
  let newConversationInFlight = false;
82
93
 
@@ -273,6 +284,11 @@ export async function refreshConversations(options = {}) {
273
284
  });
274
285
  renderConvList();
275
286
  updateCompactButton();
287
+ // A reset bumps the loader version, which aborts any in-flight filter drain;
288
+ // restart it so an active filter keeps covering the unloaded pages.
289
+ if (normalizeConversationFilter(conversationListFilterText)) {
290
+ void drainConversationListForFilter();
291
+ }
276
292
  }
277
293
 
278
294
  export function renderConvList() {
@@ -282,9 +298,19 @@ export function renderConvList() {
282
298
  if (updatedAtDelta !== 0) return updatedAtDelta;
283
299
  return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
284
300
  });
301
+ const activeFilter = normalizeConversationFilter(conversationListFilterText);
302
+ const visible = activeFilter ? filterConversations(sorted, activeFilter) : sorted;
285
303
  const pendingByConversation = getPendingQuestionCountsByConversation();
286
304
  let hasProcessingConversation = false;
287
305
  const footerHtml = (() => {
306
+ if (activeFilter) {
307
+ // While a filter is active the drain loads older pages by itself; a
308
+ // "scroll for more" hint would point at a gesture that does nothing, and
309
+ // "Searching…" must only show while something is actually loading.
310
+ return (conversationFilterDrainActive || conversationListPaginationState.isLoading)
311
+ ? '<div class="conv-list-footer">Searching older conversations…</div>'
312
+ : '';
313
+ }
288
314
  if (conversationListPaginationState.isLoading) {
289
315
  return '<div class="conv-list-footer">Loading older conversations…</div>';
290
316
  }
@@ -293,10 +319,14 @@ export function renderConvList() {
293
319
  }
294
320
  return '';
295
321
  })();
296
- if (sorted.length === 0) {
322
+ if (visible.length === 0) {
297
323
  ensureProcessingDotTimer(false);
298
- list.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">No conversations yet</div>';
324
+ const emptyText = activeFilter && sorted.length > 0
325
+ ? 'No conversations match your filter'
326
+ : 'No conversations yet';
327
+ list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
299
328
  lastConvListHtml = '';
329
+ scheduleConversationListBoundaryCheck();
300
330
  return;
301
331
  }
302
332
  const conversationView = (conversation) => {
@@ -312,7 +342,7 @@ export function renderConvList() {
312
342
  if (processing) hasProcessingConversation = true;
313
343
  return { visualState, processing };
314
344
  };
315
- const listHtml = `${sorted.map((c) => {
345
+ const listHtml = `${visible.map((c) => {
316
346
  const view = conversationView(c);
317
347
  const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
318
348
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
@@ -320,10 +350,12 @@ export function renderConvList() {
320
350
  const providerIndicatorHtml = providerIndicatorLabel
321
351
  ? `<span class="conv-provider-indicator"${providerIndicatorKey ? ` data-provider="${providerIndicatorKey}"` : ''}>${providerIndicatorLabel}</span>`
322
352
  : '';
353
+ // "↗ win-test": another relay's agent created this conversation.
354
+ const originMarkerHtml = renderConversationOriginMarkerHtml(c);
323
355
  return `
324
356
  <div class="conv-item worker-ui-${view.visualState}${c.id === currentConvId ? ' active' : ''}" onclick="openConversation('${c.id}')">
325
357
  <div class="conv-title">${escHtml(c.title)}${processingDots ? `<span class="conv-processing-dots">${escHtml(` ${processingDots}`)}</span>` : ''}${c.archived ? ' <span style="font-size:0.68rem;color:var(--muted)">(archived)</span>' : ''}${pendingByConversation[c.id] ? ` <span class="conv-open-questions">${pendingByConversation[c.id]} open</span>` : ''}</div>
326
- <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${providerIndicatorHtml}</div>
358
+ <div class="conv-meta"><span class="conv-meta-primary">${fmtDate(c.updatedAt)} · ${c.messageCount} msg${c.messageCount !== 1 ? 's' : ''}</span>${originMarkerHtml}${providerIndicatorHtml}</div>
327
359
  <button class="conv-delete" onclick="deleteConv(event,'${c.id}')" title="Delete">🗑</button>
328
360
  </div>`;
329
361
  }).join('')}${footerHtml}`;
@@ -427,6 +459,7 @@ export async function openConversation(id, options = {}) {
427
459
  await flushConversationDraft(previousConversationId);
428
460
  window.clearImageEditTarget?.();
429
461
  }
462
+ const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
430
463
  const capturedVersion = ++openConversationVersion;
431
464
  setCurrentConv(id);
432
465
  if (repoBrowserState.activeRoot === 'workspace') {
@@ -440,7 +473,13 @@ export async function openConversation(id, options = {}) {
440
473
  repoBrowserState.error = '';
441
474
  }
442
475
  closeSidebar();
443
- clearAttachments();
476
+ // Reopening the conversation on screen (session bind, clicking it again, a
477
+ // push or search jump) must leave its composer alone: emptied attachments
478
+ // would read as a user edit and be saved, dropping them from the draft.
479
+ if (switchingConversation) {
480
+ clearAttachments();
481
+ beginConversationDraftSwitch(nextConversationId);
482
+ }
444
483
  document.getElementById('chat-title').textContent = conversations[id]?.title || id;
445
484
  if (didLeaveStatusView) {
446
485
  restoreInFlightThinking(null);
@@ -1145,11 +1184,115 @@ export function initConversationListLazyLoading() {
1145
1184
  scheduleConversationListBoundaryCheck();
1146
1185
  }
1147
1186
 
1187
+ // A title filter has to search conversations that only exist on unloaded pages,
1188
+ // so an active filter drains the remaining pages in the background. Each page
1189
+ // re-renders via applyPage, so matches appear as they load.
1190
+ // A filter change aborts the running drain and starts a fresh one, so the new
1191
+ // filter gets a full retry budget.
1192
+ async function drainConversationListForFilter() {
1193
+ if (conversationFilterDrainActive) return;
1194
+ conversationFilterDrainActive = true;
1195
+ const generation = conversationFilterGeneration;
1196
+ renderConvList();
1197
+ try {
1198
+ await drainRemainingPages(
1199
+ conversationListLoader,
1200
+ () => generation === conversationFilterGeneration
1201
+ && !!normalizeConversationFilter(conversationListFilterText),
1202
+ );
1203
+ } finally {
1204
+ conversationFilterDrainActive = false;
1205
+ const filterActive = !!normalizeConversationFilter(conversationListFilterText);
1206
+ if (filterActive && generation !== conversationFilterGeneration) {
1207
+ void drainConversationListForFilter();
1208
+ } else {
1209
+ // The "Searching older conversations…" footer keys off the drain flag.
1210
+ renderConvList();
1211
+ if (filterActive) announceConversationFilterResults();
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ // Announced only when the (debounced) filter applies and when its drain ends,
1217
+ // not on every page render, so screen readers are not flooded while typing.
1218
+ // Unchanged text is not rewritten: refresh-triggered drains (session bind,
1219
+ // reconnect) must not re-announce without a user action.
1220
+ function announceConversationFilterResults() {
1221
+ const status = document.getElementById('conv-filter-status');
1222
+ if (!status) return;
1223
+ const activeFilter = normalizeConversationFilter(conversationListFilterText);
1224
+ let text = '';
1225
+ if (activeFilter) {
1226
+ const count = filterConversations(Object.values(conversations), activeFilter).length;
1227
+ if (!conversationListLoader.getState().hasMore) {
1228
+ text = describeFilterMatchCount(count);
1229
+ } else if (conversationFilterDrainActive) {
1230
+ text = `${describeFilterMatchCount(count)} so far, searching older conversations`;
1231
+ } else {
1232
+ // The drain gave up with pages left, so the search is not complete.
1233
+ text = describeFilterMatchCount(count, { loadedOnly: true });
1234
+ }
1235
+ }
1236
+ if (status.textContent !== text) status.textContent = text;
1237
+ }
1238
+
1239
+ function setConversationListFilter(text) {
1240
+ const next = String(text ?? '');
1241
+ if (next === conversationListFilterText) return;
1242
+ conversationListFilterText = next;
1243
+ conversationFilterGeneration += 1;
1244
+ const list = getConversationListElement();
1245
+ if (list) list.scrollTop = 0;
1246
+ renderConvList();
1247
+ if (normalizeConversationFilter(next)) void drainConversationListForFilter();
1248
+ announceConversationFilterResults();
1249
+ }
1250
+
1251
+ export function initConversationFilter() {
1252
+ const input = document.getElementById('conv-filter-input');
1253
+ const clearButton = document.getElementById('conv-filter-clear');
1254
+ if (!input || input.dataset.filterBound === '1') return;
1255
+ input.dataset.filterBound = '1';
1256
+ let debounceTimer = null;
1257
+ const syncClearButton = () => {
1258
+ if (clearButton) clearButton.hidden = input.value === '';
1259
+ };
1260
+ const applyFilterNow = () => {
1261
+ if (debounceTimer) {
1262
+ clearTimeout(debounceTimer);
1263
+ debounceTimer = null;
1264
+ }
1265
+ syncClearButton();
1266
+ setConversationListFilter(input.value);
1267
+ };
1268
+ input.addEventListener('input', () => {
1269
+ syncClearButton();
1270
+ if (debounceTimer) clearTimeout(debounceTimer);
1271
+ debounceTimer = setTimeout(applyFilterNow, CONV_FILTER_DEBOUNCE_MS);
1272
+ });
1273
+ input.addEventListener('keydown', (event) => {
1274
+ if (event.key !== 'Escape' || input.value === '') return;
1275
+ event.stopPropagation();
1276
+ input.value = '';
1277
+ applyFilterNow();
1278
+ });
1279
+ clearButton?.addEventListener('click', () => {
1280
+ input.value = '';
1281
+ applyFilterNow();
1282
+ input.focus();
1283
+ });
1284
+ syncClearButton();
1285
+ }
1286
+
1148
1287
  export async function deleteConv(e, id) {
1149
1288
  e.stopPropagation();
1150
1289
  if (!confirm('Delete this conversation?')) return;
1151
1290
  const result = await deleteConversationApi(id);
1152
1291
  if (!result) return;
1292
+ if (result.ok === false) {
1293
+ showTransientRelayNotice(String(result.message || '').trim() || 'This conversation could not be deleted.', 6000);
1294
+ return;
1295
+ }
1153
1296
  delete conversations[id];
1154
1297
  renderConvList();
1155
1298
  if (currentConvId === id) {