@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
@@ -43,6 +43,8 @@ function insertEmojiAtCaret(emoji) {
43
43
  input.selectionEnd = nextCaret;
44
44
  autoResize(input);
45
45
  input.focus();
46
+ // Runs the composer's input pipeline, which saves the draft.
47
+ input.dispatchEvent(new Event('input', { bubbles: true }));
46
48
  }
47
49
 
48
50
  function handleEmojiChoice(emoji) {
@@ -1,3 +1,5 @@
1
+ import { captureTranscriptAnchor, restoreTranscriptAnchor } from './transcript-viewport-anchor.mjs';
2
+
1
3
  const FONT_SCALE_STORAGE_KEY = 'copilot_font_scale';
2
4
  const FONT_SCALE_MIN = 0.5;
3
5
  const FONT_SCALE_MAX = 1.5;
@@ -53,41 +55,6 @@ function normalizeFontScaleSelectValue(raw = '') {
53
55
  return clampFontScale(numeric);
54
56
  }
55
57
 
56
- function getMessageViewportAnchor() {
57
- const container = document.getElementById('messages');
58
- if (!container) return null;
59
- const containerRect = container.getBoundingClientRect();
60
- const messages = Array.from(container.querySelectorAll('.msg[data-message-id]'));
61
- for (const message of messages) {
62
- const rect = message.getBoundingClientRect();
63
- if (rect.bottom <= containerRect.top || rect.top >= containerRect.bottom) continue;
64
- const messageId = String(message.dataset.messageId || '').trim();
65
- if (!messageId) continue;
66
- return {
67
- messageId,
68
- offsetTop: rect.top - containerRect.top,
69
- };
70
- }
71
- return null;
72
- }
73
-
74
- function restoreMessageViewportAnchor(anchor) {
75
- if (!anchor?.messageId) return;
76
- const container = document.getElementById('messages');
77
- if (!container) return;
78
- const messageId = String(anchor.messageId || '').trim();
79
- if (!messageId) return;
80
- const message = Array.from(container.querySelectorAll('.msg[data-message-id]'))
81
- .find((node) => String(node?.dataset?.messageId || '').trim() === messageId);
82
- if (!message) return;
83
- const containerRect = container.getBoundingClientRect();
84
- const rect = message.getBoundingClientRect();
85
- const delta = rect.top - containerRect.top - Number(anchor.offsetTop || 0);
86
- if (Math.abs(delta) > 0.5) {
87
- container.scrollTop += delta;
88
- }
89
- }
90
-
91
58
  export function syncFontScaleSelect() {
92
59
  const select = document.getElementById('font-scale-select');
93
60
  if (!select) return;
@@ -117,7 +84,7 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
117
84
  syncFontScaleSelect();
118
85
  return normalized;
119
86
  }
120
- const anchor = preserveMessageAnchor ? getMessageViewportAnchor() : null;
87
+ const anchor = preserveMessageAnchor ? captureTranscriptAnchor(document.getElementById('messages')) : null;
121
88
  fontScaleValue = normalized;
122
89
  document.documentElement.style.setProperty('--font-scale', normalized.toFixed(4));
123
90
  document.documentElement.style.setProperty('--font-scale-percent', `${Math.round(normalized * 100)}%`);
@@ -125,7 +92,7 @@ export function setFontScale(nextScale, { persist = true, preserveMessageAnchor
125
92
  syncFontScaleSelect();
126
93
  if (anchor) {
127
94
  requestAnimationFrame(() => {
128
- restoreMessageViewportAnchor(anchor);
95
+ restoreTranscriptAnchor(document.getElementById('messages'), anchor);
129
96
  });
130
97
  }
131
98
  return normalized;
@@ -36,7 +36,7 @@ import {
36
36
  loadGrokSettings,
37
37
  loadOpenAISettings,
38
38
  } from './api-client.js';
39
- import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft } from './conversation-view.js';
39
+ import { renderMessages, restoreInFlightThinking, focusConversationMessageById, flushConversationDraft, hydrateConversationDraft, beginConversationDraftSwitch } from './conversation-view.js';
40
40
  import { setBackgroundTasksConversation, setConversationBackgroundTasks } from './background-tasks-view.mjs';
41
41
  import { mergeConversationPreviews } from './preview-cards.mjs';
42
42
  import { loadRelayQuestions, getPendingQuestionCountsByConversation } from './ask-user-view.js';
@@ -47,15 +47,23 @@ import { shouldApplyConversationLoad } from './activity-replay-state.mjs';
47
47
  import { createInfiniteLoader } from './infinite-loader.js';
48
48
  import {
49
49
  buildNewConversationModelChoices,
50
+ newConversationContextTierState,
50
51
  reasoningChoicesForProviderModel,
51
52
  resolvePreferredReasoningEffort,
52
53
  } from './new-conversation-model-choice.mjs';
54
+ import { buildCatalogModelOptions } from './model-selector-options.mjs';
53
55
  import { isReasoningOffUnsupported, reasoningEffortOptionLabel, reasoningEffortOptionTitle } from './reasoning-effort-labels.mjs';
54
56
  import {
55
57
  conversationProviderIndicatorKey,
56
58
  conversationProviderIndicatorLabel,
57
59
  } from './conversation-provider-indicator.mjs';
58
60
  import { leaveStatusView } from './status-view.mjs';
61
+ import {
62
+ normalizeConversationFilter,
63
+ filterConversations,
64
+ drainRemainingPages,
65
+ describeFilterMatchCount,
66
+ } from './conversation-list-filter.mjs';
59
67
 
60
68
  const PROCESSING_DOT_FRAMES = [' ', '. ', '.. ', '...'];
61
69
  const PROCESSING_DOT_INTERVAL_MS = 1000;
@@ -72,9 +80,13 @@ const CLAUDE_LONG_CONTEXT_PATTERN = /\[1m\]$/i;
72
80
  const OPENAI_IMAGE_SIZE_STORAGE_KEY = 'copilot_openai_image_size';
73
81
  const NEW_CHAT_CWD_STORAGE_KEY = 'copilot_new_chat_cwd';
74
82
  const NEW_CHAT_CUSTOM_CWD_VALUE = '__custom__';
83
+ const CONV_FILTER_DEBOUNCE_MS = 220;
75
84
  let processingDotFrame = 0;
76
85
  let processingDotTimer = null;
77
86
  let lastConvListHtml = '';
87
+ let conversationListFilterText = '';
88
+ let conversationFilterDrainActive = false;
89
+ let conversationFilterGeneration = 0;
78
90
  let openConversationVersion = 0;
79
91
  let newConversationInFlight = false;
80
92
 
@@ -271,6 +283,11 @@ export async function refreshConversations(options = {}) {
271
283
  });
272
284
  renderConvList();
273
285
  updateCompactButton();
286
+ // A reset bumps the loader version, which aborts any in-flight filter drain;
287
+ // restart it so an active filter keeps covering the unloaded pages.
288
+ if (normalizeConversationFilter(conversationListFilterText)) {
289
+ void drainConversationListForFilter();
290
+ }
274
291
  }
275
292
 
276
293
  export function renderConvList() {
@@ -280,9 +297,19 @@ export function renderConvList() {
280
297
  if (updatedAtDelta !== 0) return updatedAtDelta;
281
298
  return String(b?.id || '').trim().localeCompare(String(a?.id || '').trim());
282
299
  });
300
+ const activeFilter = normalizeConversationFilter(conversationListFilterText);
301
+ const visible = activeFilter ? filterConversations(sorted, activeFilter) : sorted;
283
302
  const pendingByConversation = getPendingQuestionCountsByConversation();
284
303
  let hasProcessingConversation = false;
285
304
  const footerHtml = (() => {
305
+ if (activeFilter) {
306
+ // While a filter is active the drain loads older pages by itself; a
307
+ // "scroll for more" hint would point at a gesture that does nothing, and
308
+ // "Searching…" must only show while something is actually loading.
309
+ return (conversationFilterDrainActive || conversationListPaginationState.isLoading)
310
+ ? '<div class="conv-list-footer">Searching older conversations…</div>'
311
+ : '';
312
+ }
286
313
  if (conversationListPaginationState.isLoading) {
287
314
  return '<div class="conv-list-footer">Loading older conversations…</div>';
288
315
  }
@@ -291,10 +318,14 @@ export function renderConvList() {
291
318
  }
292
319
  return '';
293
320
  })();
294
- if (sorted.length === 0) {
321
+ if (visible.length === 0) {
295
322
  ensureProcessingDotTimer(false);
296
- list.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">No conversations yet</div>';
323
+ const emptyText = activeFilter && sorted.length > 0
324
+ ? 'No conversations match your filter'
325
+ : 'No conversations yet';
326
+ list.innerHTML = `<div style="padding:12px;color:var(--muted);font-size:0.85rem;text-align:center">${emptyText}</div>${footerHtml}`;
297
327
  lastConvListHtml = '';
328
+ scheduleConversationListBoundaryCheck();
298
329
  return;
299
330
  }
300
331
  const conversationView = (conversation) => {
@@ -310,7 +341,7 @@ export function renderConvList() {
310
341
  if (processing) hasProcessingConversation = true;
311
342
  return { visualState, processing };
312
343
  };
313
- const listHtml = `${sorted.map((c) => {
344
+ const listHtml = `${visible.map((c) => {
314
345
  const view = conversationView(c);
315
346
  const processingDots = view.processing ? PROCESSING_DOT_FRAMES[processingDotFrame] : '';
316
347
  const providerIndicatorLabel = conversationProviderIndicatorLabel(c);
@@ -425,6 +456,7 @@ export async function openConversation(id, options = {}) {
425
456
  await flushConversationDraft(previousConversationId);
426
457
  window.clearImageEditTarget?.();
427
458
  }
459
+ const switchingConversation = !!nextConversationId && previousConversationId !== nextConversationId;
428
460
  const capturedVersion = ++openConversationVersion;
429
461
  setCurrentConv(id);
430
462
  if (repoBrowserState.activeRoot === 'workspace') {
@@ -438,7 +470,13 @@ export async function openConversation(id, options = {}) {
438
470
  repoBrowserState.error = '';
439
471
  }
440
472
  closeSidebar();
441
- clearAttachments();
473
+ // Reopening the conversation on screen (session bind, clicking it again, a
474
+ // push or search jump) must leave its composer alone: emptied attachments
475
+ // would read as a user edit and be saved, dropping them from the draft.
476
+ if (switchingConversation) {
477
+ clearAttachments();
478
+ beginConversationDraftSwitch(nextConversationId);
479
+ }
442
480
  document.getElementById('chat-title').textContent = conversations[id]?.title || id;
443
481
  if (didLeaveStatusView) {
444
482
  restoreInFlightThinking(null);
@@ -640,6 +678,32 @@ function populateNewConversationSizeSelect(providerType = 'github', selectedMode
640
678
  if (status) status.textContent = 'Image size used for generated outputs in this chat.';
641
679
  }
642
680
 
681
+ // Read-only: the composer chip is the only place a tier is chosen (see
682
+ // newConversationContextTierState), so this just tells the user what the
683
+ // picked model gives them before the chat exists.
684
+ function populateNewConversationContextRow(providerType = 'github', selectedModel = '') {
685
+ const select = document.getElementById('new-conversation-context-select');
686
+ const row = document.getElementById('new-conversation-context-row');
687
+ if (!select || !row) return;
688
+ const { visible, options } = newConversationContextTierState(newConversationCatalogCache || {}, {
689
+ provider: normalizeNewConversationProviderType(providerType),
690
+ modelId: selectedModel,
691
+ });
692
+ select.innerHTML = '';
693
+ if (!visible) {
694
+ row.style.display = 'none';
695
+ return;
696
+ }
697
+ for (const tier of options) {
698
+ const option = document.createElement('option');
699
+ option.value = tier.value;
700
+ option.textContent = tier.label;
701
+ select.appendChild(option);
702
+ }
703
+ select.value = options[0].value;
704
+ row.style.display = 'block';
705
+ }
706
+
643
707
  async function populateNewConversationReasoningSelect(selectedModel = '') {
644
708
  const select = document.getElementById('new-conversation-reasoning-select');
645
709
  const status = document.getElementById('new-conversation-reasoning-status');
@@ -727,29 +791,25 @@ async function populateNewConversationModelSelect(providerType = 'github') {
727
791
  const target = document.getElementById('new-conversation-model-select');
728
792
  if (!target) return false;
729
793
  target.innerHTML = '';
730
- const source = document.getElementById('model-select');
731
- const sourceLabelByValue = new Map(
732
- Array.from(source?.options || []).map((option) => [
733
- String(option.value || '').trim(),
734
- String(option.textContent || option.value || '').trim(),
735
- ]),
736
- );
737
794
  const catalog = newConversationCatalogCache || await loadModelCatalog();
738
795
  if (!catalog || !Array.isArray(catalog.models)) return false;
739
796
  const normalizedProvider = normalizeNewConversationProviderType(providerType);
797
+ const eligibleModels = catalog.models
798
+ .map((modelId) => String(modelId || '').trim())
799
+ .filter((modelId) => modelMatchesNewConversationProvider(catalog, modelId, normalizedProvider))
800
+ .filter((modelId) => (
801
+ normalizedProvider !== 'openai-image' || isOpenAIImageModelId(modelId)
802
+ ));
803
+ // The same builder the composer's #model-select uses, on the same catalog,
804
+ // so the two pickers agree on order and wording by construction. The modal
805
+ // used to borrow labels from the composer, which is scoped to the OPEN
806
+ // conversation's provider and so knew nothing about the models of another.
807
+ const hasAuto = eligibleModels.some((modelId) => modelId.toLowerCase() === 'auto');
740
808
  const choices = buildNewConversationModelChoices(
741
- catalog.models
742
- .filter((modelId) => modelMatchesNewConversationProvider(catalog, modelId, normalizedProvider))
743
- .filter((modelId) => (
744
- normalizedProvider !== 'openai-image' || isOpenAIImageModelId(modelId)
745
- ))
746
- .map((modelId) => {
747
- const value = String(modelId || '').trim();
748
- return {
749
- value,
750
- label: sourceLabelByValue.get(value) || value,
751
- };
752
- }),
809
+ buildCatalogModelOptions(eligibleModels, {
810
+ metadataByModel: catalog.modelMetadataByModel || {},
811
+ annotateContextWindow: normalizedProvider !== 'claude',
812
+ }).filter((option) => hasAuto || option.value.toLowerCase() !== 'auto'),
753
813
  );
754
814
  for (const choice of choices) {
755
815
  const option = document.createElement('option');
@@ -767,6 +827,7 @@ async function populateNewConversationModelSelect(providerType = 'github') {
767
827
  updateNewConversationProviderHelp(normalizedProvider);
768
828
  syncNewConversationReasoningLabel(normalizedProvider);
769
829
  populateNewConversationSizeSelect(normalizedProvider, target.value);
830
+ populateNewConversationContextRow(normalizedProvider, target.value);
770
831
  await populateNewConversationReasoningSelect(target.value);
771
832
  return true;
772
833
  }
@@ -939,6 +1000,7 @@ async function openNewConversationModelModal() {
939
1000
  modelSelect.addEventListener('change', () => {
940
1001
  const provider = String(document.getElementById('new-conversation-provider-select')?.value || '').trim();
941
1002
  populateNewConversationSizeSelect(provider, modelSelect.value);
1003
+ populateNewConversationContextRow(provider, modelSelect.value);
942
1004
  void populateNewConversationReasoningSelect(modelSelect.value);
943
1005
  });
944
1006
  }
@@ -1119,11 +1181,115 @@ export function initConversationListLazyLoading() {
1119
1181
  scheduleConversationListBoundaryCheck();
1120
1182
  }
1121
1183
 
1184
+ // A title filter has to search conversations that only exist on unloaded pages,
1185
+ // so an active filter drains the remaining pages in the background. Each page
1186
+ // re-renders via applyPage, so matches appear as they load.
1187
+ // A filter change aborts the running drain and starts a fresh one, so the new
1188
+ // filter gets a full retry budget.
1189
+ async function drainConversationListForFilter() {
1190
+ if (conversationFilterDrainActive) return;
1191
+ conversationFilterDrainActive = true;
1192
+ const generation = conversationFilterGeneration;
1193
+ renderConvList();
1194
+ try {
1195
+ await drainRemainingPages(
1196
+ conversationListLoader,
1197
+ () => generation === conversationFilterGeneration
1198
+ && !!normalizeConversationFilter(conversationListFilterText),
1199
+ );
1200
+ } finally {
1201
+ conversationFilterDrainActive = false;
1202
+ const filterActive = !!normalizeConversationFilter(conversationListFilterText);
1203
+ if (filterActive && generation !== conversationFilterGeneration) {
1204
+ void drainConversationListForFilter();
1205
+ } else {
1206
+ // The "Searching older conversations…" footer keys off the drain flag.
1207
+ renderConvList();
1208
+ if (filterActive) announceConversationFilterResults();
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ // Announced only when the (debounced) filter applies and when its drain ends,
1214
+ // not on every page render, so screen readers are not flooded while typing.
1215
+ // Unchanged text is not rewritten: refresh-triggered drains (session bind,
1216
+ // reconnect) must not re-announce without a user action.
1217
+ function announceConversationFilterResults() {
1218
+ const status = document.getElementById('conv-filter-status');
1219
+ if (!status) return;
1220
+ const activeFilter = normalizeConversationFilter(conversationListFilterText);
1221
+ let text = '';
1222
+ if (activeFilter) {
1223
+ const count = filterConversations(Object.values(conversations), activeFilter).length;
1224
+ if (!conversationListLoader.getState().hasMore) {
1225
+ text = describeFilterMatchCount(count);
1226
+ } else if (conversationFilterDrainActive) {
1227
+ text = `${describeFilterMatchCount(count)} so far, searching older conversations`;
1228
+ } else {
1229
+ // The drain gave up with pages left, so the search is not complete.
1230
+ text = describeFilterMatchCount(count, { loadedOnly: true });
1231
+ }
1232
+ }
1233
+ if (status.textContent !== text) status.textContent = text;
1234
+ }
1235
+
1236
+ function setConversationListFilter(text) {
1237
+ const next = String(text ?? '');
1238
+ if (next === conversationListFilterText) return;
1239
+ conversationListFilterText = next;
1240
+ conversationFilterGeneration += 1;
1241
+ const list = getConversationListElement();
1242
+ if (list) list.scrollTop = 0;
1243
+ renderConvList();
1244
+ if (normalizeConversationFilter(next)) void drainConversationListForFilter();
1245
+ announceConversationFilterResults();
1246
+ }
1247
+
1248
+ export function initConversationFilter() {
1249
+ const input = document.getElementById('conv-filter-input');
1250
+ const clearButton = document.getElementById('conv-filter-clear');
1251
+ if (!input || input.dataset.filterBound === '1') return;
1252
+ input.dataset.filterBound = '1';
1253
+ let debounceTimer = null;
1254
+ const syncClearButton = () => {
1255
+ if (clearButton) clearButton.hidden = input.value === '';
1256
+ };
1257
+ const applyFilterNow = () => {
1258
+ if (debounceTimer) {
1259
+ clearTimeout(debounceTimer);
1260
+ debounceTimer = null;
1261
+ }
1262
+ syncClearButton();
1263
+ setConversationListFilter(input.value);
1264
+ };
1265
+ input.addEventListener('input', () => {
1266
+ syncClearButton();
1267
+ if (debounceTimer) clearTimeout(debounceTimer);
1268
+ debounceTimer = setTimeout(applyFilterNow, CONV_FILTER_DEBOUNCE_MS);
1269
+ });
1270
+ input.addEventListener('keydown', (event) => {
1271
+ if (event.key !== 'Escape' || input.value === '') return;
1272
+ event.stopPropagation();
1273
+ input.value = '';
1274
+ applyFilterNow();
1275
+ });
1276
+ clearButton?.addEventListener('click', () => {
1277
+ input.value = '';
1278
+ applyFilterNow();
1279
+ input.focus();
1280
+ });
1281
+ syncClearButton();
1282
+ }
1283
+
1122
1284
  export async function deleteConv(e, id) {
1123
1285
  e.stopPropagation();
1124
1286
  if (!confirm('Delete this conversation?')) return;
1125
1287
  const result = await deleteConversationApi(id);
1126
1288
  if (!result) return;
1289
+ if (result.ok === false) {
1290
+ showTransientRelayNotice(String(result.message || '').trim() || 'This conversation could not be deleted.', 6000);
1291
+ return;
1292
+ }
1127
1293
  delete conversations[id];
1128
1294
  renderConvList();
1129
1295
  if (currentConvId === id) {
@@ -1,6 +1,152 @@
1
+ import { tokenLabel } from './context-tier-options.mjs';
2
+
3
+ /**
4
+ * The composer placeholder, decided by the MODEL FAMILY of the current
5
+ * selection (maintainer's rule: the hint names who answers, whatever runtime serves
6
+ * it — claude-* through Cursor's catalog still reads "Message Claude…").
7
+ * Provider only breaks ties: Auto/empty follows the conversation's bound
8
+ * provider, unknown families on the OpenAI BYOK provider read OpenAI, and
9
+ * everything else falls back to the historical Copilot text.
10
+ */
11
+ export function composerPlaceholderFor({ modelId = '', providerType = '' } = {}) {
12
+ const provider = String(providerType || '').trim().toLowerCase();
13
+ const id = String(modelId || '').trim().toLowerCase().replace(/\[[^\]]*\]$/, '');
14
+ const providerFallback = () => {
15
+ if (provider === 'claude') return 'Message Claude…';
16
+ if (provider === 'grok') return 'Message Grok…';
17
+ if (provider === 'openai' || provider === 'openai-byok') return 'Message OpenAI…';
18
+ if (provider === 'cursor') return 'Message Cursor…';
19
+ return 'Message Copilot…';
20
+ };
21
+ if (!id || id === 'auto') return providerFallback();
22
+ if (id.startsWith('claude-')) return 'Message Claude…';
23
+ if (id.startsWith('grok-')) return 'Message Grok…';
24
+ if (id.startsWith('gpt-')) return 'Message GPT…';
25
+ if (id.startsWith('gemini-')) return 'Message Gemini…';
26
+ // Composer is Cursor's house model family.
27
+ if (id.startsWith('composer-')) return 'Message Cursor…';
28
+ return providerFallback();
29
+ }
30
+
31
+ /**
32
+ * Human label for a model id, tuned to fit narrow composer selects.
33
+ *
34
+ * Claude ids drop the redundant "claude-" prefix (Opus/Sonnet/Haiku/Fable are
35
+ * unmistakably Claude), join hyphenated version parts with dots, and drop
36
+ * trailing -YYYYMMDD snapshot dates: `claude-fable-5-1` -> "Fable 5.1",
37
+ * `claude-haiku-4-5-20251001` -> "Haiku 4.5". A bracketed capability suffix
38
+ * survives verbatim: `claude-opus-5[1m]` -> "Opus 5 [1m]".
39
+ */
40
+ export function humanizeModelLabel(modelId = '') {
41
+ const text = String(modelId || '').trim();
42
+ if (!text) return '';
43
+ if (/^gpt-/i.test(text)) {
44
+ return text
45
+ .replace(/^gpt-/i, 'GPT-')
46
+ .replace(/-codex$/i, ' Codex')
47
+ .replace(/-mini$/i, ' Mini');
48
+ }
49
+ if (/^claude-/i.test(text)) {
50
+ const suffixMatch = /\[([^\]]+)\]$/.exec(text);
51
+ const base = suffixMatch ? text.slice(0, suffixMatch.index) : text;
52
+ const parts = base
53
+ .replace(/^claude-/i, '')
54
+ .split('-')
55
+ .filter((part, index, all) => !(index === all.length - 1 && /^\d{8}$/.test(part)));
56
+ const words = [];
57
+ for (const part of parts) {
58
+ const isNumeric = /^\d+(\.\d+)?$/.test(part);
59
+ if (isNumeric && words.length && words[words.length - 1].isNumeric) {
60
+ words[words.length - 1].text += `.${part}`;
61
+ } else {
62
+ words.push({ text: isNumeric ? part : part.charAt(0).toUpperCase() + part.slice(1), isNumeric });
63
+ }
64
+ }
65
+ const label = words.map((word) => word.text).join(' ');
66
+ return suffixMatch ? `${label} [${suffixMatch[1]}]` : label;
67
+ }
68
+ if (/^gemini-/i.test(text)) {
69
+ return text
70
+ .replace(/^gemini-/i, 'Gemini ')
71
+ .split('-')
72
+ .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
73
+ .join(' ');
74
+ }
75
+ return text;
76
+ }
77
+
78
+ const VARIANT_EFFORT_SUFFIX = /^(.*)-(none|minimal|low|medium|high|xhigh|max)$/i;
79
+
80
+ // "gpt-5-high" style ids carry the effort as a suffix; the label shows it in
81
+ // parentheses and the metadata lookup uses the base id.
82
+ export function splitModelVariantId(modelVariantId = '') {
83
+ const value = String(modelVariantId || '').trim();
84
+ if (!value) return { baseModelId: '', reasoningEffort: null };
85
+ const match = value.match(VARIANT_EFFORT_SUFFIX);
86
+ if (!match) return { baseModelId: value, reasoningEffort: null };
87
+ return {
88
+ baseModelId: String(match[1] || '').trim(),
89
+ reasoningEffort: String(match[2] || '').trim().toLowerCase(),
90
+ };
91
+ }
92
+
93
+ // Server catalogs key metadata by the id they publish; stored preferences and
94
+ // variant ids may differ only in case.
95
+ export function modelMetadataFor(modelId = '', metadataByModel = {}) {
96
+ const map = metadataByModel && typeof metadataByModel === 'object' ? metadataByModel : {};
97
+ const { baseModelId } = splitModelVariantId(modelId);
98
+ if (!baseModelId) return null;
99
+ const direct = map[baseModelId];
100
+ if (direct && typeof direct === 'object') return direct;
101
+ const lower = baseModelId.toLowerCase();
102
+ const key = Object.keys(map).find((candidate) => candidate.toLowerCase() === lower);
103
+ return key && map[key] && typeof map[key] === 'object' ? map[key] : null;
104
+ }
105
+
106
+ // Number(null) is 0, which would put every unindexed model first.
107
+ function finiteIndexOrNull(value) {
108
+ if (value === null || value === undefined || value === '') return null;
109
+ const index = Number(value);
110
+ return Number.isFinite(index) && index >= 0 ? index : null;
111
+ }
112
+
113
+ export function catalogOrderFor(modelId = '', metadataByModel = {}) {
114
+ return finiteIndexOrNull(modelMetadataFor(modelId, metadataByModel)?.catalogIndex);
115
+ }
116
+
117
+ // Runtime display name when the catalog has one ("GPT-5.4 mini"), otherwise
118
+ // the compact humanized id; variant ids keep their "(effort)" tail.
119
+ export function catalogModelLabel(modelId = '', metadataByModel = {}, { autoValue = 'auto' } = {}) {
120
+ const value = String(modelId || '').trim();
121
+ if (value.toLowerCase() === String(autoValue || 'auto').trim().toLowerCase()) return 'Auto';
122
+ const { baseModelId, reasoningEffort } = splitModelVariantId(value);
123
+ if (!baseModelId) return value;
124
+ const displayName = String(modelMetadataFor(value, metadataByModel)?.displayName || '').trim();
125
+ const baseLabel = displayName || humanizeModelLabel(baseModelId);
126
+ return reasoningEffort ? `${baseLabel} (${reasoningEffort})` : baseLabel;
127
+ }
128
+
129
+ export function contextWindowSuffix(modelId = '', metadataByModel = {}) {
130
+ const metadata = modelMetadataFor(modelId, metadataByModel);
131
+ if (!metadata) return '';
132
+ const real = Number(metadata.contextWindowTokens);
133
+ const tokens = Number.isFinite(real) && real > 0 ? real : Number(metadata.defaultContextLimitTokens);
134
+ return Number.isFinite(tokens) && tokens > 0 ? ` · ${tokenLabel(tokens)}` : '';
135
+ }
136
+
137
+ /**
138
+ * Auto first, then the server's catalog order where it supplies one
139
+ * (orderFor → finite index), then everything else alphabetically by bare
140
+ * label. Copilot's canonical order therefore wins for Copilot models while
141
+ * SDK-only catalogs (no index) keep the alphabetical list they always had.
142
+ * suffixFor is applied after sorting and after collision detection so the
143
+ * sort key and the "same label" check never see the window annotation.
144
+ */
1
145
  export function normalizeModelSelectorOptions(models = [], {
2
146
  autoValue = 'auto',
3
147
  labelFor = (modelId) => modelId,
148
+ orderFor = () => null,
149
+ suffixFor = () => '',
4
150
  } = {}) {
5
151
  const normalizedAuto = String(autoValue || 'auto').trim() || 'auto';
6
152
  const values = Array.from(new Set(
@@ -8,7 +154,10 @@ export function normalizeModelSelectorOptions(models = [], {
8
154
  .map((modelId) => String(modelId || '').trim())
9
155
  .filter(Boolean),
10
156
  )).filter((modelId) => modelId.toLowerCase() !== normalizedAuto.toLowerCase());
157
+ const orderOf = (modelId) => finiteIndexOrNull(orderFor(modelId)) ?? Number.POSITIVE_INFINITY;
11
158
  values.sort((left, right) => {
159
+ const indexOrder = orderOf(left) - orderOf(right);
160
+ if (indexOrder) return indexOrder;
12
161
  const labelOrder = String(labelFor(left) || left).localeCompare(
13
162
  String(labelFor(right) || right),
14
163
  undefined,
@@ -16,10 +165,45 @@ export function normalizeModelSelectorOptions(models = [], {
16
165
  );
17
166
  return labelOrder || left.localeCompare(right);
18
167
  });
19
- return [
168
+ const options = [
20
169
  { value: normalizedAuto, label: String(labelFor(normalizedAuto) || normalizedAuto) },
21
170
  ...values.map((value) => ({ value, label: String(labelFor(value) || value) })),
22
171
  ];
172
+ // Date-stripping in humanizeModelLabel can collapse an alias and its dated
173
+ // snapshot ("claude-fable-5-1" + "claude-fable-5-1-20251103") into the same
174
+ // label; two indistinguishable rows are worse than one ugly one, so
175
+ // colliding labels fall back to the raw id.
176
+ const labelCounts = new Map();
177
+ for (const option of options) {
178
+ labelCounts.set(option.label, (labelCounts.get(option.label) || 0) + 1);
179
+ }
180
+ return options.map((option) => {
181
+ const label = labelCounts.get(option.label) > 1 ? option.value : option.label;
182
+ const suffix = option.value === normalizedAuto ? '' : String(suffixFor(option.value) || '');
183
+ return { value: option.value, label: `${label}${suffix}` };
184
+ });
185
+ }
186
+
187
+ /**
188
+ * The one option list both pickers render (composer #model-select and the
189
+ * New Chat modal), so they cannot disagree on order or wording. Callers filter
190
+ * by provider afterwards; annotateContextWindow is off for Claude SDK
191
+ * conversations, whose window the merged metadata does not know (see
192
+ * buildContextTierOptions).
193
+ */
194
+ export function buildCatalogModelOptions(models = [], {
195
+ autoValue = 'auto',
196
+ metadataByModel = {},
197
+ annotateContextWindow = true,
198
+ } = {}) {
199
+ return normalizeModelSelectorOptions(models, {
200
+ autoValue,
201
+ labelFor: (modelId) => catalogModelLabel(modelId, metadataByModel, { autoValue }),
202
+ orderFor: (modelId) => catalogOrderFor(modelId, metadataByModel),
203
+ suffixFor: annotateContextWindow
204
+ ? (modelId) => contextWindowSuffix(modelId, metadataByModel)
205
+ : () => '',
206
+ });
23
207
  }
24
208
 
25
209
  export function modelSelectorOptionsEqual(currentOptions = [], nextOptions = []) {
@@ -1,3 +1,7 @@
1
+ import { nearestSupportedReasoningEffort } from './conversation-preferences.mjs';
2
+ import { buildContextTierOptions, UNKNOWN_WINDOW_LABEL } from './context-tier-options.mjs';
3
+ import { modelMetadataFor } from './model-selector-options.mjs';
4
+
1
5
  export function shouldPromptForNewConversationModel({ provider = '' } = {}) {
2
6
  const normalizedProvider = String(provider || '').trim().toLowerCase();
3
7
  return normalizedProvider === 'openai'
@@ -54,14 +58,30 @@ export function reasoningChoicesForProviderModel(catalog = {}, {
54
58
  return normalizeReasoningEfforts(catalog?.reasoningByModel?.[normalizedModelId] || []);
55
59
  }
56
60
 
61
+ // Same clamp as the composer (nearestSupportedReasoningEffort) so a remembered
62
+ // tier lands on the same rung whichever picker starts the chat.
57
63
  export function resolvePreferredReasoningEffort(efforts = [], preferredValues = []) {
58
- const normalizedEfforts = normalizeReasoningEfforts(efforts);
59
- if (!normalizedEfforts.length) return '';
60
- const candidates = Array.isArray(preferredValues) ? preferredValues : [preferredValues];
61
- for (const candidate of candidates) {
62
- const value = String(candidate || '').trim().toLowerCase();
63
- if (value && normalizedEfforts.includes(value)) return value;
64
- }
65
- const firstNonNone = normalizedEfforts.find((value) => value !== 'none');
66
- return firstNonNone || normalizedEfforts[0];
64
+ return nearestSupportedReasoningEffort(
65
+ Array.isArray(preferredValues) ? preferredValues : [preferredValues],
66
+ normalizeReasoningEfforts(efforts),
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The read-only context row of the New Chat modal. Only Copilot chats show it:
72
+ * the modal has no tier preference to persist (the composer's long_context
73
+ * choice is stored only as a Claude "[1m]" id), so it mirrors what the composer
74
+ * chip will read for the chosen model and hides when no window is known
75
+ * ("auto", stale metadata).
76
+ */
77
+ export function newConversationContextTierState(catalog = {}, { provider = '', modelId = '' } = {}) {
78
+ const providerKey = normalizeProviderKey(provider);
79
+ if (providerKey !== 'github') return { visible: false, options: [] };
80
+ const options = buildContextTierOptions({
81
+ modelId,
82
+ providerType: providerKey,
83
+ metadata: modelMetadataFor(modelId, catalog?.modelMetadataByModel || {}) || {},
84
+ });
85
+ const known = options.filter((option) => option.label !== UNKNOWN_WINDOW_LABEL);
86
+ return { visible: known.length > 0, options: known };
67
87
  }