@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.
- package/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +25 -10
- package/package.json +7 -4
- package/server/README.md +1 -1
- package/server/claude-worker/claude-session-process.mjs +981 -44
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/db-schema.mjs +20 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/api-client.js +22 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +34 -8
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +919 -194
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +145 -5
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
- package/server/public/app/settings-modal.js +8 -6
- package/server/public/app/socket-handlers.js +19 -0
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +206 -9
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/repositories/message-repository.mjs +61 -18
- package/server/repositories/question-repository.mjs +15 -0
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/messages-routes.mjs +488 -46
- package/server/routes/sessions-routes.mjs +298 -55
- package/server/server-runtime.mjs +65 -25
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- 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 ?
|
|
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
|
-
|
|
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 (
|
|
321
|
+
if (visible.length === 0) {
|
|
297
322
|
ensureProcessingDotTimer(false);
|
|
298
|
-
|
|
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 = `${
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
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
|
|
357
|
-
: 'New Copilot conversations run on the Copilot CLI with the web-relay extension
|
|
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
|
|
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
|
+
}
|