@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
@@ -66,91 +66,52 @@
66
66
  * from another conversation is never restored
67
67
  * - chat.draft string
68
68
  * - chat.draftAttachments attachment metadata array
69
+ *
70
+ * The module is three files, because one had passed the per-file ceiling: this
71
+ * one owns the PUBLIC API (read, write, load, migrate) and what a write means;
72
+ * `cache.ts` owns the storage (the maps, the LRU order, the idle TTL, the dirty
73
+ * flag); `merge.ts` owns the write counter and the "which copy wins when a blob
74
+ * lands" decision. The maps live only in `cache.ts`, so "is this session dirty"
75
+ * and "drop this session" cannot be answered from two places that disagree.
69
76
  */
70
77
 
71
78
  import { notifySessionKey, notifySessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
79
+ import {
80
+ clearDirty,
81
+ dropEntry,
82
+ dropPersistTimer,
83
+ entryForWrite,
84
+ evictIfNeeded,
85
+ isDirty,
86
+ lastAccess,
87
+ markDirty,
88
+ readEntry,
89
+ setPersistTimer,
90
+ stampAccess,
91
+ touchEntry,
92
+ writeEntry,
93
+ } from '@/shared/lib/workspace/session-state/cache';
94
+ import {
95
+ currentWriteSeq,
96
+ forgetWrites,
97
+ keysWrittenAfter,
98
+ mergeStoredBlob,
99
+ noteWrite,
100
+ } from '@/shared/lib/workspace/session-state/merge';
72
101
 
73
102
  type SessionState = Record<string, unknown>;
74
103
 
75
- const cache = new Map<string, SessionState>();
76
- /** Epoch ms of the last user-driven access per session (get/set/hydrate). */
77
- const lastTouched = new Map<string, number>();
78
- const dirtySessions = new Map<string, boolean>();
79
- const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
80
-
81
104
  const PERSIST_DEBOUNCE_MS = 600;
82
105
 
83
- // Bounded LRU cache: cap the number of sessions kept in memory so the tab
84
- // doesn't grow unboundedly as the user visits more sessions.
85
- const MAX_CACHED_SESSIONS = 10;
86
- /** A session whose last user access is older than this is evicted regardless
87
- * of the size cap, so stale per-session UI state (drafts, panel layouts)
88
- * stops occupying memory 10 minutes after the user last opened it. */
89
- const SESSION_IDLE_TTL_MS = 10 * 60 * 1000;
90
-
91
- /**
92
- * Reorder a cache entry to the end of the Map's iteration order (the
93
- * most-recently-used position) via delete-then-set. Map.set on an existing
94
- * key updates the value but keeps its original position, so the explicit
95
- * delete is required for true LRU semantics.
96
- */
97
- function touchCacheEntry(sessionId: string): void {
98
- const value = cache.get(sessionId);
99
- if (value !== undefined) {
100
- cache.delete(sessionId);
101
- cache.set(sessionId, value);
102
- }
103
- lastTouched.set(sessionId, Date.now());
104
- }
105
-
106
- /**
107
- * Evict the oldest clean sessions until the cache is within bounds.
108
- *
109
- * Dirty-safety rule: a session with unsaved writes (`dirtySessions.get(id) ===
110
- * true`) must never be evicted, or the user's drafts and editor state would be
111
- * silently lost. If every candidate is dirty (or is the protected id currently
112
- * being written), stop evicting rather than lose data.
113
- *
114
- * Entries idle past SESSION_IDLE_TTL_MS are evicted first — even when the
115
- * cache is under the size cap — so long-unused sessions drop out on schedule.
116
- */
117
- function evictIfNeeded(protectedId: string | null): void {
118
- const now = Date.now();
119
- const evict = (id: string): void => {
120
- cache.delete(id);
121
- dirtySessions.delete(id);
122
- lastTouched.delete(id);
123
- const timer = persistTimers.get(id);
124
- if (timer) {
125
- clearTimeout(timer);
126
- persistTimers.delete(id);
127
- }
128
- };
129
- // TTL pass: safe to skip only while the timer still holds the unsaved state
130
- // or the session is the one currently being written.
131
- for (const [id, touched] of lastTouched) {
132
- if (now - touched <= SESSION_IDLE_TTL_MS) continue;
133
- if (id === protectedId) continue;
134
- if (dirtySessions.get(id)) continue;
135
- evict(id);
136
- }
137
- while (cache.size > MAX_CACHED_SESSIONS) {
138
- let evicted = false;
139
- for (const id of cache.keys()) {
140
- if (id === protectedId) continue;
141
- if (dirtySessions.get(id)) continue;
142
- evict(id);
143
- evicted = true;
144
- break;
145
- }
146
- if (!evicted) break;
147
- }
106
+ /** Evict past the cap, dropping this session's write history with it. */
107
+ function evictNow(protectedId: string | null): void {
108
+ evictIfNeeded(protectedId, forgetWrites);
148
109
  }
149
110
 
150
111
  export function getSessionValue<T>(sessionId: string | null, key: string): T | undefined {
151
112
  if (!sessionId) return undefined;
152
- touchCacheEntry(sessionId);
153
- return cache.get(sessionId)?.[key] as T | undefined;
113
+ touchEntry(sessionId);
114
+ return readEntry(sessionId)?.[key] as T | undefined;
154
115
  }
155
116
 
156
117
  /**
@@ -166,18 +127,20 @@ export function getSessionValue<T>(sessionId: string | null, key: string): T | u
166
127
  */
167
128
  export function getSessionState(sessionId: string | null): Readonly<Record<string, unknown>> | undefined {
168
129
  if (!sessionId) return undefined;
169
- return cache.get(sessionId);
130
+ return readEntry(sessionId);
170
131
  }
171
132
 
172
133
  export function setSessionKey(sessionId: string | null, key: string, value: unknown): void {
173
134
  if (!sessionId) return;
174
- const state = cache.get(sessionId) ?? {};
175
- state[key] = value;
176
- cache.set(sessionId, state);
177
- touchCacheEntry(sessionId);
178
- dirtySessions.set(sessionId, true);
135
+ // Mutated IN PLACE, never copied: `useSessionState` compares the store's entry
136
+ // against its own copy by IDENTITY to decide whether a fresher write exists,
137
+ // and a copy per write made every reader see a different object for one slot.
138
+ entryForWrite(sessionId)[key] = value;
139
+ touchEntry(sessionId);
140
+ markDirty(sessionId);
141
+ noteWrite(sessionId, key);
179
142
  schedulePersist(sessionId);
180
- evictIfNeeded(sessionId);
143
+ evictNow(sessionId);
181
144
  notifySessionKey(sessionId, key);
182
145
  }
183
146
 
@@ -188,22 +151,25 @@ export function setSessionKey(sessionId: string | null, key: string, value: unkn
188
151
  */
189
152
  export function clearSessionKey(sessionId: string | null, key: string): void {
190
153
  if (!sessionId) return;
191
- const state = cache.get(sessionId);
154
+ const state = readEntry(sessionId);
192
155
  if (!state || !(key in state)) return;
193
- delete state[key];
194
- touchCacheEntry(sessionId);
195
- dirtySessions.set(sessionId, true);
156
+ delete (state as SessionState)[key];
157
+ touchEntry(sessionId);
158
+ markDirty(sessionId);
159
+ // A DELETE is a write: a blob that still carries the key must not bring it
160
+ // back (a retired key cleared on this side would return from the other tab's
161
+ // older snapshot).
162
+ noteWrite(sessionId, key);
196
163
  schedulePersist(sessionId);
197
164
  notifySessionKey(sessionId, key);
198
165
  }
199
166
 
200
167
  function schedulePersist(sessionId: string): void {
201
- const existing = persistTimers.get(sessionId);
202
- if (existing) clearTimeout(existing);
203
- persistTimers.set(
168
+ dropPersistTimer(sessionId);
169
+ setPersistTimer(
204
170
  sessionId,
205
171
  setTimeout(() => {
206
- persistTimers.delete(sessionId);
172
+ dropPersistTimer(sessionId);
207
173
  void persistSession(sessionId);
208
174
  }, PERSIST_DEBOUNCE_MS),
209
175
  );
@@ -211,9 +177,9 @@ function schedulePersist(sessionId: string): void {
211
177
 
212
178
  async function persistSession(sessionId: string): Promise<void> {
213
179
  if (typeof window === 'undefined' || !sessionId) return;
214
- if (!dirtySessions.get(sessionId)) return;
215
- dirtySessions.set(sessionId, false);
216
- const state = cache.get(sessionId) ?? {};
180
+ if (!isDirty(sessionId)) return;
181
+ clearDirty(sessionId);
182
+ const state = readEntry(sessionId) ?? {};
217
183
  try {
218
184
  await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/state`, {
219
185
  method: 'POST',
@@ -222,18 +188,14 @@ async function persistSession(sessionId: string): Promise<void> {
222
188
  });
223
189
  } catch (err) {
224
190
  console.warn('session-state persist failed:', err);
225
- dirtySessions.set(sessionId, true);
191
+ markDirty(sessionId);
226
192
  }
227
193
  }
228
194
 
229
195
  /** Immediately persist any pending writes for a session (used on switch). */
230
196
  export function flushSession(sessionId: string | null): Promise<void> {
231
197
  if (!sessionId || typeof window === 'undefined') return Promise.resolve();
232
- const timer = persistTimers.get(sessionId);
233
- if (timer) {
234
- clearTimeout(timer);
235
- persistTimers.delete(sessionId);
236
- }
198
+ dropPersistTimer(sessionId);
237
199
  return persistSession(sessionId);
238
200
  }
239
201
 
@@ -241,27 +203,34 @@ export function flushSession(sessionId: string | null): Promise<void> {
241
203
  export async function loadSession(sessionId: string): Promise<void> {
242
204
  if (!sessionId) return;
243
205
  if (typeof window === 'undefined') return;
206
+ /**
207
+ * The write counter as of the moment the read STARTED.
208
+ *
209
+ * Everything written after this is newer than the response, because the
210
+ * response describes the row as it was when the request arrived. Two
211
+ * mechanisms are needed and neither is sufficient alone: `dirtySessions`
212
+ * covers a write whose persist has not landed yet, while this covers one that
213
+ * already persisted — at which point the dirty flag is clear and a stale
214
+ * response would otherwise be adopted wholesale. Measured: a file opened
215
+ * during the first `/state` read came back with `layout.openedFiles` gone.
216
+ */
217
+ const startedAt = currentWriteSeq();
244
218
  try {
245
219
  const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/state`);
246
220
  if (res.ok) {
247
221
  const data = (await res.json()) as { state?: SessionState };
248
222
  const incoming = data.state && typeof data.state === 'object' ? data.state : {};
249
- const existing = cache.get(sessionId) ?? {};
250
- // Only keys written locally while the fetch was in flight win over the
251
- // stored blob. Merging the WHOLE local cache over the blob (the old
252
- // behavior) resurrected stale values on every tab: a second browser
253
- // opening the session had cached the pre-write blob, and its stale
254
- // copy clobbered another tab's fresh writes — queue items written in
255
- // one tab never appeared in the other.
256
- const dirty = dirtySessions.get(sessionId) === true;
257
- const merged = dirty ? { ...incoming, ...existing } : incoming;
258
- cache.set(sessionId, merged);
259
- touchCacheEntry(sessionId);
223
+ const existing = readEntry(sessionId) ?? {};
224
+ writeEntry(
225
+ sessionId,
226
+ mergeStoredBlob(incoming, existing, keysWrittenAfter(sessionId, startedAt), isDirty(sessionId)),
227
+ );
228
+ touchEntry(sessionId);
260
229
  }
261
230
  } catch (err) {
262
231
  console.warn('session-state load failed:', err);
263
232
  }
264
- evictIfNeeded(sessionId);
233
+ evictNow(sessionId);
265
234
  // The blob replaces every slot at once, so a follower that read the store
266
235
  // before it arrived (no pick yet) has to re-read — including a private copy
267
236
  // held by `useSessionState`, which does not follow per-slot writes.
@@ -272,9 +241,9 @@ export async function loadSession(sessionId: string): Promise<void> {
272
241
  /** Seed the cache synchronously (e.g. from SSR-provided data). */
273
242
  export function hydrateSession(sessionId: string | null, state: SessionState): void {
274
243
  if (!sessionId) return;
275
- cache.set(sessionId, { ...(cache.get(sessionId) ?? {}), ...state });
276
- touchCacheEntry(sessionId);
277
- evictIfNeeded(sessionId);
244
+ writeEntry(sessionId, { ...(readEntry(sessionId) ?? {}), ...state });
245
+ touchEntry(sessionId);
246
+ evictNow(sessionId);
278
247
  notifySessionKey(sessionId);
279
248
  notifySessionReplaced(sessionId);
280
249
  }
@@ -285,12 +254,12 @@ export function hydrateSession(sessionId: string | null, state: SessionState): v
285
254
  * its last open is within SESSION_IDLE_TTL_MS.
286
255
  */
287
256
  export function recordSessionOpen(sessionId: string | null): void {
288
- if (sessionId) lastTouched.set(sessionId, Date.now());
257
+ if (sessionId) stampAccess(sessionId);
289
258
  }
290
259
 
291
260
  /** Epoch ms of the user's last open of this session in this tab, if any. */
292
261
  export function getLastOpenedAt(sessionId: string): number | undefined {
293
- return lastTouched.get(sessionId);
262
+ return lastAccess(sessionId);
294
263
  }
295
264
 
296
265
  /**
@@ -302,14 +271,8 @@ export function getLastOpenedAt(sessionId: string): number | undefined {
302
271
  * the delete and resurrect the state.
303
272
  */
304
273
  export function forgetSession(sessionId: string): void {
305
- const timer = persistTimers.get(sessionId);
306
- if (timer) {
307
- clearTimeout(timer);
308
- persistTimers.delete(sessionId);
309
- }
310
- cache.delete(sessionId);
311
- dirtySessions.delete(sessionId);
312
- lastTouched.delete(sessionId);
274
+ dropEntry(sessionId);
275
+ forgetWrites(sessionId);
313
276
  notifySessionKey(sessionId);
314
277
  notifySessionReplaced(sessionId);
315
278
  }
@@ -319,15 +282,14 @@ export function forgetSession(sessionId: string): void {
319
282
  * by a spawn, then discard the transient slot.
320
283
  */
321
284
  export function migrateSessionState(fromId: string, toId: string): void {
322
- const from = cache.get(fromId);
323
- if (from) cache.set(toId, { ...from, ...(cache.get(toId) ?? {}) });
324
- touchCacheEntry(toId);
325
- dirtySessions.set(toId, true);
285
+ const from = readEntry(fromId);
286
+ if (from) writeEntry(toId, { ...from, ...(readEntry(toId) ?? {}) });
287
+ touchEntry(toId);
288
+ markDirty(toId);
326
289
  schedulePersist(toId);
327
- cache.delete(fromId);
328
- dirtySessions.delete(fromId);
329
- lastTouched.delete(fromId);
330
- evictIfNeeded(toId);
290
+ dropEntry(fromId);
291
+ forgetWrites(fromId);
292
+ evictNow(toId);
331
293
  // The adopted slot gained the whole transient state; the transient one lost it.
332
294
  notifySessionKey(toId);
333
295
  notifySessionKey(fromId);
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop workspace group's three panel widths, resolved from one place.
8
+ *
9
+ * `WorkspacePanels` used to compute these inline, which was correct but made the
10
+ * component's own contract hard to read and impossible to unit-test: every rule
11
+ * here — the two-call order, the pair caps, the leftover width a collapsed chat
12
+ * hands the editor — is arithmetic over values the caller already has.
13
+ *
14
+ * Pure: no hooks, no DOM. The caller measures the group and reads the stored
15
+ * widths; this module decides what those mean.
16
+ */
17
+
18
+ import {
19
+ DEFAULT_EDITOR_FRACTIONS,
20
+ DEFAULT_EDITOR_WIDTHS,
21
+ HANDLE_WIDTH,
22
+ MIN_EDITOR_PANEL_WIDTH,
23
+ panelFraction,
24
+ pairMaxSize,
25
+ resolvePanelWidth,
26
+ type EditorWidthMode,
27
+ type PanelWidths,
28
+ } from '@/shared/lib/workspace/panel-widths';
29
+ import { DEFAULT_RIGHT_PANEL_FRACTION } from '@/shared/lib/workspace/right-panels';
30
+
31
+ export interface WorkspacePanelWidthsArgs {
32
+ /** Measured area of the group, or null before the observer reports. */
33
+ available: number | null;
34
+ /** This session's remembered widths. */
35
+ panelWidths: PanelWidths;
36
+ /** Which slot the editor column is using (a diff wants more room). */
37
+ editorWidthMode: EditorWidthMode;
38
+ /** The right panel's active view, whose slot and defaults apply. */
39
+ activeRightPanel: string;
40
+ /** The active view's own defaults and floor, when it has them. */
41
+ activePanel?: { defaultFraction?: number; defaultWidth?: number; minWidth?: number };
42
+ showEditor: boolean;
43
+ showRightPanel: boolean;
44
+ /** The chat column is collapsed, so it holds no floor and no pair share. */
45
+ chatHidden: boolean;
46
+ }
47
+
48
+ export interface WorkspacePanelWidths {
49
+ right: number;
50
+ editor: number;
51
+ /** Drag ceilings; `undefined` before the group is measured. */
52
+ editorMax: number | undefined;
53
+ rightMax: number | undefined;
54
+ /** Separators actually rendered, which also consume width. */
55
+ handleCount: number;
56
+ /** The right panel's floor, which the drag ceiling also reserves. */
57
+ rightMin: number;
58
+ }
59
+
60
+ export function resolveWorkspacePanelWidths(args: WorkspacePanelWidthsArgs): WorkspacePanelWidths {
61
+ const { available, panelWidths, editorWidthMode, activeRightPanel, activePanel, showEditor, showRightPanel, chatHidden } = args;
62
+
63
+ // The separator before the editor is gone while the chat is collapsed: there
64
+ // is nothing on its left to transfer width to, and the drag's neighbour walk
65
+ // skips a collapsed panel, so it would render a handle that does nothing.
66
+ const handleCount = (showEditor && !chatHidden ? 1 : 0) + (showRightPanel ? 1 : 0);
67
+ const rightMin = activePanel?.minWidth ?? MIN_EDITOR_PANEL_WIDTH;
68
+
69
+ // The editor and the right panel are a COUPLED pair: the right panel is
70
+ // resolved first, reserving the editor's WANTED share (the editor has no
71
+ // result yet), and the editor then reserves the right panel's ACTUAL width.
72
+ // That asymmetry is deliberate — an editor asking for more than its default
73
+ // makes the right panel give way first, and the pair still sums to the total.
74
+ const editorFraction = panelFraction(panelWidths[editorWidthMode], DEFAULT_EDITOR_FRACTIONS[editorWidthMode], available);
75
+ const editorReservePx = available != null && available > 0 ? Math.round(editorFraction * available) : 0;
76
+
77
+ const right = resolvePanelWidth({
78
+ stored: panelWidths.right?.[activeRightPanel],
79
+ defaultFraction: activePanel?.defaultFraction ?? DEFAULT_RIGHT_PANEL_FRACTION,
80
+ defaultPx: activePanel?.defaultWidth ?? MIN_EDITOR_PANEL_WIDTH,
81
+ available,
82
+ min: rightMin,
83
+ pairReservePx: showEditor ? editorReservePx : 0,
84
+ // The editor has not been resolved yet, so only its floor can be reserved.
85
+ siblingWidth: showEditor ? MIN_EDITOR_PANEL_WIDTH : 0,
86
+ handleCount,
87
+ chatHidden,
88
+ });
89
+
90
+ // With the chat collapsed the editor takes everything the right panel and the
91
+ // separators do not. Resolving from its remembered FRACTION would leave the
92
+ // freed width as an empty gap, which is the opposite of what the mode
93
+ // promises; the remembered width is untouched, so leaving restores it.
94
+ const editor = chatHidden
95
+ ? Math.max(
96
+ MIN_EDITOR_PANEL_WIDTH,
97
+ available != null && available > 0
98
+ ? Math.floor(available - (showRightPanel ? right : 0) - handleCount * HANDLE_WIDTH)
99
+ : DEFAULT_EDITOR_WIDTHS[editorWidthMode],
100
+ )
101
+ : resolvePanelWidth({
102
+ stored: panelWidths[editorWidthMode],
103
+ defaultFraction: DEFAULT_EDITOR_FRACTIONS[editorWidthMode],
104
+ defaultPx: DEFAULT_EDITOR_WIDTHS[editorWidthMode],
105
+ available,
106
+ min: MIN_EDITOR_PANEL_WIDTH,
107
+ pairReservePx: showRightPanel ? right : 0,
108
+ siblingWidth: showRightPanel ? right : 0,
109
+ handleCount,
110
+ });
111
+
112
+ return {
113
+ right,
114
+ editor,
115
+ // `resolvePanelWidth` caps what it HANDS to `defaultSize`, but a separator
116
+ // drag bypasses it — the resizer transfers pixels between two panels clamped
117
+ // only by their own `maxSize`. Each max is its own cap and the pair total
118
+ // minus the sibling's FLOOR (it gives way down to its minimum mid-drag).
119
+ editorMax: pairMaxSize({ available, siblingMin: rightMin, siblingOpen: showRightPanel, chatHidden }),
120
+ rightMax: pairMaxSize({ available, siblingMin: MIN_EDITOR_PANEL_WIDTH, siblingOpen: showEditor, chatHidden }),
121
+ handleCount,
122
+ rightMin,
123
+ };
124
+ }
@@ -32,3 +32,32 @@ export interface SidebarStatusEntry {
32
32
 
33
33
  /** `sidebar:status` — keyed by session id. Absent id means "no volatile fields". */
34
34
  export type SidebarStatusPayload = Record<string, SidebarStatusEntry>;
35
+
36
+ /**
37
+ * One live session's row fields that can still move between turn boundaries.
38
+ *
39
+ * Exactly two, and the list is closed on purpose: everything else about a row
40
+ * (title, folder, cwd, worktree, archived) only changes on a STRUCTURAL event
41
+ * that publishes the structure for its own reason. These two are the ones a
42
+ * run advances mid-turn — the transcript appends, and a subagent's transcript
43
+ * appears beside it (which is what lights the row's roster chevron).
44
+ */
45
+ export interface SidebarActivityEntry {
46
+ /** Newest transcript entry's ISO timestamp — the row's age and its
47
+ * `LATEST_SESSION` position. */
48
+ updatedAt?: string;
49
+ /** Whether the session has any subagent transcript on disk. */
50
+ hasSubagents?: boolean;
51
+ }
52
+
53
+ /**
54
+ * `sidebar:activity` — session id → the live fields above, for the sessions
55
+ * with a run in flight.
56
+ *
57
+ * Only a running session can move between turn boundaries, so an id that is
58
+ * absent here simply keeps what the structure gave it. Both sides read the same
59
+ * sources (the newest JSONL entry's own `timestamp`, and the subagent
60
+ * transcript directory), so the client merges field by field and lets the
61
+ * newer stamp win.
62
+ */
63
+ export type SidebarActivityPayload = Record<string, SidebarActivityEntry>;
@@ -48,6 +48,16 @@ export interface SettingsState {
48
48
  * cannot render a broken editor, only a different one.
49
49
  */
50
50
  editorFont: string;
51
+ /**
52
+ * Colour theme for the code editor, INDEPENDENT of `theme` above.
53
+ *
54
+ * `auto` (the default) keeps the editor on the app palette. Any other value
55
+ * is a Shiki theme id from `EDITOR_THEMES` — the editor then paints its own
56
+ * surface, gutter, selection and tokens while the console around it keeps the
57
+ * app palette. `resolveEditorTheme` treats an id this build no longer ships
58
+ * as `auto`, so a downgrade renders the app palette rather than half a theme.
59
+ */
60
+ editorTheme: string;
51
61
  /** Generate a session title from the first run. omp suppresses its
52
62
  * own auto-titling under `--mode rpc-ui` (PI_NO_TITLE), so without this a
53
63
  * Kabmo session keeps its `New Session - <timestamp>` placeholder. Asked