@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
@@ -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,14 +404,17 @@ 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>
402
- <button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>
417
+ ${task.stoppable === false ? '' : `<button type="button" class="bg-task-stop" data-task-id="${escHtml(task.taskId)}" ${stopping ? 'disabled' : ''}>${stopping ? 'Stopping…' : 'Stop'}</button>`}
403
418
  </span>
404
419
  </span>
405
420
  </div>${treeHolder}`;
@@ -48,7 +48,10 @@ import {
48
48
  SHARED_CONVERSATION_TOKEN,
49
49
  getConversationWatcherCount,
50
50
  generateId,
51
+ imageEditTarget,
52
+ hasPendingUserMessageForConversation,
51
53
  } from './store.js';
54
+ import { createTranscriptResizeKeeper } from './transcript-viewport-anchor.mjs';
52
55
  import {
53
56
  verifyExistingSession,
54
57
  verifyToken,
@@ -74,7 +77,7 @@ import {
74
77
  reportSharedViewerPresence,
75
78
  setNetworkRequestsEnabled,
76
79
  } from './api-client.js';
77
- import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading } from './journal-view.js';
80
+ import { loadConversations, refreshConversations, openConversation, renderConvList, applyLoadedConversationState, initConversationListLazyLoading, initConversationFilter } from './journal-view.js';
78
81
  import {
79
82
  newConversation,
80
83
  confirmNewConversationModel,
@@ -85,7 +88,7 @@ import {
85
88
  loadRelayQuestions,
86
89
  getPendingQuestionCountsByConversation,
87
90
  } from './ask-user-view.js';
88
- 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';
89
92
  import { loadRelayBoards, submitRelayBoardAction } from './relay-board-view.js';
90
93
  import {
91
94
  restoreInFlightThinking,
@@ -97,7 +100,10 @@ import {
97
100
  updateComposerSlashMenu,
98
101
  getConversationLoadedMessageCount,
99
102
  loadOlderConversationMessages,
100
- syncComposerControlState,
103
+ syncComposerButtonState,
104
+ syncCancellableSteerButtons,
105
+ syncComposerAfterUserEdit,
106
+ isConversationDraftHeldBySend,
101
107
  persistComposerAttachments,
102
108
  flushConversationDraft,
103
109
  initConversationHistoryLazyLoading,
@@ -110,7 +116,7 @@ import {
110
116
  } from './conversation-view.js';
111
117
  import { bindChatSelectionGuard, chatSelectionGuard, isChatInteractionHeld } from './selection-guard.mjs';
112
118
  import { loadRepoBrowserTree, openRepoBrowser, closeRepoBrowser, setRepoBrowserSessionInfo, resetWorkspaceRepoBrowserForRootChange } from './attachments-view.js';
113
- import { handleAttachmentInput, retryAttachmentUpload, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
119
+ import { handleAttachmentInput, retryAttachmentUpload, openAnnotateEditorForPending, openAnnotateEditorForPreview, handleComposerPaste, handleComposerDrop, refreshComposerAttachmentWarning, removeAttachment, clearAttachments, openUploadedAttachmentViewer, setFilePreviewMode, toggleFilePreviewHtml, copyFilePreviewImage, closeFilePreview, goBackFilePreview, openWorkspaceFilePreview, openWorkspaceFilePreviewFromRepo, setRepoBrowserRoot, setRepoBrowserViewMode, toggleRepoBrowserHidden, toggleRepoBrowserHeavy, refreshRepoBrowser, focusRepoTree, setRepoCurrentPath, confirmRepoBrowserCwdPick } from './attachments-view.js';
114
120
  import { initEmojiPicker, toggleEmojiPicker } from './emoji-view.js';
115
121
  import { initSlashAutocomplete } from './slash-autocomplete.mjs';
116
122
  import { dataTransferHasFiles } from './composer-paste.mjs';
@@ -122,8 +128,14 @@ import {
122
128
  resolveConversationComposerSelection,
123
129
  } from './conversation-preferences.mjs';
124
130
  import {
131
+ buildCatalogModelOptions,
132
+ catalogModelLabel,
133
+ composerPlaceholderFor,
134
+ contextWindowSuffix,
135
+ humanizeModelLabel,
136
+ modelMetadataFor,
125
137
  modelSelectorOptionsEqual,
126
- normalizeModelSelectorOptions,
138
+ splitModelVariantId,
127
139
  } from './model-selector-options.mjs';
128
140
  import {
129
141
  buildContextTierOptions,
@@ -166,7 +178,7 @@ import {
166
178
  initFullscreenButton,
167
179
  promptInstallApp,
168
180
  toggleFullscreen,
169
- applyPwaManifestFromSettings,
181
+ adoptLegacyPwaAppName,
170
182
  registerPwaShell,
171
183
  updatePwaAppName,
172
184
  } from './pwa-install.js';
@@ -265,7 +277,7 @@ import {
265
277
  toggleCursorProvider,
266
278
  applyCursorSettingsState,
267
279
  refreshCursorSettingsState,
268
- updateWindowsAutostartSettingFromToggle,
280
+ updateWindowsAutostartMode,
269
281
  previewTurnCeilingSetting,
270
282
  updateTurnCeilingSetting,
271
283
  previewBackgroundTaskTimeoutSetting,
@@ -311,6 +323,14 @@ import {
311
323
  runCliInstall,
312
324
  cancelCliInstallRun,
313
325
  } from './cli-install-ui.js';
326
+ import {
327
+ updateAutoCheckSetting,
328
+ checkForUpdatesNow,
329
+ applyAvailableUpdate,
330
+ cancelRunningUpdate,
331
+ dismissAvailableUpdate,
332
+ dismissUpdateOutcome,
333
+ } from './update-ui.js';
314
334
 
315
335
  const MODEL_STORAGE_KEY = 'copilot_selected_model';
316
336
  // The New Chat modal used to keep its own model key, so a selection made there
@@ -413,6 +433,8 @@ let modelVariantCatalogState = {
413
433
  variants: [],
414
434
  enabledVariantIds: [],
415
435
  reasoningByModel: {},
436
+ contextLimitsByModel: {},
437
+ modelMetadataByModel: {},
416
438
  source: null,
417
439
  refreshedAt: null,
418
440
  warning: null,
@@ -1065,49 +1087,12 @@ function syncQueueStatusMenuEntry(payload = null) {
1065
1087
  }
1066
1088
 
1067
1089
 
1068
- function splitVariantId(modelVariantId = '') {
1069
- const value = String(modelVariantId || '').trim();
1070
- if (!value) return { baseModelId: '', reasoningEffort: null };
1071
- const match = value.match(/^(.*)-(none|low|medium|high|xhigh|max)$/i);
1072
- if (!match) return { baseModelId: value, reasoningEffort: null };
1073
- return {
1074
- baseModelId: String(match[1] || '').trim(),
1075
- reasoningEffort: String(match[2] || '').trim().toLowerCase(),
1076
- };
1077
- }
1078
-
1079
- function humanizeModelLabel(modelId = '') {
1080
- const text = String(modelId || '').trim();
1081
- if (!text) return '';
1082
- if (/^gpt-/i.test(text)) {
1083
- return text
1084
- .replace(/^gpt-/i, 'GPT-')
1085
- .replace(/-codex$/i, ' Codex')
1086
- .replace(/-mini$/i, ' Mini');
1087
- }
1088
- if (/^claude-/i.test(text)) {
1089
- return text
1090
- .replace(/^claude-/i, 'Claude ')
1091
- .split('-')
1092
- .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
1093
- .join(' ');
1094
- }
1095
- if (/^gemini-/i.test(text)) {
1096
- return text
1097
- .replace(/^gemini-/i, 'Gemini ')
1098
- .split('-')
1099
- .map((part) => (/^\d+(\.\d+)?$/.test(part) ? part : (part.charAt(0).toUpperCase() + part.slice(1))))
1100
- .join(' ');
1101
- }
1102
- return text;
1103
- }
1104
-
1090
+ // Bare label (no window suffix) for places outside the option list, such as
1091
+ // the runtime-lock pin.
1105
1092
  function modelOptionLabel(modelVariantId = '') {
1106
- if (String(modelVariantId || '').trim().toLowerCase() === AUTO_MODEL_OPTION) return 'Auto';
1107
- const { baseModelId, reasoningEffort } = splitVariantId(modelVariantId);
1108
- if (!baseModelId) return modelVariantId;
1109
- const baseLabel = humanizeModelLabel(baseModelId);
1110
- return reasoningEffort ? `${baseLabel} (${reasoningEffort})` : baseLabel;
1093
+ return catalogModelLabel(modelVariantId, modelCatalogState.modelMetadataByModel, {
1094
+ autoValue: AUTO_MODEL_OPTION,
1095
+ });
1111
1096
  }
1112
1097
 
1113
1098
  function normalizeReasoningEffortList(efforts = []) {
@@ -1158,7 +1143,7 @@ function syncModelMetadataBlocker(message = '') {
1158
1143
  reasoningSelect.disabled = blocked;
1159
1144
  reasoningSelect.title = blocked ? 'Reasoning metadata unavailable' : 'Reasoning effort';
1160
1145
  }
1161
- window.syncComposerControlState?.();
1146
+ window.syncComposerButtonState?.();
1162
1147
  }
1163
1148
 
1164
1149
  function currentConversationHasMessages() {
@@ -1314,6 +1299,9 @@ function clearModelMetadataHardFail() {
1314
1299
  async function startAppWithErrorHandling() {
1315
1300
  try {
1316
1301
  await initApp();
1302
+ // Post-auth: move a legacy per-browser install app name to the relay so
1303
+ // manifest fetches (Android WebAPK checks included) stop reverting it.
1304
+ void adoptLegacyPwaAppName();
1317
1305
  return true;
1318
1306
  } catch (error) {
1319
1307
  console.error('[bootstrap] initApp failed', error);
@@ -1332,6 +1320,25 @@ function activeComposerProviderType() {
1332
1320
  ).trim().toLowerCase();
1333
1321
  }
1334
1322
 
1323
+ /**
1324
+ * Keeps "Message <who>…" in step with the current model selection. The two
1325
+ * standing overrides win: the shared read-only text owns the field for the
1326
+ * whole session, and an active image-edit target owns it until cleared
1327
+ * (clearImageEditTarget calls back in through the window global).
1328
+ */
1329
+ function syncComposerPlaceholder() {
1330
+ const input = document.getElementById('msg-input');
1331
+ if (!input) return;
1332
+ if (isSharedReaderMode()) return;
1333
+ if (imageEditTarget) return;
1334
+ const rawValue = String(document.getElementById('model-select')?.value || '');
1335
+ const { baseModelId } = splitModelVariantId(rawValue);
1336
+ input.placeholder = composerPlaceholderFor({
1337
+ modelId: baseModelId || rawValue,
1338
+ providerType: normalizeModelSelectorProviderType(activeComposerProviderType()),
1339
+ });
1340
+ }
1341
+
1335
1342
  function normalizeModelSelectorProviderType(providerType = '') {
1336
1343
  const normalized = String(providerType || '').trim().toLowerCase();
1337
1344
  if (normalized === 'openai' || normalized === 'openai-byok') return 'openai';
@@ -1379,10 +1386,19 @@ function modelVisibleForActiveProvider(modelId, activeProviderType, providersByM
1379
1386
  return !((hasOpenAIByok || hasClaude || hasCursor || hasGrok) && !hasNonExclusiveProvider);
1380
1387
  }
1381
1388
 
1382
- function buildModelSelectorOptions(models = [], providersByModel = {}, activeProviderType = '') {
1383
- const normalizedOptions = normalizeModelSelectorOptions(models.length ? models : [FALLBACK_MODEL], {
1389
+ // metadataByModel is a parameter because the catalog refresh builds the next
1390
+ // option list before it commits nextState; reading modelCatalogState there
1391
+ // would label the new models with the old metadata.
1392
+ function buildModelSelectorOptions(
1393
+ models = [],
1394
+ providersByModel = {},
1395
+ activeProviderType = '',
1396
+ metadataByModel = modelCatalogState.modelMetadataByModel,
1397
+ ) {
1398
+ const normalizedOptions = buildCatalogModelOptions(models.length ? models : [FALLBACK_MODEL], {
1384
1399
  autoValue: AUTO_MODEL_OPTION,
1385
- labelFor: modelOptionLabel,
1400
+ metadataByModel,
1401
+ annotateContextWindow: normalizeModelSelectorProviderType(activeProviderType) !== 'claude',
1386
1402
  });
1387
1403
  return normalizedOptions.filter((option) => modelVisibleForActiveProvider(
1388
1404
  option.value,
@@ -1522,9 +1538,14 @@ function updateModelCatalogState(payload) {
1522
1538
  : [];
1523
1539
  const currentModel = String(payload?.currentModel || models[0] || '').trim();
1524
1540
  const defaultModel = String(payload?.defaultModel || models[0] || '').trim();
1525
- const normalizedModelOptions = normalizeModelSelectorOptions(models.length ? models : [FALLBACK_MODEL], {
1541
+ const modelMetadataByModel = payload?.modelMetadataByModel && typeof payload.modelMetadataByModel === 'object'
1542
+ ? payload.modelMetadataByModel
1543
+ : {};
1544
+ // Stored in catalog order so every consumer of modelCatalogState.models
1545
+ // (composer, provider rescope, auto fallback) walks the same sequence.
1546
+ const normalizedModelOptions = buildCatalogModelOptions(models.length ? models : [FALLBACK_MODEL], {
1526
1547
  autoValue: AUTO_MODEL_OPTION,
1527
- labelFor: modelOptionLabel,
1548
+ metadataByModel: modelMetadataByModel,
1528
1549
  });
1529
1550
  const normalizedModels = normalizedModelOptions.map((option) => option.value);
1530
1551
 
@@ -1569,9 +1590,7 @@ function updateModelCatalogState(payload) {
1569
1590
  ]))
1570
1591
  : {},
1571
1592
  reasoningEfforts: normalizeReasoningEffortList(payload?.reasoningEfforts || []),
1572
- modelMetadataByModel: payload?.modelMetadataByModel && typeof payload.modelMetadataByModel === 'object'
1573
- ? payload.modelMetadataByModel
1574
- : {},
1593
+ modelMetadataByModel,
1575
1594
  claudeContextTiersByModel: payload?.claudeContextTiersByModel && typeof payload.claudeContextTiersByModel === 'object'
1576
1595
  ? Object.fromEntries(Object.entries(payload.claudeContextTiersByModel).map(([modelId, tiers]) => [
1577
1596
  String(modelId || '').trim().toLowerCase(),
@@ -1590,6 +1609,7 @@ function updateModelCatalogState(payload) {
1590
1609
  normalizedModels,
1591
1610
  nextState.providersByModel,
1592
1611
  activeProviderType,
1612
+ nextState.modelMetadataByModel,
1593
1613
  );
1594
1614
  const nextModels = nextOptions.map((option) => option.value);
1595
1615
  const currentOptions = Array.from(select.options)
@@ -1670,6 +1690,10 @@ function updateModelCatalogState(payload) {
1670
1690
 
1671
1691
  syncModelMetadataBlocker();
1672
1692
  syncAutoModelAvailability();
1693
+ // After syncAutoModelAvailability: it can rewrite select.value (auto
1694
+ // fallback, runtime-lock pin) and the placeholder must describe the final
1695
+ // selection, not the transient one.
1696
+ syncComposerPlaceholder();
1673
1697
  // The rebuilt catalog may now contain the conversation's preferred model for
1674
1698
  // the first time (provider models arrive after the initial catalog load).
1675
1699
  if (currentConvId) applyConversationPreferencesForConversation(currentConvId);
@@ -1703,7 +1727,7 @@ function updateContextTierSelector(modelId) {
1703
1727
  const tierOptions = buildContextTierOptions({
1704
1728
  modelId,
1705
1729
  providerType: activeComposerProviderType(),
1706
- metadata: modelCatalogState.modelMetadataByModel?.[modelId] || {},
1730
+ metadata: modelMetadataFor(modelId, modelCatalogState.modelMetadataByModel) || {},
1707
1731
  claudeTiers: modelCatalogState.claudeContextTiersByModel,
1708
1732
  });
1709
1733
  const current = select.value;
@@ -1917,6 +1941,7 @@ function applyConversationPreferences({
1917
1941
  String(preferredReasoningEffort || '').trim().toLowerCase(),
1918
1942
  );
1919
1943
  updateContextTierSelector(selection.model || modelSelect.value);
1944
+ syncComposerPlaceholder();
1920
1945
  const tierSelect = document.getElementById('context-tier-select');
1921
1946
  const desiredTier = String(preferredContextTier || 'default').trim().toLowerCase();
1922
1947
  if (tierSelect && Array.from(tierSelect.options).some((option) => option.value === desiredTier)) {
@@ -1990,6 +2015,8 @@ function initModelSelector() {
1990
2015
  if (suppressConversationPreferenceSync) return;
1991
2016
  if (currentConversationHasMessages() && select.value.toLowerCase() === AUTO_MODEL_OPTION) {
1992
2017
  syncAutoModelAvailability();
2018
+ // The revert rewrites select.value; keep the placeholder describing it.
2019
+ syncComposerPlaceholder();
1993
2020
  setModelBanner('⚠️ Auto model selection is available only for a new conversation.');
1994
2021
  return;
1995
2022
  }
@@ -2003,6 +2030,7 @@ function initModelSelector() {
2003
2030
  { persist: true },
2004
2031
  );
2005
2032
  updateContextTierSelector(select.value);
2033
+ syncComposerPlaceholder();
2006
2034
  syncSessionLockNote({ pinnedModel: currentRuntimeModelLock()?.model || '' });
2007
2035
  refreshComposerAttachmentWarning();
2008
2036
  void persistCurrentConversationPreferences().catch(() => {});
@@ -2145,6 +2173,10 @@ function applyModelVariantCatalogState(payload) {
2145
2173
  selectable: entry?.selectable !== false,
2146
2174
  enabled: !!entry?.enabled,
2147
2175
  sortOrder: Number.isFinite(Number(entry?.sortOrder)) ? Math.max(0, Math.trunc(Number(entry.sortOrder))) : 0,
2176
+ contextLimitTokens: Number.isFinite(Number(entry?.contextLimitTokens)) && Number(entry.contextLimitTokens) > 0
2177
+ ? Number(entry.contextLimitTokens)
2178
+ : null,
2179
+ pricing: entry?.pricing && typeof entry.pricing === 'object' ? entry.pricing : null,
2148
2180
  })).filter((entry) => entry.variantId && entry.baseModelId)
2149
2181
  : [];
2150
2182
  const canonicalizeId = (value) => String(value || '').trim().toLowerCase();
@@ -2169,6 +2201,8 @@ function applyModelVariantCatalogState(payload) {
2169
2201
  sortOrder: Math.min(existing.sortOrder, entry.sortOrder),
2170
2202
  label: existing.label || entry.label,
2171
2203
  provider: existing.provider || entry.provider,
2204
+ contextLimitTokens: existing.contextLimitTokens ?? entry.contextLimitTokens,
2205
+ pricing: existing.pricing || entry.pricing,
2172
2206
  });
2173
2207
  }
2174
2208
  const variants = Array.from(dedupedVariantsMap.values());
@@ -2185,6 +2219,15 @@ function applyModelVariantCatalogState(payload) {
2185
2219
  normalizeReasoningEffortList(efforts),
2186
2220
  ]))
2187
2221
  : {},
2222
+ contextLimitsByModel: payload?.contextLimitsByModel && typeof payload.contextLimitsByModel === 'object'
2223
+ ? Object.fromEntries(Object.entries(payload.contextLimitsByModel).map(([modelId, tokens]) => [
2224
+ String(modelId || '').trim().toLowerCase(),
2225
+ Number(tokens),
2226
+ ]).filter(([modelId, tokens]) => modelId && Number.isFinite(tokens) && tokens > 0))
2227
+ : {},
2228
+ modelMetadataByModel: payload?.modelMetadataByModel && typeof payload.modelMetadataByModel === 'object'
2229
+ ? payload.modelMetadataByModel
2230
+ : {},
2188
2231
  source: String(payload?.source || '').trim() || null,
2189
2232
  refreshedAt: payload?.refreshedAt || null,
2190
2233
  warning: payload?.warning ? String(payload.warning) : null,
@@ -2514,6 +2557,13 @@ function renderModelVariantCatalogBody() {
2514
2557
  ? { ...fallbackMeta, efforts: new Set(providerEfforts) }
2515
2558
  : fallbackMeta;
2516
2559
  const label = meta.label || humanizeModelLabel(baseModelId) || baseModelId;
2560
+ const windowSuffix = contextWindowSuffix(
2561
+ baseModelId,
2562
+ modelVariantCatalogState.modelMetadataByModel || modelCatalogState.modelMetadataByModel,
2563
+ );
2564
+ const windowChip = windowSuffix
2565
+ ? `<span class="model-reasoning-chip model-reasoning-chip-muted">${escHtml(windowSuffix.replace(/^ · /, ''))}</span>`
2566
+ : '';
2517
2567
  const variantRowsHtml = variantRows.map((row) => {
2518
2568
  const selectable = row.selectable !== false;
2519
2569
  // SDK-provider rows (Claude/Cursor/Grok) carry their own enabled flag; only
@@ -2551,7 +2601,7 @@ function renderModelVariantCatalogBody() {
2551
2601
  return `
2552
2602
  <div class="model-base-group">
2553
2603
  <div class="model-base-header">
2554
- <div class="model-base-title">${escHtml(label)}</div>
2604
+ <div class="model-base-title">${escHtml(label)}${windowChip ? ` ${windowChip}` : ''}</div>
2555
2605
  <div class="model-base-reasoning">
2556
2606
  <span class="model-base-reasoning-label">Reasoning</span>
2557
2607
  ${renderReasoningEffortChipRow(meta.efforts)}
@@ -3194,6 +3244,10 @@ async function refreshSessionWorkerStatus() {
3194
3244
  syncQueueStatusMenuEntry(status);
3195
3245
  if (setSessionWorkerStatesFromStatusPayload(status.sessionWorker)) {
3196
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();
3197
3251
  }
3198
3252
  updateCliStatus();
3199
3253
  const nextWorkerState = currentSdkSessionId ? getSessionWorkerState(currentSdkSessionId) : null;
@@ -3224,8 +3278,13 @@ async function pollAuthenticatedCurrentConversationLive() {
3224
3278
  const currentId = String(currentConvId || '').trim();
3225
3279
  if (!currentId) return;
3226
3280
  const currentConversation = conversations[currentId] || null;
3281
+ // A locally-sent message still waiting in the queue must keep the poll
3282
+ // armed: while it waits, inFlight is null, so the poll itself tears the
3283
+ // bubble down — leaving neither localTurnStatus nor an indicator to revive
3284
+ // polling once a worker finally picks the message up.
3227
3285
  const isProcessing = String(currentConversation?.localTurnStatus || '').trim().toLowerCase() === 'processing'
3228
- || !!document.getElementById('thinking-indicator');
3286
+ || !!document.getElementById('thinking-indicator')
3287
+ || hasPendingUserMessageForConversation(currentId);
3229
3288
  if (!isProcessing) return;
3230
3289
  // Refreshing while the user selects or drags in the chat would rebuild the
3231
3290
  // DOM under the selection; the guard's release callback re-polls right away.
@@ -3260,9 +3319,18 @@ function startLiveConversationPolling() {
3260
3319
  }, 900);
3261
3320
  }
3262
3321
 
3322
+ const transcriptResizeKeeper = createTranscriptResizeKeeper({
3323
+ getElement: () => document.getElementById('messages'),
3324
+ });
3325
+
3263
3326
  function setupViewportTracking() {
3264
3327
  syncViewportMetrics();
3265
- 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
+ };
3266
3334
  window.addEventListener('resize', update, { passive: true });
3267
3335
  window.addEventListener('orientationchange', update, { passive: true });
3268
3336
  if (window.visualViewport) {
@@ -3301,7 +3369,7 @@ function setupViewportTracking() {
3301
3369
  if (input && input.dataset.viewportBound !== '1') {
3302
3370
  input.dataset.viewportBound = '1';
3303
3371
  input.addEventListener('input', () => {
3304
- syncComposerControlState();
3372
+ syncComposerAfterUserEdit();
3305
3373
  }, { passive: true });
3306
3374
  input.addEventListener('focus', () => {
3307
3375
  document.body.classList.add('keyboard-open');
@@ -3376,6 +3444,7 @@ function initMessageScrollPersistence() {
3376
3444
  if (!el || el.dataset.scrollPersistenceBound === '1') return;
3377
3445
  el.dataset.scrollPersistenceBound = '1';
3378
3446
  el.addEventListener('scroll', () => {
3447
+ transcriptResizeKeeper.recordScroll();
3379
3448
  const convId = String(currentConvId || '').trim();
3380
3449
  if (!convId) return;
3381
3450
  saveConversationScrollTop(convId, el.scrollTop);
@@ -3893,7 +3962,11 @@ async function initApp() {
3893
3962
  // The direct flush below can be killed mid-flight by the browser; the
3894
3963
  // queued copy survives and is replayed by Background Sync. If the direct
3895
3964
  // write wins, the replay hits a draft version conflict and is dropped.
3896
- if (!appSharedMode) void enqueueDraftFlushForBackgroundSync(currentConvId);
3965
+ if (!appSharedMode) {
3966
+ void enqueueDraftFlushForBackgroundSync(currentConvId, {
3967
+ afterPendingSend: isConversationDraftHeldBySend(currentConvId),
3968
+ });
3969
+ }
3897
3970
  void flushConversationDraft(currentConvId);
3898
3971
  closeTmuxInspectorView();
3899
3972
  });
@@ -4113,6 +4186,7 @@ async function initApp() {
4113
4186
  initEmojiPicker();
4114
4187
  initSlashAutocomplete();
4115
4188
  initConversationListLazyLoading();
4189
+ initConversationFilter();
4116
4190
  initConversationHistoryLazyLoading();
4117
4191
  initBubbleActionHandlers();
4118
4192
  initMessageScrollPersistence();
@@ -4164,9 +4238,6 @@ async function bootstrap() {
4164
4238
  .then((registrations) => Promise.all(registrations.map((registration) => registration.unregister().catch(() => false))))
4165
4239
  .catch(() => {});
4166
4240
  }
4167
- if (!sharedMode) {
4168
- await applyPwaManifestFromSettings();
4169
- }
4170
4241
  if (!sharedMode) {
4171
4242
  registerPwaShell();
4172
4243
  initInstallButton();
@@ -4219,6 +4290,12 @@ window.confirmGrokLogout = confirmGrokLogout;
4219
4290
  window.confirmCliInstall = confirmCliInstall;
4220
4291
  window.runCliInstall = runCliInstall;
4221
4292
  window.cancelCliInstallRun = cancelCliInstallRun;
4293
+ window.updateAutoCheckSetting = updateAutoCheckSetting;
4294
+ window.checkForUpdatesNow = checkForUpdatesNow;
4295
+ window.applyAvailableUpdate = applyAvailableUpdate;
4296
+ window.cancelRunningUpdate = cancelRunningUpdate;
4297
+ window.dismissAvailableUpdate = dismissAvailableUpdate;
4298
+ window.dismissUpdateOutcome = dismissUpdateOutcome;
4222
4299
  window.saveGrokSettings = saveGrokSettings;
4223
4300
  window.toggleGrokProvider = toggleGrokProvider;
4224
4301
  window.saveCursorSettings = saveCursorSettings;
@@ -4231,7 +4308,7 @@ window.saveGrokAllowanceSettings = saveGrokAllowanceSettings;
4231
4308
  window.resetGrokAllowanceAccounting = resetGrokAllowanceAccounting;
4232
4309
  window.toggleCursorProvider = toggleCursorProvider;
4233
4310
  window.updateShowSuspendHostSetting = updateShowSuspendHostSetting;
4234
- window.updateWindowsAutostartSettingFromToggle = updateWindowsAutostartSettingFromToggle;
4311
+ window.updateWindowsAutostartMode = updateWindowsAutostartMode;
4235
4312
  window.previewTurnCeilingSetting = previewTurnCeilingSetting;
4236
4313
  window.updateTurnCeilingSetting = updateTurnCeilingSetting;
4237
4314
  window.previewBackgroundTaskTimeoutSetting = previewBackgroundTaskTimeoutSetting;
@@ -4285,8 +4362,11 @@ window.retryAttachmentUpload = retryAttachmentUpload;
4285
4362
  window.removeAttachment = removeAttachment;
4286
4363
  window.clearAttachments = clearAttachments;
4287
4364
  window.openUploadedAttachmentViewer = openUploadedAttachmentViewer;
4365
+ window.openAnnotateEditorForPending = openAnnotateEditorForPending;
4366
+ window.openAnnotateEditorForPreview = openAnnotateEditorForPreview;
4288
4367
  window.setFilePreviewMode = setFilePreviewMode;
4289
4368
  window.toggleFilePreviewHtml = toggleFilePreviewHtml;
4369
+ window.copyFilePreviewImage = copyFilePreviewImage;
4290
4370
  window.closeFilePreview = closeFilePreview;
4291
4371
  window.goBackFilePreview = goBackFilePreview;
4292
4372
  window.openWorkspaceFilePreview = openWorkspaceFilePreview;
@@ -4301,14 +4381,18 @@ window.confirmRepoBrowserCwdPick = confirmRepoBrowserCwdPick;
4301
4381
  window.toggleEmojiPicker = toggleEmojiPicker;
4302
4382
  window.submitRelayQuestionChoice = submitRelayQuestionChoice;
4303
4383
  window.submitRelayQuestionAnswer = submitRelayQuestionAnswer;
4384
+ window.submitRelayQuestionMultiSelect = submitRelayQuestionMultiSelect;
4304
4385
  window.submitRelayStructuredAnswer = submitRelayStructuredAnswer;
4305
4386
  window.onRelayQuestionDraftInput = onRelayQuestionDraftInput;
4387
+ window.onRelayQuestionMultiSelectChange = onRelayQuestionMultiSelectChange;
4388
+ window.setRelayQuestionMultiMode = setRelayQuestionMultiMode;
4306
4389
  window.handleRelayQuestionKey = handleRelayQuestionKey;
4307
4390
  window.openPendingQuestionFromBanner = openPendingQuestionFromBanner;
4308
4391
  window.submitRelayBoardAction = submitRelayBoardAction;
4309
4392
  window.compactCurrentConversation = compactCurrentConversation;
4310
4393
  window.sendMessage = sendMessage;
4311
- window.syncComposerControlState = syncComposerControlState;
4394
+ window.syncComposerButtonState = syncComposerButtonState;
4395
+ window.syncComposerPlaceholder = syncComposerPlaceholder;
4312
4396
  window.persistComposerAttachments = persistComposerAttachments;
4313
4397
  window.appendMessage = appendMessage;
4314
4398
  window.loadOlderConversationMessages = loadOlderConversationMessages;