@kabmo-labs/kabmo 0.8.0 → 0.9.0

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 (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The harness both session-load suites share.
8
+ *
9
+ * Split out when the suite passed the per-file ceiling. It mounts the REAL
10
+ * `useSessionLoad` through a `Probe` component and gates every `/api/chat/:id`
11
+ * response, so a suite decides when — and with what — a fetch lands.
12
+ *
13
+ * The committed-history contract itself:
14
+ *
15
+ * While a run is live the timeline's rows are the STREAM's, and a committed
16
+ * fetch is a lagging snapshot: for a fresh spawn there may be no transcript on
17
+ * disk at all (omp buffers its writes until the first assistant message
18
+ * settles), and it never carries a segment still streaming. Applying one
19
+ * mid-run cost the operator their own turn in both directions, measured on real
20
+ * sessions:
21
+ *
22
+ * - an empty payload EMPTIED the list, so the turn they had just sent
23
+ * vanished until a reload;
24
+ * - a payload carrying the file's own copy of that turn was merged UNDER the
25
+ * live rows, whose ids differ (`msg-…-user` vs the echoed id), so the turn
26
+ * rendered twice.
27
+ *
28
+ * Both shapes needed the same hole: the guard was keyed on the AI placeholder,
29
+ * which the stream RELEASES at the first assistant `message_end` while the run
30
+ * continues. These cases pin the run as the ownership signal, and pin the two
31
+ * directions the guard must still let through when no run is live.
32
+ */
33
+
34
+ import { afterAll, afterEach, beforeAll } from 'bun:test';
35
+ import { Window } from 'happy-dom';
36
+ import { h, render } from 'preact';
37
+ import { act } from 'preact/test-utils';
38
+ import { useCallback, useRef, useState } from 'preact/hooks';
39
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
40
+ import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
41
+ import type { ChatMessageData } from '@/shared/types';
42
+
43
+ /** The runner's own fetch, reached through `Bun` so a leak is not mistaken for it. */
44
+ const originalFetch = Bun.fetch;
45
+
46
+ /** The optimistic bubble `executeSend` mounts for a locally sent turn. */
47
+ export const OPTIMISTIC_USER: ChatMessageData = { id: 'msg-1-user', role: 'user', content: 'halo', date: 'Today, 12:22 PM' };
48
+ /** The empty assistant row the stream fills. */
49
+ export const AI_PLACEHOLDER: ChatMessageData = { id: 'msg-2-ai', role: 'ai', content: '' };
50
+ /** The same turn as the transcript records it, under its own id. */
51
+ export const ECHOED_TURN: ChatMessageData = { id: 'omp-echo', role: 'user', content: 'halo' };
52
+
53
+ interface LoadGate {
54
+ url: string;
55
+ resolve: (body: unknown) => void;
56
+ }
57
+
58
+ export const gates: LoadGate[] = [];
59
+
60
+ /** One mount's observable state: what the timeline holds and what drives it. */
61
+ export interface ProbeApi {
62
+ messages: ChatMessageData[];
63
+ adoptedSessionIdRef: { current: string | null };
64
+ setMessages: (next: ChatMessageData[]) => void;
65
+ isGeneratingRef: { current: boolean };
66
+ ownRunRef: { current: boolean };
67
+ aiPlaceholderIdRef: { current: string | null };
68
+ optimisticUserIdRef: { current: string | null };
69
+ generatingCalls: boolean[];
70
+ }
71
+
72
+ export function makeApi(): ProbeApi {
73
+ return {
74
+ messages: [],
75
+ adoptedSessionIdRef: { current: null },
76
+ setMessages: () => {},
77
+ isGeneratingRef: { current: false },
78
+ ownRunRef: { current: false },
79
+ aiPlaceholderIdRef: { current: null },
80
+ optimisticUserIdRef: { current: null },
81
+ generatingCalls: [],
82
+ };
83
+ }
84
+
85
+ function Probe({ sessionId, api }: { sessionId: string | null; api: ProbeApi }) {
86
+ const [messages, setMessages] = useState<ChatMessageData[]>([]);
87
+ const isGeneratingRef = useRef(false);
88
+ const ownRunRef = useRef(false);
89
+ const streamRunRef = useRef(false);
90
+ const serverRunRef = useRef(false);
91
+ const aiPlaceholderIdRef = useRef<string | null>(null);
92
+ const optimisticUserIdRef = useRef<string | null>(null);
93
+ const pendingUserDisplaysRef = useRef<{ sent: string; display: string }[]>([]);
94
+ const metaRefreshedRef = useRef<string | null>(null);
95
+ const scrollRef = useRef<HTMLDivElement>(null);
96
+ const jumpActiveRef = useRef(false);
97
+ const jumpCountRef = useRef(0);
98
+ // Stable across renders, like the timeline's own `setGenerating` throat: a
99
+ // fresh identity would re-run the load effect on every render.
100
+ const setGenerating = useCallback((v: boolean) => {
101
+ isGeneratingRef.current = v;
102
+ api.generatingCalls.push(v);
103
+ }, [api]);
104
+
105
+ api.messages = messages;
106
+ api.setMessages = setMessages;
107
+ api.isGeneratingRef = isGeneratingRef;
108
+ api.ownRunRef = ownRunRef;
109
+ api.aiPlaceholderIdRef = aiPlaceholderIdRef;
110
+ api.optimisticUserIdRef = optimisticUserIdRef;
111
+
112
+ const { adoptedSessionIdRef } = useSessionLoad({
113
+ sessionId,
114
+ setLocalMessages: setMessages,
115
+ setGenerating,
116
+ isGeneratingRef,
117
+ ownRunRef,
118
+ streamRunRef,
119
+ serverRunRef,
120
+ aiPlaceholderIdRef,
121
+ optimisticUserIdRef,
122
+ pendingUserDisplaysRef,
123
+ cancelStreamingCoalescer: () => {},
124
+ metaRefreshedRef,
125
+ scrollRef,
126
+ jumpActiveRef,
127
+ jumpCountRef,
128
+ });
129
+ api.adoptedSessionIdRef = adoptedSessionIdRef;
130
+ return null;
131
+ }
132
+
133
+ let container: HTMLElement | undefined;
134
+
135
+ beforeAll(() => {
136
+ installDomGlobals(new Window({ url: 'http://localhost' }));
137
+ });
138
+
139
+ afterAll(() => {
140
+ restoreDomGlobals();
141
+ globalThis.fetch = originalFetch;
142
+ });
143
+
144
+ afterEach(() => {
145
+ if (container) render(null, container);
146
+ container?.remove();
147
+ container = undefined;
148
+ gates.length = 0;
149
+ });
150
+
151
+ /** Stub the loader so the test decides when (and with what) the fetch lands. */
152
+ export function stubChatFetch(): void {
153
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
154
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
155
+ const { promise, resolve } = Promise.withResolvers<Response>();
156
+ gates.push({ url, resolve: (body) => resolve(new Response(JSON.stringify(body))) });
157
+ return promise;
158
+ }) as typeof fetch;
159
+ }
160
+
161
+ /** Mount the real hook and let its session-load effect register its fetch. */
162
+ export async function mount(sessionId: string, api: ProbeApi): Promise<void> {
163
+ container ??= document.body.appendChild(document.createElement('div'));
164
+ await act(async () => {
165
+ render(h(Probe, { sessionId, api }), container as HTMLElement);
166
+ });
167
+ await act(async () => {});
168
+ }
169
+
170
+ /** Land the registered fetch, as the server's response would. */
171
+ export async function resolveLoad(messages: unknown[], index = 0): Promise<void> {
172
+ const gate = gates[index];
173
+ if (!gate) throw new Error('no session fetch was registered');
174
+ await act(async () => {
175
+ gate.resolve({ session: { id: 'sess-1', messages }, hasMore: false, oldestIndex: 0 });
176
+ });
177
+ for (let i = 0; i < 3; i += 1) await act(async () => {});
178
+ }
179
+
180
+ /**
181
+ * Re-render the mounted Probe with a new session id, which is what the spawn
182
+ * adoption (`new-…` → UUID) does when the URL flips while the child streams:
183
+ * the session-load effect re-runs and ISSUES its history fetch at that moment,
184
+ * mid-run. The fetch registered before this call is `gates[0]`; the one this
185
+ * issues is `gates[1]`.
186
+ */
187
+ export async function switchSession(sessionId: string, api: ProbeApi): Promise<void> {
188
+ await act(async () => {
189
+ render(h(Probe, { sessionId, api }), container as HTMLElement);
190
+ });
191
+ await act(async () => {});
192
+ }
193
+
194
+ /** A run this page started, with its optimistic rows already on screen. */
195
+ export async function startLiveRun(api: ProbeApi): Promise<void> {
196
+ await act(async () => {
197
+ api.isGeneratingRef.current = true;
198
+ api.ownRunRef.current = true;
199
+ api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
200
+ api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
201
+ });
202
+ }
203
+
204
+ export const ids = (api: ProbeApi) => api.messages.map((m) => m.id);
205
+
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  /**
7
- * The committed-history fetch vs a run this page started.
7
+ * The committed-history fetch vs a run this page started, and a spawn's arrival.
8
8
  *
9
9
  * While a run is live the timeline's rows are the STREAM's, and a committed
10
10
  * fetch is a lagging snapshot: for a fresh spawn there may be no transcript on
@@ -23,170 +23,25 @@
23
23
  * which the stream RELEASES at the first assistant `message_end` while the run
24
24
  * continues. These cases pin the run as the ownership signal, and pin the two
25
25
  * directions the guard must still let through when no run is live.
26
+ *
27
+ * The harness is `session-load.test-util.ts`.
26
28
  */
27
29
 
28
- import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
29
- import { Window } from 'happy-dom';
30
- import { h, render } from 'preact';
30
+ import { describe, expect, test } from 'bun:test';
31
31
  import { act } from 'preact/test-utils';
32
- import { useCallback, useRef, useState } from 'preact/hooks';
33
- import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
34
- import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
35
- import type { ChatMessageData } from '@/shared/types';
36
-
37
- /** The runner's own fetch, reached through `Bun` so a leak is not mistaken for it. */
38
- const originalFetch = Bun.fetch;
39
-
40
- /** The optimistic bubble `executeSend` mounts for a locally sent turn. */
41
- const OPTIMISTIC_USER: ChatMessageData = { id: 'msg-1-user', role: 'user', content: 'halo', date: 'Today, 12:22 PM' };
42
- /** The empty assistant row the stream fills. */
43
- const AI_PLACEHOLDER: ChatMessageData = { id: 'msg-2-ai', role: 'ai', content: '' };
44
- /** The same turn as the transcript records it, under its own id. */
45
- const ECHOED_TURN: ChatMessageData = { id: 'omp-echo', role: 'user', content: 'halo' };
46
-
47
- interface LoadGate {
48
- url: string;
49
- resolve: (body: unknown) => void;
50
- }
51
-
52
- const gates: LoadGate[] = [];
53
-
54
- /** One mount's observable state: what the timeline holds and what drives it. */
55
- interface ProbeApi {
56
- messages: ChatMessageData[];
57
- setMessages: (next: ChatMessageData[]) => void;
58
- isGeneratingRef: { current: boolean };
59
- ownRunRef: { current: boolean };
60
- aiPlaceholderIdRef: { current: string | null };
61
- optimisticUserIdRef: { current: string | null };
62
- generatingCalls: boolean[];
63
- }
64
-
65
- function makeApi(): ProbeApi {
66
- return {
67
- messages: [],
68
- setMessages: () => {},
69
- isGeneratingRef: { current: false },
70
- ownRunRef: { current: false },
71
- aiPlaceholderIdRef: { current: null },
72
- optimisticUserIdRef: { current: null },
73
- generatingCalls: [],
74
- };
75
- }
76
-
77
- function Probe({ sessionId, api }: { sessionId: string | null; api: ProbeApi }) {
78
- const [messages, setMessages] = useState<ChatMessageData[]>([]);
79
- const isGeneratingRef = useRef(false);
80
- const ownRunRef = useRef(false);
81
- const aiPlaceholderIdRef = useRef<string | null>(null);
82
- const optimisticUserIdRef = useRef<string | null>(null);
83
- const metaRefreshedRef = useRef<string | null>(null);
84
- const scrollRef = useRef<HTMLDivElement>(null);
85
- const jumpActiveRef = useRef(false);
86
- const jumpCountRef = useRef(0);
87
- // Stable across renders, like the timeline's own `setGenerating` throat: a
88
- // fresh identity would re-run the load effect on every render.
89
- const setGenerating = useCallback((v: boolean) => {
90
- isGeneratingRef.current = v;
91
- api.generatingCalls.push(v);
92
- }, [api]);
93
-
94
- api.messages = messages;
95
- api.setMessages = setMessages;
96
- api.isGeneratingRef = isGeneratingRef;
97
- api.ownRunRef = ownRunRef;
98
- api.aiPlaceholderIdRef = aiPlaceholderIdRef;
99
- api.optimisticUserIdRef = optimisticUserIdRef;
100
-
101
- useSessionLoad({
102
- sessionId,
103
- setLocalMessages: setMessages,
104
- setGenerating,
105
- isGeneratingRef,
106
- ownRunRef,
107
- aiPlaceholderIdRef,
108
- optimisticUserIdRef,
109
- cancelStreamingCoalescer: () => {},
110
- metaRefreshedRef,
111
- scrollRef,
112
- jumpActiveRef,
113
- jumpCountRef,
114
- });
115
- return null;
116
- }
117
-
118
- let container: HTMLElement | undefined;
119
-
120
- beforeAll(() => {
121
- installDomGlobals(new Window({ url: 'http://localhost' }));
122
- });
123
-
124
- afterAll(() => {
125
- restoreDomGlobals();
126
- globalThis.fetch = originalFetch;
127
- });
128
-
129
- afterEach(() => {
130
- if (container) render(null, container);
131
- container?.remove();
132
- container = undefined;
133
- gates.length = 0;
134
- });
135
-
136
- /** Stub the loader so the test decides when (and with what) the fetch lands. */
137
- function stubChatFetch(): void {
138
- globalThis.fetch = (async (input: RequestInfo | URL) => {
139
- const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
140
- const { promise, resolve } = Promise.withResolvers<Response>();
141
- gates.push({ url, resolve: (body) => resolve(new Response(JSON.stringify(body))) });
142
- return promise;
143
- }) as typeof fetch;
144
- }
145
-
146
- /** Mount the real hook and let its session-load effect register its fetch. */
147
- async function mount(sessionId: string, api: ProbeApi): Promise<void> {
148
- container ??= document.body.appendChild(document.createElement('div'));
149
- await act(async () => {
150
- render(h(Probe, { sessionId, api }), container as HTMLElement);
151
- });
152
- await act(async () => {});
153
- }
154
-
155
- /** Land the registered fetch, as the server's response would. */
156
- async function resolveLoad(messages: unknown[], index = 0): Promise<void> {
157
- const gate = gates[index];
158
- if (!gate) throw new Error('no session fetch was registered');
159
- await act(async () => {
160
- gate.resolve({ session: { id: 'sess-1', messages }, hasMore: false, oldestIndex: 0 });
161
- });
162
- for (let i = 0; i < 3; i += 1) await act(async () => {});
163
- }
164
-
165
- /**
166
- * Re-render the mounted Probe with a new session id, which is what the spawn
167
- * adoption (`new-…` → UUID) does when the URL flips while the child streams:
168
- * the session-load effect re-runs and ISSUES its history fetch at that moment,
169
- * mid-run. The fetch registered before this call is `gates[0]`; the one this
170
- * issues is `gates[1]`.
171
- */
172
- async function switchSession(sessionId: string, api: ProbeApi): Promise<void> {
173
- await act(async () => {
174
- render(h(Probe, { sessionId, api }), container as HTMLElement);
175
- });
176
- await act(async () => {});
177
- }
178
-
179
- /** A run this page started, with its optimistic rows already on screen. */
180
- async function startLiveRun(api: ProbeApi): Promise<void> {
181
- await act(async () => {
182
- api.isGeneratingRef.current = true;
183
- api.ownRunRef.current = true;
184
- api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
185
- api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
186
- });
187
- }
188
-
189
- const ids = (api: ProbeApi) => api.messages.map((m) => m.id);
32
+ import {
33
+ AI_PLACEHOLDER,
34
+ ECHOED_TURN,
35
+ OPTIMISTIC_USER,
36
+ ids,
37
+ makeApi,
38
+ mount,
39
+ resolveLoad,
40
+ gates,
41
+ startLiveRun,
42
+ stubChatFetch,
43
+ switchSession,
44
+ } from '@/client/hooks/chat/timeline/session-load.test-util';
190
45
 
191
46
  describe('a committed fetch while a run this page started is live', () => {
192
47
  test('keeps the live timeline when the payload carries the turn under its own id', async () => {
@@ -271,6 +126,50 @@ describe('a committed fetch while a run this page started is live', () => {
271
126
  });
272
127
  });
273
128
 
129
+ describe('a spawn that adopted its id after the run ended', () => {
130
+ /**
131
+ * The chat opened from a WORKSPACE (`?folderId=` and no `?sessionId=`), so the
132
+ * first send starts from a null session id and the URL flips to the child's
133
+ * UUID when the spawn answers. `isSpawnAdopt` only recognises a `new-…` →
134
+ * UUID move, and `isAdoptingInFlight` also required the run to still be
135
+ * generating — so a FAST child (the scripted one answers in milliseconds)
136
+ * finished before the URL flip, the reset branch ran, and the turn that had
137
+ * just rendered was cleared. Measured in CI on three specs at once
138
+ * (`rename.e2e.ts`, `editor.e2e.ts`'s drop case, `chat.e2e.ts`'s settle
139
+ * case): the answer was on screen and then the timeline was empty.
140
+ */
141
+ test('keeps the optimistic rows when the adopted id arrives after the run settled', async () => {
142
+ stubChatFetch();
143
+ const api = makeApi();
144
+ await mount('', api);
145
+ // The send: optimistic rows mounted, the run owned by this page…
146
+ await act(async () => {
147
+ api.isGeneratingRef.current = true;
148
+ api.ownRunRef.current = true;
149
+ api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
150
+ api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
151
+ });
152
+ // …and it ENDED before the spawn's id reached the URL.
153
+ await act(async () => {
154
+ api.isGeneratingRef.current = false;
155
+ api.ownRunRef.current = false;
156
+ });
157
+ // The spawn answered: the send path records the id it adopted, and the URL
158
+ // moves to it.
159
+ api.adoptedSessionIdRef.current = 'sess-1';
160
+
161
+ await switchSession('sess-1', api);
162
+ // The fetch the URL flip issued answers with the transcript omp has not
163
+ // written yet — the second half of the same bug, and the one that emptied
164
+ // the timeline even once the reset was skipped.
165
+ // `gates[0]`: the mount was a null session, so this flip issued the FIRST
166
+ // read.
167
+ await resolveLoad([], 0);
168
+
169
+ expect(ids(api)).toEqual([OPTIMISTIC_USER.id, AI_PLACEHOLDER.id]);
170
+ });
171
+ });
172
+
274
173
  describe('a committed fetch with no local run', () => {
275
174
  test('lands the transcript, which is the reload-mid-run and open-a-session path', async () => {
276
175
  stubChatFetch();
@@ -23,6 +23,8 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
23
23
  import type { ChatMessageData } from '@/shared/types';
24
24
  import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
25
25
  import { useSessionPagination } from '@/client/hooks/chat/timeline/session-pagination';
26
+ import { useLostRunRecovery } from '@/client/hooks/chat/timeline/lost-run';
27
+ import { isOwnSpawn, isSpawnArrival } from '@/client/hooks/chat/timeline/session-switch';
26
28
 
27
29
  export interface SessionDataShape {
28
30
  id?: string;
@@ -52,6 +54,12 @@ export interface UseSessionLoadDeps {
52
54
  * UI sets `isGeneratingRef` too, and treating that as ownership discarded
53
55
  * the whole transcript, leaving an empty timeline over a running session. */
54
56
  ownRunRef: { current: boolean };
57
+ /** The stream has announced the run in flight (see `streamRunRef`). */
58
+ streamRunRef: { current: boolean };
59
+ /** The SERVER reports a run for this session (the `stream` row). Together
60
+ * with `streamRunRef` this is the evidence the reconnect recovery needs: a
61
+ * run whose FIRST frame was lost has no `agent_start` on this client. */
62
+ serverRunRef: { current: boolean };
55
63
  /** Live AI placeholder ref: the row the streaming answer is filling. Read on
56
64
  * a session switch (cleared with the optimistic user id) and by the stream
57
65
  * callbacks; it is NOT the timeline-ownership signal — the placeholder is
@@ -60,6 +68,10 @@ export interface UseSessionLoadDeps {
60
68
  /** Live optimistic user bubble ref: cleared together with the placeholder on
61
69
  * a session switch so stale ids cannot survive into the next session. */
62
70
  optimisticUserIdRef: { current: string | null };
71
+ /** Raw composer text of steer/follow-up turns awaiting omp's echo. Cleared
72
+ * when a lost run is settled, or a later echo could be re-labelled with an
73
+ * earlier message's text. */
74
+ pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
63
75
  /** Drop queued stream updates belonging to the previous session (coalescer). */
64
76
  cancelStreamingCoalescer: () => void;
65
77
  metaRefreshedRef: { current: string | null };
@@ -73,7 +85,7 @@ export interface UseSessionLoadDeps {
73
85
  }
74
86
 
75
87
  export function useSessionLoad(deps: UseSessionLoadDeps) {
76
- const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, ownRunRef, aiPlaceholderIdRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
88
+ const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, ownRunRef, streamRunRef, serverRunRef, aiPlaceholderIdRef, optimisticUserIdRef, pendingUserDisplaysRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
77
89
 
78
90
  const [sessionData, setSessionData] = useState<SessionDataShape | null>(null);
79
91
  const [sessionLoading, setSessionLoading] = useState(false);
@@ -163,19 +175,16 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
163
175
  * A committed fetch is a LAGGING snapshot while a run is live. For a fresh
164
176
  * spawn there may be no transcript on disk at all (omp buffers its writes
165
177
  * until the first assistant message settles), and it never carries a segment
166
- * still streaming. Applying one mid-run costs the operator their own turn in
167
- * either direction — measured on real sessions: an empty payload emptied the
168
- * list (the just-sent turn vanished), and a payload carrying the file's own
169
- * copy of that turn was merged UNDER the live rows, whose ids differ
170
- * (`msg-…-user` vs the echoed id), so the turn rendered twice.
178
+ * still streaming. Applying one mid-run costs the operator their own turn —
179
+ * measured: an empty payload emptied the list, and a payload carrying the
180
+ * file's own copy of that turn was merged UNDER the live rows, whose ids
181
+ * differ, so the turn rendered twice.
171
182
  *
172
183
  * Keyed on the RUN THIS PAGE STARTED, never on `isGeneratingRef` alone: the
173
- * mount probe that resumes a live run's generating UI sets that flag too, so
174
- * keying on it made OPENING a streaming session discard its entire committed
175
- * transcript — measured on a real 333-message run: the timeline held 0 rows
176
- * with only a "Load earlier messages" button, and the button paged a window
177
- * into a list whose head was empty. A run adopted that way must still load
178
- * its history; the stream resume continues from there. */
184
+ * mount probe that resumes a live run sets that flag too, so keying on it
185
+ * made OPENING a streaming session discard its whole committed transcript
186
+ * (measured: 0 rows, only a "Load earlier messages" button). An adopted run
187
+ * must still load its history; the resume continues from there. */
179
188
  const timelineOwnedByLiveRun = useCallback(
180
189
  () => ownRunRef.current && isGeneratingRef.current,
181
190
  [isGeneratingRef, ownRunRef],
@@ -193,40 +202,57 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
193
202
  [isGeneratingRef],
194
203
  );
195
204
 
205
+ // A channel that comes back after the run's `message_end`/`agent_end` were
206
+ // lost with it: the timeline holds the stream's last partial segment and
207
+ // nothing else will ever finalize it (see `lost-run.ts`). A run that is
208
+ // STILL LIVE at reconnect is left alone here, but its lost segments are
209
+ // remembered and re-read when it settles.
210
+ const { reloadMessages, needsReloadRef } = useLostRunRecovery({
211
+ sessionId,
212
+ sessionIdRef,
213
+ adoptedSessionIdRef,
214
+ isGeneratingRef,
215
+ streamRunRef,
216
+ serverRunRef,
217
+ setGenerating,
218
+ setLocalMessages,
219
+ applyWindow,
220
+ aiPlaceholderIdRef,
221
+ optimisticUserIdRef,
222
+ pendingUserDisplaysRef,
223
+ cancelStreamingCoalescer,
224
+ });
225
+
196
226
  // Fetch session messages and details from API
197
227
  useEffect(() => {
198
228
  let active = true;
199
229
  setSessionLoading(true);
200
- // Track the previous session id so a session switch (including "New
201
- // Session" → new-…) clears the timeline, while the optimistic spawn
202
- // transition (new-… → real UUID) keeps its bubbles.
230
+ // Track the previous id so a real switch clears the timeline while a
231
+ // spawn's arrival keeps its bubbles (see `session-switch.ts`).
203
232
  if (sessionId !== prevSessionIdRef.current) {
204
- const isSpawnAdopt = sessionId && !sessionId.startsWith('new-') && prevSessionIdRef.current?.startsWith('new-');
205
- // Keep the optimistic bubbles while a local send is in flight and this
206
- // session is the one it spawned (a fresh spawn may not have passed
207
- // through "new-…"). A reload mid-run has no adopted id, so history loads.
208
- const isAdoptingInFlight = isGeneratingRef.current && adoptedSessionIdRef.current === sessionId;
209
- if (!isSpawnAdopt && !isAdoptingInFlight) {
233
+ // A spawn's arrival keeps the optimistic rows; anything else is a switch
234
+ // to another session. The two shapes — `new-…` → UUID and a chat opened
235
+ // from a workspace whose URL moves straight to the child's UUID — and why
236
+ // the second one used to clear a finished turn are in `session-switch.ts`.
237
+ const spawnArrival = isSpawnArrival({
238
+ sessionId,
239
+ previousSessionId: prevSessionIdRef.current,
240
+ adoptedSessionId: adoptedSessionIdRef.current,
241
+ });
242
+ if (!spawnArrival) {
210
243
  setLocalMessages([]);
211
244
  setGenerating(false);
212
245
  adoptedSessionIdRef.current = null;
213
246
  seededModelRef.current = null;
214
- // Metadata (model / thinking level) belongs to ONE session: leaving it
215
- // in place let a pending "new-…" composer — and any other session's
216
- // composer — display a level that session never ran with, until the
217
- // fetch replaced it.
247
+ // Metadata and the thinking seed belong to ONE session: leaving either
248
+ // in place let a composer display a level its session never ran with.
218
249
  setSessionData(null);
219
- // The thinking seed belongs to the previous session's spawn; keeping it
220
- // would report that level for the newly opened session.
221
250
  seededThinkingLevelRef.current = null;
222
251
  // The placeholder/optimistic ids belong to the PREVIOUS session's
223
- // in-flight send; message_end never arrives after a switch away (the
224
- // stream is disconnected), so they must be dropped here. A stale
225
- // placeholder would let the next session's stream reconcile into a row
226
- // that is no longer on screen. (`setGenerating(false)` above releases
227
- // the run ownership this page held — the guard reads `ownRunRef`, so a
228
- // stale value there is what used to discard a mid-run session's
229
- // committed history on return.)
252
+ // in-flight send, and `message_end` never arrives after a switch away.
253
+ // (`setGenerating(false)` above releases the run ownership this page
254
+ // held — the guard reads `ownRunRef`, so a stale value there is what
255
+ // discarded a mid-run session's committed history on return.)
230
256
  aiPlaceholderIdRef.current = null;
231
257
  optimisticUserIdRef.current = null;
232
258
  // Drop coalesced message_update frames queued by the previous
@@ -259,6 +285,10 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
259
285
  // means this payload is a lagging snapshot of a timeline the stream owns.
260
286
  const ownedAtIssue = timelineOwnedByLiveRun();
261
287
  const ownedByLiveRun = () => ownedAtIssue || timelineOwnedByLiveRun();
288
+ /** Its transcript may legitimately be missing — omp buffers its writes
289
+ * until the first assistant message settles — so an empty payload is
290
+ * "not written yet", never "nothing happened". */
291
+ const ownSpawn = () => isOwnSpawn(sessionId, adoptedSessionIdRef.current);
262
292
  fetch(`/api/chat/${sessionId}`)
263
293
  .then(res => res.json())
264
294
  .then(data => {
@@ -273,13 +303,13 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
273
303
  // operator's turn (see `timelineOwnedByLiveRun`).
274
304
  if (!ownedByLiveRun()) {
275
305
  if (fetched.length > 0) setLocalMessages(fetched);
276
- // A pending "new-…" session has nothing committed by definition.
277
- else if (!sessionId.startsWith('new-')) setLocalMessages([]);
306
+ // An own spawn has nothing committed YET (see `isOwnSpawn`).
307
+ else if (!ownSpawn()) setLocalMessages([]);
278
308
  }
279
309
  applyWindow(data.hasMore, data.oldestIndex);
280
310
  } else {
281
311
  setSessionData(seededSessionData());
282
- if (!ownedByLiveRun()) setLocalMessages([]);
312
+ if (!ownedByLiveRun() && !ownSpawn()) setLocalMessages([]);
283
313
  }
284
314
  })
285
315
  .catch(err => {
@@ -313,5 +343,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
313
343
  adoptedSessionIdRef,
314
344
  refreshSessionMeta,
315
345
  seedSession,
346
+ reloadMessages,
347
+ needsReloadRef,
316
348
  };
317
349
  }