@kortix/sdk 0.13.3 → 0.13.5

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 (204) hide show
  1. package/dist/browser/cache/idb-sync-cache.d.ts +19 -1
  2. package/dist/browser/cache/idb-sync-cache.d.ts.map +1 -1
  3. package/dist/browser/cache/idb-sync-cache.js +39 -4
  4. package/dist/browser/cache/idb-sync-cache.js.map +1 -1
  5. package/dist/browser/session-sync/session-sync-registry.d.ts +20 -0
  6. package/dist/browser/session-sync/session-sync-registry.d.ts.map +1 -1
  7. package/dist/browser/session-sync/session-sync-registry.js +69 -6
  8. package/dist/browser/session-sync/session-sync-registry.js.map +1 -1
  9. package/dist/browser/session-sync/visibility.d.ts +19 -0
  10. package/dist/browser/session-sync/visibility.d.ts.map +1 -0
  11. package/dist/browser/session-sync/visibility.js +16 -0
  12. package/dist/browser/session-sync/visibility.js.map +1 -0
  13. package/dist/browser/stores/sandbox-connection-store.js +1 -1
  14. package/dist/browser/stores/sandbox-connection-store.js.map +1 -1
  15. package/dist/browser/stores/sync-store.d.ts +27 -1
  16. package/dist/browser/stores/sync-store.d.ts.map +1 -1
  17. package/dist/browser/stores/sync-store.js +198 -41
  18. package/dist/browser/stores/sync-store.js.map +1 -1
  19. package/dist/core/client/kortix.d.ts +0 -11
  20. package/dist/core/client/kortix.d.ts.map +1 -1
  21. package/dist/core/client/kortix.js +14 -8
  22. package/dist/core/client/kortix.js.map +1 -1
  23. package/dist/core/rest/projects-client/channels.d.ts +0 -3
  24. package/dist/core/rest/projects-client/channels.d.ts.map +1 -1
  25. package/dist/core/rest/projects-client/channels.js +0 -6
  26. package/dist/core/rest/projects-client/channels.js.map +1 -1
  27. package/dist/core/rest/projects-client/index.d.ts +0 -2
  28. package/dist/core/rest/projects-client/index.d.ts.map +1 -1
  29. package/dist/core/rest/projects-client/index.js +0 -2
  30. package/dist/core/rest/projects-client/index.js.map +1 -1
  31. package/dist/core/rest/projects-client/projects.d.ts +1 -1
  32. package/dist/core/rest/projects-client/projects.d.ts.map +1 -1
  33. package/dist/core/rest/projects-client/projects.js +5 -3
  34. package/dist/core/rest/projects-client/projects.js.map +1 -1
  35. package/dist/core/rest/projects-client/sessions.d.ts +8 -33
  36. package/dist/core/rest/projects-client/sessions.d.ts.map +1 -1
  37. package/dist/core/rest/projects-client/sessions.js +1 -18
  38. package/dist/core/rest/projects-client/sessions.js.map +1 -1
  39. package/dist/core/runtime/attachment-part.d.ts +26 -0
  40. package/dist/core/runtime/attachment-part.d.ts.map +1 -0
  41. package/dist/core/runtime/attachment-part.js +41 -0
  42. package/dist/core/runtime/attachment-part.js.map +1 -0
  43. package/dist/core/runtime/client.d.ts +4 -2
  44. package/dist/core/runtime/client.d.ts.map +1 -1
  45. package/dist/core/runtime/client.js +3 -2
  46. package/dist/core/runtime/client.js.map +1 -1
  47. package/dist/core/runtime/opencode-v2-event-aliases.d.ts +184 -0
  48. package/dist/core/runtime/opencode-v2-event-aliases.d.ts.map +1 -0
  49. package/dist/core/runtime/opencode-v2-event-aliases.js +2 -0
  50. package/dist/core/runtime/opencode-v2-event-aliases.js.map +1 -0
  51. package/dist/core/runtime/pty.d.ts +11 -1
  52. package/dist/core/runtime/pty.d.ts.map +1 -1
  53. package/dist/core/runtime/pty.js +19 -6
  54. package/dist/core/runtime/pty.js.map +1 -1
  55. package/dist/core/session/connection.d.ts +64 -0
  56. package/dist/core/session/connection.d.ts.map +1 -0
  57. package/dist/core/session/connection.js +36 -0
  58. package/dist/core/session/connection.js.map +1 -0
  59. package/dist/core/session/index.d.ts +1 -0
  60. package/dist/core/session/index.d.ts.map +1 -1
  61. package/dist/core/session/index.js +1 -0
  62. package/dist/core/session/index.js.map +1 -1
  63. package/dist/core/session/preview-auth.d.ts +25 -0
  64. package/dist/core/session/preview-auth.d.ts.map +1 -1
  65. package/dist/core/session/preview-auth.js +31 -1
  66. package/dist/core/session/preview-auth.js.map +1 -1
  67. package/dist/core/session/preview-config.d.ts +26 -0
  68. package/dist/core/session/preview-config.d.ts.map +1 -0
  69. package/dist/core/session/preview-config.js +95 -0
  70. package/dist/core/session/preview-config.js.map +1 -0
  71. package/dist/core/session/preview-options.d.ts +25 -0
  72. package/dist/core/session/preview-options.d.ts.map +1 -0
  73. package/dist/core/session/preview-options.js +45 -0
  74. package/dist/core/session/preview-options.js.map +1 -0
  75. package/dist/core/session/preview.d.ts +8 -3
  76. package/dist/core/session/preview.d.ts.map +1 -1
  77. package/dist/core/session/preview.js +11 -4
  78. package/dist/core/session/preview.js.map +1 -1
  79. package/dist/core/session/rewind.d.ts +67 -24
  80. package/dist/core/session/rewind.d.ts.map +1 -1
  81. package/dist/core/session/rewind.js +74 -26
  82. package/dist/core/session/rewind.js.map +1 -1
  83. package/dist/core/session/server-store/active.d.ts +10 -12
  84. package/dist/core/session/server-store/active.d.ts.map +1 -1
  85. package/dist/core/session/server-store/active.js +11 -9
  86. package/dist/core/session/server-store/active.js.map +1 -1
  87. package/dist/core/session/server-store/url-helpers.d.ts +11 -2
  88. package/dist/core/session/server-store/url-helpers.d.ts.map +1 -1
  89. package/dist/core/session/server-store/url-helpers.js +11 -2
  90. package/dist/core/session/server-store/url-helpers.js.map +1 -1
  91. package/dist/core/session/url.d.ts +25 -2
  92. package/dist/core/session/url.d.ts.map +1 -1
  93. package/dist/core/session/url.js +87 -11
  94. package/dist/core/session/url.js.map +1 -1
  95. package/dist/core/session/working.d.ts +44 -0
  96. package/dist/core/session/working.d.ts.map +1 -1
  97. package/dist/core/session/working.js +146 -4
  98. package/dist/core/session/working.js.map +1 -1
  99. package/dist/core/session-sync/durable-log.d.ts +96 -0
  100. package/dist/core/session-sync/durable-log.d.ts.map +1 -0
  101. package/dist/core/session-sync/durable-log.js +94 -0
  102. package/dist/core/session-sync/durable-log.js.map +1 -0
  103. package/dist/core/session-sync/fragment.d.ts +27 -0
  104. package/dist/core/session-sync/fragment.d.ts.map +1 -0
  105. package/dist/core/session-sync/fragment.js +24 -0
  106. package/dist/core/session-sync/fragment.js.map +1 -0
  107. package/dist/core/session-sync/session-sync-controller.d.ts +83 -9
  108. package/dist/core/session-sync/session-sync-controller.d.ts.map +1 -1
  109. package/dist/core/session-sync/session-sync-controller.js +183 -17
  110. package/dist/core/session-sync/session-sync-controller.js.map +1 -1
  111. package/dist/core/turns/grouping.d.ts.map +1 -1
  112. package/dist/core/turns/grouping.js +60 -23
  113. package/dist/core/turns/grouping.js.map +1 -1
  114. package/dist/index.d.ts +1 -0
  115. package/dist/index.d.ts.map +1 -1
  116. package/dist/index.js +1 -0
  117. package/dist/index.js.map +1 -1
  118. package/dist/kortix.esm.min.js +24 -24
  119. package/dist/kortix.global.js +24 -24
  120. package/dist/platform/auth-core.d.ts +13 -1
  121. package/dist/platform/auth-core.d.ts.map +1 -1
  122. package/dist/platform/auth-core.js +13 -1
  123. package/dist/platform/auth-core.js.map +1 -1
  124. package/dist/react/opencode.d.ts +1 -0
  125. package/dist/react/opencode.d.ts.map +1 -1
  126. package/dist/react/opencode.js +3 -0
  127. package/dist/react/opencode.js.map +1 -1
  128. package/dist/react/use-admin-accounts.d.ts +35 -0
  129. package/dist/react/use-admin-accounts.d.ts.map +1 -1
  130. package/dist/react/use-admin-accounts.js +18 -0
  131. package/dist/react/use-admin-accounts.js.map +1 -1
  132. package/dist/react/use-opencode-events/handle-event.d.ts.map +1 -1
  133. package/dist/react/use-opencode-events/handle-event.js +26 -0
  134. package/dist/react/use-opencode-events/handle-event.js.map +1 -1
  135. package/dist/react/use-opencode-events/index.d.ts.map +1 -1
  136. package/dist/react/use-opencode-events/index.js +59 -6
  137. package/dist/react/use-opencode-events/index.js.map +1 -1
  138. package/dist/react/use-opencode-events/rehydrate-targets.d.ts +18 -0
  139. package/dist/react/use-opencode-events/rehydrate-targets.d.ts.map +1 -0
  140. package/dist/react/use-opencode-events/rehydrate-targets.js +20 -0
  141. package/dist/react/use-opencode-events/rehydrate-targets.js.map +1 -0
  142. package/dist/react/use-opencode-events/stream-revival.d.ts +37 -0
  143. package/dist/react/use-opencode-events/stream-revival.d.ts.map +1 -0
  144. package/dist/react/use-opencode-events/stream-revival.js +53 -0
  145. package/dist/react/use-opencode-events/stream-revival.js.map +1 -0
  146. package/dist/react/use-opencode-pty.d.ts +7 -1
  147. package/dist/react/use-opencode-pty.d.ts.map +1 -1
  148. package/dist/react/use-opencode-pty.js +6 -2
  149. package/dist/react/use-opencode-pty.js.map +1 -1
  150. package/dist/react/use-opencode-sessions/agents.d.ts.map +1 -1
  151. package/dist/react/use-opencode-sessions/agents.js +13 -2
  152. package/dist/react/use-opencode-sessions/agents.js.map +1 -1
  153. package/dist/react/use-opencode-sessions/commands.d.ts +12 -0
  154. package/dist/react/use-opencode-sessions/commands.d.ts.map +1 -1
  155. package/dist/react/use-opencode-sessions/commands.js +15 -3
  156. package/dist/react/use-opencode-sessions/commands.js.map +1 -1
  157. package/dist/react/use-opencode-sessions/index.d.ts +1 -0
  158. package/dist/react/use-opencode-sessions/index.d.ts.map +1 -1
  159. package/dist/react/use-opencode-sessions/index.js +1 -0
  160. package/dist/react/use-opencode-sessions/index.js.map +1 -1
  161. package/dist/react/use-opencode-sessions/keys.d.ts +8 -0
  162. package/dist/react/use-opencode-sessions/keys.d.ts.map +1 -1
  163. package/dist/react/use-opencode-sessions/keys.js +8 -0
  164. package/dist/react/use-opencode-sessions/keys.js.map +1 -1
  165. package/dist/react/use-opencode-sessions/providers.d.ts.map +1 -1
  166. package/dist/react/use-opencode-sessions/providers.js +12 -2
  167. package/dist/react/use-opencode-sessions/providers.js.map +1 -1
  168. package/dist/react/use-opencode-sessions/shared.d.ts +23 -0
  169. package/dist/react/use-opencode-sessions/shared.d.ts.map +1 -1
  170. package/dist/react/use-opencode-sessions/shared.js +31 -0
  171. package/dist/react/use-opencode-sessions/shared.js.map +1 -1
  172. package/dist/react/use-opencode-sessions/vcs.d.ts +28 -0
  173. package/dist/react/use-opencode-sessions/vcs.d.ts.map +1 -0
  174. package/dist/react/use-opencode-sessions/vcs.js +42 -0
  175. package/dist/react/use-opencode-sessions/vcs.js.map +1 -0
  176. package/dist/react/use-session-prompts.d.ts +37 -2
  177. package/dist/react/use-session-prompts.d.ts.map +1 -1
  178. package/dist/react/use-session-prompts.js +62 -15
  179. package/dist/react/use-session-prompts.js.map +1 -1
  180. package/dist/react/use-session-send.d.ts +17 -0
  181. package/dist/react/use-session-send.d.ts.map +1 -1
  182. package/dist/react/use-session-send.js +40 -4
  183. package/dist/react/use-session-send.js.map +1 -1
  184. package/dist/react/use-session-sync.d.ts +13 -1
  185. package/dist/react/use-session-sync.d.ts.map +1 -1
  186. package/dist/react/use-session-sync.js +81 -55
  187. package/dist/react/use-session-sync.js.map +1 -1
  188. package/dist/react/use-session-working.d.ts +17 -0
  189. package/dist/react/use-session-working.d.ts.map +1 -1
  190. package/dist/react/use-session-working.js +33 -3
  191. package/dist/react/use-session-working.js.map +1 -1
  192. package/package.json +3 -3
  193. package/dist/browser/session-sync/session-transcript-cache.d.ts +0 -79
  194. package/dist/browser/session-sync/session-transcript-cache.d.ts.map +0 -1
  195. package/dist/browser/session-sync/session-transcript-cache.js +0 -91
  196. package/dist/browser/session-sync/session-transcript-cache.js.map +0 -1
  197. package/dist/core/rest/projects-client/public-voice-join.d.ts +0 -59
  198. package/dist/core/rest/projects-client/public-voice-join.d.ts.map +0 -1
  199. package/dist/core/rest/projects-client/public-voice-join.js +0 -64
  200. package/dist/core/rest/projects-client/public-voice-join.js.map +0 -1
  201. package/dist/core/rest/projects-client/transcription.d.ts +0 -5
  202. package/dist/core/rest/projects-client/transcription.d.ts.map +0 -1
  203. package/dist/core/rest/projects-client/transcription.js +0 -14
  204. package/dist/core/rest/projects-client/transcription.js.map +0 -1
@@ -36,6 +36,68 @@ function isTextLikePart(part) {
36
36
  return part.type === "text" || part.type === "reasoning";
37
37
  }
38
38
  // ============================================================================
39
+ // Locating things in a list that is NOT id-sorted.
40
+ //
41
+ // `Binary.search` requires ascending ids. None of this store's lists are
42
+ // reliably ascending, for two independent reasons:
43
+ //
44
+ // 1. optimistic messages are appended out of order on purpose (see
45
+ // `optimisticRemove`, which says so), and
46
+ // 2. OpenCode 1.18.15 retired the invariant that ids ascend with time
47
+ // altogether — `MessageV2.latest()` orders by `time.created`, with the id
48
+ // only as a tie-break, and `MessageV2.page()` (every transcript read this
49
+ // store performs) has ALWAYS ordered by `time_created`.
50
+ //
51
+ // A binary HIT is still trustworthy: `Binary.search` reports `found` only on an
52
+ // exact id match. A binary MISS is not — on an unsorted list it is a coin
53
+ // flip, and treating it as proof of absence is how `upsertPart` spliced a
54
+ // duplicate part (restarting the visible stream), `removePart` no-opped (a
55
+ // cancelled tool call stayed on screen), and `applyPartDelta` dropped deltas
56
+ // (the assistant appeared to hang mid-sentence).
57
+ // ============================================================================
58
+ /**
59
+ * How coarsely `sessionActivityAt` is stamped.
60
+ *
61
+ * One second: far finer than any consumer needs (the projection compares it
62
+ * against observations bounded in tens of seconds) and coarse enough that a
63
+ * runtime streaming at ~140ms per frame updates it once, not seven times.
64
+ */
65
+ const ACTIVITY_STAMP_RESOLUTION_MS = 1_000;
66
+ /** The index of `id` in `list`, or `-1`. Binary first, linear on a miss. */
67
+ function indexOfId(list, id, idOf) {
68
+ const result = Binary.search(list, id, idOf);
69
+ if (result.found && list[result.index] !== undefined && idOf(list[result.index]) === id) {
70
+ return result.index;
71
+ }
72
+ return list.findIndex((item) => idOf(item) === id);
73
+ }
74
+ /**
75
+ * Where a message the current snapshot did not contain belongs: the first
76
+ * position holding a strictly newer message, or the end.
77
+ *
78
+ * Ordered by `time.created` with the id as the ONLY tie-break — the same order
79
+ * the server's own `MessageV2.latest()` uses, and the key `MessageV2.page()`
80
+ * pages by. That keeps a locally-known message (an SSE arrival, a re-keyed
81
+ * stub) in the sequence the next page read will produce. A message with no
82
+ * readable `time` — every `session.error` stub this store mints — cannot be
83
+ * dated and goes last, which is where the newest thing we know about belongs.
84
+ */
85
+ function insertIndexByTime(list, message) {
86
+ const created = message.time?.created;
87
+ if (created === undefined)
88
+ return list.length;
89
+ for (let index = 0; index < list.length; index++) {
90
+ const other = list[index].time?.created;
91
+ if (other === undefined)
92
+ continue;
93
+ if (other > created)
94
+ return index;
95
+ if (other === created && list[index].id > message.id)
96
+ return index;
97
+ }
98
+ return list.length;
99
+ }
100
+ // ============================================================================
39
101
  // Store Implementation
40
102
  // Track optimistic message IDs so we can remove them when the server sends
41
103
  // the real user message (which has a different, server-generated ID).
@@ -271,26 +333,52 @@ function untrackStub(sessionID, stubId) {
271
333
  }
272
334
  /** The stub id for the turn `userId` opened.
273
335
  *
274
- * Derived from the user message's own id, for two reasons. It is IDEMPOTENT —
275
- * a second `session.error` for the same turn finds the stub it already made
276
- * instead of appending another. And it SORTS immediately after that user
277
- * message: every id the store handles has the same length, so an id that
278
- * extends `userId` byte for byte precedes every id greater than `userId` and
279
- * follows `userId` itself. Position and `parentID` therefore agree, whichever
280
- * of the two a reader uses. */
336
+ * Derived from the user message's own id so it is IDEMPOTENT — a second
337
+ * `session.error` for the same turn finds the stub it already made instead of
338
+ * appending another. The stub is always PLACED directly after its user
339
+ * message (see `rekeyStubParent` and `hydrate`), and carries `parentID`;
340
+ * neither its position nor its linkage is inferred from the id. */
281
341
  const stubIdFor = (userId) => `${userId}_error`;
282
342
  /** A stub id as seen from a list that has no tracking map to hand (`hydrate`'s
283
343
  * incoming server snapshot, which can never legitimately contain one). */
284
344
  const isStubShaped = (id) => id.endsWith("_error");
345
+ /** The index in `messages` of the assistant message answering the turn
346
+ * `parentId` opened, or `-1`.
347
+ *
348
+ * `parentID` is the linkage `groupMessagesIntoTurns` reads and the only
349
+ * authoritative one. A wire assistant message that carries none is matched by
350
+ * TIME instead — `time.created` at or after the prompt's. It used to be
351
+ * matched by `m.id > parentId`, on the rationale "ids ascend, so a greater id
352
+ * is a later message". OpenCode 1.18.15 retired that invariant (turn exit now
353
+ * reads `lastAssistant.parentID === lastUser.id`, and `MessageV2.latest()`
354
+ * orders by `time.created`), and the comparison failed both ways: a real
355
+ * reply with a lower id left a stale `session.error` stub beside it, and an
356
+ * EARLIER turn's parentless reply with a higher id deleted the only record
357
+ * that this turn failed (the 2026-08-19 `ModelNotFound` report).
358
+ *
359
+ * A parentless candidate that cannot be dated — because it carries no `time`,
360
+ * or because the prompt itself is not in `messages` — is not a match: keeping
361
+ * a stub that turns out to be redundant is recoverable on the next snapshot;
362
+ * deleting the only evidence of a failed turn is not.
363
+ *
364
+ * With no parent turn to speak of, any assistant message answers. */
365
+ function findAssistantForTurn(messages, parentId) {
366
+ const parentCreated = parentId
367
+ ? messages.find((m) => m.id === parentId)?.time?.created
368
+ : undefined;
369
+ return messages.findIndex((m) => m.role === "assistant" &&
370
+ !isStubShaped(m.id) &&
371
+ (!parentId ||
372
+ m.parentID === parentId ||
373
+ (!m.parentID &&
374
+ parentCreated !== undefined &&
375
+ m.time?.created !== undefined &&
376
+ m.time.created >= parentCreated)));
377
+ }
285
378
  /** Does `messages` hold an assistant message answering the turn `parentId`
286
- * opened? `parentID` is the linkage `groupMessagesIntoTurns` reads; the id
287
- * comparison covers a wire assistant message that carries none (ids ascend,
288
- * so a greater id is a later message). With no parent turn to speak of, any
289
- * assistant message answers. */
379
+ * opened? See {@link findAssistantForTurn}. */
290
380
  function hasAssistantForTurn(messages, parentId) {
291
- return messages.some((m) => m.role === "assistant" &&
292
- !isStubShaped(m.id) &&
293
- (!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
381
+ return findAssistantForTurn(messages, parentId) !== -1;
294
382
  }
295
383
  /** Move this session's `session.error` stubs from the optimistic user message
296
384
  * `fromId` onto the server's own copy of that prompt, `toId`.
@@ -543,6 +631,7 @@ export const useSyncStore = create()((set, get) => ({
543
631
  messages: {},
544
632
  parts: {},
545
633
  sessionStatus: {},
634
+ sessionActivityAt: {},
546
635
  diffs: {},
547
636
  todos: {},
548
637
  sessionRevert: {},
@@ -571,9 +660,15 @@ export const useSyncStore = create()((set, get) => ({
571
660
  next[linearIdx] = message;
572
661
  return { messages: { ...s.messages, [sessionID]: next } };
573
662
  }
574
- // New message — insert at sorted position via binary search.
663
+ // New message — placed by `time.created`, the key the server's own
664
+ // `MessageV2.page()` orders by. It used to go in at the binary index,
665
+ // which was only ever meaningful while `hydrate` re-sorted the
666
+ // transcript by id. It no longer does (the page order IS the order),
667
+ // and ids stopped ascending with time in OpenCode 1.18.15 — so an
668
+ // id-sorted insert dropped a brand-new SSE message into the MIDDLE of
669
+ // the transcript. See `insertIndexByTime`.
575
670
  const next = [...list];
576
- next.splice(result.index, 0, message);
671
+ next.splice(insertIndexByTime(next, message), 0, message);
577
672
  return { messages: { ...s.messages, [sessionID]: next } };
578
673
  }),
579
674
  removeMessage: (sessionID, messageID) => set((s) => {
@@ -779,12 +874,20 @@ export const useSyncStore = create()((set, get) => ({
779
874
  return s;
780
875
  return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
781
876
  }),
782
- applyCommittedRevert: (sessionId, boundaryId, watermark) => set((s) => {
877
+ applyCommittedRevert: (sessionId, boundaryId, watermark, hiddenIds) => set((s) => {
783
878
  const list = s.messages[sessionId];
784
879
  if (!list || list.length === 0) {
785
880
  return { sessionRevert: { ...s.sessionRevert, [sessionId]: null } };
786
881
  }
787
- const rewind = { messageId: boundaryId, watermark, staged: false };
882
+ // `hiddenIds` is the set captured when the rewind was staged, and it
883
+ // is what decides. `watermark` only still answers for a caller that
884
+ // has no captured set — see `isWithinRewindWindow`.
885
+ const rewind = {
886
+ messageId: boundaryId,
887
+ watermark,
888
+ ...(hiddenIds ? { hiddenIds } : {}),
889
+ staged: false,
890
+ };
788
891
  const removedIds = [];
789
892
  const kept = list.filter((m) => {
790
893
  if (isWithinRewindWindow(m.id, rewind)) {
@@ -933,6 +1036,18 @@ export const useSyncStore = create()((set, get) => ({
933
1036
  };
934
1037
  });
935
1038
  },
1039
+ noteSessionActivity: (sessionID, atMs) => {
1040
+ if (!sessionID)
1041
+ return;
1042
+ const now = atMs ?? Date.now();
1043
+ const previous = get().sessionActivityAt[sessionID];
1044
+ // Quantized: a busy runtime emits parts every few tens of milliseconds and
1045
+ // nothing reads this to the millisecond. Without the floor, every
1046
+ // subscriber re-renders at the stream's own rate for a timestamp.
1047
+ if (previous !== undefined && now - previous < ACTIVITY_STAMP_RESOLUTION_MS)
1048
+ return;
1049
+ set((s) => ({ sessionActivityAt: { ...s.sessionActivityAt, [sessionID]: now } }));
1050
+ },
936
1051
  clearOptimisticMessages: (sessionID) => {
937
1052
  set((s) => {
938
1053
  const list = s.messages[sessionID];
@@ -970,9 +1085,12 @@ export const useSyncStore = create()((set, get) => ({
970
1085
  for (const message of existingMessages)
971
1086
  delete nextParts[message.id];
972
1087
  deleteOrphanPartBuckets(nextParts, [sessionID]);
1088
+ const nextActivity = { ...s.sessionActivityAt };
1089
+ delete nextActivity[sessionID];
973
1090
  return {
974
1091
  messages: { ...s.messages, [sessionID]: [] },
975
1092
  parts: nextParts,
1093
+ sessionActivityAt: nextActivity,
976
1094
  sessionStatus: { ...s.sessionStatus, [sessionID]: { type: "idle" } },
977
1095
  diffs: { ...s.diffs, [sessionID]: [] },
978
1096
  todos: { ...s.todos, [sessionID]: [] },
@@ -1059,12 +1177,16 @@ export const useSyncStore = create()((set, get) => ({
1059
1177
  // An authoritative load — the disk repaint itself, or a reconcile —
1060
1178
  // re-establishes the session, so its entry is no longer a fragment.
1061
1179
  evictedSessions.delete(sessionID);
1062
- const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
1063
1180
  const tombstones = cancelledMessageIds.get(sessionID);
1181
+ // NOT re-sorted. `MessageV2.page()` orders by `time_created`
1182
+ // server-side and always has, so the snapshot arrives in the only
1183
+ // order that is true. Re-deriving one from id strings was wrong the
1184
+ // moment OpenCode 1.18.15 retired "ids ascend with time" — and the
1185
+ // mobile store did the same re-sort with `localeCompare`, which is
1186
+ // not even byte order, so the two hosts disagreed on identical data.
1064
1187
  const incoming = msgs
1065
1188
  .filter((m) => !!m?.info?.id && !tombstones?.has(m.info.id))
1066
- .map((m) => m.info)
1067
- .sort((a, b) => cmp(a.id, b.id));
1189
+ .map((m) => m.info);
1068
1190
  const fromCache = opts?.source === "cache";
1069
1191
  if (fromCache) {
1070
1192
  for (const m of incoming)
@@ -1075,17 +1197,26 @@ export const useSyncStore = create()((set, get) => ({
1075
1197
  // COVERS (at or above the oldest message it returned) never existed
1076
1198
  // there and are dropped. Older ones are history the bounded tail did
1077
1199
  // not reach — kept, still provisional.
1200
+ //
1201
+ // The bound is derived by VALUE, not by position: `incoming` is now the
1202
+ // server's own page order, which is not an id order, so `incoming[0].id`
1203
+ // is no longer the smallest id in the page. (That this comparison is an
1204
+ // id comparison at all is a separate open question — see B-note in
1205
+ // PROGRESS.md — deliberately left as-is here.)
1078
1206
  let droppedPhantoms = null;
1079
1207
  const provisional = fromCache ? undefined : cacheSourcedIds.get(sessionID);
1080
1208
  if (provisional && provisional.size > 0 && incoming.length > 0) {
1081
- const oldestIncoming = incoming[0].id;
1209
+ let oldestIncoming = incoming[0].id;
1210
+ for (const m of incoming)
1211
+ if (m.id < oldestIncoming)
1212
+ oldestIncoming = m.id;
1082
1213
  const incomingIds = new Set(incoming.map((m) => m.id));
1083
1214
  for (const id of [...provisional]) {
1084
1215
  if (incomingIds.has(id)) {
1085
1216
  untrackId(cacheSourcedIds, sessionID, id);
1086
1217
  continue;
1087
1218
  }
1088
- if (cmp(id, oldestIncoming) >= 0 && !isOptimistic(sessionID, id)) {
1219
+ if (id >= oldestIncoming && !isOptimistic(sessionID, id)) {
1089
1220
  untrackId(cacheSourcedIds, sessionID, id);
1090
1221
  (droppedPhantoms ??= new Set()).add(id);
1091
1222
  }
@@ -1218,8 +1349,9 @@ export const useSyncStore = create()((set, get) => ({
1218
1349
  }
1219
1350
  }
1220
1351
  else {
1221
- const r = Binary.search(merged, m.id, (x) => x.id);
1222
- merged.splice(r.index, 0, m);
1352
+ // By TIME, not by a binary index over ids the snapshot never
1353
+ // ordered that way — see `insertIndexByTime`.
1354
+ merged.splice(insertIndexByTime(merged, m), 0, m);
1223
1355
  }
1224
1356
  }
1225
1357
  }
@@ -1300,9 +1432,7 @@ export const useSyncStore = create()((set, get) => ({
1300
1432
  // stub against an error-free server copy would erase the only
1301
1433
  // evidence the turn failed.
1302
1434
  if (hasAssistantForTurn(incoming, parentId)) {
1303
- const replyIdx = merged.findIndex((m) => m.role === "assistant" &&
1304
- !isStubShaped(m.id) &&
1305
- (!parentId || m.parentID === parentId || (!m.parentID && m.id > parentId)));
1435
+ const replyIdx = findAssistantForTurn(merged, parentId);
1306
1436
  const reply = replyIdx === -1 ? undefined : merged[replyIdx];
1307
1437
  const stubError = stub.error;
1308
1438
  if (reply && !reply.error && stubError) {
@@ -1320,9 +1450,13 @@ export const useSyncStore = create()((set, get) => ({
1320
1450
  merged.splice(parentIdx + 1, 0, placed);
1321
1451
  continue;
1322
1452
  }
1323
- const r = Binary.search(merged, placed.id, (x) => x.id);
1324
- if (!r.found)
1325
- merged.splice(r.index, 0, placed);
1453
+ // Its user message is not in the transcript at all. Linear on a
1454
+ // miss so a re-keyed stub never lands twice (`stubIdFor(parentId)`
1455
+ // can name a stub already merged), and by time otherwise — a stub
1456
+ // carries no `time`, so it goes last.
1457
+ if (indexOfId(merged, placed.id, (x) => x.id) === -1) {
1458
+ merged.splice(insertIndexByTime(merged, placed), 0, placed);
1459
+ }
1326
1460
  }
1327
1461
  // Merge parts: for each message, reconcile by part ID.
1328
1462
  // If a message is optimistic (still in optimisticIds), keep existing
@@ -1362,9 +1496,11 @@ export const useSyncStore = create()((set, get) => ({
1362
1496
  const mid = m.info.id;
1363
1497
  if (isOptimistic(sessionID, mid))
1364
1498
  continue; // Don't touch optimistic parts
1499
+ // Parts, not messages: this sort is untouched by the message-order
1500
+ // work and keeps its own byte-order comparison.
1365
1501
  const inParts = m.parts
1366
1502
  .filter((p) => !!p?.id)
1367
- .sort((a, b) => cmp(a.id, b.id));
1503
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
1368
1504
  // If this message still carries bridged optimistic parts, a hydrate
1369
1505
  // snapshot with real parts should replace them immediately. Otherwise
1370
1506
  // reconcile-by-extras can keep both copies and duplicate user text.
@@ -1491,6 +1627,12 @@ export const useSyncStore = create()((set, get) => ({
1491
1627
  const store = get();
1492
1628
  switch (event.type) {
1493
1629
  case "message.updated": {
1630
+ {
1631
+ const info = event.properties?.info;
1632
+ const sid = info?.sessionID ?? event.properties?.sessionID;
1633
+ if (sid)
1634
+ get().noteSessionActivity(sid);
1635
+ }
1494
1636
  const info = event.properties.info;
1495
1637
  if (!info?.sessionID)
1496
1638
  return;
@@ -1589,14 +1731,17 @@ export const useSyncStore = create()((set, get) => ({
1589
1731
  const list = s.messages[info.sessionID] ?? [];
1590
1732
  // Remove all optimistic user messages
1591
1733
  const without = list.filter((m) => !optIds.includes(m.id));
1592
- // Insert the real message at sorted position
1593
- const r = Binary.search(without, info.id, (m) => m.id);
1734
+ // Place the real message. Linear on a miss, then by TIME: a
1735
+ // false miss spliced a SECOND copy of a message already in
1736
+ // the transcript, at a binary index that means nothing on a
1737
+ // list the server never ordered by id.
1738
+ const at = indexOfId(without, info.id, (m) => m.id);
1594
1739
  let next = [...without];
1595
- if (r.found) {
1596
- next[r.index] = info;
1740
+ if (at !== -1) {
1741
+ next[at] = info;
1597
1742
  }
1598
1743
  else {
1599
- next.splice(r.index, 0, info);
1744
+ next.splice(insertIndexByTime(next, info), 0, info);
1600
1745
  }
1601
1746
  // A turn that failed before this confirmation arrived
1602
1747
  // has a `session.error` stub keyed to the optimistic id
@@ -1645,6 +1790,14 @@ export const useSyncStore = create()((set, get) => ({
1645
1790
  const part = event.properties.part;
1646
1791
  if (!part?.messageID)
1647
1792
  return;
1793
+ // The runtime just produced output. This is the evidence
1794
+ // `projectWorking` trusts above every observer — see
1795
+ // `sessionActivityAt`.
1796
+ {
1797
+ const sid = part.sessionID ?? event.properties?.sessionID;
1798
+ if (sid)
1799
+ get().noteSessionActivity(sid);
1800
+ }
1648
1801
  const eventSessionID = event.properties?.sessionID;
1649
1802
  let resolvedSessionID = part.sessionID ?? eventSessionID;
1650
1803
  if (!resolvedSessionID) {
@@ -1659,9 +1812,10 @@ export const useSyncStore = create()((set, get) => ({
1659
1812
  const existingMsgs = resolvedSessionID
1660
1813
  ? get().messages[resolvedSessionID]
1661
1814
  : undefined;
1662
- const bsResult = existingMsgs && Binary.search(existingMsgs, part.messageID, (m) => m.id);
1663
- const exists = existingMsgs && ((bsResult && bsResult.found && existingMsgs[bsResult.index]?.id === part.messageID) ||
1664
- existingMsgs.some((m) => m.id === part.messageID));
1815
+ // The `.some()` alone is authoritative; the binary search that used
1816
+ // to sit beside it as a first disjunct could only agree with it or
1817
+ // miss on a list that is not id-sorted.
1818
+ const exists = existingMsgs?.some((m) => m.id === part.messageID);
1665
1819
  if (!exists && resolvedSessionID) {
1666
1820
  store.upsertMessage(resolvedSessionID, {
1667
1821
  id: part.messageID,
@@ -1871,7 +2025,10 @@ export const useSyncStore = create()((set, get) => ({
1871
2025
  if (props.sessionID && props.messageID) {
1872
2026
  const tracked = get().sessionRevert[props.sessionID];
1873
2027
  if (tracked) {
1874
- store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark);
2028
+ // The captured set only describes the boundary it was staged for.
2029
+ // A `.committed` naming a different one falls back to the legacy
2030
+ // range rather than deleting the wrong trajectory.
2031
+ store.applyCommittedRevert(props.sessionID, props.messageID, tracked.watermark, tracked.messageId === props.messageID ? tracked.hiddenIds : undefined);
1875
2032
  }
1876
2033
  else {
1877
2034
  // F2 — no tracked local record (fresh mount / second tab that