@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
@@ -18,7 +18,7 @@ import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coal
18
18
  import { useChatTimelineAccessMode } from '@/client/hooks/chat/timeline/access-mode';
19
19
  import { useComposerModes } from '@/client/hooks/chat/timeline/composer-modes';
20
20
  import { useComposerDraft } from '@/client/hooks/chat/composer/use-draft';
21
- import { isChatRunning } from '@/client/hooks/chat/omp/session-statuses';
21
+ import { isChatRunning, isSessionStreaming } from '@/client/hooks/chat/omp/session-statuses';
22
22
  import { useWaitingDependencyChoice } from '@/client/hooks/chat/waiting/choice';
23
23
  import { useComposerRefs } from '@/client/hooks/chat/timeline/composer-refs';
24
24
 
@@ -41,10 +41,8 @@ export function useChatTimeline({
41
41
  }: UseChatTimelineOptions = {}) {
42
42
  const { sessionId, folderId, selectedFolderId, selectContextFolder, setSearchParams } = useTimelineScope();
43
43
  // Plan/goal toggles plus the plan-review surface. PER SESSION, unlike the
44
- // access mode: they describe what this conversation is doing, and a global
45
- // toggle would drag every other open chat into the same mode. One hook
46
- // returns both halves, because a mode command can produce a proposal and
47
- // answering that proposal is itself a mode command.
44
+ // access mode: they describe what this conversation is doing, and one hook
45
+ // returns both halves because a mode command can produce a proposal.
48
46
  const { modes, planReview } = useComposerModes(sessionId);
49
47
 
50
48
  // Ref mirror of loadOlder so the scroll handler can call the latest one
@@ -76,6 +74,7 @@ export function useChatTimeline({
76
74
  isGeneratingRef,
77
75
  ownRun,
78
76
  ownRunRef,
77
+ streamRunRef,
79
78
  setGenerating,
80
79
  generatingVerb,
81
80
  activityRef,
@@ -125,11 +124,16 @@ export function useChatTimeline({
125
124
  // not start is still reported by the server row and never by client state.
126
125
  const timelineRunning = isChatRunning(streamStatusMap, sessionId, ownRun);
127
126
 
127
+ // SERVER evidence a prompt was dispatched (the `stream` row), mirrored for
128
+ // the reconnect guard: a run whose FIRST frame was lost has no `agent_start`.
129
+ const serverRunRef = useRef(isSessionStreaming(streamStatusMap, sessionId));
130
+ serverRunRef.current = isSessionStreaming(streamStatusMap, sessionId);
131
+
128
132
  const aiPlaceholderIdRef = useRef<string | null>(null);
129
133
 
130
134
  const {
131
135
  sessionData,
132
- adoptedSessionIdRef, refreshSessionMeta, seedSession,
136
+ adoptedSessionIdRef, refreshSessionMeta, seedSession, reloadMessages, needsReloadRef,
133
137
  hasMore,
134
138
  loadingOlder, loadOlderError, sessionLoading, loadOlder, jumpToTurn,
135
139
  } = useSessionLoad({
@@ -138,8 +142,11 @@ export function useChatTimeline({
138
142
  setGenerating,
139
143
  isGeneratingRef,
140
144
  ownRunRef,
145
+ streamRunRef,
146
+ serverRunRef,
141
147
  aiPlaceholderIdRef,
142
148
  optimisticUserIdRef,
149
+ pendingUserDisplaysRef,
143
150
  cancelStreamingCoalescer,
144
151
  metaRefreshedRef,
145
152
  scrollRef,
@@ -147,15 +154,10 @@ export function useChatTimeline({
147
154
  jumpCountRef,
148
155
  });
149
156
  loadOlderRef.current = loadOlder;
150
-
151
157
  // Jump rail: the full-history turn index plus the paging walk that reaches a
152
158
  // turn outside the mounted window. The walk is wrapped so follow mode stands
153
159
  // down until the jump lands (see rail-jump.ts).
154
- const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: pagedJumpToTurn } = useUserTurns({
155
- sessionId,
156
- messages: localMessages,
157
- jumpToTurn,
158
- });
160
+ const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: pagedJumpToTurn } = useUserTurns({ sessionId, messages: localMessages, jumpToTurn });
159
161
  const jumpToUserTurn = useRailJump(pagedJumpToTurn, { followRef, scrollRef });
160
162
 
161
163
  // The composer's model/thinking mirrors and the mid-run stash (see the hook).
@@ -210,6 +212,8 @@ export function useChatTimeline({
210
212
  appSettings,
211
213
  enqueueExtensionDialog,
212
214
  withdrawExtensionDialog,
215
+ reloadMessages,
216
+ needsReloadRef,
213
217
  }), activityRef);
214
218
  const spawn = useSpawnTarget(sessionId);
215
219
 
@@ -243,20 +247,11 @@ export function useChatTimeline({
243
247
  });
244
248
 
245
249
  // Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
246
- // schedules the next queued prompt, with a transactional head claim so no
250
+ // schedules the next queued prompt with a transactional head claim, so no
247
251
  // reload/tab race can double-send. `stopHoldRef` serves only the mock path.
248
252
 
249
- const {
250
- handleSend,
251
- handleEditQueueItem,
252
- handleSendNowQueueItem,
253
- handleUndo,
254
- handleRetry,
255
- submitNewChat,
256
- stopGenerating,
257
- handleThinkingLevelChange,
258
- handleModelChange,
259
- } = useChatTimelineActions({
253
+ const { handleSend, handleEditQueueItem, handleSendNowQueueItem, handleUndo, handleRetry,
254
+ submitNewChat, stopGenerating, handleThinkingLevelChange, handleModelChange } = useChatTimelineActions({
260
255
  inputValue,
261
256
  setInputValue,
262
257
  setInputAttachments,
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Recovery of a run whose terminal frames were lost with the channel.
8
+ *
9
+ * The reported failure, end to end: a streaming chat left in a background tab,
10
+ * the socket torn down, the turn finished with nobody reading. The timeline
11
+ * kept the stream's last PARTIAL segment and its generating flag never
12
+ * released, while the server's `stream` row had already settled — so the
13
+ * indicator stopped and the answer stayed cut off mid-sentence.
14
+ *
15
+ * Mounted through the REAL `useSessionLoad`, because the repair is a reload of
16
+ * that hook's own state: asserting on the recovery hook's calls would pass
17
+ * while the timeline kept the partial row.
18
+ */
19
+
20
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
21
+ import { Window } from 'happy-dom';
22
+ import { h, render } from 'preact';
23
+ import { act } from 'preact/test-utils';
24
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
25
+
26
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
27
+ import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
28
+ import { resetConnection } from '@/shared/lib/realtime/connection';
29
+ import { setStatus } from '@/shared/lib/realtime/state';
30
+ import type { ChatMessageData } from '@/shared/types';
31
+
32
+ const originalFetch = Bun.fetch;
33
+ const PARTIAL: ChatMessageData = { id: 'omp-ai', role: 'ai', content: 'The answer stops mid-sent' };
34
+ const COMMITTED: ChatMessageData[] = [
35
+ { id: 'omp-user', role: 'user', content: 'tolong jelaskan' },
36
+ { id: 'omp-ai', role: 'ai', content: 'The answer, in full.' },
37
+ ];
38
+
39
+ let container: HTMLElement | undefined;
40
+ /** Render-triggered waiters, so a case can await the state it asserts on. */
41
+ const messageWaiters: { match: (rows: ChatMessageData[]) => boolean; resolve: () => void }[] = [];
42
+
43
+ beforeAll(() => {
44
+ installDomGlobals(new Window({ url: 'http://localhost' }));
45
+ });
46
+
47
+ afterAll(() => {
48
+ restoreDomGlobals();
49
+ globalThis.fetch = originalFetch;
50
+ });
51
+
52
+ afterEach(() => {
53
+ if (container) render(null, container);
54
+ container?.remove();
55
+ container = undefined;
56
+ resetConnection();
57
+ globalThis.fetch = originalFetch;
58
+ });
59
+
60
+ interface Api {
61
+ messages: ChatMessageData[];
62
+ generating: boolean;
63
+ setMessages: (next: ChatMessageData[]) => void;
64
+ /** Arm the state a dropped run leaves behind. */
65
+ loseRun: () => void;
66
+ /** The recovery's own flag: set when a still-live run lost segments, which
67
+ * is what schedules the read at its settle. A ref, not a render value — the
68
+ * flag is written without a state change, and no consumer renders for it. */
69
+ needsReloadRef: { current: boolean };
70
+ /** Run the reconnect: open → closed → open. */
71
+ reconnect: () => Promise<void>;
72
+ /** Resolves on the render where the timeline holds rows matching `match`:
73
+ * the retry is timer-driven, so this is the signal to wait on. */
74
+ awaitMessages: (match: (rows: ChatMessageData[]) => boolean) => Promise<void>;
75
+ }
76
+
77
+ interface ProbeProps {
78
+ api: Api;
79
+ }
80
+
81
+ function Probe({ api }: ProbeProps) {
82
+ const [messages, setMessages] = useState<ChatMessageData[]>([]);
83
+ const isGeneratingRef = useRef(false);
84
+ const ownRunRef = useRef(false);
85
+ const streamRunRef = useRef(false);
86
+ const serverRunRef = useRef(false);
87
+ const aiPlaceholderIdRef = useRef<string | null>(null);
88
+ const optimisticUserIdRef = useRef<string | null>(null);
89
+ const pendingUserDisplaysRef = useRef<{ sent: string; display: string }[]>([]);
90
+ const metaRefreshedRef = useRef<string | null>(null);
91
+ const scrollRef = useRef<HTMLDivElement>(null);
92
+ const jumpActiveRef = useRef(false);
93
+ const jumpCountRef = useRef(0);
94
+ const [generating, setGeneratingState] = useState(false);
95
+ useEffect(() => {
96
+ for (const waiter of [...messageWaiters]) {
97
+ if (!waiter.match(messages)) continue;
98
+ messageWaiters.splice(messageWaiters.indexOf(waiter), 1);
99
+ waiter.resolve();
100
+ }
101
+ }, [messages]);
102
+ const setGenerating = useCallback((v: boolean) => {
103
+ isGeneratingRef.current = v;
104
+ setGeneratingState(v);
105
+ }, []);
106
+
107
+ api.messages = messages;
108
+ api.awaitMessages = (match) => {
109
+ if (match(messages)) return Promise.resolve();
110
+ const { promise, resolve } = Promise.withResolvers<void>();
111
+ messageWaiters.push({ match, resolve });
112
+ return promise;
113
+ };
114
+ api.generating = generating;
115
+ api.setMessages = setMessages;
116
+ api.loseRun = () => {
117
+ isGeneratingRef.current = true;
118
+ // The stream had announced this run — that is the state a lost run is in
119
+ // (a dispatch whose first frame never arrived is a different case, and the
120
+ // recovery deliberately refuses it).
121
+ streamRunRef.current = true;
122
+ serverRunRef.current = true;
123
+ setGeneratingState(true);
124
+ aiPlaceholderIdRef.current = 'omp-ai';
125
+ setMessages([PARTIAL]);
126
+ };
127
+ api.reconnect = async () => {
128
+ await act(async () => {
129
+ setStatus('open');
130
+ setStatus('closed');
131
+ setStatus('connecting');
132
+ setStatus('open');
133
+ for (let i = 0; i < 10; i += 1) await Promise.resolve();
134
+ });
135
+ };
136
+
137
+ const load = useSessionLoad({
138
+ sessionId: 'omp-1',
139
+ setLocalMessages: setMessages,
140
+ setGenerating,
141
+ isGeneratingRef,
142
+ ownRunRef,
143
+ streamRunRef,
144
+ serverRunRef,
145
+ aiPlaceholderIdRef,
146
+ optimisticUserIdRef,
147
+ pendingUserDisplaysRef,
148
+ cancelStreamingCoalescer: () => {},
149
+ metaRefreshedRef,
150
+ scrollRef,
151
+ jumpActiveRef,
152
+ jumpCountRef,
153
+ });
154
+ api.needsReloadRef = load.needsReloadRef;
155
+ return null;
156
+ }
157
+
158
+ /** Route the two reads this path makes: the transcript, and the attach probe.
159
+ * The transcript is EMPTY until `arm()` — a run still in flight has not
160
+ * committed the turn it is streaming, and arming only after the drop is what
161
+ * makes a reload observable (an always-populated route would let a broken
162
+ * path pass by having the initial load paint the same rows). */
163
+ interface StubRoutes {
164
+ transcriptReads: number;
165
+ arm: () => void;
166
+ /** Answer the next N transcript reads with an EMPTY list — omp buffers its
167
+ * writes, so the turn that just finished is not on disk yet. */
168
+ emptyReads: (count: number) => void;
169
+ /** Resolves once the transcript has been read `count` times: the retry is
170
+ * timer-driven, so this is the signal to wait on rather than a duration. */
171
+ readsReached: (count: number) => Promise<void>;
172
+ /** Resolves on the FIRST transcript read, so the harness can wait for the
173
+ * initial load to land instead of guessing a delay. */
174
+ firstRead: Promise<void>;
175
+ }
176
+
177
+ function stubRoutes(probeAnswer: unknown): StubRoutes {
178
+ const seen = { transcriptReads: 0, armed: false, empty: 0 };
179
+ const waiters: { count: number; resolve: () => void }[] = [];
180
+ const settleWaiters = (): void => {
181
+ for (const waiter of [...waiters]) {
182
+ if (seen.transcriptReads < waiter.count) continue;
183
+ waiters.splice(waiters.indexOf(waiter), 1);
184
+ waiter.resolve();
185
+ }
186
+ };
187
+ const firstRead = Promise.withResolvers<void>();
188
+ globalThis.fetch = (async (input: unknown) => {
189
+ const url = String(input);
190
+ if (url.includes('/api/agent/')) {
191
+ return new Response(JSON.stringify(probeAnswer), { status: 200, headers: { 'content-type': 'application/json' } });
192
+ }
193
+ seen.transcriptReads += 1;
194
+ firstRead.resolve();
195
+ settleWaiters();
196
+ const empty = !seen.armed || seen.empty > 0;
197
+ if (seen.empty > 0) seen.empty -= 1;
198
+ return new Response(JSON.stringify({
199
+ session: { id: 'omp-1', messages: empty ? [] : COMMITTED },
200
+ hasMore: false,
201
+ oldestIndex: 0,
202
+ }), { status: 200, headers: { 'content-type': 'application/json' } });
203
+ }) as unknown as typeof fetch;
204
+ return {
205
+ get transcriptReads() { return seen.transcriptReads; },
206
+ arm: () => { seen.armed = true; },
207
+ emptyReads: (count) => { seen.empty = count; },
208
+ readsReached: (count) => {
209
+ if (seen.transcriptReads >= count) return Promise.resolve();
210
+ const { promise, resolve } = Promise.withResolvers<void>();
211
+ waiters.push({ count, resolve });
212
+ return promise;
213
+ },
214
+ firstRead: firstRead.promise,
215
+ };
216
+ }
217
+
218
+ async function mount(probeAnswer: unknown): Promise<{ api: Api; routes: StubRoutes }> {
219
+ const routes = stubRoutes(probeAnswer);
220
+ const api = {} as Api;
221
+ container ??= document.body.appendChild(document.createElement('div'));
222
+ await act(async () => {
223
+ render(h(Probe, { api }), container as HTMLElement);
224
+ });
225
+ // The initial history read is a real promise chain (`json()` included), so a
226
+ // bare microtask flush is not enough — and a load landing AFTER the test arms
227
+ // its partial row would clear it, which is the harness racing the behaviour
228
+ // under test. Waiting on the read itself is deterministic. It is awaited
229
+ // OUTSIDE the render's `act`: effects flush when that callback returns, so a
230
+ // fetch issued by one cannot settle inside it.
231
+ await act(async () => {
232
+ await routes.firstRead;
233
+ for (let i = 0; i < 8; i += 1) await Promise.resolve();
234
+ });
235
+ return { api, routes };
236
+ }
237
+
238
+ describe('a run whose terminal frames were lost with the channel', () => {
239
+ test('settles the local run and replaces the partial answer with the transcript', async () => {
240
+ const { api, routes } = await mount({ running: false });
241
+ await act(async () => { api.loseRun(); routes.arm(); });
242
+ expect(api.messages).toEqual([PARTIAL]);
243
+ expect(api.generating).toBe(true);
244
+
245
+ await api.reconnect();
246
+
247
+ // The rows on screen were the stream's last PARTIAL segment; the session
248
+ // JSONL is the only record of the rest.
249
+ expect(api.messages).toEqual(COMMITTED);
250
+ expect(api.generating).toBe(false);
251
+ });
252
+
253
+ test('leaves a run the server still reports as live alone — but remembers it', async () => {
254
+ // Reloading mid-turn would replace the stream's own rows with a lagging
255
+ // snapshot, so nothing is applied here. What must NOT be lost is the fact
256
+ // that segments finished during the outage: their `message_end` went into
257
+ // the dead socket, and only a full read once the run is over recovers them.
258
+ const { api, routes } = await mount({ running: true, state: { isStreaming: true, isPromptRunning: true } });
259
+ await act(async () => { api.loseRun(); routes.arm(); });
260
+
261
+ await api.reconnect();
262
+
263
+ expect(api.messages).toEqual([PARTIAL]);
264
+ expect(api.generating).toBe(true);
265
+ expect(api.needsReloadRef.current).toBe(true);
266
+ });
267
+
268
+ test('a first dial is not a reconnect and does not settle the run', async () => {
269
+ const { api, routes } = await mount({ running: false });
270
+ await act(async () => { api.loseRun(); routes.arm(); });
271
+
272
+ await act(async () => {
273
+ setStatus('connecting');
274
+ setStatus('open');
275
+ for (let i = 0; i < 10; i += 1) await Promise.resolve();
276
+ });
277
+
278
+ expect(api.messages).toEqual([PARTIAL]);
279
+ expect(api.generating).toBe(true);
280
+ });
281
+
282
+ test('a transcript omp has not flushed yet is retried until the turn lands', async () => {
283
+ // omp buffers its transcript writes: the turn that just finished can be
284
+ // absent from the file for a beat after the channel returns. One read and
285
+ // stop would leave the partial answer on screen — the state this recovery
286
+ // exists to clear — so an EMPTY answer is retried on the same front-loaded
287
+ // schedule the metadata refresh uses, and stops at the first real one.
288
+ //
289
+ // Fake timers, not a sleep: the retry is a real `setTimeout` chain, and the
290
+ // schedule is what this case is about.
291
+ const { api, routes } = await mount({ running: false });
292
+ await act(async () => {
293
+ api.loseRun();
294
+ routes.arm();
295
+ routes.emptyReads(2);
296
+ });
297
+
298
+ await api.reconnect();
299
+ // The first read came back empty, so the rows are still the partial answer.
300
+ expect(api.messages).toEqual([PARTIAL]);
301
+
302
+ // The retry is a real timer chain; wait on the RENDER that carries the
303
+ // committed rows, not on a guessed delay. Two empty answers, so the third
304
+ // read is the one with the turn in it.
305
+ // The retry is a real timer chain, and the rows land from a render it
306
+ // triggers. Awaited OUTSIDE `act`: preact defers effects until the act
307
+ // callback returns, so waiting for a render-driven signal inside one
308
+ // deadlocks.
309
+ await api.awaitMessages((rows) => rows.length === COMMITTED.length);
310
+
311
+ expect(api.messages).toEqual(COMMITTED);
312
+ expect(routes.transcriptReads).toBeGreaterThanOrEqual(4);
313
+
314
+ expect(api.messages).toEqual(COMMITTED);
315
+ expect(routes.transcriptReads).toBeGreaterThanOrEqual(3);
316
+ });
317
+
318
+ test('a lost run on a session this server does not own still settles', async () => {
319
+ // `running:false` is also the answer for a session owned by another
320
+ // instance; the frames are gone either way and the transcript is readable.
321
+ const { api, routes } = await mount({ running: false });
322
+ await act(async () => { api.loseRun(); routes.arm(); });
323
+
324
+ await api.reconnect();
325
+
326
+ expect(api.generating).toBe(false);
327
+ expect(api.messages).toEqual(COMMITTED);
328
+ });
329
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Recover a run whose terminal frames were lost with the realtime channel.
8
+ *
9
+ * The reported failure: a streaming chat was left in a background tab, the
10
+ * socket was torn down, the turn finished while nothing was reading, and the
11
+ * user came back to an answer cut off mid-sentence with the docked indicator
12
+ * already gone. Both halves of that state come from the same missing frames —
13
+ * `message_end` never replaced the streaming row, and `agent_end` never
14
+ * released the local generating flag — while the SERVER's `stream` row settled
15
+ * normally, which is why the indicator stopped but the transcript did not.
16
+ *
17
+ * The trigger is the channel reopening while this page still believes a run is
18
+ * live (`useReconnectSettle`, which also asks the server whether the run is
19
+ * really over). Recovery is two steps and both are needed:
20
+ *
21
+ * 1. settle the local run — release the generating flag and drop the ids that
22
+ * only a live stream would consume (the AI placeholder, the optimistic user
23
+ * mark, pending steer displays);
24
+ * 2. re-read the committed transcript, because the rows on screen are the
25
+ * stream's last PARTIAL segment and the session JSONL is the only record of
26
+ * the rest.
27
+ *
28
+ * The reload is deliberately unconditional about ownership: a run this page
29
+ * dispatched is over by the time this fires, so the guard in `session-load.ts`
30
+ * (which exists to keep a LAGGING snapshot away from a live run) does not
31
+ * apply here.
32
+ *
33
+ * A run that is STILL LIVE at reconnect is a different case and is NOT
34
+ * reloaded here — its rows are the stream's, and replacing them mid-turn is the
35
+ * lagging-snapshot bug the guard exists for. But its earlier segments are gone:
36
+ * their `message_end` went into the dead socket, and `agent_end` only
37
+ * materializes turns that stopped abnormally (`materializeTerminalMessages`).
38
+ * So the loss is REMEMBERED (`needsReloadRef`) and the transcript is re-read
39
+ * when the run settles, which is the only point where a full read is safe.
40
+ */
41
+
42
+ import type { Dispatch, SetStateAction } from 'preact/compat';
43
+ import { useCallback, useRef } from 'preact/hooks';
44
+
45
+ import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
46
+ import type { ChatMessageData } from '@/shared/types';
47
+ import { useReconnectSettle } from '@/client/hooks/chat/omp/reconnect';
48
+
49
+ export interface LostRunRecoveryDeps {
50
+ sessionId: string | null;
51
+ sessionIdRef: { current: string | null };
52
+ /** The id a spawn adopted, while the URL still carries the pending one. */
53
+ adoptedSessionIdRef: { current: string | null };
54
+ isGeneratingRef: { current: boolean };
55
+ /** The stream has announced the run in flight — see `useReconnectSettle`. */
56
+ streamRunRef: { current: boolean };
57
+ /** The server reports a run for this session (its `stream` row). */
58
+ serverRunRef: { current: boolean };
59
+ setGenerating: (v: boolean) => void;
60
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
61
+ /** Pagination cursor for the window the reload applies. */
62
+ applyWindow: (hasMore: unknown, oldestIndex: unknown) => void;
63
+ aiPlaceholderIdRef: { current: string | null };
64
+ optimisticUserIdRef: { current: string | null };
65
+ pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
66
+ cancelStreamingCoalescer: () => void;
67
+ }
68
+
69
+ export interface LostRunRecovery {
70
+ /** Re-read the committed transcript now (used by the run's own settle path
71
+ * when `needsReloadRef` is set — see `omp-callbacks.ts`). */
72
+ reloadMessages: () => void;
73
+ /** Set when a run that lost frames is still streaming: its earlier segments
74
+ * can only be recovered by a full read once the run is over. */
75
+ needsReloadRef: { current: boolean };
76
+ }
77
+
78
+ export function useLostRunRecovery(deps: LostRunRecoveryDeps): LostRunRecovery {
79
+ const {
80
+ sessionId,
81
+ sessionIdRef,
82
+ adoptedSessionIdRef,
83
+ isGeneratingRef,
84
+ streamRunRef,
85
+ serverRunRef,
86
+ setGenerating,
87
+ setLocalMessages,
88
+ applyWindow,
89
+ aiPlaceholderIdRef,
90
+ optimisticUserIdRef,
91
+ pendingUserDisplaysRef,
92
+ cancelStreamingCoalescer,
93
+ } = deps;
94
+
95
+ /** Replace the timeline with the session's committed transcript.
96
+ *
97
+ * A FAILED read leaves the rows alone. An EMPTY one is retried on the same
98
+ * front-loaded schedule `refreshSessionMeta` uses, and for the same reason:
99
+ * omp buffers its transcript writes, so the turn that just finished may not
100
+ * be on disk yet when the channel comes back — reading once and stopping
101
+ * would leave the partial answer in place, which is the very state this
102
+ * recovery exists to clear. The retry is bounded and stops at the first
103
+ * non-empty answer. */
104
+ const reloadMessages = useCallback(() => {
105
+ const current = sessionIdRef.current;
106
+ // A spawn answers with omp's id a beat before the URL carries it, so the
107
+ // adopted id is the one with a transcript when the pending id is still in
108
+ // the URL.
109
+ const target = current && !current.startsWith('new-')
110
+ ? current
111
+ : adoptedSessionIdRef.current;
112
+ if (!target) return;
113
+ let attempt = 0;
114
+ const tryRead = (): void => {
115
+ fetch(`/api/chat/${encodeURIComponent(target)}`)
116
+ .then(res => res.json())
117
+ .then(data => {
118
+ // The session may have moved while the read was in flight; applying
119
+ // it would paint one conversation's rows into another.
120
+ const now = sessionIdRef.current;
121
+ if (now !== target && adoptedSessionIdRef.current !== target) return;
122
+ if (!data?.session) return;
123
+ const fetched: ChatMessageData[] = data.session.messages || [];
124
+ if (fetched.length > 0) {
125
+ setLocalMessages(fetched);
126
+ applyWindow(data.hasMore, data.oldestIndex);
127
+ return;
128
+ }
129
+ const delay = SESSION_META_RETRY_SCHEDULE_MS[attempt];
130
+ attempt += 1;
131
+ if (delay !== undefined) setTimeout(tryRead, delay);
132
+ })
133
+ .catch(() => {});
134
+ };
135
+ tryRead();
136
+ }, [sessionIdRef, adoptedSessionIdRef, setLocalMessages, applyWindow]);
137
+
138
+ const needsReloadRef = useRef(false);
139
+
140
+ useReconnectSettle({
141
+ // A pending `new-…` chat has no omp child and no committed transcript: its
142
+ // run is covered by the pending view, and settling it here would drop the
143
+ // optimistic rows of a spawn still in flight.
144
+ sessionId: sessionId?.startsWith('new-') ? null : sessionId,
145
+ isGeneratingRef,
146
+ streamRunRef,
147
+ serverRunRef,
148
+ // Recorded at the DROP: the segments that finished during the outage have
149
+ // no other way back, and the read that recovers them is only safe once the
150
+ // run is over — so the flag is consumed by `onAgentEnd` (`omp-callbacks.ts`)
151
+ // rather than read here.
152
+ onFramesLost: () => { needsReloadRef.current = true; },
153
+ onSettled: () => {
154
+ setGenerating(false);
155
+ // Queued updates are the same lost segment's tail: the reload is
156
+ // authoritative, and applying them would paint a partial answer over the
157
+ // committed one.
158
+ cancelStreamingCoalescer();
159
+ optimisticUserIdRef.current = null;
160
+ pendingUserDisplaysRef.current = [];
161
+ // The placeholder mark is released, but its ROW is not deleted here: the
162
+ // committed read below is what replaces the rows, and dropping the only
163
+ // row first would blank the transcript for the retry window — and leave
164
+ // it blank for good if omp never flushed the turn. A row the stream left
165
+ // mid-flight also carries `thinking.isGenerating`, which is what pulses
166
+ // the collapsed accordion; the run is over, so nothing is generating.
167
+ aiPlaceholderIdRef.current = null;
168
+ setLocalMessages(prev => prev.map(m => (typeof m.thinking === 'object' && m.thinking?.isGenerating
169
+ ? { ...m, thinking: { ...m.thinking, isGenerating: false } }
170
+ : m)));
171
+ needsReloadRef.current = false;
172
+ reloadMessages();
173
+ },
174
+ });
175
+
176
+ return { reloadMessages, needsReloadRef };
177
+ }