@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -19,19 +19,51 @@ export function hasUploadingAttachments(attachments = []) {
19
19
  return countUploadingAttachments(attachments) > 0;
20
20
  }
21
21
 
22
+ // Why a message sent right now queues instead of steering — worker-reported
23
+ // hold reasons mapped to a human title. Stopping a turn lives on the message
24
+ // bubbles, never here, so every state below is a Send/Steer/Queue shape.
25
+ const STEERING_HOLD_TITLES = Object.freeze({
26
+ question: 'Will steer in after you answer the question',
27
+ compaction: 'Will steer in once the conversation finishes compacting',
28
+ adoption: 'Will steer in once the compaction recovery finishes',
29
+ delivery: 'Will steer in once the current delivery lands',
30
+ });
31
+
22
32
  export function deriveComposerControlState({
23
33
  hasActiveTurn = false,
24
- cancelRequested = false,
25
34
  hasDraft = false,
26
35
  sendInFlight = false,
27
36
  modelMetadataBlocked = false,
28
37
  attachmentsUploading = false,
38
+ // The conversation's session worker delivers a message typed during a live
39
+ // turn INTO that turn (mid-turn steering, advertised on its heartbeat
40
+ // snapshot) rather than queueing it behind — so the control says "Steer",
41
+ // not "Queue". Workers that do not advertise it keep the queue wording.
42
+ steeringSupported = false,
43
+ // The worker reports steering as momentarily held (open question card or
44
+ // plan approval, compaction, post-compaction adoption). A send stays allowed
45
+ // and reads "Queue": the relay holds the message and it steers in once the
46
+ // hold clears.
47
+ steeringHeld = false,
48
+ steeringHoldReason = null,
29
49
  } = {}) {
30
50
  const active = !!hasActiveTurn;
31
- const stopping = !!cancelRequested;
32
51
  const draft = !!hasDraft;
33
52
  const metadataBlocked = !!modelMetadataBlocked;
34
53
  const uploading = !!attachmentsUploading;
54
+ const held = active && !!steeringSupported && !!steeringHeld;
55
+ // The label/title/action for a draft typed during a live turn: steer into it
56
+ // where the provider supports that (queued while steering is held), queue
57
+ // behind it otherwise.
58
+ const steerNow = !!steeringSupported && !held;
59
+ const midTurnAction = steerNow ? 'steer' : 'queue';
60
+ const midTurnLabel = steerNow ? 'Steer' : 'Queue';
61
+ const midTurnTitle = held
62
+ ? (STEERING_HOLD_TITLES[String(steeringHoldReason || '').trim()]
63
+ || 'Will steer in once steering resumes')
64
+ : (steeringSupported
65
+ ? 'Steer message into the running turn'
66
+ : 'Queue message behind current turn');
35
67
 
36
68
  if (metadataBlocked && !active) {
37
69
  return {
@@ -42,59 +74,45 @@ export function deriveComposerControlState({
42
74
  };
43
75
  }
44
76
 
45
- // Uploads are eager, so the blocking window is short. Keeping the button
46
- // labelled Send/Queue (rather than switching to Stop) avoids the control
47
- // flipping meaning mid-upload while a turn is running.
77
+ // Uploads are eager, so the blocking window is short. The control keeps its
78
+ // Send/Steer/Queue meaning while it waits.
48
79
  if (uploading) {
49
- const queueing = active && draft;
80
+ const midTurn = active && draft;
50
81
  return {
51
- action: queueing ? 'queue' : 'send',
52
- label: queueing ? 'Queue' : 'Send',
82
+ action: midTurn ? midTurnAction : 'send',
83
+ label: midTurn ? midTurnLabel : 'Send',
53
84
  title: 'Waiting for attachments to finish uploading',
54
85
  disabled: true,
55
86
  };
56
87
  }
57
88
 
58
89
  if (sendInFlight) {
59
- if (active && draft) {
60
- return {
61
- action: 'queue',
62
- label: 'Queue',
63
- title: 'Queue message behind current turn',
64
- disabled: true,
65
- };
66
- }
67
- if (active) {
68
- return {
69
- action: 'stop',
70
- label: stopping ? 'Stopping…' : 'Stop',
71
- title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
72
- disabled: true,
73
- };
74
- }
75
90
  return {
76
- action: 'send',
77
- label: 'Send',
78
- title: 'Send message',
91
+ action: active && draft ? midTurnAction : 'send',
92
+ label: active && draft ? midTurnLabel : 'Send',
93
+ title: active && draft ? midTurnTitle : 'Send message',
79
94
  disabled: true,
80
95
  };
81
96
  }
82
97
 
83
98
  if (active && draft) {
84
99
  return {
85
- action: 'queue',
86
- label: 'Queue',
87
- title: 'Queue message behind current turn',
100
+ action: midTurnAction,
101
+ label: midTurnLabel,
102
+ title: midTurnTitle,
88
103
  disabled: false,
89
104
  };
90
105
  }
91
106
 
92
107
  if (active) {
108
+ // Empty composer during a live turn: nothing to send, nothing to steer.
109
+ // The label stays "Send" (it only reads "Steer" with text present) and
110
+ // re-enables the moment the user types or the turn ends.
93
111
  return {
94
- action: 'stop',
95
- label: stopping ? 'Stopping…' : 'Stop',
96
- title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
97
- disabled: stopping,
112
+ action: 'send',
113
+ label: 'Send',
114
+ title: 'Type a message to send',
115
+ disabled: true,
98
116
  };
99
117
  }
100
118
 
@@ -0,0 +1,42 @@
1
+ // Links that open one conversation when the app loads:
2
+ // ?push_conv=<id> a notification tap (sw.js opens a fresh window with it)
3
+ // ?conv=<id> a link from another relay's provenance badge
4
+ // (remoteConversationUrl in shared/remote-relay-contract.mjs)
5
+ // Both are consumed once and stripped from the address bar, so a reload does
6
+ // not jump back to that conversation.
7
+
8
+ export const CONVERSATION_DEEP_LINK_PARAMS = Object.freeze(['push_conv', 'conv']);
9
+
10
+ /**
11
+ * Reads the conversation a URL asks for. `cleanedPath` is the URL to put back
12
+ * in the address bar (path + remaining query + hash), or null when the URL
13
+ * carried none of the parameters and needs no rewrite.
14
+ */
15
+ export function readConversationDeepLink(href) {
16
+ let url;
17
+ try {
18
+ url = new URL(String(href || ''));
19
+ } catch {
20
+ return { conversationId: '', cleanedPath: null };
21
+ }
22
+ let conversationId = '';
23
+ let found = false;
24
+ for (const param of CONVERSATION_DEEP_LINK_PARAMS) {
25
+ if (!url.searchParams.has(param)) continue;
26
+ found = true;
27
+ const value = String(url.searchParams.get(param) || '').trim();
28
+ if (!conversationId && value) conversationId = value;
29
+ url.searchParams.delete(param);
30
+ }
31
+ return {
32
+ conversationId,
33
+ cleanedPath: found ? `${url.pathname}${url.search}${url.hash}` : null,
34
+ };
35
+ }
36
+
37
+ /** Reads and strips the deep link from the current location. */
38
+ export function consumeConversationDeepLink({ location = window.location, history = window.history, title = document.title } = {}) {
39
+ const { conversationId, cleanedPath } = readConversationDeepLink(location?.href);
40
+ if (cleanedPath !== null) history?.replaceState?.(null, title, cleanedPath);
41
+ return conversationId;
42
+ }
@@ -0,0 +1,187 @@
1
+ // Per-conversation record of the server draft this client's composer last
2
+ // agreed with. The composer text is local state that changes on every
3
+ // keystroke; this record changes only when the server acknowledges a save or
4
+ // the composer adopts a remote draft. Its updatedAt is the base version every
5
+ // draft save is checked against, so it must never be touched by a local edit.
6
+
7
+ import { serializeDraftAttachments } from './composer-attachment-cache.mjs';
8
+ import { normalizeDraftTimestampMs } from './conversation-draft-timestamp-utils.mjs';
9
+
10
+ // Sent with every draft save. It is what lets the server conflict-check an
11
+ // explicit null base: clients from before this protocol also send null (JSON
12
+ // keeps it), and for them null has to keep meaning "unconditional".
13
+ export const DRAFT_SYNC_PROTOCOL_VERSION = 2;
14
+
15
+ // Must equal the server's MAX_CONVERSATION_DRAFT_LENGTH (a test pins it). The
16
+ // server truncates longer drafts, so the client saves and compares only this
17
+ // prefix; otherwise a long composer never matches its acknowledged draft and
18
+ // is re-saved on every refresh. The composer itself (and the message) is not
19
+ // limited — only what survives as a draft.
20
+ export const MAX_DRAFT_TEXT_LENGTH = 20_000;
21
+
22
+ // Cuts at a code-point boundary, exactly like the server: a cut through a
23
+ // surrogate pair would store a lone surrogate, which SQLite hands back as
24
+ // U+FFFD — a different text at the same version.
25
+ export function draftTextForSync(text) {
26
+ const value = String(text || '');
27
+ if (value.length <= MAX_DRAFT_TEXT_LENGTH) return value;
28
+ const cut = value.slice(0, MAX_DRAFT_TEXT_LENGTH);
29
+ const last = cut.charCodeAt(cut.length - 1);
30
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
31
+ }
32
+
33
+ const syncedDraftsByConversation = new Map();
34
+
35
+ function conversationKey(conversationId) {
36
+ return String(conversationId || '').trim();
37
+ }
38
+
39
+ /** Order-sensitive fingerprint of a draft attachment list; null when absent. */
40
+ export function draftAttachmentsKey(attachments) {
41
+ if (attachments === undefined || attachments === null) return null;
42
+ return serializeDraftAttachments(Array.isArray(attachments) ? attachments : [], { max: Number.MAX_SAFE_INTEGER })
43
+ .map((row) => row.sha256)
44
+ .join(',');
45
+ }
46
+
47
+ export function getSyncedDraft(conversationId) {
48
+ return syncedDraftsByConversation.get(conversationKey(conversationId)) || null;
49
+ }
50
+
51
+ export function recordSyncedDraft(conversationId, {
52
+ text = '',
53
+ attachments = undefined,
54
+ updatedAt = null,
55
+ } = {}) {
56
+ const id = conversationKey(conversationId);
57
+ if (!id) return null;
58
+ const previous = syncedDraftsByConversation.get(id) || null;
59
+ const incomingKey = draftAttachmentsKey(attachments);
60
+ const record = {
61
+ text: String(text || ''),
62
+ attachmentsKey: incomingKey === null ? (previous?.attachmentsKey ?? null) : incomingKey,
63
+ updatedAt: updatedAt || null,
64
+ };
65
+ syncedDraftsByConversation.set(id, record);
66
+ return record;
67
+ }
68
+
69
+ export function forgetSyncedDraft(conversationId) {
70
+ syncedDraftsByConversation.delete(conversationKey(conversationId));
71
+ }
72
+
73
+ /**
74
+ * A save is a no-op when the server already holds exactly this text (and,
75
+ * for saves that carry attachments, exactly these attachments) — but only
76
+ * while no newer server version is known. If one is (a remote draft deferred
77
+ * while the user typed), the save must still go out so its version check
78
+ * surfaces that draft instead of silently keeping a stale base.
79
+ */
80
+ export function isDraftSaveNoop({ synced = null, text = '', attachmentsKey = null, knownUpdatedAt = null } = {}) {
81
+ if (!synced) return false;
82
+ if (normalizeDraftTimestampMs(knownUpdatedAt) > normalizeDraftTimestampMs(synced.updatedAt)) return false;
83
+ if (String(text || '') !== synced.text) return false;
84
+ if (attachmentsKey === null) return true;
85
+ return synced.attachmentsKey !== null && attachmentsKey === synced.attachmentsKey;
86
+ }
87
+
88
+ /**
89
+ * Decides a 409 draft conflict.
90
+ * - converged: the server already holds what we wanted to save.
91
+ * - adopt: the local text is unchanged since the last sync, so the remote
92
+ * draft simply wins.
93
+ * - keep-local: both sides edited; the local (newest) keystroke wins and the
94
+ * remote text becomes the one offered for restore.
95
+ */
96
+ export function resolveDraftConflict({
97
+ localText = '',
98
+ localAttachmentsKey = null,
99
+ baseText = null,
100
+ baseAttachmentsKey = null,
101
+ serverText = '',
102
+ serverAttachmentsKey = null,
103
+ } = {}) {
104
+ const local = String(localText || '');
105
+ const attachmentsMatch = (other) => localAttachmentsKey === null || localAttachmentsKey === other;
106
+ if (local === String(serverText || '') && attachmentsMatch(serverAttachmentsKey)) return 'converged';
107
+ if (baseText !== null && local === baseText && attachmentsMatch(baseAttachmentsKey)) return 'adopt';
108
+ return 'keep-local';
109
+ }
110
+
111
+ /**
112
+ * Whether an incoming remote draft may replace the composer of the
113
+ * conversation on screen. Only an unmodified composer (text and attachments
114
+ * still equal to the last synced draft) adopts it, focused or not. A modified
115
+ * one never does — not even for an equal or newer version: its edit is unsaved
116
+ * (a failed flush leaves exactly this state), and the caller re-saves it so the
117
+ * version check decides. An upload still in flight counts as a modification.
118
+ * With nothing synced yet there is no saved edit to protect.
119
+ * Attachment keys are null when unknown and then do not take part.
120
+ */
121
+ export function shouldApplyIncomingDraftToComposer({
122
+ inputText = '',
123
+ incomingText = '',
124
+ syncedText = null,
125
+ inputAttachmentsKey = null,
126
+ incomingAttachmentsKey = null,
127
+ syncedAttachmentsKey = null,
128
+ attachmentsUploading = false,
129
+ } = {}) {
130
+ if (attachmentsUploading) return false;
131
+ const current = draftTextForSync(inputText);
132
+ const sameAttachments = (other) => inputAttachmentsKey === null || other === null || inputAttachmentsKey === other;
133
+ if (current === String(incomingText || '') && sameAttachments(incomingAttachmentsKey)) return true;
134
+ if (syncedText === null) return true;
135
+ return current === syncedText && sameAttachments(syncedAttachmentsKey);
136
+ }
137
+
138
+ /**
139
+ * PATCH body for the pagehide Background Sync copy of a draft flush, or null
140
+ * when the composer holds nothing unsaved. Compared against the synced draft,
141
+ * not the local text (which every keystroke updates).
142
+ */
143
+ export function draftFlushRequestBody({
144
+ inputText = '',
145
+ synced = null,
146
+ fallbackText = '',
147
+ fallbackUpdatedAt = null,
148
+ clientId = null,
149
+ afterPendingSend = false,
150
+ } = {}) {
151
+ const draftText = draftTextForSync(inputText);
152
+ const serverText = synced ? synced.text : draftTextForSync(fallbackText);
153
+ if (draftText === serverText) return null;
154
+ if (afterPendingSend) {
155
+ // An emptied composer after a send is what the send leaves anyway.
156
+ if (!draftText.trim()) return null;
157
+ // The send will move the server's version past this base. acceptOwnSend
158
+ // lets the replay through only while that move is this client's own (the
159
+ // send transaction records the sender); anyone else's newer draft still
160
+ // wins with a 409.
161
+ return {
162
+ draftText,
163
+ clientId,
164
+ draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
165
+ baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
166
+ acceptOwnSend: true,
167
+ };
168
+ }
169
+ return {
170
+ draftText,
171
+ clientId,
172
+ draftSyncVersion: DRAFT_SYNC_PROTOCOL_VERSION,
173
+ baseDraftUpdatedAt: (synced ? synced.updatedAt : fallbackUpdatedAt) || null,
174
+ };
175
+ }
176
+
177
+ // A queued draft flush replayed later than this (Firefox/Safari replay the
178
+ // outbox only on the next page load, possibly hours on) would write text
179
+ // nobody is looking at over whatever the draft became meanwhile. sw.js
180
+ // mirrors this value; a test pins them together.
181
+ export const DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
182
+
183
+ export function isStaleDraftFlushEntry(entry, now = Date.now()) {
184
+ if (String(entry?.kind || '') !== 'draft-flush') return false;
185
+ const createdAt = Number(entry?.createdAt || 0);
186
+ return !Number.isFinite(createdAt) || createdAt <= 0 || now - createdAt > DRAFT_FLUSH_MAX_AGE_MS;
187
+ }
@@ -0,0 +1,86 @@
1
+ // Title filtering for the conversation sidebar. Kept DOM-free so it can be unit
2
+ // tested; journal-view.js touches window/document at module scope and cannot be
3
+ // imported from node:test.
4
+
5
+ // NFC first so a decomposed "é" (e + U+0301, as some IMEs and pasted text
6
+ // produce) matches a precomposed one. Case folding is toLocaleLowerCase only, so
7
+ // multi-char folds are a known limit: "ß" does not match "SS".
8
+ function foldForMatch(text) {
9
+ return String(text ?? '').normalize('NFC').toLocaleLowerCase();
10
+ }
11
+
12
+ export function normalizeConversationFilter(text) {
13
+ return foldForMatch(text).trim();
14
+ }
15
+
16
+ export function conversationMatchesFilter(conversation, normalizedFilter) {
17
+ if (!normalizedFilter) return true;
18
+ return foldForMatch(conversation?.title).includes(normalizedFilter);
19
+ }
20
+
21
+ export function filterConversations(conversations, filterText) {
22
+ const list = Array.isArray(conversations) ? conversations : [];
23
+ const normalized = normalizeConversationFilter(filterText);
24
+ if (!normalized) return list;
25
+ return list.filter((conversation) => conversationMatchesFilter(conversation, normalized));
26
+ }
27
+
28
+ export function describeFilterMatchCount(count, { loadedOnly = false } = {}) {
29
+ const n = Math.max(0, Number(count) || 0);
30
+ const noun = loadedOnly ? 'loaded conversation' : 'conversation';
31
+ if (n === 0) return `No ${noun}s match`;
32
+ return n === 1 ? `1 ${noun} matches` : `${n} ${noun}s match`;
33
+ }
34
+
35
+ function cursorKey(state) {
36
+ try {
37
+ return JSON.stringify(state?.nextCursor ?? null);
38
+ } catch {
39
+ return '';
40
+ }
41
+ }
42
+
43
+ // While a filter is active the sidebar has to search conversations that only
44
+ // exist on unloaded pages, so pull every remaining page through the loader.
45
+ // shouldContinue() is checked before each page so clearing the filter (or
46
+ // switching it) aborts the drain. loadMore() resolving falsy is ambiguous:
47
+ // - the loader is busy (a boundary-check load/prefetch is in flight): wait it
48
+ // out without spending idle retries, so the drain does not give up while the
49
+ // list is still loading. busyRetries caps a genuinely stuck fetch.
50
+ // - a reset bumped the loader version mid-fetch, or the fetch failed: retry,
51
+ // but stop after maxIdleRetries consecutive no-progress attempts so a failing
52
+ // endpoint is not hammered forever.
53
+ // An advanced cursor counts as progress even when the page was empty.
54
+ export async function drainRemainingPages(loader, shouldContinue = () => true, {
55
+ retryDelayMs = 200,
56
+ maxIdleRetries = 10,
57
+ maxBusyRetries = 300,
58
+ delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
59
+ } = {}) {
60
+ if (!loader || typeof loader.loadMore !== 'function' || typeof loader.getState !== 'function') {
61
+ return false;
62
+ }
63
+ let loadedAnything = false;
64
+ let idleRetries = 0;
65
+ let busyRetries = 0;
66
+ while (loader.getState().hasMore && shouldContinue()) {
67
+ const cursorBefore = cursorKey(loader.getState());
68
+ const loaded = await loader.loadMore();
69
+ const state = loader.getState();
70
+ if (loaded) loadedAnything = true;
71
+ if (loaded || cursorKey(state) !== cursorBefore) {
72
+ idleRetries = 0;
73
+ busyRetries = 0;
74
+ continue;
75
+ }
76
+ if (state.isLoading || state.isPrefetching) {
77
+ busyRetries += 1;
78
+ if (busyRetries > maxBusyRetries) break;
79
+ } else {
80
+ idleRetries += 1;
81
+ if (idleRetries > maxIdleRetries) break;
82
+ }
83
+ await delay(retryDelayMs);
84
+ }
85
+ return loadedAnything;
86
+ }
@@ -35,6 +35,14 @@ export function conversationProviderIndicatorLabel(conversation = null) {
35
35
  return '';
36
36
  }
37
37
 
38
+ // A question card names the runtime that asks, the way the list pill names it.
39
+ // Unresolved reads plain "Question": every card used to say "Copilot question",
40
+ // Claude's and Cursor's included.
41
+ export function questionCardHeadLabel(conversation = null) {
42
+ const provider = conversationProviderIndicatorLabel(conversation);
43
+ return provider ? `${provider} question` : 'Question';
44
+ }
45
+
38
46
  const SESSION_LOCK_LABELS = {
39
47
  github: 'GitHub Copilot',
40
48
  openai: 'OpenAI',