@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,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which copy wins when a session's stored blob arrives.
8
+ *
9
+ * Split out of `store.ts` for the file-size ceiling, and it is a coherent unit
10
+ * rather than an arbitrary cut: both halves answer the same question, and the
11
+ * store is the only caller. The store owns the CACHE (an entry's lifetime, the
12
+ * persist debounce, eviction); this owns the DECISION (a response describes the
13
+ * row as it was when the request arrived, so anything written since is newer).
14
+ */
15
+
16
+ type SessionState = Record<string, unknown>;
17
+
18
+ /**
19
+ * A monotonic counter bumped by every local write, and the counter value each
20
+ * `(session, key)` was last written at.
21
+ *
22
+ * This exists for ONE race, and `dirtySessions` cannot cover it: a load's
23
+ * response is served from a snapshot taken when the request arrived, while a
24
+ * write made in the meantime persists on its own schedule. Once that persist
25
+ * succeeds the dirty flag is cleared — so a response that arrives after it but
26
+ * was computed before it would be adopted as-is and silently drop the write.
27
+ * Measured in the browser (a rare e2e failure, `outline.e2e.ts`): a file opened
28
+ * while the page's first `/state` read was in flight came back with
29
+ * `layout.openedFiles` gone, so the editor drew "Select a file to open" over a
30
+ * file the user had just clicked.
31
+ *
32
+ * `loadSession` records the counter when it STARTS and merges exactly the keys
33
+ * written after that point, which is what makes "wrote it while the read was in
34
+ * flight" survive regardless of when the persist landed.
35
+ */
36
+ let writeSeq = 0;
37
+ const writeLog = new Map<string, Map<string, number>>();
38
+
39
+ /** The counter as of now — a read takes this before it issues its request. */
40
+ export function currentWriteSeq(): number {
41
+ return writeSeq;
42
+ }
43
+
44
+ /** Record that `key` was written locally just now. */
45
+ export function noteWrite(sessionId: string, key: string): void {
46
+ writeSeq += 1;
47
+ let perSession = writeLog.get(sessionId);
48
+ if (!perSession) {
49
+ perSession = new Map();
50
+ writeLog.set(sessionId, perSession);
51
+ }
52
+ perSession.set(key, writeSeq);
53
+ }
54
+
55
+ /** The keys of `sessionId` written after `since` (strictly later). */
56
+ export function keysWrittenAfter(sessionId: string, since: number): Set<string> {
57
+ const written = new Set<string>();
58
+ const perSession = writeLog.get(sessionId);
59
+ if (!perSession) return written;
60
+ for (const [key, seq] of perSession) {
61
+ if (seq > since) written.add(key);
62
+ }
63
+ return written;
64
+ }
65
+
66
+ /** Drop a session's write history — evicted, forgotten or adopted away. */
67
+ export function forgetWrites(sessionId: string): void {
68
+ writeLog.delete(sessionId);
69
+ }
70
+
71
+ /**
72
+ * The blob to cache, given the response, the local entry, the keys written
73
+ * while the read was in flight, and whether the entry is still dirty.
74
+ *
75
+ * Only the keys written during the window win over the stored blob. Merging the
76
+ * WHOLE local cache over the blob (the old behavior) resurrected stale values on
77
+ * every tab: a second browser opening the session had cached the pre-write blob,
78
+ * and its stale copy clobbered another tab's fresh writes — queue items written
79
+ * in one tab never appeared in the other.
80
+ */
81
+ export function mergeStoredBlob(
82
+ incoming: SessionState,
83
+ existing: SessionState,
84
+ written: Set<string>,
85
+ dirty: boolean,
86
+ ): SessionState {
87
+ // A dirty entry has a persist that has not landed: the whole local copy is
88
+ // newer than the response.
89
+ if (dirty) return { ...incoming, ...existing };
90
+ if (written.size === 0) return incoming;
91
+ const merged: SessionState = { ...incoming };
92
+ for (const key of written) {
93
+ // A key the local side still holds wins outright; one it no longer holds
94
+ // was CLEARED during the read (`clearSessionKey`), so the response's copy is
95
+ // the stale one and must not resurrect it.
96
+ if (key in existing) merged[key] = existing[key];
97
+ else delete merged[key];
98
+ }
99
+ return merged;
100
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The blob-arrival half of the session-state store: what wins when a stored
8
+ * blob lands, and what a read must not overwrite.
9
+ *
10
+ * Split from `store.test.ts` for the per-file ceiling. The contract pinned here
11
+ * is the one that produced two measured bugs:
12
+ *
13
+ * - A response is served from a snapshot taken when the request arrived, so a
14
+ * key written WHILE the read was in flight is newer — even once its own
15
+ * persist landed and cleared the dirty flag. Without the write counter, a
16
+ * file opened during the page's first `/state` read came back with
17
+ * `layout.openedFiles` gone.
18
+ * - A key CLEARED in that window must not be resurrected by a response that
19
+ * still carries it.
20
+ */
21
+
22
+ import { describe, expect, test } from 'bun:test';
23
+
24
+ import {
25
+ clearSessionKey,
26
+ flushSession,
27
+ getSessionValue,
28
+ hydrateSession,
29
+ loadSession,
30
+ setSessionKey,
31
+ } from '@/shared/lib/workspace/session-state/store';
32
+ import { counter, follow, installStoreTeardown, use } from '@/shared/lib/workspace/session-state/harness.test-util';
33
+
34
+ installStoreTeardown();
35
+
36
+ describe('session-state store: blob arrival (loadSession)', () => {
37
+ function stubWindow(): void {
38
+ Reflect.set(globalThis, 'window', {});
39
+ }
40
+
41
+ function stubFetch(response: unknown): void {
42
+ Reflect.set(globalThis, 'fetch', async () => response);
43
+ }
44
+
45
+ /**
46
+ * A fetch stub that HOLDS the blob read and lets writes through.
47
+ *
48
+ * Gating every call deadlocks the case: the write's own persist is a `POST`
49
+ * to the same path, and a stub that awaits the gate for both means the flush
50
+ * can never complete while the read is held.
51
+ */
52
+ function gateReads(state: unknown): () => void {
53
+ const { promise: gate, resolve } = Promise.withResolvers<void>();
54
+ Reflect.set(globalThis, 'fetch', async (_url: string, init?: { method?: string }) => {
55
+ if (init?.method === 'POST') return { ok: true, json: async () => ({}) };
56
+ await gate;
57
+ return { ok: true, json: async () => ({ state }) };
58
+ });
59
+ return resolve;
60
+ }
61
+
62
+ test('a clean session adopts the stored blob and notifies', async () => {
63
+ const id = use('load-clean');
64
+ stubWindow();
65
+ stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
66
+ const probe = counter();
67
+ follow(id, null, probe.listener);
68
+
69
+ await loadSession(id);
70
+ expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
71
+ expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
72
+ expect(probe.calls).toBe(1);
73
+ });
74
+
75
+ test('a dirty session keeps its local writes over the blob', async () => {
76
+ const id = use('load-dirty');
77
+ stubWindow();
78
+ setSessionKey(id, 'a', 99);
79
+ stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
80
+
81
+ await loadSession(id);
82
+ expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'a')).toBe(99);
83
+ expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
84
+ });
85
+
86
+ test('a non-ok response leaves the cache untouched but still notifies', async () => {
87
+ const id = use('load-error');
88
+ stubWindow();
89
+ stubFetch({ ok: false, json: async () => ({}) });
90
+ const probe = counter();
91
+ follow(id, null, probe.listener);
92
+
93
+ await loadSession(id);
94
+ expect(getSessionValue(id, 'a')).toBeUndefined();
95
+ expect(probe.calls).toBe(1);
96
+ });
97
+
98
+ test('a rejected fetch is swallowed and notifies', async () => {
99
+ const id = use('load-throw');
100
+ stubWindow();
101
+ Reflect.set(globalThis, 'fetch', async () => {
102
+ throw new Error('offline');
103
+ });
104
+ const probe = counter();
105
+ follow(id, null, probe.listener);
106
+
107
+ // The store logs the failure; keep the expected warning out of the run log.
108
+ const warn = console.warn;
109
+ console.warn = () => {};
110
+ try {
111
+ await loadSession(id);
112
+ } finally {
113
+ console.warn = warn;
114
+ }
115
+ expect(getSessionValue(id, 'a')).toBeUndefined();
116
+ expect(probe.calls).toBe(1);
117
+ });
118
+
119
+ test('loadSession without a window returns early: no fetch, no notify', async () => {
120
+ const id = use('load-nowindow');
121
+ // Removed for the case and put back after it: another file's hook can have
122
+ // installed one while this file runs, and leaving it missing would break
123
+ // whichever case follows.
124
+ const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
125
+ Reflect.deleteProperty(globalThis, 'window');
126
+ let fetched = false;
127
+ Reflect.set(globalThis, 'fetch', async () => {
128
+ fetched = true;
129
+ throw new Error('must not be called');
130
+ });
131
+ try {
132
+ const probe = counter();
133
+ follow(id, null, probe.listener);
134
+ await loadSession(id);
135
+ expect(fetched).toBe(false);
136
+ expect(probe.calls).toBe(0);
137
+ } finally {
138
+ if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
139
+ Reflect.set(globalThis, 'fetch', Bun.fetch);
140
+ }
141
+ });
142
+
143
+ /**
144
+ * A write that lands WHILE the read is in flight must survive the response,
145
+ * even when its own persist already landed (so the dirty flag is clear).
146
+ *
147
+ * This is the race behind a rare e2e failure: the page's first `/state` read
148
+ * is served from a snapshot taken before the click, the click writes and
149
+ * persists, the persist clears `dirty`, and the late response was then adopted
150
+ * wholesale — `layout.openedFiles` came back empty and the editor drew
151
+ * "Select a file to open" over the file the user had just opened.
152
+ */
153
+ test('a key written during the read wins over the response, even once it persisted', async () => {
154
+ const id = use('load-inflight');
155
+ stubWindow();
156
+ const resolve = gateReads({ a: 1, b: 2 });
157
+
158
+ const pending = loadSession(id);
159
+ // The write and its persist both land while the response is held.
160
+ setSessionKey(id, 'a', 99);
161
+ await flushSession(id);
162
+ resolve();
163
+ await pending;
164
+
165
+ expect<number | undefined>(getSessionValue(id, 'a')).toBe(99);
166
+ // The rest of the blob is still adopted: only the written key is protected.
167
+ expect<number | undefined>(getSessionValue(id, 'b')).toBe(2);
168
+ });
169
+
170
+ test('a key untouched during the read is adopted from the response', async () => {
171
+ const id = use('load-untouched');
172
+ stubWindow();
173
+ const resolve = gateReads({ a: 1 });
174
+
175
+ const pending = loadSession(id);
176
+ // A DIFFERENT key, written and persisted during the same window.
177
+ setSessionKey(id, 'other', true);
178
+ await flushSession(id);
179
+ resolve();
180
+ await pending;
181
+
182
+ expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
183
+ expect<boolean | undefined>(getSessionValue(id, 'other')).toBe(true);
184
+ });
185
+
186
+ test('a cleared key is not resurrected by a response that still carries it', async () => {
187
+ const id = use('load-cleared');
188
+ stubWindow();
189
+ hydrateSession(id, { retired: 'old', keep: 1 });
190
+ const resolve = gateReads({ retired: 'old', keep: 1 });
191
+
192
+ const pending = loadSession(id);
193
+ clearSessionKey(id, 'retired');
194
+ await flushSession(id);
195
+ resolve();
196
+ await pending;
197
+
198
+ expect(getSessionValue(id, 'retired')).toBeUndefined();
199
+ expect<number | undefined>(getSessionValue(id, 'keep')).toBe(1);
200
+ });
201
+
202
+ test('flushSession is a resolved no-op without a window or id', async () => {
203
+ await expect(flushSession(null)).resolves.toBeUndefined();
204
+ await expect(flushSession(use('flush'))).resolves.toBeUndefined();
205
+ });
206
+ });
@@ -6,63 +6,37 @@
6
6
  /**
7
7
  * Pins the session-state store's observer contract and cache lifecycle.
8
8
  *
9
- * The store is a module singleton with private Maps, so the tests drive it
10
- * only through its exports and tear every touched session down with
11
- * `forgetSession` (which clears the cache entry, its dirty flag and its
12
- * pending persist timer). What matters here: a write notifies exactly the
13
- * followers of that (session, slot), a blob arriving notifies every follower
14
- * of the session, a delete notifies, and the merge rules keep a local write
15
- * from being clobbered by a stored blob that predates it.
9
+ * The store is the single source of a session's UI state: every panel reads and
10
+ * writes through it, and it persists the whole blob through
11
+ * `GET/POST /api/sessions/:sessionId/state`. Two contracts matter enough to pin
12
+ * here rather than in a component test:
13
+ *
14
+ * - **Notification routing.** A write notifies the slot's followers and the
15
+ * session-wide ones, never another session's, and an unsubscribed listener
16
+ * stops receiving while an in-flight pass still reaches it.
17
+ * - **Cache lifecycle.** The cache is bounded (10 sessions) and TTL'd (10
18
+ * minutes), and eviction never takes a session with unsaved writes.
19
+ *
20
+ * The blob-arrival half — the write-during-read race — is `store-load.test.ts`,
21
+ * and the wholesale-replacement bus is `replacement.test.ts`.
16
22
  */
17
23
 
18
- import { afterEach, describe, expect, setSystemTime, test } from 'bun:test';
24
+ import { describe, expect, setSystemTime, test } from 'bun:test';
19
25
 
20
- import { notifySessionKey, subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
26
+ import { notifySessionKey } from '@/shared/lib/workspace/session-state/listeners';
21
27
  import {
22
28
  clearSessionKey,
23
- flushSession,
24
29
  forgetSession,
25
30
  getLastOpenedAt,
26
31
  getSessionValue,
27
32
  hydrateSession,
28
- loadSession,
29
33
  migrateSessionState,
30
34
  recordSessionOpen,
31
35
  setSessionKey,
32
36
  } from '@/shared/lib/workspace/session-state/store';
37
+ import { counter, follow, installStoreTeardown, use } from '@/shared/lib/workspace/session-state/harness.test-util';
33
38
 
34
- const originalWindow = Reflect.get(globalThis, 'window');
35
- /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
36
- const originalFetch = Bun.fetch;
37
- const touched: string[] = [];
38
- const unsubs: Array<() => void> = [];
39
-
40
- /** Register a session id so `afterEach` forgets it. */
41
- function use(id: string): string {
42
- touched.push(id);
43
- return id;
44
- }
45
-
46
- /** Subscribe and auto-unsubscribe at teardown. */
47
- function follow(sessionId: string | null, key: string | null, listener: () => void): () => void {
48
- const unsubscribe = subscribeSessionKey(sessionId, key, listener);
49
- unsubs.push(unsubscribe);
50
- return unsubscribe;
51
- }
52
-
53
- /** Count how many times `notifySessionKey` reaches a listener. */
54
- function counter(): { calls: number; listener: () => void } {
55
- const state = { calls: 0, listener: () => { state.calls += 1; } };
56
- return state;
57
- }
58
-
59
- afterEach(() => {
60
- for (const unsubscribe of unsubs.splice(0)) unsubscribe();
61
- for (const id of touched.splice(0)) forgetSession(id);
62
- Reflect.set(globalThis, 'window', originalWindow);
63
- Reflect.set(globalThis, 'fetch', originalFetch);
64
- setSystemTime();
65
- });
39
+ installStoreTeardown();
66
40
 
67
41
  describe('session-state store: read/write', () => {
68
42
  test('a null session id is a no-op for reads and writes', () => {
@@ -211,102 +185,6 @@ describe('session-state store: notification routing', () => {
211
185
  });
212
186
  });
213
187
 
214
- describe('session-state store: blob arrival (loadSession)', () => {
215
- function stubWindow(): void {
216
- Reflect.set(globalThis, 'window', {});
217
- }
218
-
219
- function stubFetch(response: unknown): void {
220
- Reflect.set(globalThis, 'fetch', async () => response);
221
- }
222
-
223
- test('a clean session adopts the stored blob and notifies', async () => {
224
- const id = use('load-clean');
225
- stubWindow();
226
- stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
227
- const probe = counter();
228
- follow(id, null, probe.listener);
229
-
230
- await loadSession(id);
231
- expect<number | undefined>(getSessionValue(id, 'a')).toBe(1);
232
- expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
233
- expect(probe.calls).toBe(1);
234
- });
235
-
236
- test('a dirty session keeps its local writes over the blob', async () => {
237
- const id = use('load-dirty');
238
- stubWindow();
239
- setSessionKey(id, 'a', 99);
240
- stubFetch({ ok: true, json: async () => ({ state: { a: 1, b: 2 } }) });
241
-
242
- await loadSession(id);
243
- expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'a')).toBe(99);
244
- expect<string | number | boolean | Record<string, unknown> | unknown[] | null | undefined>(getSessionValue(id, 'b')).toBe(2);
245
- });
246
-
247
- test('a non-ok response leaves the cache untouched but still notifies', async () => {
248
- const id = use('load-error');
249
- stubWindow();
250
- stubFetch({ ok: false, json: async () => ({}) });
251
- const probe = counter();
252
- follow(id, null, probe.listener);
253
-
254
- await loadSession(id);
255
- expect(getSessionValue(id, 'a')).toBeUndefined();
256
- expect(probe.calls).toBe(1);
257
- });
258
-
259
- test('a rejected fetch is swallowed and notifies', async () => {
260
- const id = use('load-throw');
261
- stubWindow();
262
- Reflect.set(globalThis, 'fetch', async () => {
263
- throw new Error('offline');
264
- });
265
- const probe = counter();
266
- follow(id, null, probe.listener);
267
-
268
- // The store logs the failure; keep the expected warning out of the run log.
269
- const warn = console.warn;
270
- console.warn = () => {};
271
- try {
272
- await loadSession(id);
273
- } finally {
274
- console.warn = warn;
275
- }
276
- expect(getSessionValue(id, 'a')).toBeUndefined();
277
- expect(probe.calls).toBe(1);
278
- });
279
-
280
- test('loadSession without a window returns early: no fetch, no notify', async () => {
281
- const id = use('load-nowindow');
282
- // Removed for the case and put back after it: another file's hook can have
283
- // installed one while this file runs, and leaving it missing would break
284
- // whichever case follows.
285
- const hadWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
286
- Reflect.deleteProperty(globalThis, 'window');
287
- let fetched = false;
288
- Reflect.set(globalThis, 'fetch', async () => {
289
- fetched = true;
290
- throw new Error('must not be called');
291
- });
292
- try {
293
- const probe = counter();
294
- follow(id, null, probe.listener);
295
- await loadSession(id);
296
- expect(fetched).toBe(false);
297
- expect(probe.calls).toBe(0);
298
- } finally {
299
- if (hadWindow) Object.defineProperty(globalThis, 'window', hadWindow);
300
- Reflect.set(globalThis, 'fetch', Bun.fetch);
301
- }
302
- });
303
-
304
- test('flushSession is a resolved no-op without a window or id', async () => {
305
- await expect(flushSession(null)).resolves.toBeUndefined();
306
- await expect(flushSession(use('flush'))).resolves.toBeUndefined();
307
- });
308
- });
309
-
310
188
  describe('session-state store: bounded cache', () => {
311
189
  test('the oldest clean session is evicted past the size cap', () => {
312
190
  const ids = Array.from({ length: 11 }, (_unused, index) => use(`lru-${index}`));