@oar-sh/oar 0.9.1 → 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 (146) hide show
  1. package/.github/extensions/web-relay/extension.mjs +0 -3
  2. package/.github/extensions/web-relay/polling/heartbeat.mjs +2 -55
  3. package/.github/extensions/web-relay/polling/polling-loop.mjs +0 -1
  4. package/.github/extensions/web-relay/runtime/api-client.mjs +2 -34
  5. package/.github/extensions/web-relay/runtime/config-loader.mjs +2 -104
  6. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +2 -170
  7. package/.github/extensions/web-relay/runtime/session-io.mjs +1 -71
  8. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +2 -325
  9. package/.github/extensions/web-relay/skills/prompt-context.mjs +2 -160
  10. package/CHANGELOG.md +269 -0
  11. package/README.md +407 -424
  12. package/bin/oar.js +142 -11
  13. package/package.json +8 -4
  14. package/server/README.md +59 -22
  15. package/server/claude-worker/claude-sdk-adapter.mjs +7 -2
  16. package/server/claude-worker/claude-session-process.mjs +1013 -45
  17. package/server/claude-worker/claude-session-worker.mjs +54 -9
  18. package/server/claude-worker/claude-turn-publisher.mjs +110 -19
  19. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  20. package/server/copilot-sdk-runtime.mjs +12 -3
  21. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  22. package/server/copilot-worker/copilot-attachments.mjs +35 -2
  23. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  24. package/server/copilot-worker/copilot-model-switch.mjs +425 -0
  25. package/server/copilot-worker/copilot-prompt-context.mjs +20 -8
  26. package/server/copilot-worker/copilot-question-bridge.mjs +129 -16
  27. package/server/copilot-worker/copilot-sdk-adapter.mjs +59 -11
  28. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +136 -12
  29. package/server/copilot-worker/copilot-sdk-session-process.mjs +2753 -527
  30. package/server/copilot-worker/copilot-sdk-session-worker.mjs +94 -25
  31. package/server/cursor-worker/cursor-session-worker.mjs +5 -5
  32. package/server/cursor-worker/cursor-turn-runner.mjs +50 -4
  33. package/server/db-schema.mjs +61 -1
  34. package/server/features.mjs +155 -41
  35. package/server/grok-worker/grok-session-worker.mjs +5 -5
  36. package/server/grok-worker/grok-turn-runner.mjs +45 -4
  37. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  38. package/server/public/app/annotate-core.mjs +181 -0
  39. package/server/public/app/annotate-editor.js +617 -0
  40. package/server/public/app/api-client.js +86 -5
  41. package/server/public/app/ask-user-view.js +102 -13
  42. package/server/public/app/attachments-view.js +172 -3
  43. package/server/public/app/background-task-timeout-options.mjs +9 -0
  44. package/server/public/app/background-tasks-view.mjs +34 -19
  45. package/server/public/app/bootstrap.js +153 -69
  46. package/server/public/app/composer-control-state.mjs +52 -34
  47. package/server/public/app/context-tier-options.mjs +24 -4
  48. package/server/public/app/conversation-draft-sync.mjs +187 -0
  49. package/server/public/app/conversation-list-filter.mjs +86 -0
  50. package/server/public/app/conversation-preferences.mjs +47 -10
  51. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  52. package/server/public/app/conversation-view.js +1032 -218
  53. package/server/public/app/emoji-view.js +2 -0
  54. package/server/public/app/font-scaling.js +4 -37
  55. package/server/public/app/journal-view.js +190 -24
  56. package/server/public/app/model-selector-options.mjs +185 -1
  57. package/server/public/app/new-conversation-model-choice.mjs +29 -9
  58. package/server/public/app/pwa-install.js +64 -136
  59. package/server/public/app/question-multi-select.mjs +52 -0
  60. package/server/public/app/reasoning-effort-labels.mjs +13 -3
  61. package/server/public/app/relay-prompt-sanitizer.mjs +26 -3
  62. package/server/public/app/router.js +120 -11
  63. package/server/public/app/settings-modal.js +225 -20
  64. package/server/public/app/settings-tabs.js +1 -1
  65. package/server/public/app/socket-handlers.js +47 -4
  66. package/server/public/app/steered-turn-merge.mjs +100 -0
  67. package/server/public/app/store.js +159 -16
  68. package/server/public/app/sync-outbox.mjs +16 -8
  69. package/server/public/app/tool-activity-emoji.mjs +36 -0
  70. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  71. package/server/public/app/update-ui.js +194 -0
  72. package/server/public/index.html +483 -24
  73. package/server/public/sw.js +7 -0
  74. package/server/relay-tools.md +2 -0
  75. package/server/relay.mjs +23 -21
  76. package/server/repositories/message-repository.mjs +78 -21
  77. package/server/repositories/question-repository.mjs +19 -1
  78. package/server/repositories/session-repository.mjs +7 -31
  79. package/server/routes/ask-user-routes.mjs +10 -0
  80. package/server/routes/messages-routes.mjs +689 -159
  81. package/server/routes/sessions-routes.mjs +574 -80
  82. package/server/server-runtime.mjs +393 -925
  83. package/server/services/claude-auth-service.mjs +7 -1
  84. package/server/services/cli-install-service.mjs +8 -27
  85. package/server/services/cli-process-runner.mjs +30 -0
  86. package/server/services/copilot-model-discovery-service.mjs +202 -0
  87. package/server/services/data-dir-resolution.mjs +47 -0
  88. package/server/services/grok-auth-service.mjs +9 -1
  89. package/server/services/live-turn-picker.mjs +47 -0
  90. package/server/services/model-variant-catalog-service.mjs +1183 -0
  91. package/server/services/oar-cli-helpers.mjs +6 -2
  92. package/server/services/relay-prompt-sanitizer.mjs +30 -3
  93. package/server/services/relay-question-context.mjs +46 -0
  94. package/server/services/sdk-session-import-service.mjs +144 -16
  95. package/server/services/sdk-session-sync-service.mjs +84 -27
  96. package/server/services/session-worker-registry-service.mjs +116 -3
  97. package/server/services/session-worker-supervisor-service.mjs +7 -1
  98. package/server/services/session-worker-websocket-service.mjs +119 -24
  99. package/server/services/status-event-service.mjs +8 -2
  100. package/server/services/steer-resend.mjs +45 -0
  101. package/server/services/update-check-service.mjs +220 -0
  102. package/server/services/update-install-service.mjs +279 -0
  103. package/server/services/windows-autostart-service.mjs +42 -6
  104. package/server/services/windows-boot-autostart-service.mjs +487 -0
  105. package/shared/ask-user-bridge.mjs +37 -5
  106. package/shared/background-task-timeout.mjs +12 -4
  107. package/shared/claude-model-catalog.mjs +49 -0
  108. package/shared/context-window-fallbacks.mjs +1 -0
  109. package/shared/control-poller.mjs +4 -1
  110. package/shared/copilot-model-order.mjs +182 -0
  111. package/shared/media-embed-instructions.mjs +35 -0
  112. package/shared/model-descriptors.mjs +181 -30
  113. package/shared/model-id.mjs +6 -0
  114. package/shared/pwa-app-name.mjs +44 -0
  115. package/shared/question-multi-select.mjs +16 -0
  116. package/shared/relay-stop-failure.mjs +15 -0
  117. package/shared/slash-command-guard.mjs +76 -0
  118. package/shared/steer-note.mjs +66 -0
  119. package/shared/steer-settle-failure.mjs +89 -0
  120. package/shared/steer-settle-markers.mjs +18 -0
  121. package/shared/update-semver.mjs +50 -0
  122. package/shared/worker-crash-guard.mjs +40 -14
  123. package/shared/worker-runtime/api-client.mjs +34 -0
  124. package/shared/worker-runtime/config-loader.mjs +104 -0
  125. package/shared/worker-runtime/heartbeat.mjs +109 -0
  126. package/shared/worker-runtime/prompt-context.mjs +160 -0
  127. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  128. package/shared/worker-runtime/send-and-wait-errors.mjs +170 -0
  129. package/shared/worker-runtime/worker-websocket-link.mjs +436 -0
  130. package/server/claude-worker/claude-session-test-harness.mjs +0 -236
  131. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  132. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -329
  133. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  134. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  135. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  136. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  137. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  138. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  139. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  140. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  141. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  142. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  143. package/server/routes/messages-routes-test-harness.mjs +0 -111
  144. package/server/services/fixtures/claude-auth-stub.sh +0 -62
  145. package/server/services/fixtures/cli-install-stub.sh +0 -75
  146. package/server/services/fixtures/grok-stub.sh +0 -86
@@ -1,4 +1,4 @@
1
- import { BASE, TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform } from './store.js';
1
+ import { BASE, TOKEN, authHeaders, updateWorkspaceRootHints, applyContextUsageBar, readContextUsageRatio, currentConvId, conversations, setCliOnline, setCloudflaredTunnelState, setActiveRuntimeSessionCount, setRuntimeSessionBindingCount, setContextIndicatorMode, setServerPlatform, setUpdateState } from './store.js';
2
2
 
3
3
  // fetch() has no default timeout. Without one, a request issued while the app is
4
4
  // being backgrounded can hang indefinitely on a half-open mobile connection and
@@ -93,6 +93,7 @@ export async function verifyExistingSession(tokenCandidate = '') {
93
93
  setActiveRuntimeSessionCount(payload?.activeRuntimeSessionCount);
94
94
  setRuntimeSessionBindingCount(payload?.runtimeSessionBindingCount);
95
95
  if (payload?.platform) setServerPlatform(payload.platform);
96
+ setUpdateState(payload?.update || null);
96
97
  }
97
98
  if (response.ok) noteFetchSuccess();
98
99
  return {
@@ -150,6 +151,7 @@ export async function verifyToken(token) {
150
151
  setActiveRuntimeSessionCount(payload?.activeRuntimeSessionCount);
151
152
  setRuntimeSessionBindingCount(payload?.runtimeSessionBindingCount);
152
153
  if (payload?.platform) setServerPlatform(payload.platform);
154
+ setUpdateState(payload?.update || null);
153
155
  }
154
156
  if (response.ok) noteFetchSuccess();
155
157
  return {
@@ -179,6 +181,7 @@ export async function refreshWorkspaceRootHints() {
179
181
  setActiveRuntimeSessionCount(status?.activeRuntimeSessionCount);
180
182
  setRuntimeSessionBindingCount(status?.runtimeSessionBindingCount);
181
183
  if (status?.platform) setServerPlatform(status.platform);
184
+ setUpdateState(status?.update || null);
182
185
  }
183
186
  return status;
184
187
  }
@@ -411,10 +414,13 @@ export async function loadWindowsAutostartSetting() {
411
414
  return apiFetch('/api/settings/windows-autostart');
412
415
  }
413
416
 
414
- export async function updateWindowsAutostartSetting(enabled) {
417
+ export async function updateWindowsAutostartSetting(mode) {
418
+ // Mode string ('off' | 'signin' | 'boot'); a boolean still works for the
419
+ // pre-mode call sites and maps onto signin/off server-side.
420
+ const body = typeof mode === 'boolean' ? { enabled: mode } : { mode: String(mode) };
415
421
  return apiFetch('/api/settings/windows-autostart', {
416
422
  method: 'POST',
417
- body: JSON.stringify({ enabled: !!enabled }),
423
+ body: JSON.stringify(body),
418
424
  });
419
425
  }
420
426
 
@@ -440,6 +446,61 @@ export async function updateBackgroundTaskTimeoutSetting(timeoutMinutes) {
440
446
  });
441
447
  }
442
448
 
449
+ export async function loadPwaAppNameSetting() {
450
+ return apiFetch('/api/settings/pwa-app-name');
451
+ }
452
+
453
+ export async function updatePwaAppNameSetting(appName) {
454
+ return apiFetch('/api/settings/pwa-app-name', {
455
+ method: 'POST',
456
+ body: JSON.stringify({ appName: String(appName ?? '') }),
457
+ });
458
+ }
459
+
460
+ export async function loadUpdateState() {
461
+ return apiFetch('/api/update/state');
462
+ }
463
+
464
+ export async function setUpdateAutoCheck(enabled) {
465
+ return apiFetch('/api/update/auto-check', {
466
+ method: 'POST',
467
+ body: JSON.stringify({ enabled: enabled === true }),
468
+ });
469
+ }
470
+
471
+ export async function checkForUpdateNow() {
472
+ return apiFetch('/api/update/check', { method: 'POST' });
473
+ }
474
+
475
+ export async function dismissUpdate(body) {
476
+ return apiFetch('/api/update/dismiss', {
477
+ method: 'POST',
478
+ body: JSON.stringify(body || {}),
479
+ });
480
+ }
481
+
482
+ export async function applyUpdate(version) {
483
+ return apiFetch('/api/update/apply', {
484
+ method: 'POST',
485
+ body: JSON.stringify({ version: String(version || '') }),
486
+ });
487
+ }
488
+
489
+ export async function cancelUpdate() {
490
+ return apiFetch('/api/update/cancel', { method: 'POST' });
491
+ }
492
+
493
+ export async function loadFeatureFlagSettings() {
494
+ return apiFetch('/api/settings/features');
495
+ }
496
+
497
+ export async function updateFeatureFlagSetting(name, enabled) {
498
+ return apiFetch('/api/settings/features', {
499
+ method: 'POST',
500
+ body: JSON.stringify({ name: String(name || ''), enabled: enabled === true }),
501
+ });
502
+ }
503
+
443
504
  export async function launchSessionWorker(sdkSessionId) {
444
505
  const sessionId = String(sdkSessionId || '').trim();
445
506
  if (!sessionId) return null;
@@ -594,10 +655,30 @@ export async function searchMessages(options = {}) {
594
655
  return apiFetch(`/api/search/messages?${params.toString()}`);
595
656
  }
596
657
 
658
+ // Raw fetch instead of apiFetch: a refused delete (the conversation is still
659
+ // working, or its session would not stop) must surface the server's reason.
660
+ // The long timeout covers stopping the session and deleting its CLI session.
597
661
  export async function deleteConversation(id) {
598
662
  const convId = String(id || '').trim();
599
- if (!convId) return null;
600
- return apiFetch(`/api/conversation/${convId}`, { method: 'DELETE' });
663
+ if (!convId || !areNetworkRequestsEnabled()) return null;
664
+ const url = `/api/conversation/${encodeURIComponent(convId)}`;
665
+ try {
666
+ const response = await fetch(`${BASE}${url}`, {
667
+ signal: requestTimeoutSignal(LONG_REQUEST_TIMEOUT_MS),
668
+ method: 'DELETE',
669
+ headers: {
670
+ 'Content-Type': 'application/json',
671
+ ...authHeaders(),
672
+ },
673
+ });
674
+ const payload = await response.json().catch(() => null);
675
+ if (!response.ok) return { ...(payload || {}), ok: false, status: response.status };
676
+ noteFetchSuccess();
677
+ return payload || { ok: true };
678
+ } catch (error) {
679
+ noteFetchFailure(url, error);
680
+ return null;
681
+ }
601
682
  }
602
683
 
603
684
  export async function updateMessageShareVisibility(conversationId, messageId, hiddenFromShares) {
@@ -10,11 +10,23 @@ import { loadRelayQuestions as loadRelayQuestionsApi, answerRelayQuestion, answe
10
10
  import { enqueueOutboxRequest, registerOutboxSync } from './sync-outbox.mjs';
11
11
  import { renderLinkedPlainText } from './router.js';
12
12
  import { schemaFieldsFromQuestion } from './question-schema-view.mjs';
13
+ import { composeMultiSelectAnswer, isMultiSelectQuestion, offersMultiSelectToggle } from './question-multi-select.mjs';
13
14
  import { isChatInteractionHeld } from './selection-guard.mjs';
15
+ import { questionCardHeadLabel } from './conversation-provider-indicator.mjs';
14
16
 
15
17
  let relayQuestionRenderHash = '';
16
18
  let renderedRelayQuestionIds = new Set();
17
19
  const relayQuestionStructuredDrafts = new Map();
20
+ // Ticked choices of a multi-select card (question id → Set of labels), kept
21
+ // across re-renders until the card is answered.
22
+ const relayQuestionMultiDrafts = new Map();
23
+ // The "Select several" switch position per card (question id → boolean) on
24
+ // cards that offer it (Copilot); absent = the provider's flag decides.
25
+ const relayQuestionMultiMode = new Map();
26
+
27
+ function multiModeFor(question) {
28
+ return question ? relayQuestionMultiMode.get(question.id) : undefined;
29
+ }
18
30
  const RELAY_QUESTION_AUTO_SCROLL_THRESHOLD_PX = 80;
19
31
 
20
32
  function distanceFromBottom(el) {
@@ -75,6 +87,8 @@ export function upsertRelayQuestion(question) {
75
87
  relayQuestions.set(question.id, question);
76
88
  updatePendingQuestionBanner();
77
89
  window.renderConvList?.();
90
+ // A question opening or settling flips the composer's steering hold.
91
+ window.syncComposerButtonState?.();
78
92
  renderRelayQuestions();
79
93
  }
80
94
 
@@ -98,10 +112,19 @@ export async function loadRelayQuestions(conversationId) {
98
112
  relayQuestionStructuredDrafts.delete(questionId);
99
113
  }
100
114
  }
115
+ // Multi-select ticks and the "Select several" switch position die with the
116
+ // card, whoever settled it (this device, another one, a timeout).
117
+ for (const draftMap of [relayQuestionMultiDrafts, relayQuestionMultiMode]) {
118
+ for (const questionId of draftMap.keys()) {
119
+ const question = next.get(questionId);
120
+ if (!question || question.status !== 'pending') draftMap.delete(questionId);
121
+ }
122
+ }
101
123
  relayQuestions.clear();
102
124
  for (const [id, q] of next.entries()) relayQuestions.set(id, q);
103
125
  updatePendingQuestionBanner();
104
126
  window.renderConvList?.();
127
+ window.syncComposerButtonState?.();
105
128
 
106
129
  if (!conversationId && pending.length) {
107
130
  const latest = pending
@@ -212,6 +235,7 @@ export function renderRelayQuestions() {
212
235
  answeredAt: q.answeredAt || '',
213
236
  createdAt: q.createdAt || '',
214
237
  choices: Array.isArray(q.choices) ? q.choices : [],
238
+ multiSelect: isMultiSelectQuestion(q, multiModeFor(q)),
215
239
  schema: q.requestSchema || null,
216
240
  }))
217
241
  );
@@ -249,24 +273,41 @@ export function renderRelayQuestions() {
249
273
  interactiveHtml = renderMultiFieldForm(question);
250
274
  } else {
251
275
  const choices = Array.isArray(question.choices) ? question.choices : [];
252
- const choiceHtml = choices.length
253
- ? (question.status === 'pending'
254
- ? `<div class="relay-question-choices">${
255
- choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
256
- }</div>`
257
- : `<div class="relay-question-choices">${
258
- choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
259
- }</div>`)
276
+ // A multi-select card (the provider said several may be picked) offers
277
+ // checkmarks and ONE Reply; ticking never answers by itself.
278
+ const multiSelect = isMultiSelectQuestion(question, multiModeFor(question));
279
+ const ticked = relayQuestionMultiDrafts.get(question.id) || new Set();
280
+ // Copilot cards: a switch between one-click answers and checkmarks,
281
+ // pre-set from the model's flag or the question's wording.
282
+ const toggleHtml = question.status === 'pending' && offersMultiSelectToggle(question)
283
+ ? `<label class="relay-question-multi-toggle"><input type="checkbox" data-relay-focus-key="multi-toggle"${multiSelect ? ' checked' : ''} onchange="setRelayQuestionMultiMode('${question.id}', this.checked)"> Select several</label>`
260
284
  : '';
285
+ let choiceHtml = '';
286
+ if (choices.length && question.status === 'pending' && multiSelect) {
287
+ choiceHtml = `<div class="relay-question-choices relay-question-multi" id="relay-question-multi-${question.id}">${
288
+ choices.map((choice) => `<label class="relay-question-choice relay-question-choice-check"><input type="checkbox" data-choice-value="${escHtml(choice)}" data-relay-focus-key="multi-${escHtml(choice)}"${ticked.has(choice) ? ' checked' : ''} onchange="onRelayQuestionMultiSelectChange('${question.id}', this.dataset.choiceValue, this.checked)"> ${escHtml(choice)}</label>`).join('')
289
+ }</div>`;
290
+ } else if (choices.length && question.status === 'pending') {
291
+ choiceHtml = `<div class="relay-question-choices">${
292
+ choices.map((choice) => `<button class="relay-question-choice" data-choice="${escHtml(choice)}" data-relay-focus-key="choice-${escHtml(choice)}" onclick="submitRelayQuestionChoice('${question.id}', this.dataset.choice)">${escHtml(choice)}</button>`).join('')
293
+ }</div>`;
294
+ } else if (choices.length) {
295
+ choiceHtml = `<div class="relay-question-choices">${
296
+ choices.map((choice) => `<button class="relay-question-choice" disabled>${escHtml(choice)}</button>`).join('')
297
+ }</div>`;
298
+ }
261
299
  const showReplyControls = question.status === 'pending';
262
300
  const draftText = String(relayQuestionDrafts.get(question.id) || '');
301
+ const replyPlaceholder = multiSelect
302
+ ? 'Add another answer or a note (optional)… (Ctrl/Cmd+Enter to send)'
303
+ : 'Type a reply… (Ctrl/Cmd+Enter to send)';
263
304
  const replyHtml = showReplyControls
264
305
  ? `<div class="relay-question-reply">
265
- <textarea id="relay-question-input-${question.id}" placeholder="Type a reply… (Ctrl/Cmd+Enter to send)" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
266
- <button class="relay-question-submit" onclick="submitRelayQuestionAnswer('${question.id}')">Reply</button>
306
+ <textarea id="relay-question-input-${question.id}" placeholder="${replyPlaceholder}" onkeydown="handleRelayQuestionKey(event, '${question.id}')" oninput="onRelayQuestionDraftInput('${question.id}', this.value)">${escHtml(draftText)}</textarea>
307
+ <button class="relay-question-submit" onclick="${multiSelect ? 'submitRelayQuestionMultiSelect' : 'submitRelayQuestionAnswer'}('${question.id}')">${multiSelect ? 'Reply with selection' : 'Reply'}</button>
267
308
  </div>`
268
309
  : '';
269
- interactiveHtml = `${choiceHtml}${replyHtml}`;
310
+ interactiveHtml = `${choiceHtml}${toggleHtml}${replyHtml}`;
270
311
  }
271
312
 
272
313
  const answeredHtml = question.status === 'answered' && question.answer
@@ -278,7 +319,7 @@ export function renderRelayQuestions() {
278
319
 
279
320
  wrapper.innerHTML = `
280
321
  <div class="relay-question-card${question.status === 'timed_out' ? ' relay-question-timed-out' : ''}">
281
- <div class="relay-question-head">Copilot question${modeTag} · ${fmtDate(question.createdAt)}</div>
322
+ <div class="relay-question-head">${questionCardHeadLabel(conversations[question.conversationId])}${modeTag} · ${fmtDate(question.createdAt)}</div>
282
323
  <div class="relay-question-body">${renderLinkedPlainText(question.prompt || '')}</div>
283
324
  ${contextHtml}
284
325
  ${answeredHtml}
@@ -467,6 +508,7 @@ export async function submitRelayStructuredAnswer(questionId) {
467
508
  if (r.question) relayQuestions.set(questionId, r.question);
468
509
  updatePendingQuestionBanner();
469
510
  window.renderConvList?.();
511
+ window.syncComposerButtonState?.();
470
512
  renderRelayQuestions();
471
513
  window.showTransientRelayNotice?.(`✅ Answer received · Agent continuing…`, 7000);
472
514
  } catch (e) {
@@ -480,6 +522,44 @@ export async function submitRelayQuestionChoice(questionId, choice) {
480
522
  await submitRelayQuestionAnswer(questionId, choice);
481
523
  }
482
524
 
525
+ /** Ticked labels of a multi-select card, in the card's own order. */
526
+ function tickedMultiSelectLabels(questionId) {
527
+ const container = document.getElementById(`relay-question-multi-${questionId}`);
528
+ if (!container) return [...(relayQuestionMultiDrafts.get(questionId) || [])];
529
+ return Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
530
+ .map((el) => String(el.getAttribute('data-choice-value') || ''));
531
+ }
532
+
533
+ /**
534
+ * The "Select several" switch: flip the card between one-click answers and
535
+ * checkmarks. Ticks made so far are kept, and the card re-renders in place.
536
+ */
537
+ export function setRelayQuestionMultiMode(questionId, enabled) {
538
+ const id = String(questionId || '');
539
+ if (!id) return;
540
+ relayQuestionMultiMode.set(id, enabled === true);
541
+ renderRelayQuestions();
542
+ }
543
+
544
+ export function onRelayQuestionMultiSelectChange(questionId, label, checked) {
545
+ const id = String(questionId || '');
546
+ const set = relayQuestionMultiDrafts.get(id) || new Set();
547
+ if (checked) set.add(String(label || ''));
548
+ else set.delete(String(label || ''));
549
+ relayQuestionMultiDrafts.set(id, set);
550
+ }
551
+
552
+ /**
553
+ * Answer a multi-select card: every ticked choice, plus whatever was typed as
554
+ * an extra answer, joined the way the provider's own UI would ("A, C").
555
+ */
556
+ export async function submitRelayQuestionMultiSelect(questionId) {
557
+ const input = document.getElementById(`relay-question-input-${questionId}`);
558
+ const answer = composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '');
559
+ if (!answer) return;
560
+ await submitRelayQuestionAnswer(questionId, answer);
561
+ }
562
+
483
563
  // Offline fallback: park the answer in the durable outbox so Background Sync
484
564
  // (or the next page load) delivers it. A replay against an already-answered
485
565
  // or expired question gets a 4xx and is dropped, so this cannot double-answer.
@@ -499,7 +579,13 @@ async function enqueueRelayAnswerForSync(questionId, body) {
499
579
 
500
580
  export async function submitRelayQuestionAnswer(questionId, presetAnswer = null) {
501
581
  const input = document.getElementById(`relay-question-input-${questionId}`);
502
- const answer = String(presetAnswer != null ? presetAnswer : (input?.value || '')).trim();
582
+ // Ctrl/Cmd+Enter in a multi-select card's text field sends the ticked
583
+ // choices along with the typed text, exactly like its Reply button.
584
+ const pendingQuestion = relayQuestions.get(questionId) || null;
585
+ const composed = presetAnswer == null && isMultiSelectQuestion(pendingQuestion, multiModeFor(pendingQuestion))
586
+ ? composeMultiSelectAnswer(tickedMultiSelectLabels(questionId), input?.value || '')
587
+ : null;
588
+ const answer = String(presetAnswer != null ? presetAnswer : (composed ?? (input?.value || ''))).trim();
503
589
  if (!answer) return;
504
590
 
505
591
  const card = document.querySelector(`.relay-question-container[data-question-id="${questionId}"]`);
@@ -522,9 +608,12 @@ export async function submitRelayQuestionAnswer(questionId, presetAnswer = null)
522
608
  }
523
609
  relayQuestionDrafts.delete(questionId);
524
610
  relayQuestionStructuredDrafts.delete(questionId);
611
+ relayQuestionMultiDrafts.delete(questionId);
612
+ relayQuestionMultiMode.delete(questionId);
525
613
  relayQuestions.set(questionId, r.question);
526
614
  updatePendingQuestionBanner();
527
615
  window.renderConvList?.();
616
+ window.syncComposerButtonState?.();
528
617
  renderRelayQuestions();
529
618
  window.showTransientRelayNotice?.(`✅ Answer received: ${answer} · Agent continuing…`, 7000);
530
619
  } catch (e) {
@@ -12,7 +12,9 @@ import {
12
12
  workspaceRootPath,
13
13
  showTransientRelayNotice,
14
14
  getConversationCurrentWorkspaceRootPath,
15
+ IS_SHARED_VIEW,
15
16
  } from './store.js';
17
+ import { openAnnotateEditor } from './annotate-editor.js';
16
18
  import {
17
19
  uploadAttachment,
18
20
  loadWorkspaceFilePreview,
@@ -219,16 +221,20 @@ export function renderAttachmentPreview() {
219
221
  if (!selectedAttachments.length) {
220
222
  el.innerHTML = '';
221
223
  el.classList.remove('visible');
222
- window.syncComposerControlState?.();
224
+ window.syncComposerButtonState?.();
223
225
  syncComposerAttachmentWarning();
224
226
  return;
225
227
  }
226
228
 
227
229
  el.innerHTML = selectedAttachments.map((att, idx) => {
228
230
  const state = String(att?.uploadState || 'uploaded');
231
+ const annotatable = att.isImage && att.previewUrl && isAnnotatableImageType(att.type);
229
232
  const thumb = att.isImage && att.previewUrl
230
- ? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}">`
233
+ ? `<img src="${escHtml(att.previewUrl)}" alt="${escHtml(att.name)}"${annotatable ? ` onclick="openAnnotateEditorForPending(${idx})" title="Annotate ${escHtml(att.name)}" style="cursor: pointer;"` : ''}>`
231
234
  : `<div class="attachment-preview-meta" style="height:88px;display:flex;align-items:center;justify-content:center">📎</div>`;
235
+ const annotateBadge = annotatable
236
+ ? `<span class="attachment-preview-annotate" aria-hidden="true">🖍️</span>`
237
+ : '';
232
238
  const overlay = state === 'pending' || state === 'uploading'
233
239
  ? `<div class="attachment-preview-status attachment-preview-status-uploading" role="status" aria-label="Uploading ${escHtml(att.name)}"><span class="attachment-preview-spinner"></span></div>`
234
240
  : state === 'error'
@@ -238,13 +244,14 @@ export function renderAttachmentPreview() {
238
244
  <div class="attachment-preview-item attachment-preview-${escHtml(state)}">
239
245
  <button class="attachment-preview-remove" onclick="removeAttachment(${idx})" title="Remove">×</button>
240
246
  ${thumb}
247
+ ${annotateBadge}
241
248
  ${overlay}
242
249
  <div class="attachment-preview-meta">${escHtml(att.name)}${att.size ? ` · ${formatBytes(att.size)}` : ''}</div>
243
250
  </div>
244
251
  `;
245
252
  }).join('');
246
253
  el.classList.add('visible');
247
- window.syncComposerControlState?.();
254
+ window.syncComposerButtonState?.();
248
255
  syncComposerAttachmentWarning();
249
256
  }
250
257
 
@@ -286,6 +293,41 @@ export function clearAttachments() {
286
293
  renderAttachmentPreview();
287
294
  }
288
295
 
296
+ function composerAttachmentSha(attachment) {
297
+ return String(attachment?.sha256 || attachment?.uploaded?.sha256 || '').trim().toLowerCase();
298
+ }
299
+
300
+ /**
301
+ * Removes the composer entries a send took with it and keeps anything added
302
+ * after that snapshot, uploading or not. Matches by identity first, then by
303
+ * content hash — switching away and back mid-send rebuilds the composer from
304
+ * the draft cache as new objects — and removes one entry per sent attachment,
305
+ * so a genuine second copy survives. Persisting what remains is the caller's
306
+ * job.
307
+ */
308
+ export function removeComposerAttachments(sent = []) {
309
+ const pending = (Array.isArray(sent) ? sent : []).filter(Boolean);
310
+ if (!pending.length) return;
311
+ const removed = new Set();
312
+ const unmatched = [];
313
+ for (const att of pending) {
314
+ if (selectedAttachments.includes(att) && !removed.has(att)) removed.add(att);
315
+ else unmatched.push(att);
316
+ }
317
+ for (const att of unmatched) {
318
+ const sha = composerAttachmentSha(att);
319
+ if (!sha) continue;
320
+ const match = selectedAttachments.find((candidate) => !removed.has(candidate) && composerAttachmentSha(candidate) === sha);
321
+ if (match) removed.add(match);
322
+ }
323
+ if (!removed.size) return;
324
+ const kept = selectedAttachments.filter((att) => !removed.has(att));
325
+ for (const att of removed) releaseAttachmentPreviewUrl(att);
326
+ selectedAttachments.length = 0;
327
+ selectedAttachments.push(...kept);
328
+ renderAttachmentPreview();
329
+ }
330
+
289
331
  /**
290
332
  * Replaces composer attachments wholesale, used when hydrating a conversation's
291
333
  * cached draft attachments. Object URLs from the outgoing set are released.
@@ -374,6 +416,86 @@ export function retryAttachmentUpload(idx) {
374
416
  void startAttachmentUpload(attachment);
375
417
  }
376
418
 
419
+ // ─── Marker annotations ───────────────────────────────────────────────────────
420
+
421
+ /** Animated GIFs would silently flatten to one frame, SVGs can't be served back. */
422
+ function isAnnotatableImageType(type) {
423
+ const normalized = String(type || '').trim().toLowerCase();
424
+ return normalized.startsWith('image/')
425
+ && normalized !== 'image/gif'
426
+ && normalized !== 'image/svg+xml';
427
+ }
428
+
429
+ async function fetchImageBlob(url) {
430
+ // Upload/generated-image content URLs are cookie-authenticated (the same way
431
+ // the <img> tags load them), so a plain same-origin fetch works.
432
+ const response = await fetch(url, { credentials: 'same-origin' });
433
+ if (!response.ok) throw new Error('Could not load the image');
434
+ return response.blob();
435
+ }
436
+
437
+ /**
438
+ * Swaps a pending composer attachment for its annotated copy. Keyed on the
439
+ * attachment object, not its index — the list can shift while the editor is
440
+ * open. Returns false (editor stays open) when the slot vanished.
441
+ */
442
+ async function replacePendingAttachment(target, file) {
443
+ const prepared = await prepareIncomingFile(file, { source: 'annotate', now: new Date(), index: 0 });
444
+ // Resolve the slot AFTER the await: prepareIncomingFile can spend real time
445
+ // downscaling, and a draft re-hydration or conversation switch in that window
446
+ // must not make the splice land on an unrelated attachment.
447
+ const idx = selectedAttachments.indexOf(target);
448
+ if (idx === -1) {
449
+ releaseAttachmentPreviewUrl(prepared);
450
+ showTransientRelayNotice('That attachment is no longer in the composer.');
451
+ return false;
452
+ }
453
+ const [replaced] = selectedAttachments.splice(idx, 1, prepared);
454
+ releaseAttachmentPreviewUrl(replaced);
455
+ renderAttachmentPreview();
456
+ void startAttachmentUpload(prepared);
457
+ window.persistComposerAttachments?.();
458
+ return true;
459
+ }
460
+
461
+ export function openAnnotateEditorForPending(idx) {
462
+ const attachment = selectedAttachments[idx];
463
+ if (!attachment?.isImage || !isAnnotatableImageType(attachment.type)) return;
464
+ openAnnotateEditor({
465
+ name: attachment.name,
466
+ getBlob: async () => (attachment.file ? attachment.file : fetchImageBlob(attachment.previewUrl)),
467
+ onAccept: (file) => replacePendingAttachment(attachment, file),
468
+ });
469
+ }
470
+
471
+ /** True when the currently previewed lightbox item can host the 🖍️ button. */
472
+ export function filePreviewSupportsAnnotation() {
473
+ if (IS_SHARED_VIEW) return false;
474
+ const payload = filePreviewState?.payload;
475
+ return payload?.kind === 'image'
476
+ && !!String(payload.rawUrl || '')
477
+ && isAnnotatableImageType(payload.contentType);
478
+ }
479
+
480
+ export function openAnnotateEditorForPreview() {
481
+ if (!filePreviewSupportsAnnotation()) return;
482
+ const url = String(filePreviewState.payload.rawUrl || '');
483
+ const name = String(filePreviewState.payload.name || 'screenshot');
484
+ closeFilePreview();
485
+ openAnnotateEditor({
486
+ name,
487
+ getBlob: () => fetchImageBlob(url),
488
+ onAccept: async (file) => {
489
+ // The sent message is never mutated: the annotated copy joins the
490
+ // composer for the next message. ingestFiles enforces the attachment
491
+ // cap and shows its own over-cap notice; an empty result keeps the
492
+ // editor open so a slot can be freed and accept retried.
493
+ const added = await ingestFiles([file], { source: 'annotate' });
494
+ return added.length > 0;
495
+ },
496
+ });
497
+ }
498
+
377
499
  /**
378
500
  * Single ingestion path shared by the file picker, paste and drag-and-drop.
379
501
  * Files are normalized, optionally downscaled, capped, and uploaded immediately
@@ -480,6 +602,53 @@ function updateFilePreviewUiState() {
480
602
  const backBtn = document.getElementById('file-preview-back');
481
603
  backBtn.hidden = filePreviewHistory.length === 0;
482
604
  backBtn.disabled = filePreviewHistory.length === 0;
605
+ const annotateBtn = document.getElementById('file-preview-annotate-btn');
606
+ if (annotateBtn) annotateBtn.hidden = !filePreviewSupportsAnnotation();
607
+ const copyBtn = document.getElementById('file-preview-copy-btn');
608
+ if (copyBtn) copyBtn.hidden = payload?.kind !== 'image';
609
+ }
610
+
611
+ /**
612
+ * Copy the previewed image to the clipboard as PNG. Re-encoding through a
613
+ * canvas is deliberate: the source may be JPEG/WebP and the async Clipboard
614
+ * API only reliably accepts image/png. Same-origin sources (the drive and
615
+ * workspace file routes, data: URIs) keep the canvas clean; a cross-origin
616
+ * image taints it and lands in the failure note instead of throwing.
617
+ */
618
+ export async function copyFilePreviewImage() {
619
+ const btn = document.getElementById('file-preview-copy-btn');
620
+ const img = document.querySelector('#file-preview-body .file-preview-image img');
621
+ const flash = (text) => {
622
+ if (!btn) return;
623
+ btn.textContent = text;
624
+ setTimeout(() => { btn.textContent = 'Copy'; }, 1600);
625
+ };
626
+ if (!img?.src) return;
627
+ try {
628
+ if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
629
+ throw new Error('Clipboard image copy is not supported in this browser');
630
+ }
631
+ const blob = await new Promise((resolve, reject) => {
632
+ const image = new Image();
633
+ image.onload = () => {
634
+ try {
635
+ const canvas = document.createElement('canvas');
636
+ canvas.width = image.naturalWidth || image.width;
637
+ canvas.height = image.naturalHeight || image.height;
638
+ canvas.getContext('2d').drawImage(image, 0, 0);
639
+ canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('Could not encode image'))), 'image/png');
640
+ } catch (error) {
641
+ reject(error);
642
+ }
643
+ };
644
+ image.onerror = () => reject(new Error('Could not load image'));
645
+ image.src = img.src;
646
+ });
647
+ await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
648
+ flash('Copied ✓');
649
+ } catch {
650
+ flash('Copy failed');
651
+ }
483
652
  }
484
653
 
485
654
  function snapshotFilePreviewState() {
@@ -0,0 +1,9 @@
1
+ // Mirrors shared/background-task-timeout.mjs, which the browser cannot import
2
+ // (only server/public is served). shared/background-task-timeout.test.mjs
3
+ // asserts the two stay identical.
4
+ //
5
+ // The settings slider shows this until the relay's own value loads, and keeps
6
+ // showing it if that request fails, so it must be the default the relay
7
+ // enforces: 4 hours. 0 means no limit only when chosen explicitly.
8
+
9
+ export const DEFAULT_BACKGROUND_TASK_TIMEOUT_MINUTES = 240;