@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
@@ -19,19 +19,51 @@ export function hasUploadingAttachments(attachments = []) {
19
19
  return countUploadingAttachments(attachments) > 0;
20
20
  }
21
21
 
22
+ // Why a message sent right now queues instead of steering — worker-reported
23
+ // hold reasons mapped to a human title. Stopping a turn lives on the message
24
+ // bubbles, never here, so every state below is a Send/Steer/Queue shape.
25
+ const STEERING_HOLD_TITLES = Object.freeze({
26
+ question: 'Will steer in after you answer the question',
27
+ compaction: 'Will steer in once the conversation finishes compacting',
28
+ adoption: 'Will steer in once the compaction recovery finishes',
29
+ delivery: 'Will steer in once the current delivery lands',
30
+ });
31
+
22
32
  export function deriveComposerControlState({
23
33
  hasActiveTurn = false,
24
- cancelRequested = false,
25
34
  hasDraft = false,
26
35
  sendInFlight = false,
27
36
  modelMetadataBlocked = false,
28
37
  attachmentsUploading = false,
38
+ // The conversation's session worker delivers a message typed during a live
39
+ // turn INTO that turn (mid-turn steering, advertised on its heartbeat
40
+ // snapshot) rather than queueing it behind — so the control says "Steer",
41
+ // not "Queue". Workers that do not advertise it keep the queue wording.
42
+ steeringSupported = false,
43
+ // The worker reports steering as momentarily held (open question card or
44
+ // plan approval, compaction, post-compaction adoption). A send stays allowed
45
+ // and reads "Queue": the relay holds the message and it steers in once the
46
+ // hold clears.
47
+ steeringHeld = false,
48
+ steeringHoldReason = null,
29
49
  } = {}) {
30
50
  const active = !!hasActiveTurn;
31
- const stopping = !!cancelRequested;
32
51
  const draft = !!hasDraft;
33
52
  const metadataBlocked = !!modelMetadataBlocked;
34
53
  const uploading = !!attachmentsUploading;
54
+ const held = active && !!steeringSupported && !!steeringHeld;
55
+ // The label/title/action for a draft typed during a live turn: steer into it
56
+ // where the provider supports that (queued while steering is held), queue
57
+ // behind it otherwise.
58
+ const steerNow = !!steeringSupported && !held;
59
+ const midTurnAction = steerNow ? 'steer' : 'queue';
60
+ const midTurnLabel = steerNow ? 'Steer' : 'Queue';
61
+ const midTurnTitle = held
62
+ ? (STEERING_HOLD_TITLES[String(steeringHoldReason || '').trim()]
63
+ || 'Will steer in once steering resumes')
64
+ : (steeringSupported
65
+ ? 'Steer message into the running turn'
66
+ : 'Queue message behind current turn');
35
67
 
36
68
  if (metadataBlocked && !active) {
37
69
  return {
@@ -42,59 +74,45 @@ export function deriveComposerControlState({
42
74
  };
43
75
  }
44
76
 
45
- // Uploads are eager, so the blocking window is short. Keeping the button
46
- // labelled Send/Queue (rather than switching to Stop) avoids the control
47
- // flipping meaning mid-upload while a turn is running.
77
+ // Uploads are eager, so the blocking window is short. The control keeps its
78
+ // Send/Steer/Queue meaning while it waits.
48
79
  if (uploading) {
49
- const queueing = active && draft;
80
+ const midTurn = active && draft;
50
81
  return {
51
- action: queueing ? 'queue' : 'send',
52
- label: queueing ? 'Queue' : 'Send',
82
+ action: midTurn ? midTurnAction : 'send',
83
+ label: midTurn ? midTurnLabel : 'Send',
53
84
  title: 'Waiting for attachments to finish uploading',
54
85
  disabled: true,
55
86
  };
56
87
  }
57
88
 
58
89
  if (sendInFlight) {
59
- if (active && draft) {
60
- return {
61
- action: 'queue',
62
- label: 'Queue',
63
- title: 'Queue message behind current turn',
64
- disabled: true,
65
- };
66
- }
67
- if (active) {
68
- return {
69
- action: 'stop',
70
- label: stopping ? 'Stopping…' : 'Stop',
71
- title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
72
- disabled: true,
73
- };
74
- }
75
90
  return {
76
- action: 'send',
77
- label: 'Send',
78
- title: 'Send message',
91
+ action: active && draft ? midTurnAction : 'send',
92
+ label: active && draft ? midTurnLabel : 'Send',
93
+ title: active && draft ? midTurnTitle : 'Send message',
79
94
  disabled: true,
80
95
  };
81
96
  }
82
97
 
83
98
  if (active && draft) {
84
99
  return {
85
- action: 'queue',
86
- label: 'Queue',
87
- title: 'Queue message behind current turn',
100
+ action: midTurnAction,
101
+ label: midTurnLabel,
102
+ title: midTurnTitle,
88
103
  disabled: false,
89
104
  };
90
105
  }
91
106
 
92
107
  if (active) {
108
+ // Empty composer during a live turn: nothing to send, nothing to steer.
109
+ // The label stays "Send" (it only reads "Steer" with text present) and
110
+ // re-enables the moment the user types or the turn ends.
93
111
  return {
94
- action: 'stop',
95
- label: stopping ? 'Stopping…' : 'Stop',
96
- title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
97
- disabled: stopping,
112
+ action: 'send',
113
+ label: 'Send',
114
+ title: 'Type a message to send',
115
+ disabled: true,
98
116
  };
99
117
  }
100
118
 
@@ -1,11 +1,31 @@
1
1
  // Mirrors shared/model-id.mjs, which the browser cannot import (only
2
2
  // server/public is served).
3
3
  const CLAUDE_LONG_CONTEXT_LIMIT_TOKENS = 1000000;
4
- const UNKNOWN_WINDOW_LABEL = '—';
4
+ export const UNKNOWN_WINDOW_LABEL = '—';
5
5
 
6
- function tokenLabel(tokens) {
6
+ // Shared by the context chip and both model pickers so a window reads the same
7
+ // everywhere: "400K", "1M", "1.05M" (the chip is 84px wide; "1050K" overflows).
8
+ export function tokenLabel(tokens) {
7
9
  const limit = Number(tokens);
8
- return Number.isFinite(limit) && limit > 0 ? `${Math.round(limit / 1000)}K` : UNKNOWN_WINDOW_LABEL;
10
+ if (!Number.isFinite(limit) || limit <= 0) return UNKNOWN_WINDOW_LABEL;
11
+ if (limit >= 1000000) {
12
+ return `${String((limit / 1000000).toFixed(2)).replace(/\.?0+$/, '')}M`;
13
+ }
14
+ return `${Math.round(limit / 1000)}K`;
15
+ }
16
+
17
+ // This chip is a context-TIER selector, so the default option must show the
18
+ // default tier's own limit: the relay now caps defaultContextLimitTokens at
19
+ // the runtime's real window (haiku 144K, gpt-5.4-mini 400K), while a model
20
+ // with a long-context tier keeps a smaller default (gpt-5.6-terra: 400K
21
+ // default, 1.05M long). Labelling the default with the full window would
22
+ // render both of terra's tiers as "1.05M". The real window is only the
23
+ // fallback for metadata that carries no derived default at all.
24
+ function defaultWindowTokens(meta = {}) {
25
+ const derived = Number(meta.defaultContextLimitTokens);
26
+ if (Number.isFinite(derived) && derived > 0) return derived;
27
+ const real = Number(meta.contextWindowTokens);
28
+ return Number.isFinite(real) && real > 0 ? real : null;
9
29
  }
10
30
 
11
31
  function claudeTiersForModel(modelId, claudeTiers) {
@@ -33,7 +53,7 @@ export function buildContextTierOptions({
33
53
  claudeTiers = null,
34
54
  } = {}) {
35
55
  const meta = metadata && typeof metadata === 'object' ? metadata : {};
36
- const defaultLabel = tokenLabel(meta.defaultContextLimitTokens);
56
+ const defaultLabel = tokenLabel(defaultWindowTokens(meta));
37
57
  const provider = String(providerType || '').trim().toLowerCase();
38
58
  const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
39
59
  if (claudeTierList) {
@@ -0,0 +1,187 @@
1
+ // Per-conversation record of the server draft this client's composer last
2
+ // agreed with. The composer text is local state that changes on every
3
+ // keystroke; this record changes only when the server acknowledges a save or
4
+ // the composer adopts a remote draft. Its updatedAt is the base version every
5
+ // draft save is checked against, so it must never be touched by a local edit.
6
+
7
+ import { serializeDraftAttachments } from './composer-attachment-cache.mjs';
8
+ import { normalizeDraftTimestampMs } from './conversation-draft-timestamp-utils.mjs';
9
+
10
+ // Sent with every draft save. It is what lets the server conflict-check an
11
+ // explicit null base: clients from before this protocol also send null (JSON
12
+ // keeps it), and for them null has to keep meaning "unconditional".
13
+ export const DRAFT_SYNC_PROTOCOL_VERSION = 2;
14
+
15
+ // Must equal the server's MAX_CONVERSATION_DRAFT_LENGTH (a test pins it). The
16
+ // server truncates longer drafts, so the client saves and compares only this
17
+ // prefix; otherwise a long composer never matches its acknowledged draft and
18
+ // is re-saved on every refresh. The composer itself (and the message) is not
19
+ // limited — only what survives as a draft.
20
+ export const MAX_DRAFT_TEXT_LENGTH = 20_000;
21
+
22
+ // Cuts at a code-point boundary, exactly like the server: a cut through a
23
+ // surrogate pair would store a lone surrogate, which SQLite hands back as
24
+ // U+FFFD — a different text at the same version.
25
+ export function draftTextForSync(text) {
26
+ const value = String(text || '');
27
+ if (value.length <= MAX_DRAFT_TEXT_LENGTH) return value;
28
+ const cut = value.slice(0, MAX_DRAFT_TEXT_LENGTH);
29
+ const last = cut.charCodeAt(cut.length - 1);
30
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
31
+ }
32
+
33
+ const syncedDraftsByConversation = new Map();
34
+
35
+ function conversationKey(conversationId) {
36
+ return String(conversationId || '').trim();
37
+ }
38
+
39
+ /** Order-sensitive fingerprint of a draft attachment list; null when absent. */
40
+ export function draftAttachmentsKey(attachments) {
41
+ if (attachments === undefined || attachments === null) return null;
42
+ return serializeDraftAttachments(Array.isArray(attachments) ? attachments : [], { max: Number.MAX_SAFE_INTEGER })
43
+ .map((row) => row.sha256)
44
+ .join(',');
45
+ }
46
+
47
+ export function getSyncedDraft(conversationId) {
48
+ return syncedDraftsByConversation.get(conversationKey(conversationId)) || null;
49
+ }
50
+
51
+ export function recordSyncedDraft(conversationId, {
52
+ text = '',
53
+ attachments = undefined,
54
+ updatedAt = null,
55
+ } = {}) {
56
+ const id = conversationKey(conversationId);
57
+ if (!id) return null;
58
+ const previous = syncedDraftsByConversation.get(id) || null;
59
+ const incomingKey = draftAttachmentsKey(attachments);
60
+ const record = {
61
+ text: String(text || ''),
62
+ attachmentsKey: incomingKey === null ? (previous?.attachmentsKey ?? null) : incomingKey,
63
+ updatedAt: updatedAt || null,
64
+ };
65
+ syncedDraftsByConversation.set(id, record);
66
+ return record;
67
+ }
68
+
69
+ export function forgetSyncedDraft(conversationId) {
70
+ syncedDraftsByConversation.delete(conversationKey(conversationId));
71
+ }
72
+
73
+ /**
74
+ * A save is a no-op when the server already holds exactly this text (and,
75
+ * for saves that carry attachments, exactly these attachments) — but only
76
+ * while no newer server version is known. If one is (a remote draft deferred
77
+ * while the user typed), the save must still go out so its version check
78
+ * surfaces that draft instead of silently keeping a stale base.
79
+ */
80
+ export function isDraftSaveNoop({ synced = null, text = '', attachmentsKey = null, knownUpdatedAt = null } = {}) {
81
+ if (!synced) return false;
82
+ if (normalizeDraftTimestampMs(knownUpdatedAt) > normalizeDraftTimestampMs(synced.updatedAt)) return false;
83
+ if (String(text || '') !== synced.text) return false;
84
+ if (attachmentsKey === null) return true;
85
+ return synced.attachmentsKey !== null && attachmentsKey === synced.attachmentsKey;
86
+ }
87
+
88
+ /**
89
+ * Decides a 409 draft conflict.
90
+ * - converged: the server already holds what we wanted to save.
91
+ * - adopt: the local text is unchanged since the last sync, so the remote
92
+ * draft simply wins.
93
+ * - keep-local: both sides edited; the local (newest) keystroke wins and the
94
+ * remote text becomes the one offered for restore.
95
+ */
96
+ export function resolveDraftConflict({
97
+ localText = '',
98
+ localAttachmentsKey = null,
99
+ baseText = null,
100
+ baseAttachmentsKey = null,
101
+ serverText = '',
102
+ serverAttachmentsKey = null,
103
+ } = {}) {
104
+ const local = String(localText || '');
105
+ const attachmentsMatch = (other) => localAttachmentsKey === null || localAttachmentsKey === other;
106
+ if (local === String(serverText || '') && attachmentsMatch(serverAttachmentsKey)) return 'converged';
107
+ if (baseText !== null && local === baseText && attachmentsMatch(baseAttachmentsKey)) return 'adopt';
108
+ return 'keep-local';
109
+ }
110
+
111
+ /**
112
+ * Whether an incoming remote draft may replace the composer of the
113
+ * conversation on screen. Only an unmodified composer (text and attachments
114
+ * still equal to the last synced draft) adopts it, focused or not. A modified
115
+ * one never does — not even for an equal or newer version: its edit is unsaved
116
+ * (a failed flush leaves exactly this state), and the caller re-saves it so the
117
+ * version check decides. An upload still in flight counts as a modification.
118
+ * With nothing synced yet there is no saved edit to protect.
119
+ * Attachment keys are null when unknown and then do not take part.
120
+ */
121
+ export function shouldApplyIncomingDraftToComposer({
122
+ inputText = '',
123
+ incomingText = '',
124
+ syncedText = null,
125
+ inputAttachmentsKey = null,
126
+ incomingAttachmentsKey = null,
127
+ syncedAttachmentsKey = null,
128
+ attachmentsUploading = false,
129
+ } = {}) {
130
+ if (attachmentsUploading) return false;
131
+ const current = draftTextForSync(inputText);
132
+ const sameAttachments = (other) => inputAttachmentsKey === null || other === null || inputAttachmentsKey === other;
133
+ if (current === String(incomingText || '') && sameAttachments(incomingAttachmentsKey)) return true;
134
+ if (syncedText === null) return true;
135
+ return current === syncedText && sameAttachments(syncedAttachmentsKey);
136
+ }
137
+
138
+ /**
139
+ * PATCH body for the pagehide Background Sync copy of a draft flush, or null
140
+ * when the composer holds nothing unsaved. Compared against the synced draft,
141
+ * not the local text (which every keystroke updates).
142
+ */
143
+ export function draftFlushRequestBody({
144
+ inputText = '',
145
+ synced = null,
146
+ fallbackText = '',
147
+ fallbackUpdatedAt = null,
148
+ clientId = null,
149
+ afterPendingSend = false,
150
+ } = {}) {
151
+ const draftText = draftTextForSync(inputText);
152
+ const serverText = synced ? synced.text : draftTextForSync(fallbackText);
153
+ if (draftText === serverText) return null;
154
+ if (afterPendingSend) {
155
+ // An emptied composer after a send is what the send leaves anyway.
156
+ if (!draftText.trim()) return null;
157
+ // The send will move the server's version past this base. acceptOwnSend
158
+ // lets the replay through only while that move is this client's own (the
159
+ // send transaction records the sender); anyone else's newer draft still
160
+ // wins with a 409.
161
+ return {
162
+ draftText,
163
+ clientId,
164
+ draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
165
+ baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
166
+ acceptOwnSend: true,
167
+ };
168
+ }
169
+ return {
170
+ draftText,
171
+ clientId,
172
+ draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
173
+ baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
174
+ };
175
+ }
176
+
177
+ // A queued draft flush replayed later than this (Firefox/Safari replay the
178
+ // outbox only on the next page load, possibly hours on) would write text
179
+ // nobody is looking at over whatever the draft became meanwhile. sw.js
180
+ // mirrors this value; a test pins them together.
181
+ export const DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
182
+
183
+ export function isStaleDraftFlushEntry(entry, now = Date.now()) {
184
+ if (String(entry?.kind || '') !== 'draft-flush') return false;
185
+ const createdAt = Number(entry?.createdAt || 0);
186
+ return !Number.isFinite(createdAt) || createdAt <= 0 || now - createdAt > DRAFT_FLUSH_MAX_AGE_MS;
187
+ }
@@ -0,0 +1,86 @@
1
+ // Title filtering for the conversation sidebar. Kept DOM-free so it can be unit
2
+ // tested; journal-view.js touches window/document at module scope and cannot be
3
+ // imported from node:test.
4
+
5
+ // NFC first so a decomposed "é" (e + U+0301, as some IMEs and pasted text
6
+ // produce) matches a precomposed one. Case folding is toLocaleLowerCase only, so
7
+ // multi-char folds are a known limit: "ß" does not match "SS".
8
+ function foldForMatch(text) {
9
+ return String(text ?? '').normalize('NFC').toLocaleLowerCase();
10
+ }
11
+
12
+ export function normalizeConversationFilter(text) {
13
+ return foldForMatch(text).trim();
14
+ }
15
+
16
+ export function conversationMatchesFilter(conversation, normalizedFilter) {
17
+ if (!normalizedFilter) return true;
18
+ return foldForMatch(conversation?.title).includes(normalizedFilter);
19
+ }
20
+
21
+ export function filterConversations(conversations, filterText) {
22
+ const list = Array.isArray(conversations) ? conversations : [];
23
+ const normalized = normalizeConversationFilter(filterText);
24
+ if (!normalized) return list;
25
+ return list.filter((conversation) => conversationMatchesFilter(conversation, normalized));
26
+ }
27
+
28
+ export function describeFilterMatchCount(count, { loadedOnly = false } = {}) {
29
+ const n = Math.max(0, Number(count) || 0);
30
+ const noun = loadedOnly ? 'loaded conversation' : 'conversation';
31
+ if (n === 0) return `No ${noun}s match`;
32
+ return n === 1 ? `1 ${noun} matches` : `${n} ${noun}s match`;
33
+ }
34
+
35
+ function cursorKey(state) {
36
+ try {
37
+ return JSON.stringify(state?.nextCursor ?? null);
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ // While a filter is active the sidebar has to search conversations that only
44
+ // exist on unloaded pages, so pull every remaining page through the loader.
45
+ // shouldContinue() is checked before each page so clearing the filter (or
46
+ // switching it) aborts the drain. loadMore() resolving falsy is ambiguous:
47
+ // - the loader is busy (a boundary-check load/prefetch is in flight): wait it
48
+ // out without spending idle retries, so the drain does not give up while the
49
+ // list is still loading. busyRetries caps a genuinely stuck fetch.
50
+ // - a reset bumped the loader version mid-fetch, or the fetch failed: retry,
51
+ // but stop after maxIdleRetries consecutive no-progress attempts so a failing
52
+ // endpoint is not hammered forever.
53
+ // An advanced cursor counts as progress even when the page was empty.
54
+ export async function drainRemainingPages(loader, shouldContinue = () => true, {
55
+ retryDelayMs = 200,
56
+ maxIdleRetries = 10,
57
+ maxBusyRetries = 300,
58
+ delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
59
+ } = {}) {
60
+ if (!loader || typeof loader.loadMore !== 'function' || typeof loader.getState !== 'function') {
61
+ return false;
62
+ }
63
+ let loadedAnything = false;
64
+ let idleRetries = 0;
65
+ let busyRetries = 0;
66
+ while (loader.getState().hasMore && shouldContinue()) {
67
+ const cursorBefore = cursorKey(loader.getState());
68
+ const loaded = await loader.loadMore();
69
+ const state = loader.getState();
70
+ if (loaded) loadedAnything = true;
71
+ if (loaded || cursorKey(state) !== cursorBefore) {
72
+ idleRetries = 0;
73
+ busyRetries = 0;
74
+ continue;
75
+ }
76
+ if (state.isLoading || state.isPrefetching) {
77
+ busyRetries += 1;
78
+ if (busyRetries > maxBusyRetries) break;
79
+ } else {
80
+ idleRetries += 1;
81
+ if (idleRetries > maxIdleRetries) break;
82
+ }
83
+ await delay(retryDelayMs);
84
+ }
85
+ return loadedAnything;
86
+ }
@@ -69,6 +69,49 @@ export function resolveConversationComposerSelection({
69
69
  };
70
70
  }
71
71
 
72
+ // Every effort rung any provider speaks, lowest first. 'ultracode' sits on top
73
+ // but is a Claude mode rather than "more thinking", so it is never a clamp
74
+ // target and a remembered ultracode does not degrade to xhigh (it falls
75
+ // through to the next candidate — the existing "drops cleanly" rule).
76
+ export const REASONING_EFFORT_LADDER = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultracode'];
77
+
78
+ function normalizeEffortList(efforts = []) {
79
+ return (Array.isArray(efforts) ? efforts : [])
80
+ .map((effort) => String(effort || '').trim().toLowerCase())
81
+ .filter(Boolean);
82
+ }
83
+
84
+ /**
85
+ * Clamp rule, per candidate in priority order:
86
+ * 1. the candidate itself when the model supports it;
87
+ * 2. else the highest supported rung BELOW it, ignoring 'none'
88
+ * (gpt-5.6-terra@max → gpt-5.4-mini lands on xhigh);
89
+ * 3. else the lowest supported rung ABOVE it, ignoring 'none'
90
+ * (a remembered 'minimal' on a model whose floor is 'low' → low);
91
+ * 4. else the next candidate.
92
+ * With no candidate usable: the lowest non-'none' rung the model offers, or
93
+ * 'none' when that is all there is. Effort names off the ladder (image
94
+ * quality values, unknown providers) only ever match exactly.
95
+ */
96
+ export function nearestSupportedReasoningEffort(candidates = [], supportedEfforts = []) {
97
+ const options = normalizeEffortList(supportedEfforts);
98
+ if (!options.length) return '';
99
+ const rungOf = (effort) => REASONING_EFFORT_LADDER.indexOf(effort);
100
+ const ranked = options
101
+ .filter((option) => option !== 'none' && rungOf(option) >= 0)
102
+ .sort((left, right) => rungOf(left) - rungOf(right));
103
+ for (const candidate of normalizeEffortList(candidates)) {
104
+ if (options.includes(candidate)) return candidate;
105
+ const rung = rungOf(candidate);
106
+ if (rung < 0 || candidate === 'ultracode') continue;
107
+ const below = ranked.filter((option) => rungOf(option) < rung);
108
+ if (below.length) return below[below.length - 1];
109
+ const above = ranked.find((option) => rungOf(option) > rung && option !== 'ultracode');
110
+ if (above) return above;
111
+ }
112
+ return options.find((option) => option !== 'none') || options[0];
113
+ }
114
+
72
115
  // Priority: an explicit request (conversation preference, or the effort being
73
116
  // carried across a user model change) beats what the previous conversation left
74
117
  // in the DOM. Reversing those two is what let a New Chat "high" become "low".
@@ -78,14 +121,8 @@ export function resolveComposerReasoningEffort({
78
121
  currentEffort = '',
79
122
  supportedEfforts = [],
80
123
  } = {}) {
81
- const options = (Array.isArray(supportedEfforts) ? supportedEfforts : [])
82
- .map((effort) => String(effort || '').trim().toLowerCase())
83
- .filter(Boolean);
84
- if (!options.length) return '';
85
- const candidates = [preferredEffort, storedEffort, currentEffort]
86
- .map((effort) => String(effort || '').trim().toLowerCase())
87
- .filter(Boolean);
88
- const match = candidates.find((candidate) => options.includes(candidate));
89
- if (match) return match;
90
- return options.find((option) => option !== 'none') || options[0];
124
+ return nearestSupportedReasoningEffort(
125
+ [preferredEffort, storedEffort, currentEffort],
126
+ supportedEfforts,
127
+ );
91
128
  }
@@ -35,6 +35,14 @@ export function conversationProviderIndicatorLabel(conversation = null) {
35
35
  return '';
36
36
  }
37
37
 
38
+ // A question card names the runtime that asks, the way the list pill names it.
39
+ // Unresolved reads plain "Question": every card used to say "Copilot question",
40
+ // Claude's and Cursor's included.
41
+ export function questionCardHeadLabel(conversation = null) {
42
+ const provider = conversationProviderIndicatorLabel(conversation);
43
+ return provider ? `${provider} question` : 'Question';
44
+ }
45
+
38
46
  const SESSION_LOCK_LABELS = {
39
47
  github: 'GitHub Copilot',
40
48
  openai: 'OpenAI',