@kabmo-labs/kabmo 0.7.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 (351) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +403 -384
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +10 -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/header-controls/index.tsx +24 -1
  24. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  25. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  26. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  27. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  28. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  29. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  30. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  31. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  32. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  33. package/src/client/components/layout/desktop-layout/index.tsx +55 -69
  34. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  35. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  36. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  37. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  38. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  40. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  41. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  42. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  43. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  44. package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
  45. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  46. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  47. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  48. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  49. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  50. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  51. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  52. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  53. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  54. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  55. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  56. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  57. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  58. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  59. package/src/client/components/mobile/worktree-session.ts +51 -0
  60. package/src/client/components/settings/Modal.tsx +8 -0
  61. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  62. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  63. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  64. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  65. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
  66. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  67. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  68. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
  69. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  70. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  71. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  72. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  73. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  74. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  75. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  76. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  77. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  78. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  79. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  80. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  81. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  82. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  83. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
  84. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  85. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  86. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  87. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  88. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  89. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  90. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  91. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  92. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  93. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  94. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  95. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  96. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  97. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  98. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  99. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  100. package/src/client/components/workspace/editor/Body.tsx +4 -7
  101. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  102. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  103. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  104. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  105. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  106. package/src/client/components/workspace/editor/index.tsx +7 -9
  107. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  108. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  109. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  110. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  111. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  112. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  113. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  114. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  115. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  116. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  117. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  118. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  119. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  120. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  121. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  122. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  123. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  124. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  125. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  126. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  127. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  128. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  129. package/src/client/hooks/chat/timeline/index.ts +27 -28
  130. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  131. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  132. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  133. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  134. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  135. package/src/client/hooks/chat/timeline/send.ts +11 -1
  136. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  137. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  138. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  139. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  140. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  141. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  142. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  143. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  144. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  145. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  146. package/src/client/hooks/editor/find-state.ts +1 -1
  147. package/src/client/hooks/editor/theme.test.ts +206 -0
  148. package/src/client/hooks/editor/theme.ts +82 -0
  149. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  150. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  151. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  152. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  153. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  154. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  155. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  156. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  157. package/src/client/hooks/ui/long-press.test.ts +174 -0
  158. package/src/client/hooks/ui/long-press.ts +112 -0
  159. package/src/client/hooks/ui/settings-modal.ts +56 -0
  160. package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
  161. package/src/client/hooks/ui/updates/shell-download.ts +153 -28
  162. package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
  163. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  164. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  165. package/src/client/hooks/workspace/diff-file.ts +29 -10
  166. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  167. package/src/client/hooks/workspace/editor-view.ts +61 -0
  168. package/src/client/hooks/workspace/file-create.ts +41 -11
  169. package/src/client/hooks/workspace/file-listing.ts +56 -1
  170. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  171. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  172. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  173. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  174. package/src/client/hooks/workspace/worktree/index.ts +165 -0
  175. package/src/client/lib/desktop/bridge.ts +45 -0
  176. package/src/client/lib/search-reveal.ts +1 -1
  177. package/src/client/main.tsx +5 -1
  178. package/src/client/tailwind.css +56 -8
  179. package/src/server/index.ts +7 -2
  180. package/src/server/lib/auth/guard.test.ts +28 -0
  181. package/src/server/lib/auth/guard.ts +10 -0
  182. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  183. package/src/server/lib/db/schema.ts +4 -0
  184. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  185. package/src/server/lib/fs/canonical.ts +52 -0
  186. package/src/server/lib/fs/relative-path.ts +48 -0
  187. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  188. package/src/server/lib/fs/root.ts +39 -10
  189. package/src/server/lib/fs/worktree/carry.ts +134 -0
  190. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  191. package/src/server/lib/fs/worktree/create.ts +262 -0
  192. package/src/server/lib/fs/worktree/errors.ts +43 -0
  193. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  194. package/src/server/lib/fs/worktree/list.ts +142 -0
  195. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  196. package/src/server/lib/fs/worktree/location.ts +114 -0
  197. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  198. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  199. package/src/server/lib/fs/worktree/remove.ts +152 -0
  200. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  201. package/src/server/lib/fs/worktree/topology.ts +142 -0
  202. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  203. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  204. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  205. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  206. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  207. package/src/server/lib/lifecycle/probe.ts +121 -13
  208. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  209. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  210. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  211. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  212. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  213. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  214. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  215. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  216. package/src/server/lib/panels/local-edits.server.ts +33 -0
  217. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  218. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  219. package/src/server/lib/panels/sync.server.test.ts +125 -147
  220. package/src/server/lib/panels/sync.server.ts +153 -48
  221. package/src/server/lib/realtime/signals.server.ts +10 -0
  222. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  223. package/src/server/lib/realtime/topics.server.ts +16 -0
  224. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  225. package/src/server/lib/updates/shell-assets.ts +200 -0
  226. package/src/server/lib/updates/shell-cache.ts +105 -0
  227. package/src/server/lib/updates/shell-pages.test.ts +187 -0
  228. package/src/server/lib/updates/shell-pages.ts +157 -0
  229. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  230. package/src/server/lib/updates/shell-release.test.ts +59 -71
  231. package/src/server/lib/updates/shell-release.ts +178 -198
  232. package/src/server/routes/fs/action-create.test.ts +18 -13
  233. package/src/server/routes/fs/action-nested.test.ts +184 -0
  234. package/src/server/routes/fs/action.ts +47 -16
  235. package/src/server/routes/fs/git-merge.test.ts +11 -1
  236. package/src/server/routes/fs/read.test.ts +24 -7
  237. package/src/server/routes/fs/scope.test.ts +15 -10
  238. package/src/server/routes/fs/scope.ts +3 -14
  239. package/src/server/routes/index.ts +2 -0
  240. package/src/server/routes/updates/android.ts +6 -1
  241. package/src/server/routes/updates/desktop.ts +59 -0
  242. package/src/server/routes/updates/index.ts +2 -0
  243. package/src/server/routes/updates/ios.ts +5 -1
  244. package/src/server/routes/worktrees/create.ts +138 -0
  245. package/src/server/routes/worktrees/index.ts +25 -0
  246. package/src/server/routes/worktrees/list.ts +134 -0
  247. package/src/server/routes/worktrees/remove.ts +102 -0
  248. package/src/server/routes/worktrees/routes.test.ts +326 -0
  249. package/src/server/routes/worktrees/shared.ts +90 -0
  250. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  251. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  252. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  253. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  254. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  255. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  256. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  257. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  258. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  259. package/src/shared/lib/code/editor/keymap.ts +3 -3
  260. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  261. package/src/shared/lib/code/editor/lines.ts +8 -48
  262. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  263. package/src/shared/lib/code/editor/marks.ts +13 -25
  264. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  265. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  266. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  267. package/src/shared/lib/code/editor/themes.ts +165 -0
  268. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  269. package/src/shared/lib/code/highlight-memo.ts +149 -0
  270. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  271. package/src/shared/lib/code/highlighter.ts +3 -1
  272. package/src/shared/lib/code/idle.ts +37 -0
  273. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  274. package/src/shared/lib/code/outline/rows.ts +94 -0
  275. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  276. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  277. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  278. package/src/shared/lib/fs/file-kind.ts +47 -13
  279. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  280. package/src/shared/lib/fs/file-tree.ts +159 -0
  281. package/src/shared/lib/fs/file-url.test.ts +52 -0
  282. package/src/shared/lib/fs/file-url.ts +37 -0
  283. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  284. package/src/shared/lib/fs/patch-sides.ts +139 -0
  285. package/src/shared/lib/fs/paths.test.ts +24 -1
  286. package/src/shared/lib/fs/paths.ts +17 -0
  287. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  288. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  289. package/src/shared/lib/realtime/protocol.ts +16 -0
  290. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  291. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  292. package/src/shared/lib/updates/shell-arch.ts +54 -0
  293. package/src/shared/lib/updates/shell-ua.ts +24 -4
  294. package/src/shared/lib/workspace/active-project.ts +23 -0
  295. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  296. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  297. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  298. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  299. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  300. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  301. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  302. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  303. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  304. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  305. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  306. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  307. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  308. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  309. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  310. package/src/shared/types/fs.ts +10 -0
  311. package/src/shared/types/realtime.ts +29 -0
  312. package/src/shared/types/settings/state.ts +10 -0
  313. package/src/shared/types/workspace.ts +14 -0
  314. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  315. package/src/test-support/file-explorer.test-util.ts +71 -40
  316. package/src/test-support/loopback.ts +183 -0
  317. package/dist/client/static/js/index-nzyjf92m.css +0 -1
  318. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  319. package/src/client/components/common/code-editor/handler.ts +0 -175
  320. package/src/client/components/common/code-editor/index.tsx +0 -314
  321. package/src/client/components/common/code-surface/measure.ts +0 -148
  322. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  323. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  324. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  325. package/src/client/hooks/editor/scroll-container.ts +0 -26
  326. package/src/client/hooks/editor/use-code-window.ts +0 -100
  327. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  328. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  329. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  330. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  331. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  332. package/src/shared/lib/code/editor/commands.ts +0 -260
  333. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  334. package/src/shared/lib/code/editor/edits.ts +0 -122
  335. package/src/shared/lib/code/editor/history.test.ts +0 -118
  336. package/src/shared/lib/code/editor/history.ts +0 -101
  337. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  338. package/src/shared/lib/code/editor/layers.ts +0 -67
  339. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  340. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  341. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  342. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  343. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  344. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  345. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  346. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  347. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  348. package/src/shared/lib/code/lazy-window.ts +0 -156
  349. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  350. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  351. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One file's matches inside a `grep`/`glob` card.
8
+ *
9
+ * Two costs live here, and both were paid on every expand of the card:
10
+ *
11
+ * 1. **The tokenizer.** Every file's lines were tokenized in the parent's render,
12
+ * in one synchronous pass — the profile of a `grep` expand put 40% of its
13
+ * long task inside Shiki's regex engine. The highlight is deferred per FILE
14
+ * (`useDeferredHighlightLines`), so the card paints the matches first and the
15
+ * colour follows on an idle slice.
16
+ * 2. **The row count.** `MAX_OUTPUT_LINES` caps the whole output, not one file,
17
+ * so a single 900-line file group mounted 900 rows inside a `max-h-80`
18
+ * scroller that shows ~14. The window is per file now, opened on the first
19
+ * lines (a match group is read from its top) with the rest one click away.
20
+ */
21
+
22
+ import { useMemo, useState } from 'preact/hooks';
23
+ import { ChevronRight, FileText } from 'lucide-preact';
24
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
25
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
26
+
27
+ /** Rows mounted for one file before the reader asks for the rest. */
28
+ export const MAX_MATCH_LINES_PER_FILE = 60;
29
+
30
+ interface MatchRow {
31
+ lineNum: number;
32
+ text: string;
33
+ isMatch: boolean;
34
+ }
35
+
36
+ export interface FileMatchesProps {
37
+ path: string;
38
+ lines: MatchRow[];
39
+ matchCount: number;
40
+ isGlobList: boolean;
41
+ }
42
+
43
+ export function FileMatches({ path, lines, matchCount, isGlobList }: FileMatchesProps) {
44
+ const [showAll, setShowAll] = useState(false);
45
+ const visible = useMemo(
46
+ () => (showAll ? lines : lines.slice(0, MAX_MATCH_LINES_PER_FILE)),
47
+ [lines, showAll],
48
+ );
49
+ const hidden = lines.length - visible.length;
50
+
51
+ const lang = getLanguageFromPath(path);
52
+ const highlighted = useDeferredHighlightLines(
53
+ useMemo(() => visible.map((line) => line.text).join('\n'), [visible]),
54
+ lang,
55
+ );
56
+
57
+ return (
58
+ <div className="overflow-hidden rounded-lg border border-ink/8 bg-paper">
59
+ {/* File Header */}
60
+ <div className="flex items-center justify-between border-b border-ink/6 bg-canvas/40 px-2.5 py-1.5">
61
+ <div className="flex min-w-0 items-center gap-1.5">
62
+ <FileText size={12} className="shrink-0 text-ink/40" />
63
+ <span className="truncate font-mono text-[11px] font-medium text-ink/85">{path}</span>
64
+ </div>
65
+ {matchCount > 0 && !isGlobList && (
66
+ <span className="ml-2 shrink-0 rounded bg-ink/5 px-1.5 py-0.2 font-mono text-[9.5px] text-ink/50">
67
+ {matchCount} {matchCount === 1 ? 'hit' : 'hits'}
68
+ </span>
69
+ )}
70
+ </div>
71
+
72
+ {/* Code Lines inside file — satu scroll horizontal per file, semua row selebar konten terlebar */}
73
+ {visible.length > 0 && (
74
+ <div className="overflow-x-auto overscroll-x-contain">
75
+ <div className="w-max min-w-full divide-y divide-ink/[0.04] font-mono text-[11px] leading-relaxed select-text">
76
+ {visible.map((line, lineIdx) => (
77
+ <div
78
+ key={lineIdx}
79
+ className={`flex items-start gap-2.5 px-2.5 py-1 transition-colors ${
80
+ line.isMatch ? 'bg-warning/[0.08] text-ink' : 'bg-paper text-ink/60 hover:bg-ink/[0.02]'
81
+ }`}
82
+ >
83
+ <span
84
+ className={`sticky left-0 z-10 flex w-9 shrink-0 select-none items-center justify-end bg-inherit font-mono text-[10px] ${
85
+ line.isMatch ? 'font-semibold text-warning-dark dark:text-warning' : 'text-ink/30'
86
+ }`}
87
+ >
88
+ {line.isMatch && <ChevronRight size={10} className="mr-0.5 text-warning" />}
89
+ {line.lineNum}
90
+ </span>
91
+ <pre
92
+ className="shiki whitespace-pre font-mono"
93
+ dangerouslySetInnerHTML={{ __html: highlighted[lineIdx] || '&nbsp;' }}
94
+ />
95
+ </div>
96
+ ))}
97
+ </div>
98
+ {hidden > 0 && (
99
+ <button
100
+ type="button"
101
+ onClick={() => setShowAll(true)}
102
+ className="w-full border-t border-ink/6 bg-canvas/40 px-2.5 py-1 text-left font-mono text-[10px] text-ink/55 transition-colors hover:bg-ink/5 hover:text-ink"
103
+ >
104
+ … {hidden} more {hidden === 1 ? 'line' : 'lines'} — show all {lines.length}
105
+ </button>
106
+ )}
107
+ </div>
108
+ )}
109
+ </div>
110
+ );
111
+ }
@@ -1,8 +1,7 @@
1
1
  import { useEffect, useMemo, useState } from 'preact/hooks';
2
2
  import { FileCode, FileImage, FileText, Folder, FolderOpen, Info, Loader2 } from 'lucide-preact';
3
3
  import { getLanguageFromPath } from '@/shared/lib/code/language';
4
- import { highlightCode } from '@/shared/lib/code/syntax-highlight';
5
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
4
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
6
5
  import { parseDirListing, parseNumberedCode } from '@/shared/lib/code/parser';
7
6
  import { getImageMimeType } from '@/shared/lib/fs/file-kind';
8
7
  import { buildFsRawUrl, isVirtualPath, stripReadSelector } from '@/shared/lib/fs/paths';
@@ -192,11 +191,12 @@ export function Read({ tool, targetFilePath, output }: ReadPanelProps) {
192
191
  const truncatedCode = useMemo(() => truncateTailLines(parsedCode.cleanCode, MAX_OUTPUT_LINES), [parsedCode.cleanCode]);
193
192
  const visibleLines = truncatedCode.skipped > 0 ? parsedCode.lines.slice(truncatedCode.skipped) : parsedCode.lines;
194
193
 
195
- const syntaxReady = useSyntaxReady();
196
- const highlightedCode = useMemo(() => {
197
- if (!truncatedCode.text) return '';
198
- return highlightCode(truncatedCode.text, lang);
199
- }, [truncatedCode, lang, syntaxReady]);
194
+ // The tokenizer runs off the render that a tap scheduled (see
195
+ // `deferred-highlight`): a 1000-line read measured a 220–300 ms long task on
196
+ // a throttled phone, all of it before the card could paint. The plain-text
197
+ // frame it draws first carries the same characters, and a block already in
198
+ // the memo renders highlighted on that first paint.
199
+ const highlightedCode = useDeferredHighlight(truncatedCode.text, lang);
200
200
 
201
201
  return (
202
202
  <div className="space-y-2">
@@ -1,10 +1,8 @@
1
1
  import { useMemo, useState } from 'preact/hooks';
2
- import { ChevronRight, FileSearch, FileText, FolderSearch } from 'lucide-preact';
2
+ import { FileSearch, FolderSearch } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
- import { getLanguageFromPath } from '@/shared/lib/code/language';
6
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
7
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
+ import { FileMatches } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches';
8
6
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
9
7
 
10
8
  interface MatchLine {
@@ -189,18 +187,6 @@ export function SearchTool({ tool }: { tool: ToolCallData }) {
189
187
  .filter((f): f is ParsedFileMatches => f !== null);
190
188
  }, [files, filterText]);
191
189
 
192
- const syntaxReady = useSyntaxReady();
193
-
194
- const highlightedByFile = useMemo(() => {
195
- return filteredFiles.map((file) => {
196
- const lang = getLanguageFromPath(file.path);
197
- return highlightLines(
198
- file.lines.map((line) => line.text).join('\n'),
199
- lang
200
- );
201
- });
202
- }, [filteredFiles, syntaxReady]);
203
-
204
190
  if (!output && files.length === 0) {
205
191
  return (
206
192
  <div className="flex items-center gap-2 rounded-lg border border-dashed border-ink/15 px-3 py-2.5 text-[11.5px] text-ink/45">
@@ -262,49 +248,13 @@ export function SearchTool({ tool }: { tool: ToolCallData }) {
262
248
  )}
263
249
  <div className="max-h-80 space-y-2 overflow-y-auto overscroll-contain pr-0.5">
264
250
  {filteredFiles.map((file, fileIdx) => (
265
- <div key={fileIdx} className="overflow-hidden rounded-lg border border-ink/8 bg-paper">
266
- {/* File Header */}
267
- <div className="flex items-center justify-between border-b border-ink/6 bg-canvas/40 px-2.5 py-1.5">
268
- <div className="flex min-w-0 items-center gap-1.5">
269
- <FileText size={12} className="shrink-0 text-ink/40" />
270
- <span className="truncate font-mono text-[11px] font-medium text-ink/85">{file.path}</span>
271
- </div>
272
- {file.matchCount > 0 && !isGlobList && (
273
- <span className="ml-2 shrink-0 rounded bg-ink/5 px-1.5 py-0.2 font-mono text-[9.5px] text-ink/50">
274
- {file.matchCount} {file.matchCount === 1 ? 'hit' : 'hits'}
275
- </span>
276
- )}
277
- </div>
278
-
279
- {/* Code Lines inside file — satu scroll horizontal per file, semua row selebar konten terlebar */}
280
- {file.lines.length > 0 && (
281
- <div className="overflow-x-auto overscroll-x-contain">
282
- <div className="w-max min-w-full divide-y divide-ink/[0.04] font-mono text-[11px] leading-relaxed select-text">
283
- {file.lines.map((line, lineIdx) => (
284
- <div
285
- key={lineIdx}
286
- className={`flex items-start gap-2.5 px-2.5 py-1 transition-colors ${
287
- line.isMatch ? 'bg-warning/[0.08] text-ink' : 'bg-paper text-ink/60 hover:bg-ink/[0.02]'
288
- }`}
289
- >
290
- <span
291
- className={`sticky left-0 z-10 flex w-9 shrink-0 select-none items-center justify-end bg-inherit font-mono text-[10px] ${
292
- line.isMatch ? 'font-semibold text-warning-dark dark:text-warning' : 'text-ink/30'
293
- }`}
294
- >
295
- {line.isMatch && <ChevronRight size={10} className="mr-0.5 text-warning" />}
296
- {line.lineNum}
297
- </span>
298
- <pre
299
- className="shiki whitespace-pre font-mono"
300
- dangerouslySetInnerHTML={{ __html: highlightedByFile[fileIdx]?.[lineIdx] || '&nbsp;' }}
301
- />
302
- </div>
303
- ))}
304
- </div>
305
- </div>
306
- )}
307
- </div>
251
+ <FileMatches
252
+ key={fileIdx}
253
+ path={file.path}
254
+ lines={file.lines}
255
+ matchCount={file.matchCount}
256
+ isGlobList={isGlobList}
257
+ />
308
258
  ))}
309
259
 
310
260
  {filteredFiles.length === 0 && (
@@ -37,8 +37,7 @@ import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspa
37
37
  import { artifactViewKind } from '@/shared/lib/chat/tool/artifact-view';
38
38
  import { getLanguageFromPath } from '@/shared/lib/code/language';
39
39
  import { parseNumberedCode } from '@/shared/lib/code/parser';
40
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
41
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
40
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
42
41
 
43
42
  interface ArtifactBodyProps {
44
43
  /** The whole artifact, verbatim. */
@@ -103,13 +102,9 @@ export function ArtifactBody({ text, path = '' }: ArtifactBodyProps) {
103
102
  * and `121|code`) and a blank line keeps its place in the count.
104
103
  */
105
104
  function ArtifactCode({ lines, path }: { lines: string[]; path: string }) {
106
- const syntaxReady = useSyntaxReady();
107
105
  const parsed = useMemo(() => parseNumberedCode(lines.join('\n')), [lines]);
108
106
  const lang = getLanguageFromPath(path);
109
- const html = useMemo(
110
- () => highlightLines(parsed.cleanCode, lang),
111
- [parsed.cleanCode, lang, syntaxReady],
112
- );
107
+ const html = useDeferredHighlightLines(parsed.cleanCode, lang);
113
108
 
114
109
  return (
115
110
  <div className="flex items-start font-mono text-[11px] leading-[20px] select-text">
@@ -4,19 +4,20 @@
4
4
  */
5
5
 
6
6
  /**
7
- * A long diff must SCROLL inside its height ceiling.
7
+ * A diff card's own chrome: the height ceiling and the counts.
8
8
  *
9
- * The bug: the wrapper carried only `max-h-72 overflow-hidden`, and the panel
10
- * views resolve `h-full` against their parent. `height: 100%` against a box
11
- * whose height is `auto` resolves to `auto`, so the view grew to its full
12
- * content — measured 1521px inside a 288px parent — and `overflow-hidden`
13
- * clipped the rest. `scrollHeight === clientHeight`, so nothing could scroll
14
- * and the reader simply could not reach the bottom of the diff.
9
+ * The bug the ceiling pins: the wrapper carried only `max-h-72 overflow-hidden`
10
+ * and the view inside resolved `h-full` against its parent. `height: 100%`
11
+ * against a box whose height is `auto` resolves to `auto`, so the view grew to
12
+ * its full content — measured 1521px inside a 288px parent — and `overflow-hidden`
13
+ * clipped the rest. `scrollHeight === clientHeight`, so nothing could scroll.
14
+ * `min-h-0` on the flex column is what gives the child a definite height.
15
15
  *
16
- * Asserted on the classes the fix depends on, because the failure mode is a
17
- * LAYOUT resolution that happy-dom does not compute: `min-h-0` on a flex column
18
- * is what gives the child a definite height, and `overflow-hidden` on the
19
- * wrapper is what stops a second scroller from appearing.
16
+ * The ROWS are not asserted here any more. The card renders through
17
+ * CodeMirror's merge view, which draws only its viewport — and happy-dom
18
+ * computes no layout, so a viewport is empty. Row rendering is covered where it
19
+ * can be observed: `git-panel.e2e.ts` against the real panel, and
20
+ * `patch-sides.test.ts` for the pair the card hands over.
20
21
  */
21
22
 
22
23
  import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
@@ -49,11 +50,11 @@ afterAll(() => {
49
50
  /** omp's excerpt diff, long enough to overflow any ceiling. */
50
51
  const EXCERPT = Array.from({ length: 120 }, (_, i) => `${i + 1}|line ${i + 1}`).join('\n');
51
52
 
52
- async function mount() {
53
+ async function mount(text = EXCERPT) {
53
54
  container = document.createElement('div');
54
55
  document.body.appendChild(container);
55
56
  await act(async () => {
56
- render(h(DiffView, { text: EXCERPT, path: 'src/x.ts' }), container);
57
+ render(h(DiffView, { text, path: 'src/x.ts' }), container);
57
58
  });
58
59
  return container;
59
60
  }
@@ -75,25 +76,23 @@ describe('DiffView height ceiling', () => {
75
76
  expect(wrap.className).toContain('flex');
76
77
  expect(wrap.className).toContain('flex-col');
77
78
  // Without `min-h-0` a flex item refuses to shrink below its content, the
78
- // view's `h-full` resolves to `auto`, and nothing scrolls.
79
+ // view's height resolves to `auto`, and nothing scrolls.
79
80
  expect(wrap.className).toContain('min-h-0');
80
81
  });
81
82
 
82
- test('the view inside is the element that scrolls', async () => {
83
- const el = await mount();
84
- const wrap = el.querySelector('div[class*="max-h-72"]') as HTMLElement;
85
- const view = wrap.firstElementChild as HTMLElement;
86
- expect(view.className).toContain('overflow-auto');
87
- expect(view.className).toContain('h-full');
83
+ test('the header counts come from the patch, not from git', async () => {
84
+ // ` 1|a / -2|b / +2|c` is an excerpt diff: one deletion, one addition.
85
+ const el = await mount(' 1|a\n-2|b\n+2|c');
86
+ const text = el.textContent ?? '';
87
+ expect(text).toContain('+1');
88
+ expect(text).toContain('\u22121');
88
89
  });
89
90
 
90
- test('a short diff still renders its rows', async () => {
91
- container = document.createElement('div');
92
- document.body.appendChild(container);
93
- await act(async () => {
94
- render(h(DiffView, { text: ' 1|a\n-2|b\n+2|c', path: 'src/x.ts' }), container);
95
- });
96
- expect(container.textContent).toContain('c');
97
- expect(container.textContent).not.toContain('2|b');
91
+ test('a header-only patch falls back to the raw text', async () => {
92
+ const el = await mount('--- a/x\n+++ b/x\n');
93
+ // Nothing to compare means nothing to draw: the reader still sees the bytes
94
+ // the tool returned rather than an empty box.
95
+ expect(el.textContent).toContain('--- a/x');
96
+ expect(el.querySelector('.cm-editor')).toBeNull();
98
97
  });
99
98
  });
@@ -17,20 +17,23 @@
17
17
  * first, so ONE parser serves both — and because the conversion recovers the
18
18
  * gutter numbers, the rows get real line numbers instead of `...`.
19
19
  *
20
- * The rendering itself is the diff panel's own `UnifiedView`/`SplitView`, so a
21
- * diff in the chat and a diff of the working tree are the same surface rather
22
- * than two implementations that drift.
20
+ * The rendering is the diff panel's own merge view, so a diff in the chat and a
21
+ * diff of the working tree are the same surface rather than two implementations
22
+ * that drift. A card has only the PATCH, not the two documents, so the two sides
23
+ * are reconstructed from the parsed rows — a context row appears on both, a `-`
24
+ * only on the original, a `+` only on the new one.
23
25
  */
24
26
 
25
27
  import { useMemo, useState } from 'preact/hooks';
26
28
  import type { ReactNode } from 'preact/compat';
27
29
  import { Columns2, Rows3, WrapText } from 'lucide-preact';
28
- import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
30
+ import { patchSides } from '@/shared/lib/fs/patch-sides';
29
31
  import { excerptDiffToUnified, isExcerptDiff } from '@/shared/lib/fs/excerpt-diff';
30
32
  import { getLanguageFromPath } from '@/shared/lib/code/language';
31
33
  import { CopyButton } from '@/client/components/common/CopyButton';
32
- import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
33
- import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
34
+ import { MergeDiffView } from '@/client/components/workspace/diff-panel/MergeDiffView';
35
+ import { useEditorFontStack } from '@/client/hooks/editor/font';
36
+ import { EDITOR_DEFAULT_FONT_SIZE } from '@/shared/lib/code/editor/typography';
34
37
 
35
38
  /** A small icon-only toolbar button, `aria-pressed` for its own state. */
36
39
  function ToggleButton({
@@ -80,21 +83,19 @@ export function DiffView({ text, path = '', maxHeightClass = 'max-h-72' }: DiffV
80
83
  // omp's excerpt is converted first; a git unified diff passes through.
81
84
  const unified = useMemo(() => (isExcerptDiff(text) ? excerptDiffToUnified(text) : text), [text]);
82
85
 
83
- // Only the view on screen is built: a full-context diff would otherwise
84
- // allocate both the flat line list and the paired split rows to draw one.
85
- const parsed = useMemo(
86
- () => parseUnifiedDiff(unified, { views: split ? 'split' : 'unified' }),
87
- [unified, split],
88
- );
86
+ // A card has a PATCH and no documents, so the two sides are rebuilt from it:
87
+ // context on both, `-` on the original, `+` on the new one. That is the same
88
+ // alignment the merge view would compute from two files, restricted to the
89
+ // hunks the patch carries.
90
+ const sides = useMemo(() => patchSides(unified), [unified]);
91
+ const editorFontFamily = useEditorFontStack();
89
92
 
90
93
  // The excerpt's header carries no filename, so the caller's path is the only
91
- // name the block can show; the unified header is preferred when it exists.
92
- const fileLabel = parsed.lines.find((line) => line.type === 'meta')?.text
93
- ? ''
94
- : path.split('/').pop() ?? '';
95
- const language = getLanguageFromPath(path || parsed.splitRows[0]?.right?.text || '');
94
+ // name the block can show — unless the patch names itself.
95
+ const fileLabel = sides.hasHunk ? '' : (path.split('/').pop() ?? '');
96
+ const language = getLanguageFromPath(path || sides.next.split('\n')[0] || '');
96
97
 
97
- if (parsed.lines.length === 0 && parsed.splitRows.length === 0) {
98
+ if (sides.rows === 0) {
98
99
  return (
99
100
  <pre className="max-h-48 overflow-auto rounded-lg border border-ink/8 bg-paper p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-all text-ink/80 select-text">
100
101
  {text}
@@ -109,8 +110,8 @@ export function DiffView({ text, path = '', maxHeightClass = 'max-h-72' }: DiffV
109
110
  <span className="truncate font-mono text-[10.5px] text-ink/60">{fileLabel}</span>
110
111
  )}
111
112
  <span className="flex items-center gap-1.5 font-mono text-[10px]">
112
- <span className="text-success">+{parsed.additions}</span>
113
- <span className="text-error">{'\u2212'}{parsed.deletions}</span>
113
+ <span className="text-success">+{sides.additions}</span>
114
+ <span className="text-error">{'\u2212'}{sides.deletions}</span>
114
115
  </span>
115
116
  <span className="ml-auto flex items-center gap-1">
116
117
  <ToggleButton
@@ -150,12 +151,24 @@ export function DiffView({ text, path = '', maxHeightClass = 'max-h-72' }: DiffV
150
151
  own content and this wrapper only bounds it. */}
151
152
  <div className={`flex ${maxHeightClass} min-h-0 flex-col overflow-hidden`}>
152
153
  {/* No tab key: this diff lives inside a chat card, and the timeline —
153
- not the card — is what the reader scrolls. */}
154
- {split ? (
155
- <SplitView rows={parsed.splitRows} language={language} wordWrap={wordWrap} tabKey={null} />
156
- ) : (
157
- <UnifiedView lines={parsed.lines} language={language} wordWrap={wordWrap} tabKey={null} />
158
- )}
154
+ not the card — is what the reader scrolls. No counts either: the
155
+ header above already prints them from the patch. */}
156
+ <MergeDiffView
157
+ original={sides.original}
158
+ next={sides.next}
159
+ language={language}
160
+ viewMode={split ? 'split' : 'unified'}
161
+ // Collapsed gaps: the reconstructed sides carry blank padding up to
162
+ // each hunk's real line, so showing every row would draw thousands of
163
+ // empty lines between two changes.
164
+ contentMode="diff"
165
+ wordWrap={wordWrap}
166
+ ignoreWhitespace={false}
167
+ tabKey={null}
168
+ empty={false}
169
+ fontFamily={editorFontFamily}
170
+ fontSize={EDITOR_DEFAULT_FONT_SIZE}
171
+ />
159
172
  </div>
160
173
  </div>
161
174
  );
@@ -1,7 +1,6 @@
1
1
  import { useMemo } from 'preact/hooks';
2
2
  import { getLanguageFromPath } from '@/shared/lib/code/language';
3
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
4
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
3
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
5
4
  import { CopyButton } from '@/client/components/common/CopyButton';
6
5
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
7
6
 
@@ -45,16 +44,17 @@ interface ExcerptCodeProps {
45
44
  export function ExcerptCode({ lines, path = '', tag }: ExcerptCodeProps) {
46
45
  const rows = useMemo(() => splitExcerptRows(lines), [lines]);
47
46
  const lang = getLanguageFromPath(path);
48
- const syntaxReady = useSyntaxReady();
49
47
 
50
- const { html, hidden } = useMemo(() => {
51
- // Only the tail is rendered, so only the tail is highlighted — tokenizing
52
- // the dropped head would be work whose output is thrown away.
48
+ // Only the tail is rendered, so only the tail is highlighted — tokenizing the
49
+ // dropped head would be work whose output is thrown away. The tokenizer itself
50
+ // runs off the expand that asked for it (see `deferred-highlight`).
51
+ const { code: visibleCode, hidden } = useMemo(() => {
53
52
  const code = rows.map((row) => row.code);
54
53
  const truncated = truncateTailLines(code.join('\n'), MAX_OUTPUT_LINES);
55
54
  const visible = truncated.skipped > 0 ? code.slice(truncated.skipped) : code;
56
- return { html: highlightLines(visible.join('\n'), lang), hidden: truncated.skipped };
57
- }, [rows, lang, syntaxReady]);
55
+ return { code: visible.join('\n'), hidden: truncated.skipped };
56
+ }, [rows]);
57
+ const html = useDeferredHighlightLines(visibleCode, lang);
58
58
 
59
59
  const visibleRows = hidden > 0 ? rows.slice(hidden) : rows;
60
60
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A `<pre>` whose contents are tokenized off the render that asked for them.
8
+ *
9
+ * A hook cannot be called inside a `render` callback (Bash's `OutputWindow` and
10
+ * SearchTool's per-file rows both draw code from one), so the deferred highlight
11
+ * lives in this component instead: a caller hands over the code and the
12
+ * language, and the markup arrives one idle slice later.
13
+ *
14
+ * The `shiki` class is on the `<pre>` from the first paint, so the plain-text
15
+ * frame and the tokenized one are the same element — the swap changes colour
16
+ * and nothing else.
17
+ */
18
+
19
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
20
+
21
+ interface HighlightedCodeProps {
22
+ code: string;
23
+ language: string;
24
+ className?: string;
25
+ }
26
+
27
+ export function HighlightedCode({ code, language, className = '' }: HighlightedCodeProps) {
28
+ const lines = useDeferredHighlightLines(code, language);
29
+ return (
30
+ <pre
31
+ className={`shiki ${className}`}
32
+ dangerouslySetInnerHTML={{ __html: lines.join('\n') }}
33
+ />
34
+ );
35
+ }
@@ -2,8 +2,7 @@ import type { ReactNode } from 'preact/compat';
2
2
  import { useMemo } from 'preact/hooks';
3
3
  import { Braces } from 'lucide-preact';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
- import { highlightJson } from '@/shared/lib/code/syntax-highlight';
6
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
7
6
 
8
7
  interface JsonCodeBlockProps {
9
8
  /** Formatted JSON string to render */
@@ -33,8 +32,9 @@ export function JsonCodeBlock({
33
32
  }: JsonCodeBlockProps) {
34
33
  const effectiveMaxHeight = maxHeightClass || (compact ? 'max-h-40' : 'max-h-56');
35
34
  const lines = useMemo(() => jsonString.split('\n'), [jsonString]);
36
- const syntaxReady = useSyntaxReady();
37
- const highlighted = useMemo(() => highlightJson(jsonString), [jsonString, syntaxReady]);
35
+ // `json` is an eager grammar, so the memo usually answers on the first paint;
36
+ // a cold card still draws the text immediately and colours it a beat later.
37
+ const highlighted = useDeferredHighlight(jsonString, 'json');
38
38
 
39
39
  return (
40
40
  <div className={`overflow-hidden ${compact ? 'rounded-md' : 'rounded-lg'} border border-ink/8 bg-paper ${className}`}>