@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
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// `backgroundTasks` on reload; hidden whenever the set is empty.
|
|
6
6
|
|
|
7
7
|
import { apiFetch } from './api-client.js';
|
|
8
|
+
import { humanizeModelLabel } from './model-selector-options.mjs';
|
|
9
|
+
import { prefixToolActivityEmoji } from './tool-activity-emoji.mjs';
|
|
8
10
|
import {
|
|
9
11
|
getPreviewsForConversation,
|
|
10
12
|
renderPreviewRowsInto,
|
|
@@ -69,11 +71,26 @@ function taskKindLabel(task) {
|
|
|
69
71
|
return label === 'Subagent' && subagentType ? subagentType : label;
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
74
|
+
// "claude-opus-5-5" → "Opus 5.5". A bare alias ("opus") is what a spawn pin
|
|
75
|
+
// carries until the subagent's first answer names the real model.
|
|
76
|
+
export function formatTaskModelLabel(model) {
|
|
77
|
+
const value = String(model || '').trim();
|
|
78
|
+
if (!value) return '';
|
|
79
|
+
if (/^claude-/i.test(value)) return humanizeModelLabel(value);
|
|
80
|
+
if (/^[a-z]+(\[[^\]]+\])?$/i.test(value)) return value.charAt(0).toUpperCase() + value.slice(1);
|
|
81
|
+
return humanizeModelLabel(value);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Detail line: the live tool call when the worker saw one ("🪓 Tool
|
|
85
|
+
// (PowerShell): cd …", cropped to the row by CSS), else the SDK's progress
|
|
86
|
+
// summary, else the bare tool name.
|
|
87
|
+
export function taskActivityText(task) {
|
|
88
|
+
const toolCall = String(task?.lastToolCall || '').trim();
|
|
89
|
+
if (toolCall) return { text: prefixToolActivityEmoji(toolCall), toolCall: true };
|
|
90
|
+
const summary = String(task?.summary || '').trim();
|
|
91
|
+
if (summary) return { text: summary, toolCall: false };
|
|
92
|
+
const lastToolName = String(task?.lastToolName || '').trim();
|
|
93
|
+
return { text: lastToolName ? `using ${lastToolName}` : '', toolCall: false };
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
// Compact token counts: "812 tok", "62.2k tok", "1.2M tok". Exported for the
|
|
@@ -180,7 +197,7 @@ function buildWorkflowAgentRow(agent) {
|
|
|
180
197
|
const activityText = state === 'running' && lastToolName ? `— using ${lastToolName}` : '';
|
|
181
198
|
|
|
182
199
|
const meta = [];
|
|
183
|
-
const model =
|
|
200
|
+
const model = formatTaskModelLabel(agent.model);
|
|
184
201
|
if (model) meta.push(model);
|
|
185
202
|
const tokens = formatTaskTokens(agent.tokens);
|
|
186
203
|
if (tokens) meta.push(tokens);
|
|
@@ -364,16 +381,11 @@ function renderTaskRow(conversationId, task) {
|
|
|
364
381
|
const key = `${conversationId}:${task.taskId}`;
|
|
365
382
|
const stopping = stopsInFlight.has(key);
|
|
366
383
|
const elapsed = formatElapsed(task.startedAt);
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const
|
|
372
|
-
const lastToolName = String(task.lastToolName || '').trim();
|
|
373
|
-
const detail = [
|
|
374
|
-
summary || (lastToolName ? `using ${lastToolName}` : ''),
|
|
375
|
-
formatTaskModel(task),
|
|
376
|
-
].filter(Boolean).join(' · ');
|
|
384
|
+
// Token usage and the model render on the row's right side instead of
|
|
385
|
+
// riding the detail line, so a long tool call can never crowd them out.
|
|
386
|
+
const activity = taskActivityText(task);
|
|
387
|
+
const modelLabel = formatTaskModelLabel(task.model);
|
|
388
|
+
const modelTitle = task.modelInherited === true ? `${task.model} (inherited from the session)` : task.model;
|
|
377
389
|
const tokens = formatTaskTokens(task.totalTokens);
|
|
378
390
|
// Workflow rows with a digest get a fold chevron plus an (initially empty)
|
|
379
391
|
// tree container right below the row; renderBackgroundTasksPanel fills the
|
|
@@ -392,10 +404,13 @@ function renderTaskRow(conversationId, task) {
|
|
|
392
404
|
${foldButton}<span class="bg-task-icon" title="${escHtml(task.taskType || 'task')}">${taskIcon(task.taskType)}</span>
|
|
393
405
|
<span class="bg-task-main">
|
|
394
406
|
<span class="bg-task-desc">${escHtml(task.description || task.taskId)}</span>
|
|
395
|
-
${
|
|
407
|
+
${activity.text ? `<span class="bg-task-detail${activity.toolCall ? ' bg-task-toolcall' : ''}" title="${escHtml(activity.text)}">${escHtml(activity.text)}</span>` : ''}
|
|
396
408
|
</span>
|
|
397
409
|
<span class="bg-task-side">
|
|
398
|
-
<span class="bg-task-
|
|
410
|
+
<span class="bg-task-side-head">
|
|
411
|
+
${modelLabel ? `<span class="bg-task-model" title="${escHtml(modelTitle)}">${escHtml(modelLabel)}</span>` : ''}
|
|
412
|
+
<span class="bg-task-badge">${escHtml(taskKindLabel(task))}</span>
|
|
413
|
+
</span>
|
|
399
414
|
<span class="bg-task-side-status">
|
|
400
415
|
${tokens ? `<span class="bg-task-tokens">${escHtml(tokens)}</span>` : ''}
|
|
401
416
|
<span class="bg-task-elapsed">${escHtml(elapsed)}</span>
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
imageEditTarget,
|
|
52
52
|
hasPendingUserMessageForConversation,
|
|
53
53
|
} from './store.js';
|
|
54
|
+
import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
|
|
54
55
|
import {
|
|
55
56
|
verifyExistingSession,
|
|
56
57
|
verifyToken,
|
|
@@ -76,7 +77,7 @@ import {
|
|
|
76
77
|
reportSharedViewerPresence,
|
|
77
78
|
setNetworkRequestsEnabled,
|
|
78
79
|
} from './api-client.js';
|
|
79
|
-
import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading } from './journal-view.js';
|
|
80
|
+
import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
|
|
80
81
|
import {
|
|
81
82
|
newConversation,
|
|
82
83
|
confirmNewConversationModel,
|
|
@@ -87,7 +88,7 @@ import {
|
|
|
87
88
|
loadRelayQuestions,
|
|
88
89
|
getPendingQuestionCountsByConversation,
|
|
89
90
|
} from './ask-user-view.js';
|
|
90
|
-
import { openPendingQuestionFromBanner, submitRelayQuestionChoice, submitRelayQuestionAnswer, submitRelayStructuredAnswer, onRelayQuestionDraftInput, handleRelayQuestionKey } from './ask-user-view.js';
|
|
91
|
+
import { openPendingQuestionFromBanner, submitRelayQuestionChoice, submitRelayQuestionAnswer, submitRelayQuestionMultiSelect, submitRelayStructuredAnswer, onRelayQuestionDraftInput, onRelayQuestionMultiSelectChange, setRelayQuestionMultiMode, handleRelayQuestionKey } from './ask-user-view.js';
|
|
91
92
|
import { loadRelayBoards, submitRelayBoardAction } from './relay-board-view.js';
|
|
92
93
|
import {
|
|
93
94
|
restoreInFlightThinking,
|
|
@@ -99,7 +100,10 @@ import {
|
|
|
99
100
|
updateComposerSlashMenu,
|
|
100
101
|
getConversationLoadedMessageCount,
|
|
101
102
|
loadOlderConversationMessages,
|
|
102
|
-
|
|
103
|
+
syncComposerButtonState,
|
|
104
|
+
syncCancellableSteerButtons,
|
|
105
|
+
syncComposerAfterUserEdit,
|
|
106
|
+
isConversationDraftHeldBySend,
|
|
103
107
|
persistComposerAttachments,
|
|
104
108
|
flushConversationDraft,
|
|
105
109
|
initConversationHistoryLazyLoading,
|
|
@@ -1139,7 +1143,7 @@ function syncModelMetadataBlocker(message = '') {
|
|
|
1139
1143
|
reasoningSelect.disabled = blocked;
|
|
1140
1144
|
reasoningSelect.title = blocked ? 'Reasoning metadata unavailable' : 'Reasoning effort';
|
|
1141
1145
|
}
|
|
1142
|
-
window.
|
|
1146
|
+
window.syncComposerButtonState?.();
|
|
1143
1147
|
}
|
|
1144
1148
|
|
|
1145
1149
|
function currentConversationHasMessages() {
|
|
@@ -3240,6 +3244,10 @@ async function refreshSessionWorkerStatus() {
|
|
|
3240
3244
|
syncQueueStatusMenuEntry(status);
|
|
3241
3245
|
if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
|
|
3242
3246
|
renderConvList();
|
|
3247
|
+
// A worker steering snapshot may have changed; the composer's Steer
|
|
3248
|
+
// button (gate, hold) and the pushed rows' Cancel controls derive from it.
|
|
3249
|
+
syncComposerButtonState();
|
|
3250
|
+
syncCancellableSteerButtons();
|
|
3243
3251
|
}
|
|
3244
3252
|
updateCliStatus();
|
|
3245
3253
|
const nextWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
|
|
@@ -3311,9 +3319,18 @@ function startLiveConversationPolling() {
|
|
|
3311
3319
|
}, 900);
|
|
3312
3320
|
}
|
|
3313
3321
|
|
|
3322
|
+
const transcriptResizeKeeper = createTranscriptResizeKeeper({
|
|
3323
|
+
getElement: () => document.getElementById('messages'),
|
|
3324
|
+
});
|
|
3325
|
+
|
|
3314
3326
|
function setupViewportTracking() {
|
|
3315
3327
|
syncViewportMetrics();
|
|
3316
|
-
|
|
3328
|
+
// After the metrics: --app-height decides the transcript's height, and the
|
|
3329
|
+
// keeper's restore reads the post-resize layout.
|
|
3330
|
+
const update = () => {
|
|
3331
|
+
syncViewportMetrics();
|
|
3332
|
+
transcriptResizeKeeper.handleResize();
|
|
3333
|
+
};
|
|
3317
3334
|
window.addEventListener('resize', update, { passive: true });
|
|
3318
3335
|
window.addEventListener('orientationchange', update, { passive: true });
|
|
3319
3336
|
if (window.visualViewport) {
|
|
@@ -3352,7 +3369,7 @@ function setupViewportTracking() {
|
|
|
3352
3369
|
if (input && input.dataset.viewportBound !== '1') {
|
|
3353
3370
|
input.dataset.viewportBound = '1';
|
|
3354
3371
|
input.addEventListener('input', () => {
|
|
3355
|
-
|
|
3372
|
+
syncComposerAfterUserEdit();
|
|
3356
3373
|
}, { passive: true });
|
|
3357
3374
|
input.addEventListener('focus', () => {
|
|
3358
3375
|
document.body.classList.add('keyboard-open');
|
|
@@ -3427,6 +3444,7 @@ function initMessageScrollPersistence() {
|
|
|
3427
3444
|
if (!el || el.dataset.scrollPersistenceBound === '1') return;
|
|
3428
3445
|
el.dataset.scrollPersistenceBound = '1';
|
|
3429
3446
|
el.addEventListener('scroll', () => {
|
|
3447
|
+
transcriptResizeKeeper.recordScroll();
|
|
3430
3448
|
const convId = String(currentConvId || '').trim();
|
|
3431
3449
|
if (!convId) return;
|
|
3432
3450
|
saveConversationScrollTop(convId, el.scrollTop);
|
|
@@ -3944,7 +3962,11 @@ async function initApp() {
|
|
|
3944
3962
|
// The direct flush below can be killed mid-flight by the browser; the
|
|
3945
3963
|
// queued copy survives and is replayed by Background Sync. If the direct
|
|
3946
3964
|
// write wins, the replay hits a draft version conflict and is dropped.
|
|
3947
|
-
if (!appSharedMode)
|
|
3965
|
+
if (!appSharedMode) {
|
|
3966
|
+
void enqueueDraftFlushForBackgroundSync(currentConvId, {
|
|
3967
|
+
afterPendingSend: isConversationDraftHeldBySend(currentConvId),
|
|
3968
|
+
});
|
|
3969
|
+
}
|
|
3948
3970
|
void flushConversationDraft(currentConvId);
|
|
3949
3971
|
closeTmuxInspectorView();
|
|
3950
3972
|
});
|
|
@@ -4164,6 +4186,7 @@ async function initApp() {
|
|
|
4164
4186
|
initEmojiPicker();
|
|
4165
4187
|
initSlashAutocomplete();
|
|
4166
4188
|
initConversationListLazyLoading();
|
|
4189
|
+
initConversationFilter();
|
|
4167
4190
|
initConversationHistoryLazyLoading();
|
|
4168
4191
|
initBubbleActionHandlers();
|
|
4169
4192
|
initMessageScrollPersistence();
|
|
@@ -4358,14 +4381,17 @@ window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
|
|
|
4358
4381
|
window.toggleEmojiPicker = toggleEmojiPicker;
|
|
4359
4382
|
window.submitRelayQuestionChoice = submitRelayQuestionChoice;
|
|
4360
4383
|
window.submitRelayQuestionAnswer = submitRelayQuestionAnswer;
|
|
4384
|
+
window.submitRelayQuestionMultiSelect = submitRelayQuestionMultiSelect;
|
|
4361
4385
|
window.submitRelayStructuredAnswer = submitRelayStructuredAnswer;
|
|
4362
4386
|
window.onRelayQuestionDraftInput = onRelayQuestionDraftInput;
|
|
4387
|
+
window.onRelayQuestionMultiSelectChange = onRelayQuestionMultiSelectChange;
|
|
4388
|
+
window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
|
|
4363
4389
|
window.handleRelayQuestionKey = handleRelayQuestionKey;
|
|
4364
4390
|
window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
|
|
4365
4391
|
window.submitRelayBoardAction = submitRelayBoardAction;
|
|
4366
4392
|
window.compactCurrentConversation = compactCurrentConversation;
|
|
4367
4393
|
window.sendMessage = sendMessage;
|
|
4368
|
-
window.
|
|
4394
|
+
window.syncComposerButtonState = syncComposerButtonState;
|
|
4369
4395
|
window.syncComposerPlaceholder = syncComposerPlaceholder;
|
|
4370
4396
|
window.persistComposerAttachments = persistComposerAttachments;
|
|
4371
4397
|
window.appendMessage = appendMessage;
|
|
@@ -19,19 +19,51 @@ export function hasUploadingAttachments(attachments = []) {
|
|
|
19
19
|
return countUploadingAttachments(attachments) > 0;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// Why a message sent right now queues instead of steering — worker-reported
|
|
23
|
+
// hold reasons mapped to a human title. Stopping a turn lives on the message
|
|
24
|
+
// bubbles, never here, so every state below is a Send/Steer/Queue shape.
|
|
25
|
+
const STEERING_HOLD_TITLES = Object.freeze({
|
|
26
|
+
question: 'Will steer in after you answer the question',
|
|
27
|
+
compaction: 'Will steer in once the conversation finishes compacting',
|
|
28
|
+
adoption: 'Will steer in once the compaction recovery finishes',
|
|
29
|
+
delivery: 'Will steer in once the current delivery lands',
|
|
30
|
+
});
|
|
31
|
+
|
|
22
32
|
export function deriveComposerControlState({
|
|
23
33
|
hasActiveTurn = false,
|
|
24
|
-
cancelRequested = false,
|
|
25
34
|
hasDraft = false,
|
|
26
35
|
sendInFlight = false,
|
|
27
36
|
modelMetadataBlocked = false,
|
|
28
37
|
attachmentsUploading = false,
|
|
38
|
+
// The conversation's session worker delivers a message typed during a live
|
|
39
|
+
// turn INTO that turn (mid-turn steering, advertised on its heartbeat
|
|
40
|
+
// snapshot) rather than queueing it behind — so the control says "Steer",
|
|
41
|
+
// not "Queue". Workers that do not advertise it keep the queue wording.
|
|
42
|
+
steeringSupported = false,
|
|
43
|
+
// The worker reports steering as momentarily held (open question card or
|
|
44
|
+
// plan approval, compaction, post-compaction adoption). A send stays allowed
|
|
45
|
+
// and reads "Queue": the relay holds the message and it steers in once the
|
|
46
|
+
// hold clears.
|
|
47
|
+
steeringHeld = false,
|
|
48
|
+
steeringHoldReason = null,
|
|
29
49
|
} = {}) {
|
|
30
50
|
const active = !!hasActiveTurn;
|
|
31
|
-
const stopping = !!cancelRequested;
|
|
32
51
|
const draft = !!hasDraft;
|
|
33
52
|
const metadataBlocked = !!modelMetadataBlocked;
|
|
34
53
|
const uploading = !!attachmentsUploading;
|
|
54
|
+
const held = active && !!steeringSupported && !!steeringHeld;
|
|
55
|
+
// The label/title/action for a draft typed during a live turn: steer into it
|
|
56
|
+
// where the provider supports that (queued while steering is held), queue
|
|
57
|
+
// behind it otherwise.
|
|
58
|
+
const steerNow = !!steeringSupported && !held;
|
|
59
|
+
const midTurnAction = steerNow ? 'steer' : 'queue';
|
|
60
|
+
const midTurnLabel = steerNow ? 'Steer' : 'Queue';
|
|
61
|
+
const midTurnTitle = held
|
|
62
|
+
? (STEERING_HOLD_TITLES[String(steeringHoldReason || '').trim()]
|
|
63
|
+
|| 'Will steer in once steering resumes')
|
|
64
|
+
: (steeringSupported
|
|
65
|
+
? 'Steer message into the running turn'
|
|
66
|
+
: 'Queue message behind current turn');
|
|
35
67
|
|
|
36
68
|
if (metadataBlocked && !active) {
|
|
37
69
|
return {
|
|
@@ -42,59 +74,45 @@ export function deriveComposerControlState({
|
|
|
42
74
|
};
|
|
43
75
|
}
|
|
44
76
|
|
|
45
|
-
// Uploads are eager, so the blocking window is short.
|
|
46
|
-
//
|
|
47
|
-
// flipping meaning mid-upload while a turn is running.
|
|
77
|
+
// Uploads are eager, so the blocking window is short. The control keeps its
|
|
78
|
+
// Send/Steer/Queue meaning while it waits.
|
|
48
79
|
if (uploading) {
|
|
49
|
-
const
|
|
80
|
+
const midTurn = active && draft;
|
|
50
81
|
return {
|
|
51
|
-
action:
|
|
52
|
-
label:
|
|
82
|
+
action: midTurn ? midTurnAction : 'send',
|
|
83
|
+
label: midTurn ? midTurnLabel : 'Send',
|
|
53
84
|
title: 'Waiting for attachments to finish uploading',
|
|
54
85
|
disabled: true,
|
|
55
86
|
};
|
|
56
87
|
}
|
|
57
88
|
|
|
58
89
|
if (sendInFlight) {
|
|
59
|
-
if (active && draft) {
|
|
60
|
-
return {
|
|
61
|
-
action: 'queue',
|
|
62
|
-
label: 'Queue',
|
|
63
|
-
title: 'Queue message behind current turn',
|
|
64
|
-
disabled: true,
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
if (active) {
|
|
68
|
-
return {
|
|
69
|
-
action: 'stop',
|
|
70
|
-
label: stopping ? 'Stopping…' : 'Stop',
|
|
71
|
-
title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
|
|
72
|
-
disabled: true,
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
90
|
return {
|
|
76
|
-
action: 'send',
|
|
77
|
-
label: 'Send',
|
|
78
|
-
title: 'Send message',
|
|
91
|
+
action: active && draft ? midTurnAction : 'send',
|
|
92
|
+
label: active && draft ? midTurnLabel : 'Send',
|
|
93
|
+
title: active && draft ? midTurnTitle : 'Send message',
|
|
79
94
|
disabled: true,
|
|
80
95
|
};
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
if (active && draft) {
|
|
84
99
|
return {
|
|
85
|
-
action:
|
|
86
|
-
label:
|
|
87
|
-
title:
|
|
100
|
+
action: midTurnAction,
|
|
101
|
+
label: midTurnLabel,
|
|
102
|
+
title: midTurnTitle,
|
|
88
103
|
disabled: false,
|
|
89
104
|
};
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
if (active) {
|
|
108
|
+
// Empty composer during a live turn: nothing to send, nothing to steer.
|
|
109
|
+
// The label stays "Send" (it only reads "Steer" with text present) and
|
|
110
|
+
// re-enables the moment the user types or the turn ends.
|
|
93
111
|
return {
|
|
94
|
-
action: '
|
|
95
|
-
label:
|
|
96
|
-
title:
|
|
97
|
-
disabled:
|
|
112
|
+
action: 'send',
|
|
113
|
+
label: 'Send',
|
|
114
|
+
title: 'Type a message to send',
|
|
115
|
+
disabled: true,
|
|
98
116
|
};
|
|
99
117
|
}
|
|
100
118
|
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Per-conversation record of the server draft this client's composer last
|
|
2
|
+
// agreed with. The composer text is local state that changes on every
|
|
3
|
+
// keystroke; this record changes only when the server acknowledges a save or
|
|
4
|
+
// the composer adopts a remote draft. Its updatedAt is the base version every
|
|
5
|
+
// draft save is checked against, so it must never be touched by a local edit.
|
|
6
|
+
|
|
7
|
+
import { serializeDraftAttachments } from './composer-attachment-cache.mjs';
|
|
8
|
+
import { normalizeDraftTimestampMs } from './conversation-draft-timestamp-utils.mjs';
|
|
9
|
+
|
|
10
|
+
// Sent with every draft save. It is what lets the server conflict-check an
|
|
11
|
+
// explicit null base: clients from before this protocol also send null (JSON
|
|
12
|
+
// keeps it), and for them null has to keep meaning "unconditional".
|
|
13
|
+
export const DRAFT_SYNC_PROTOCOL_VERSION = 2;
|
|
14
|
+
|
|
15
|
+
// Must equal the server's MAX_CONVERSATION_DRAFT_LENGTH (a test pins it). The
|
|
16
|
+
// server truncates longer drafts, so the client saves and compares only this
|
|
17
|
+
// prefix; otherwise a long composer never matches its acknowledged draft and
|
|
18
|
+
// is re-saved on every refresh. The composer itself (and the message) is not
|
|
19
|
+
// limited — only what survives as a draft.
|
|
20
|
+
export const MAX_DRAFT_TEXT_LENGTH = 20_000;
|
|
21
|
+
|
|
22
|
+
// Cuts at a code-point boundary, exactly like the server: a cut through a
|
|
23
|
+
// surrogate pair would store a lone surrogate, which SQLite hands back as
|
|
24
|
+
// U+FFFD — a different text at the same version.
|
|
25
|
+
export function draftTextForSync(text) {
|
|
26
|
+
const value = String(text || '');
|
|
27
|
+
if (value.length <= MAX_DRAFT_TEXT_LENGTH) return value;
|
|
28
|
+
const cut = value.slice(0, MAX_DRAFT_TEXT_LENGTH);
|
|
29
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
30
|
+
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const syncedDraftsByConversation = new Map();
|
|
34
|
+
|
|
35
|
+
function conversationKey(conversationId) {
|
|
36
|
+
return String(conversationId || '').trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Order-sensitive fingerprint of a draft attachment list; null when absent. */
|
|
40
|
+
export function draftAttachmentsKey(attachments) {
|
|
41
|
+
if (attachments === undefined || attachments === null) return null;
|
|
42
|
+
return serializeDraftAttachments(Array.isArray(attachments) ? attachments : [], { max: Number.MAX_SAFE_INTEGER })
|
|
43
|
+
.map((row) => row.sha256)
|
|
44
|
+
.join(',');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getSyncedDraft(conversationId) {
|
|
48
|
+
return syncedDraftsByConversation.get(conversationKey(conversationId)) || null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function recordSyncedDraft(conversationId, {
|
|
52
|
+
text = '',
|
|
53
|
+
attachments = undefined,
|
|
54
|
+
updatedAt = null,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const id = conversationKey(conversationId);
|
|
57
|
+
if (!id) return null;
|
|
58
|
+
const previous = syncedDraftsByConversation.get(id) || null;
|
|
59
|
+
const incomingKey = draftAttachmentsKey(attachments);
|
|
60
|
+
const record = {
|
|
61
|
+
text: String(text || ''),
|
|
62
|
+
attachmentsKey: incomingKey === null ? (previous?.attachmentsKey ?? null) : incomingKey,
|
|
63
|
+
updatedAt: updatedAt || null,
|
|
64
|
+
};
|
|
65
|
+
syncedDraftsByConversation.set(id, record);
|
|
66
|
+
return record;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function forgetSyncedDraft(conversationId) {
|
|
70
|
+
syncedDraftsByConversation.delete(conversationKey(conversationId));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A save is a no-op when the server already holds exactly this text (and,
|
|
75
|
+
* for saves that carry attachments, exactly these attachments) — but only
|
|
76
|
+
* while no newer server version is known. If one is (a remote draft deferred
|
|
77
|
+
* while the user typed), the save must still go out so its version check
|
|
78
|
+
* surfaces that draft instead of silently keeping a stale base.
|
|
79
|
+
*/
|
|
80
|
+
export function isDraftSaveNoop({ synced = null, text = '', attachmentsKey = null, knownUpdatedAt = null } = {}) {
|
|
81
|
+
if (!synced) return false;
|
|
82
|
+
if (normalizeDraftTimestampMs(knownUpdatedAt) > normalizeDraftTimestampMs(synced.updatedAt)) return false;
|
|
83
|
+
if (String(text || '') !== synced.text) return false;
|
|
84
|
+
if (attachmentsKey === null) return true;
|
|
85
|
+
return synced.attachmentsKey !== null && attachmentsKey === synced.attachmentsKey;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Decides a 409 draft conflict.
|
|
90
|
+
* - converged: the server already holds what we wanted to save.
|
|
91
|
+
* - adopt: the local text is unchanged since the last sync, so the remote
|
|
92
|
+
* draft simply wins.
|
|
93
|
+
* - keep-local: both sides edited; the local (newest) keystroke wins and the
|
|
94
|
+
* remote text becomes the one offered for restore.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveDraftConflict({
|
|
97
|
+
localText = '',
|
|
98
|
+
localAttachmentsKey = null,
|
|
99
|
+
baseText = null,
|
|
100
|
+
baseAttachmentsKey = null,
|
|
101
|
+
serverText = '',
|
|
102
|
+
serverAttachmentsKey = null,
|
|
103
|
+
} = {}) {
|
|
104
|
+
const local = String(localText || '');
|
|
105
|
+
const attachmentsMatch = (other) => localAttachmentsKey === null || localAttachmentsKey === other;
|
|
106
|
+
if (local === String(serverText || '') && attachmentsMatch(serverAttachmentsKey)) return 'converged';
|
|
107
|
+
if (baseText !== null && local === baseText && attachmentsMatch(baseAttachmentsKey)) return 'adopt';
|
|
108
|
+
return 'keep-local';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Whether an incoming remote draft may replace the composer of the
|
|
113
|
+
* conversation on screen. Only an unmodified composer (text and attachments
|
|
114
|
+
* still equal to the last synced draft) adopts it, focused or not. A modified
|
|
115
|
+
* one never does — not even for an equal or newer version: its edit is unsaved
|
|
116
|
+
* (a failed flush leaves exactly this state), and the caller re-saves it so the
|
|
117
|
+
* version check decides. An upload still in flight counts as a modification.
|
|
118
|
+
* With nothing synced yet there is no saved edit to protect.
|
|
119
|
+
* Attachment keys are null when unknown and then do not take part.
|
|
120
|
+
*/
|
|
121
|
+
export function shouldApplyIncomingDraftToComposer({
|
|
122
|
+
inputText = '',
|
|
123
|
+
incomingText = '',
|
|
124
|
+
syncedText = null,
|
|
125
|
+
inputAttachmentsKey = null,
|
|
126
|
+
incomingAttachmentsKey = null,
|
|
127
|
+
syncedAttachmentsKey = null,
|
|
128
|
+
attachmentsUploading = false,
|
|
129
|
+
} = {}) {
|
|
130
|
+
if (attachmentsUploading) return false;
|
|
131
|
+
const current = draftTextForSync(inputText);
|
|
132
|
+
const sameAttachments = (other) => inputAttachmentsKey === null || other === null || inputAttachmentsKey === other;
|
|
133
|
+
if (current === String(incomingText || '') && sameAttachments(incomingAttachmentsKey)) return true;
|
|
134
|
+
if (syncedText === null) return true;
|
|
135
|
+
return current === syncedText && sameAttachments(syncedAttachmentsKey);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* PATCH body for the pagehide Background Sync copy of a draft flush, or null
|
|
140
|
+
* when the composer holds nothing unsaved. Compared against the synced draft,
|
|
141
|
+
* not the local text (which every keystroke updates).
|
|
142
|
+
*/
|
|
143
|
+
export function draftFlushRequestBody({
|
|
144
|
+
inputText = '',
|
|
145
|
+
synced = null,
|
|
146
|
+
fallbackText = '',
|
|
147
|
+
fallbackUpdatedAt = null,
|
|
148
|
+
clientId = null,
|
|
149
|
+
afterPendingSend = false,
|
|
150
|
+
} = {}) {
|
|
151
|
+
const draftText = draftTextForSync(inputText);
|
|
152
|
+
const serverText = synced ? synced.text : draftTextForSync(fallbackText);
|
|
153
|
+
if (draftText === serverText) return null;
|
|
154
|
+
if (afterPendingSend) {
|
|
155
|
+
// An emptied composer after a send is what the send leaves anyway.
|
|
156
|
+
if (!draftText.trim()) return null;
|
|
157
|
+
// The send will move the server's version past this base. acceptOwnSend
|
|
158
|
+
// lets the replay through only while that move is this client's own (the
|
|
159
|
+
// send transaction records the sender); anyone else's newer draft still
|
|
160
|
+
// wins with a 409.
|
|
161
|
+
return {
|
|
162
|
+
draftText,
|
|
163
|
+
clientId,
|
|
164
|
+
draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
|
|
165
|
+
baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
|
|
166
|
+
acceptOwnSend: true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
draftText,
|
|
171
|
+
clientId,
|
|
172
|
+
draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
|
|
173
|
+
baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// A queued draft flush replayed later than this (Firefox/Safari replay the
|
|
178
|
+
// outbox only on the next page load, possibly hours on) would write text
|
|
179
|
+
// nobody is looking at over whatever the draft became meanwhile. sw.js
|
|
180
|
+
// mirrors this value; a test pins them together.
|
|
181
|
+
export const DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
|
|
182
|
+
|
|
183
|
+
export function isStaleDraftFlushEntry(entry, now = Date.now()) {
|
|
184
|
+
if (String(entry?.kind || '') !== 'draft-flush') return false;
|
|
185
|
+
const createdAt = Number(entry?.createdAt || 0);
|
|
186
|
+
return !Number.isFinite(createdAt) || createdAt <= 0 || now - createdAt > DRAFT_FLUSH_MAX_AGE_MS;
|
|
187
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Title filtering for the conversation sidebar. Kept DOM-free so it can be unit
|
|
2
|
+
// tested; journal-view.js touches window/document at module scope and cannot be
|
|
3
|
+
// imported from node:test.
|
|
4
|
+
|
|
5
|
+
// NFC first so a decomposed "é" (e + U+0301, as some IMEs and pasted text
|
|
6
|
+
// produce) matches a precomposed one. Case folding is toLocaleLowerCase only, so
|
|
7
|
+
// multi-char folds are a known limit: "ß" does not match "SS".
|
|
8
|
+
function foldForMatch(text) {
|
|
9
|
+
return String(text ?? '').normalize('NFC').toLocaleLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function normalizeConversationFilter(text) {
|
|
13
|
+
return foldForMatch(text).trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function conversationMatchesFilter(conversation, normalizedFilter) {
|
|
17
|
+
if (!normalizedFilter) return true;
|
|
18
|
+
return foldForMatch(conversation?.title).includes(normalizedFilter);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function filterConversations(conversations, filterText) {
|
|
22
|
+
const list = Array.isArray(conversations) ? conversations : [];
|
|
23
|
+
const normalized = normalizeConversationFilter(filterText);
|
|
24
|
+
if (!normalized) return list;
|
|
25
|
+
return list.filter((conversation) => conversationMatchesFilter(conversation, normalized));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function describeFilterMatchCount(count, { loadedOnly = false } = {}) {
|
|
29
|
+
const n = Math.max(0, Number(count) || 0);
|
|
30
|
+
const noun = loadedOnly ? 'loaded conversation' : 'conversation';
|
|
31
|
+
if (n === 0) return `No ${noun}s match`;
|
|
32
|
+
return n === 1 ? `1 ${noun} matches` : `${n} ${noun}s match`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cursorKey(state) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(state?.nextCursor ?? null);
|
|
38
|
+
} catch {
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// While a filter is active the sidebar has to search conversations that only
|
|
44
|
+
// exist on unloaded pages, so pull every remaining page through the loader.
|
|
45
|
+
// shouldContinue() is checked before each page so clearing the filter (or
|
|
46
|
+
// switching it) aborts the drain. loadMore() resolving falsy is ambiguous:
|
|
47
|
+
// - the loader is busy (a boundary-check load/prefetch is in flight): wait it
|
|
48
|
+
// out without spending idle retries, so the drain does not give up while the
|
|
49
|
+
// list is still loading. busyRetries caps a genuinely stuck fetch.
|
|
50
|
+
// - a reset bumped the loader version mid-fetch, or the fetch failed: retry,
|
|
51
|
+
// but stop after maxIdleRetries consecutive no-progress attempts so a failing
|
|
52
|
+
// endpoint is not hammered forever.
|
|
53
|
+
// An advanced cursor counts as progress even when the page was empty.
|
|
54
|
+
export async function drainRemainingPages(loader, shouldContinue = () => true, {
|
|
55
|
+
retryDelayMs = 200,
|
|
56
|
+
maxIdleRetries = 10,
|
|
57
|
+
maxBusyRetries = 300,
|
|
58
|
+
delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (!loader || typeof loader.loadMore !== 'function' || typeof loader.getState !== 'function') {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
let loadedAnything = false;
|
|
64
|
+
let idleRetries = 0;
|
|
65
|
+
let busyRetries = 0;
|
|
66
|
+
while (loader.getState().hasMore && shouldContinue()) {
|
|
67
|
+
const cursorBefore = cursorKey(loader.getState());
|
|
68
|
+
const loaded = await loader.loadMore();
|
|
69
|
+
const state = loader.getState();
|
|
70
|
+
if (loaded) loadedAnything = true;
|
|
71
|
+
if (loaded || cursorKey(state) !== cursorBefore) {
|
|
72
|
+
idleRetries = 0;
|
|
73
|
+
busyRetries = 0;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (state.isLoading || state.isPrefetching) {
|
|
77
|
+
busyRetries += 1;
|
|
78
|
+
if (busyRetries > maxBusyRetries) break;
|
|
79
|
+
} else {
|
|
80
|
+
idleRetries += 1;
|
|
81
|
+
if (idleRetries > maxIdleRetries) break;
|
|
82
|
+
}
|
|
83
|
+
await delay(retryDelayMs);
|
|
84
|
+
}
|
|
85
|
+
return loadedAnything;
|
|
86
|
+
}
|
|
@@ -35,6 +35,14 @@ export function conversationProviderIndicatorLabel(conversation = null) {
|
|
|
35
35
|
return '';
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// A question card names the runtime that asks, the way the list pill names it.
|
|
39
|
+
// Unresolved reads plain "Question": every card used to say "Copilot question",
|
|
40
|
+
// Claude's and Cursor's included.
|
|
41
|
+
export function questionCardHeadLabel(conversation = null) {
|
|
42
|
+
const provider = conversationProviderIndicatorLabel(conversation);
|
|
43
|
+
return provider ? `${provider} question` : 'Question';
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
const SESSION_LOCK_LABELS = {
|
|
39
47
|
github: 'GitHub Copilot',
|
|
40
48
|
openai: 'OpenAI',
|