@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
@@ -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
- function formatTaskModel(task) {
73
- const model = String(task?.model || '').trim();
74
- if (!model) return '';
75
- const short = model.replace(/^claude-/, '');
76
- return task?.modelInherited === true ? `${short} (inherited)` : short;
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 = String(agent.model ?? '').trim().replace(/^claude-/, '');
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
- // Second line: progress first ("using Bash" prefixed so a bare tool name
368
- // can't read as the task's kind), then model. Token usage renders as its
369
- // own always-visible element on the row's right side instead of riding
370
- // this clampable line, so a long summary can never crowd it out.
371
- const summary = String(task.summary || '').trim();
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
- ${detail ? `<span class="bg-task-detail">${escHtml(detail)}</span>` : ''}
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-badge">${escHtml(taskKindLabel(task))}</span>
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
- syncComposerControlState,
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.syncComposerControlState?.();
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
- const update = () => syncViewportMetrics();
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
- syncComposerControlState();
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) void enqueueDraftFlushForBackgroundSync(currentConvId);
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.syncComposerControlState = syncComposerControlState;
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. Keeping the button
46
- // labelled Send/Queue (rather than switching to Stop) avoids the control
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 queueing = active && draft;
80
+ const midTurn = active && draft;
50
81
  return {
51
- action: queueing ? 'queue' : 'send',
52
- label: queueing ? 'Queue' : 'Send',
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: 'queue',
86
- label: 'Queue',
87
- title: 'Queue message behind current turn',
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: 'stop',
95
- label: stopping ? 'Stopping…' : 'Stop',
96
- title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
97
- disabled: stopping,
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',