@oar-sh/oar 0.9.2 → 0.9.3

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 (94) hide show
  1. package/CHANGELOG.md +269 -0
  2. package/README.md +407 -424
  3. package/bin/oar.js +25 -10
  4. package/package.json +7 -4
  5. package/server/README.md +1 -1
  6. package/server/claude-worker/claude-session-process.mjs +981 -44
  7. package/server/claude-worker/claude-session-worker.mjs +45 -4
  8. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  9. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  10. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  11. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  12. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  16. package/server/db-schema.mjs +20 -0
  17. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  18. package/server/public/app/api-client.js +22 -2
  19. package/server/public/app/ask-user-view.js +102 -13
  20. package/server/public/app/attachments-view.js +37 -2
  21. package/server/public/app/background-task-timeout-options.mjs +9 -0
  22. package/server/public/app/background-tasks-view.mjs +33 -18
  23. package/server/public/app/bootstrap.js +34 -8
  24. package/server/public/app/composer-control-state.mjs +52 -34
  25. package/server/public/app/conversation-draft-sync.mjs +187 -0
  26. package/server/public/app/conversation-list-filter.mjs +86 -0
  27. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  28. package/server/public/app/conversation-view.js +919 -194
  29. package/server/public/app/emoji-view.js +2 -0
  30. package/server/public/app/font-scaling.js +4 -37
  31. package/server/public/app/journal-view.js +145 -5
  32. package/server/public/app/model-selector-options.mjs +1 -1
  33. package/server/public/app/question-multi-select.mjs +52 -0
  34. package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
  35. package/server/public/app/settings-modal.js +8 -6
  36. package/server/public/app/socket-handlers.js +19 -0
  37. package/server/public/app/steered-turn-merge.mjs +100 -0
  38. package/server/public/app/store.js +126 -15
  39. package/server/public/app/sync-outbox.mjs +16 -8
  40. package/server/public/app/tool-activity-emoji.mjs +36 -0
  41. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  42. package/server/public/index.html +206 -9
  43. package/server/public/sw.js +7 -0
  44. package/server/relay-tools.md +2 -0
  45. package/server/repositories/message-repository.mjs +61 -18
  46. package/server/repositories/question-repository.mjs +15 -0
  47. package/server/repositories/session-repository.mjs +7 -31
  48. package/server/routes/messages-routes.mjs +488 -46
  49. package/server/routes/sessions-routes.mjs +298 -55
  50. package/server/server-runtime.mjs +65 -25
  51. package/server/services/live-turn-picker.mjs +47 -0
  52. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  53. package/server/services/relay-question-context.mjs +46 -0
  54. package/server/services/sdk-session-import-service.mjs +49 -7
  55. package/server/services/session-worker-registry-service.mjs +75 -0
  56. package/server/services/session-worker-supervisor-service.mjs +7 -1
  57. package/server/services/session-worker-websocket-service.mjs +23 -1
  58. package/server/services/steer-resend.mjs +45 -0
  59. package/shared/background-task-timeout.mjs +12 -4
  60. package/shared/control-poller.mjs +4 -1
  61. package/shared/question-multi-select.mjs +16 -0
  62. package/shared/relay-stop-failure.mjs +15 -0
  63. package/shared/slash-command-guard.mjs +76 -0
  64. package/shared/steer-note.mjs +66 -0
  65. package/shared/steer-settle-failure.mjs +89 -0
  66. package/shared/steer-settle-markers.mjs +18 -0
  67. package/shared/worker-crash-guard.mjs +40 -25
  68. package/shared/worker-runtime/heartbeat.mjs +55 -1
  69. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  70. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  71. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  72. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  73. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  74. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  75. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  76. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  77. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  78. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  79. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  80. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  81. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  82. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  83. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  84. package/server/routes/messages-routes-test-harness.mjs +0 -111
  85. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  86. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  87. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  88. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  89. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  90. package/server/services/fixtures/cli-install-stub.sh +0 -4
  91. package/server/services/fixtures/grok-stub.cmd +0 -1
  92. package/server/services/fixtures/grok-stub.mjs +0 -89
  93. package/server/services/fixtures/grok-stub.sh +0 -4
  94. 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;
@@ -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,12 @@ import {
58
58
  conversationProviderIndicatorLabel,
59
59
  } from './conversation-provider-indicator.mjs';
60
60
  import { leaveStatusView } from './status-view.mjs';
61
+ import {
62
+ normalizeConversationFilter,
63
+ filterConversations,
64
+ drainRemainingPages,
65
+ describeFilterMatchCount,
66
+ } from './conversation-list-filter.mjs';
61
67
 
62
68
  const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
63
69
  const PROCESSING_DOT_INTERVAL_MS = 1000;
@@ -74,9 +80,13 @@ const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
74
80
  const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
75
81
  const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
76
82
  const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
83
+ const CONV_FILTER_DEBOUNCE_MS = 220;
77
84
  let processingDotFrame = 0;
78
85
  let processingDotTimer = null;
79
86
  let lastConvListHtml = '';
87
+ let conversationListFilterText = '';
88
+ let conversationFilterDrainActive = false;
89
+ let conversationFilterGeneration = 0;
80
90
  let openConversationVersion = 0;
81
91
  let newConversationInFlight = false;
82
92
 
@@ -273,6 +283,11 @@ export async function refreshConversations(options = {}) {
273
283
  });
274
284
  renderConvList();
275
285
  updateCompactButton();
286
+ // A reset bumps the loader version, which aborts any in-flight filter drain;
287
+ // restart it so an active filter keeps covering the unloaded pages.
288
+ if (normalizeConversationFilter(conversationListFilterText)) {
289
+ void drainConversationListForFilter();
290
+ }
276
291
  }
277
292
 
278
293
  export function renderConvList() {
@@ -282,9 +297,19 @@ export function renderConvList() {
282
297
  if (updatedAtDelta !== 0) return updatedAtDelta;
283
298
  return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
284
299
  });
300
+ const activeFilter = normalizeConversationFilter(conversationListFilterText);
301
+ const visible = activeFilter ? filterConversations(sorted, activeFilter) : sorted;
285
302
  const pendingByConversation = getPendingQuestionCountsByConversation();
286
303
  let hasProcessingConversation = false;
287
304
  const footerHtml = (() => {
305
+ if (activeFilter) {
306
+ // While a filter is active the drain loads older pages by itself; a
307
+ // "scroll for more" hint would point at a gesture that does nothing, and
308
+ // "Searching…" must only show while something is actually loading.
309
+ return (conversationFilterDrainActive || conversationListPaginationState.isLoading)
310
+ ? '<div class="conv-list-footer">Searching older conversations…</div>'
311
+ : '';
312
+ }
288
313
  if (conversationListPaginationState.isLoading) {
289
314
  return '<div class="conv-list-footer">Loading older conversations…</div>';
290
315
  }
@@ -293,10 +318,14 @@ export function renderConvList() {
293
318
  }
294
319
  return '';
295
320
  })();
296
- if (sorted.length === 0) {
321
+ if (visible.length === 0) {
297
322
  ensureProcessingDotTimer(false);
298
- list.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">No conversations yet</div>';
323
+ const emptyText = activeFilter && sorted.length > 0
324
+ ? 'No conversations match your filter'
325
+ : 'No conversations yet';
326
+ list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
299
327
  lastConvListHtml = '';
328
+ scheduleConversationListBoundaryCheck();
300
329
  return;
301
330
  }
302
331
  const conversationView = (conversation) => {
@@ -312,7 +341,7 @@ export function renderConvList() {
312
341
  if (processing) hasProcessingConversation = true;
313
342
  return { visualState, processing };
314
343
  };
315
- const listHtml = `${sorted.map((c) => {
344
+ const listHtml = `${visible.map((c) => {
316
345
  const view = conversationView(c);
317
346
  const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
318
347
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
@@ -427,6 +456,7 @@ export async function openConversation(id, options = {}) {
427
456
  await flushConversationDraft(previousConversationId);
428
457
  window.clearImageEditTarget?.();
429
458
  }
459
+ const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
430
460
  const capturedVersion = ++openConversationVersion;
431
461
  setCurrentConv(id);
432
462
  if (repoBrowserState.activeRoot === 'workspace') {
@@ -440,7 +470,13 @@ export async function openConversation(id, options = {}) {
440
470
  repoBrowserState.error = '';
441
471
  }
442
472
  closeSidebar();
443
- clearAttachments();
473
+ // Reopening the conversation on screen (session bind, clicking it again, a
474
+ // push or search jump) must leave its composer alone: emptied attachments
475
+ // would read as a user edit and be saved, dropping them from the draft.
476
+ if (switchingConversation) {
477
+ clearAttachments();
478
+ beginConversationDraftSwitch(nextConversationId);
479
+ }
444
480
  document.getElementById('chat-title').textContent = conversations[id]?.title || id;
445
481
  if (didLeaveStatusView) {
446
482
  restoreInFlightThinking(null);
@@ -1145,11 +1181,115 @@ export function initConversationListLazyLoading() {
1145
1181
  scheduleConversationListBoundaryCheck();
1146
1182
  }
1147
1183
 
1184
+ // A title filter has to search conversations that only exist on unloaded pages,
1185
+ // so an active filter drains the remaining pages in the background. Each page
1186
+ // re-renders via applyPage, so matches appear as they load.
1187
+ // A filter change aborts the running drain and starts a fresh one, so the new
1188
+ // filter gets a full retry budget.
1189
+ async function drainConversationListForFilter() {
1190
+ if (conversationFilterDrainActive) return;
1191
+ conversationFilterDrainActive = true;
1192
+ const generation = conversationFilterGeneration;
1193
+ renderConvList();
1194
+ try {
1195
+ await drainRemainingPages(
1196
+ conversationListLoader,
1197
+ () => generation === conversationFilterGeneration
1198
+ && !!normalizeConversationFilter(conversationListFilterText),
1199
+ );
1200
+ } finally {
1201
+ conversationFilterDrainActive = false;
1202
+ const filterActive = !!normalizeConversationFilter(conversationListFilterText);
1203
+ if (filterActive && generation !== conversationFilterGeneration) {
1204
+ void drainConversationListForFilter();
1205
+ } else {
1206
+ // The "Searching older conversations…" footer keys off the drain flag.
1207
+ renderConvList();
1208
+ if (filterActive) announceConversationFilterResults();
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ // Announced only when the (debounced) filter applies and when its drain ends,
1214
+ // not on every page render, so screen readers are not flooded while typing.
1215
+ // Unchanged text is not rewritten: refresh-triggered drains (session bind,
1216
+ // reconnect) must not re-announce without a user action.
1217
+ function announceConversationFilterResults() {
1218
+ const status = document.getElementById('conv-filter-status');
1219
+ if (!status) return;
1220
+ const activeFilter = normalizeConversationFilter(conversationListFilterText);
1221
+ let text = '';
1222
+ if (activeFilter) {
1223
+ const count = filterConversations(Object.values(conversations), activeFilter).length;
1224
+ if (!conversationListLoader.getState().hasMore) {
1225
+ text = describeFilterMatchCount(count);
1226
+ } else if (conversationFilterDrainActive) {
1227
+ text = `${describeFilterMatchCount(count)} so far, searching older conversations`;
1228
+ } else {
1229
+ // The drain gave up with pages left, so the search is not complete.
1230
+ text = describeFilterMatchCount(count, { loadedOnly: true });
1231
+ }
1232
+ }
1233
+ if (status.textContent !== text) status.textContent = text;
1234
+ }
1235
+
1236
+ function setConversationListFilter(text) {
1237
+ const next = String(text ?? '');
1238
+ if (next === conversationListFilterText) return;
1239
+ conversationListFilterText = next;
1240
+ conversationFilterGeneration += 1;
1241
+ const list = getConversationListElement();
1242
+ if (list) list.scrollTop = 0;
1243
+ renderConvList();
1244
+ if (normalizeConversationFilter(next)) void drainConversationListForFilter();
1245
+ announceConversationFilterResults();
1246
+ }
1247
+
1248
+ export function initConversationFilter() {
1249
+ const input = document.getElementById('conv-filter-input');
1250
+ const clearButton = document.getElementById('conv-filter-clear');
1251
+ if (!input || input.dataset.filterBound === '1') return;
1252
+ input.dataset.filterBound = '1';
1253
+ let debounceTimer = null;
1254
+ const syncClearButton = () => {
1255
+ if (clearButton) clearButton.hidden = input.value === '';
1256
+ };
1257
+ const applyFilterNow = () => {
1258
+ if (debounceTimer) {
1259
+ clearTimeout(debounceTimer);
1260
+ debounceTimer = null;
1261
+ }
1262
+ syncClearButton();
1263
+ setConversationListFilter(input.value);
1264
+ };
1265
+ input.addEventListener('input', () => {
1266
+ syncClearButton();
1267
+ if (debounceTimer) clearTimeout(debounceTimer);
1268
+ debounceTimer = setTimeout(applyFilterNow, CONV_FILTER_DEBOUNCE_MS);
1269
+ });
1270
+ input.addEventListener('keydown', (event) => {
1271
+ if (event.key !== 'Escape' || input.value === '') return;
1272
+ event.stopPropagation();
1273
+ input.value = '';
1274
+ applyFilterNow();
1275
+ });
1276
+ clearButton?.addEventListener('click', () => {
1277
+ input.value = '';
1278
+ applyFilterNow();
1279
+ input.focus();
1280
+ });
1281
+ syncClearButton();
1282
+ }
1283
+
1148
1284
  export async function deleteConv(e, id) {
1149
1285
  e.stopPropagation();
1150
1286
  if (!confirm('Delete this conversation?')) return;
1151
1287
  const result = await deleteConversationApi(id);
1152
1288
  if (!result) return;
1289
+ if (result.ok === false) {
1290
+ showTransientRelayNotice(String(result.message || '').trim() || 'This conversation could not be deleted.', 6000);
1291
+ return;
1292
+ }
1153
1293
  delete conversations[id];
1154
1294
  renderConvList();
1155
1295
  if (currentConvId === id) {
@@ -2,7 +2,7 @@ import { tokenLabel } from './context-tier-options.mjs';
2
2
 
3
3
  /**
4
4
  * The composer placeholder, decided by the MODEL FAMILY of the current
5
- * selection (Simon's rule: the hint names who answers, whatever runtime serves
5
+ * selection (maintainer's rule: the hint names who answers, whatever runtime serves
6
6
  * it — claude-* through Cursor's catalog still reads "Message Claude…").
7
7
  * Provider only breaks ties: Auto/empty follows the conversation's bound
8
8
  * provider, unknown families on the OpenAI BYOK provider read OpenAI, and
@@ -0,0 +1,52 @@
1
+ // Multi-select question cards: the pure rules the card renderer and its
2
+ // submit path share, kept DOM-free so they are unit-testable.
3
+ //
4
+ // Two card hints ride the question's `context` (kept by the relay's
5
+ // services/relay-question-context.mjs allow-list):
6
+ // - `multiSelect: true` — several answers are allowed. Claude states this on
7
+ // every AskUserQuestion question; Copilot models declare it through the
8
+ // relay's own `ask_user` (`multi_select`), and the worker falls back to the
9
+ // wording ("select all that apply").
10
+ // - `allowMultiSelect: true` — the provider cannot always tell (Copilot), so
11
+ // the card offers a "Select several" switch. Claude cards never carry it:
12
+ // they follow Claude's flag strictly (maintainer decision, 2026-09-25).
13
+ // A multi-select card offers checkmarks and one "Reply with selection"; the
14
+ // answer is the selected labels joined with ", " — plus whatever the user
15
+ // typed as an extra answer — which is Claude's own multi-select answer format.
16
+
17
+ function choiceCount(question) {
18
+ return Array.isArray(question?.choices) ? question.choices.length : 0;
19
+ }
20
+
21
+ /** Whether the card offers the "Select several" switch. */
22
+ export function offersMultiSelectToggle(question) {
23
+ if (!question || typeof question !== 'object') return false;
24
+ return choiceCount(question) >= 2 && question?.context?.allowMultiSelect === true;
25
+ }
26
+
27
+ /**
28
+ * Whether the card renders checkmarks. `override` is the user's switch
29
+ * position for this card (true/false), or undefined while untouched — then the
30
+ * provider's flag decides. The switch only counts on cards that offer it.
31
+ */
32
+ export function isMultiSelectQuestion(question, override = undefined) {
33
+ if (!question || typeof question !== 'object') return false;
34
+ if (choiceCount(question) < 2) return false;
35
+ if (typeof override === 'boolean' && offersMultiSelectToggle(question)) return override;
36
+ return question?.context?.multiSelect === true;
37
+ }
38
+
39
+ /**
40
+ * The answer string for a multi-select card: the selected labels in offer
41
+ * order, then the free-text answer (if any) as one more item. Empty when
42
+ * nothing was selected or typed.
43
+ */
44
+ export function composeMultiSelectAnswer(selectedLabels, freeText = '') {
45
+ const labels = (Array.isArray(selectedLabels) ? selectedLabels : [])
46
+ .map((label) => String(label ?? '').trim())
47
+ .filter(Boolean)
48
+ .filter((label, index, all) => all.indexOf(label) === index);
49
+ const extra = String(freeText ?? '').trim();
50
+ if (extra && !labels.includes(extra)) labels.push(extra);
51
+ return labels.join(', ');
52
+ }
@@ -108,6 +108,21 @@ function stripAttachmentPromptArtifacts(text) {
108
108
  // structurally — the heading plus its single paragraph — rather than verbatim.
109
109
  const MEDIA_EMBED_BLOCK_PATTERN = /##\s*Embedding media in replies\s+[\s\S]*?(?=\n\s*\n|$)/gi;
110
110
 
111
+ // A steered message's hidden note (shared/steer-note.mjs) leads the prompt the
112
+ // runtime stored, right after any mode marker. Matched by its stable opening
113
+ // words, because the browser copy of this module cannot import shared/.
114
+ const STEER_NOTE_PATTERN = /^\s*\[Sent while you were still working on my previous message\.[^\]\n]*\]\s*/;
115
+
116
+ // The Claude worker's slash-command guard (shared/slash-command-guard.mjs): a
117
+ // zero-width space in front of a "/x" it delivers as text. trim() keeps it, so
118
+ // it is matched on its own, and only where it guards a "/".
119
+ const SLASH_COMMAND_GUARD_PATTERN = /^\u200B(?=\s*\/)/;
120
+
121
+ function stripLeadingRelayNotes(text) {
122
+ const stripped = String(text || '').replace(STEER_NOTE_PATTERN, '').replace(SLASH_COMMAND_GUARD_PATTERN, '');
123
+ return stripped.trim() ? stripped.trim() : String(text || '').trim();
124
+ }
125
+
111
126
  export function stripRelayPromptContext(text, relayMode = '') {
112
127
  const value = stripAttachmentPromptArtifacts(text)
113
128
  .replace(MEDIA_EMBED_BLOCK_PATTERN, '')
@@ -116,7 +131,7 @@ export function stripRelayPromptContext(text, relayMode = '') {
116
131
  const patterns = buildPromptPrefixPatterns(relayMode);
117
132
  for (const pattern of patterns) {
118
133
  const stripped = value.replace(pattern, '').trim();
119
- if (stripped && stripped !== value) return stripped;
134
+ if (stripped && stripped !== value) return stripLeadingRelayNotes(stripped);
120
135
  }
121
- return value;
136
+ return stripLeadingRelayNotes(value);
122
137
  }
@@ -33,6 +33,7 @@ import {
33
33
  updateFeatureFlagSetting as requestFeatureFlagSetting,
34
34
  requestRelayRestart,
35
35
  } from './api-client.js';
36
+ import { DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES } from './background-task-timeout-options.mjs';
36
37
  import { syncFontScaleSelect } from './font-scaling.js';
37
38
  import { syncPwaAppNameInput } from './pwa-install.js';
38
39
  import { normalizeKnownCwdPath } from './cwd-picker.js';
@@ -152,9 +153,10 @@ export async function updateTurnCeilingSetting(value) {
152
153
  }
153
154
  }
154
155
 
155
- // Mirrors shared/background-task-timeout.mjs (the browser cannot import it).
156
- // Bounds come from the API response; these are just pre-response defaults.
157
- let backgroundTaskTimeoutMinutes = 0;
156
+ // Starts at the default the relay enforces, so the slider tells the truth while
157
+ // the settings request is in flight or after it failed. Bounds come from the
158
+ // API response.
159
+ let backgroundTaskTimeoutMinutes = DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES;
158
160
  let backgroundTaskTimeoutUpdateInFlight = false;
159
161
 
160
162
  function syncBackgroundTaskTimeoutSlider() {
@@ -353,8 +355,8 @@ export function applyCopilotSettingsState(settings = {}, { error = '' } = {}) {
353
355
  status.dataset.state = 'error';
354
356
  } else {
355
357
  status.textContent = copilotSettingsState.engine === 'sdk'
356
- ? 'New Copilot conversations run on the experimental headless SDK worker. No tmux inspector for these sessions.'
357
- : 'New Copilot conversations run on the Copilot CLI with the web-relay extension (current default).';
358
+ ? 'New Copilot conversations run on the headless SDK worker. No tmux inspector for these sessions.'
359
+ : 'New Copilot conversations run on the Copilot CLI with the web-relay extension.';
358
360
  status.dataset.state = copilotSettingsState.engine === 'sdk' ? 'active' : 'unconfigured';
359
361
  }
360
362
  }
@@ -396,7 +398,7 @@ export async function saveCopilotSettings() {
396
398
  settled = result;
397
399
  showTransientRelayNotice(
398
400
  String(result.engine) === 'sdk'
399
- ? 'Copilot engine set to SDK (experimental). New conversations use the headless worker; running sessions keep the extension until they restart.'
401
+ ? 'Copilot engine set to SDK. New conversations use the headless worker; running sessions keep their engine until they restart.'
400
402
  : 'Copilot engine set to Extension. New conversations use the Copilot CLI.',
401
403
  6000,
402
404
  );
@@ -29,6 +29,7 @@ import {
29
29
  clearSubagentCancelInFlight,
30
30
  setConversationWatcherCount,
31
31
  setUpdateState,
32
+ applySessionWorkerSteering,
32
33
  } from './store.js';
33
34
  import { scheduleContextUsageRefresh } from './api-client.js';
34
35
  import { publishStatusEvent, recordStatusEvent } from './status-store.mjs';
@@ -66,6 +67,8 @@ import {
66
67
  removeUserBubbleCancelButton,
67
68
  updateSubagentBubbleFromStatus,
68
69
  markSubagentStopUnsupported,
70
+ syncComposerButtonState,
71
+ syncCancellableSteerButtons,
69
72
  } from './conversation-view.js';
70
73
  import { loadRepoBrowserTree, refreshRepoBrowserIfWorkspaceOpen } from './attachments-view.js';
71
74
  import { clearMessageSearchRuntimeState } from './message-search-view.js';
@@ -627,11 +630,27 @@ export async function connectSocket(overrideDeps) {
627
630
  runtimeSessionId: String(runtimeSessionId || conversations[id].runtimeSessionId || '').trim() || null,
628
631
  };
629
632
  }
633
+ // The composer reads the worker's steering snapshot under the session id
634
+ // just bound; fetch it now rather than on the next status poll.
635
+ refreshSessionWorkerStatus().catch(() => {});
630
636
  await refreshConversations();
631
637
  if (currentConvId === id) {
632
638
  await openConversation(id);
633
639
  }
634
640
  });
641
+ // A worker's steering snapshot changed (its heartbeat): the composer's
642
+ // Steer/Queue label and the pushed rows' Cancel follow it at once instead of
643
+ // on the next status poll.
644
+ socket.on('session_worker_steering', ({ sdkSessionId, steering } = {}) => {
645
+ const changed = applySessionWorkerSteering(sdkSessionId, steering);
646
+ if (changed === null) {
647
+ refreshSessionWorkerStatus().catch(() => {});
648
+ return;
649
+ }
650
+ if (!changed) return;
651
+ syncComposerButtonState();
652
+ syncCancellableSteerButtons();
653
+ });
635
654
  socket.on('message_status', ({ messageId, conversationId, status }) => {
636
655
  const normalizedStatus = String(status || '').trim().toLowerCase();
637
656
  const clearsProcessingStatus = ['done', 'failed', 'dropped', 'pending', 'parked', 'cancelled'].includes(normalizedStatus);
@@ -0,0 +1,100 @@
1
+ // Mid-turn steering: a message sent while a Claude turn was running is pushed
2
+ // into the live turn. Three settle shapes reach the transcript:
3
+ // - handoff: the interrupted reply settles with kind='absorbed', and the rest
4
+ // of the turn (with the final reply) lands on the steered message's own row.
5
+ // Those rows render as ONE flow — absorbed reply, steered user bubble,
6
+ // continuing reply.
7
+ // - fold: the CLI answered the steer inside the running turn; its row settles
8
+ // with a kind='folded' stub, a compact marker under its own user message.
9
+ // It never merges forward — the next message is an ordinary turn.
10
+ // - stopped: the steer was pushed into a turn the user then stopped, so it
11
+ // went unanswered (kind='stopped', a marker with Resend).
12
+ // The steered user message of a fold or stop is still classed as steered.
13
+ //
14
+ // One idempotent DOM pass, same contract as syncTranscriptSeparators: every
15
+ // insertion path (full render, live append, prepended history page) re-runs it,
16
+ // so classes heal when the pair arrives across two loads and un-apply when a
17
+ // re-render drops the absorbed marker. Kept DOM-only and import-light so it is
18
+ // jsdom-testable (conversation-view.js touches window at module scope).
19
+ import { SEPARATOR_CLASS } from './transcript-separators.mjs';
20
+
21
+ export const ABSORBED_MSG_CLASS = 'msg-absorbed';
22
+ export const STEERED_MSG_CLASS = 'msg-steered';
23
+ export const STEERED_CONTINUATION_CLASS = 'msg-steered-continuation';
24
+ export const FOLDED_MSG_CLASS = 'msg-folded';
25
+ export const STEER_STOPPED_MSG_CLASS = 'msg-steer-stopped';
26
+
27
+ /** The assistant row kinds that are settle markers of a steer, not replies. */
28
+ export const STEER_MARKER_CLASS_BY_KIND = Object.freeze({
29
+ folded: FOLDED_MSG_CLASS,
30
+ stopped: STEER_STOPPED_MSG_CLASS,
31
+ });
32
+
33
+ function isSteerMarker(node) {
34
+ return !!node?.classList
35
+ && node.classList.contains('assistant')
36
+ && (node.classList.contains(FOLDED_MSG_CLASS) || node.classList.contains(STEER_STOPPED_MSG_CLASS));
37
+ }
38
+
39
+ /**
40
+ * The `.msg` row above, refusing to cross a separator row: a steering pair is
41
+ * seconds apart, so a day or compaction break between two rows proves they are
42
+ * NOT that pair, and pulling them together across it would look broken.
43
+ */
44
+ function previousMessageNode(node) {
45
+ for (let prev = node?.previousElementSibling; prev; prev = prev.previousElementSibling) {
46
+ if (prev.classList?.contains(SEPARATOR_CLASS)) return null;
47
+ if (prev.classList?.contains('msg')) return prev;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** The `.msg` row below, refusing to cross a separator row. */
53
+ function nextMessageNode(node) {
54
+ for (let next = node?.nextElementSibling; next; next = next.nextElementSibling) {
55
+ if (next.classList?.contains(SEPARATOR_CLASS)) return null;
56
+ if (next.classList?.contains('msg')) return next;
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function setClass(node, className, on) {
62
+ if (!node?.classList) return 0;
63
+ if (node.classList.contains(className) === on) return 0;
64
+ node.classList.toggle(className, on);
65
+ return 1;
66
+ }
67
+
68
+ /**
69
+ * Sync the merge classes over the container's rows. Returns the number of
70
+ * class changes (0 = the pass was a no-op), which the tests assert on.
71
+ */
72
+ export function syncSteeredTurnMerge(container) {
73
+ if (!container || !container.children) return 0;
74
+ let changes = 0;
75
+ for (const node of container.children) {
76
+ if (!node.classList?.contains('msg')) continue;
77
+ if (node.classList.contains('user')) {
78
+ // A user row is steered iff the row above it is an absorbed reply, or
79
+ // its own settle marker (fold/stop) sits right below it.
80
+ const previous = previousMessageNode(node);
81
+ const steered = (!!previous
82
+ && previous.classList.contains('assistant')
83
+ && previous.classList.contains(ABSORBED_MSG_CLASS))
84
+ || isSteerMarker(nextMessageNode(node));
85
+ changes += setClass(node, STEERED_MSG_CLASS, steered);
86
+ continue;
87
+ }
88
+ if (node.classList.contains('assistant')) {
89
+ // The reply continuing a steered message keeps the group together. A
90
+ // settle marker is not a reply: it keeps its own compact styling.
91
+ const previous = previousMessageNode(node);
92
+ const continues = !isSteerMarker(node)
93
+ && !!previous
94
+ && previous.classList.contains('user')
95
+ && previous.classList.contains(STEERED_MSG_CLASS);
96
+ changes += setClass(node, STEERED_CONTINUATION_CLASS, continues);
97
+ }
98
+ }
99
+ return changes;
100
+ }