@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
@@ -13,8 +13,7 @@
13
13
  import { useMemo } from 'preact/hooks';
14
14
  import type { HashlineSection, HashlineVerb } from '@/shared/lib/omp/session/hashline-patch';
15
15
  import { getLanguageFromPath } from '@/shared/lib/code/language';
16
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
17
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
16
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
18
17
 
19
18
  /** A hashline `CUT` removes lines the model already saw, so it reads as the
20
19
  * destructive half of the patch; `PUT` carries the replacement body. */
@@ -22,16 +21,38 @@ function opTone(verb: HashlineVerb): string {
22
21
  return verb === 'CUT' ? 'bg-error/10 text-error' : 'bg-success/10 text-success';
23
22
  }
24
23
 
25
- export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
26
- const syntaxReady = useSyntaxReady();
27
-
28
- const htmlByOp = useMemo(() => {
29
- return sections.map((section) => {
30
- const lang = getLanguageFromPath(section.path);
31
- return section.ops.map((op) => highlightLines(op.body.join('\n'), lang));
32
- });
33
- }, [sections, syntaxReady]);
24
+ /**
25
+ * One op's added lines. A component rather than a call in the parent's map: the
26
+ * deferred highlight is a hook, and a hook cannot run inside a callback — the
27
+ * parent used to tokenize EVERY op of EVERY file in one synchronous pass, which
28
+ * is the whole cost this card was paying on expand.
29
+ */
30
+ function OpBody({ body, lang }: { body: string[]; lang: string }) {
31
+ const html = useDeferredHighlightLines(useMemo(() => body.join('\n'), [body]), lang);
32
+ return (
33
+ <>
34
+ {body.map((row, rowIndex) => {
35
+ const markup = html[rowIndex] || '';
36
+ return markup ? (
37
+ <div
38
+ key={rowIndex}
39
+ className="shiki whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed"
40
+ dangerouslySetInnerHTML={{ __html: markup }}
41
+ />
42
+ ) : (
43
+ <div
44
+ key={rowIndex}
45
+ className="whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed text-success/90"
46
+ >
47
+ {row}
48
+ </div>
49
+ );
50
+ })}
51
+ </>
52
+ );
53
+ }
34
54
 
55
+ export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
35
56
  return (
36
57
  <div className="space-y-1.5">
37
58
  <div className="flex items-center justify-between text-[9.5px] font-semibold uppercase tracking-[0.14em] text-ink/40">
@@ -43,7 +64,7 @@ export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
43
64
  )}
44
65
  </div>
45
66
  <div className="overflow-hidden rounded-lg border border-ink/8">
46
- {sections.map((section, secIdx) => (
67
+ {sections.map((section) => (
47
68
  <div key={section.path} className="border-b border-ink/6 last:border-b-0">
48
69
  <div className="flex min-w-0 items-center gap-2 bg-canvas/40 px-2.5 py-1.5">
49
70
  <span className="truncate font-mono text-[10.5px] font-medium text-ink">{section.path}</span>
@@ -65,23 +86,7 @@ export function HashlinePatch({ sections }: { sections: HashlineSection[] }) {
65
86
  </span>
66
87
  {op.spec && <span className="font-mono text-[10.5px] text-ink/70">{op.spec}</span>}
67
88
  </div>
68
- {op.body.map((row, rowIndex) => {
69
- const html = htmlByOp[secIdx]?.[index]?.[rowIndex] || '';
70
- return html ? (
71
- <div
72
- key={rowIndex}
73
- className="shiki whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed"
74
- dangerouslySetInnerHTML={{ __html: html }}
75
- />
76
- ) : (
77
- <div
78
- key={rowIndex}
79
- className="whitespace-pre bg-success/[0.05] px-2.5 font-mono text-[11px] leading-relaxed text-success/90"
80
- >
81
- {row}
82
- </div>
83
- );
84
- })}
89
+ <OpBody body={op.body} lang={getLanguageFromPath(section.path)} />
85
90
  </div>
86
91
  ))}
87
92
  </div>
@@ -2,8 +2,7 @@ import { useMemo } from 'preact/hooks';
2
2
  import { ArrowRight, CheckCircle2, Clock, Wand2 } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { getLanguageFromPath } from '@/shared/lib/code/language';
5
- import { highlightLines } from '@/shared/lib/code/syntax-highlight';
6
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
+ import { useDeferredHighlightLines } from '@/client/hooks/code/deferred-highlight';
7
6
 
8
7
  interface AstEditItem {
9
8
  file?: unknown;
@@ -51,14 +50,17 @@ export function AstEdit({ tool }: { tool: ToolCallData }) {
51
50
  ? details.changes
52
51
  : parseAstChanges(output);
53
52
 
54
- const syntaxReady = useSyntaxReady();
55
53
  const diffLang = getLanguageFromPath(paths[0]);
56
- const diffHtml = useMemo(() => {
57
- const contents = diffLines.map((line: string) =>
58
- line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') ? line.slice(1) : line
59
- );
60
- return highlightLines(contents.join('\n'), diffLang);
61
- }, [diffLines, diffLang, syntaxReady]);
54
+ const diffCode = useMemo(
55
+ () =>
56
+ diffLines
57
+ .map((line: string) =>
58
+ line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') ? line.slice(1) : line
59
+ )
60
+ .join('\n'),
61
+ [diffLines],
62
+ );
63
+ const diffHtml = useDeferredHighlightLines(diffCode, diffLang);
62
64
 
63
65
  // If Staged Proposal Mode (oh-my-pi xd://ast_edit)
64
66
  if (isStaged || ops.length > 0 || diffLines.length > 0) {
@@ -5,8 +5,9 @@ import { bashServiceOf, daemonTone, procUptime } from '@/shared/lib/omp/session/
5
5
  import { CopyButton } from '@/client/components/common/CopyButton';
6
6
  import { stripAnsiCodes } from '@/shared/lib/code/ansi';
7
7
  import { isCodeLike } from '@/shared/lib/code/language';
8
- import { highlightCode, highlightLines, tryParseJson } from '@/shared/lib/code/syntax-highlight';
9
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
8
+ import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
9
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
10
+ import { HighlightedCode } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode';
10
11
  import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
11
12
  import { OutputWindow } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/OutputWindow';
12
13
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
@@ -75,8 +76,9 @@ export function Bash({ tool }: { tool: ToolCallData }) {
75
76
  () => (jsonResult.isValid && jsonResult.pretty ? truncateTailLines(jsonResult.pretty, MAX_OUTPUT_LINES) : null),
76
77
  [jsonResult]
77
78
  );
78
- const syntaxReady = useSyntaxReady();
79
- const highlightedCommand = useMemo(() => (command ? highlightCode(command, 'bash') : ''), [command, syntaxReady]);
79
+ // Deferred: a command line is short, but the grammar's first compile is not,
80
+ // and this card opens while a run streams.
81
+ const highlightedCommand = useDeferredHighlight(command, 'bash');
80
82
 
81
83
  const service = bashServiceOf(tool);
82
84
  const serviceTone = daemonTone(service?.state);
@@ -181,7 +183,7 @@ export function Bash({ tool }: { tool: ToolCallData }) {
181
183
  <div className="flex items-start gap-2.5 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
182
184
  <span className="select-none font-bold text-ink/40">$</span>
183
185
  <pre
184
- className="min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-ink/90 select-text"
186
+ className="shiki min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-ink/90 select-text"
185
187
  dangerouslySetInnerHTML={{ __html: highlightedCommand }}
186
188
  />
187
189
  </div>
@@ -242,9 +244,10 @@ export function Bash({ tool }: { tool: ToolCallData }) {
242
244
  className="max-h-72 overflow-y-auto overscroll-contain p-3 font-mono text-[11px] leading-relaxed select-text"
243
245
  render={(visible) =>
244
246
  outputIsCode ? (
245
- <pre
247
+ <HighlightedCode
248
+ code={visible.join('\n')}
249
+ language={outputLang}
246
250
  className="overflow-x-auto whitespace-pre text-ink/85 leading-relaxed"
247
- dangerouslySetInnerHTML={{ __html: highlightLines(visible.join('\n'), outputLang).join('\n') }}
248
251
  />
249
252
  ) : (
250
253
  visible.map((line, idx) => {
@@ -9,8 +9,7 @@ import { ExcerptCode } from '@/client/components/workspace/chat-timeline/tool-re
9
9
  import { ToolPanelHeader } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/ToolPanelHeader';
10
10
  import { parseEditOutput } from '@/shared/lib/chat/excerpt';
11
11
  import { getLanguageFromPath } from '@/shared/lib/code/language';
12
- import { highlightCode } from '@/shared/lib/code/syntax-highlight';
13
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
12
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
14
13
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
15
14
  import { outputMarkdown, readToolOutput } from '@/shared/lib/chat/tool-output';
16
15
  import { hashlinePatchFromArgs } from '@/shared/lib/omp/session/hashline-patch';
@@ -146,13 +145,31 @@ export function Edit({ tool }: { tool: ToolCallData }) {
146
145
 
147
146
  const truncatedOld = useMemo(() => (oldString ? truncateTailLines(oldString, MAX_OUTPUT_LINES) : null), [oldString]);
148
147
  const truncatedNew = useMemo(() => (newString ? truncateTailLines(newString, MAX_OUTPUT_LINES) : null), [newString]);
149
- const syntaxReady = useSyntaxReady();
150
- const highlightedOld = useMemo(() => (truncatedOld ? highlightCode(truncatedOld.text, lang) : ''), [truncatedOld, lang, syntaxReady]);
151
- const highlightedNew = useMemo(() => (truncatedNew ? highlightCode(truncatedNew.text, lang) : ''), [truncatedNew, lang, syntaxReady]);
152
148
  const previewLineCount = useMemo(() => (newContent ? newContent.split('\n').length : 0), [newContent]);
153
- const highlightedPreview = useMemo(
154
- () => (newContent ? highlightCode(newContent.split('\n').slice(0, 80).join('\n'), lang) : ''),
155
- [newContent, lang, syntaxReady]
149
+
150
+ /**
151
+ * Which of the three blocks this card actually DRAWS.
152
+ *
153
+ * They are mutually exclusive in the markup below — a card with a diff shows
154
+ * neither the replacement pair nor the write preview — and the highlights
155
+ * used to run for all three on every render regardless: three tokenizer passes
156
+ * to paint one block, measured as the largest single contributor to an `edit`
157
+ * card's expand. Gating the input on the branch that renders it makes the two
158
+ * unused passes `''`, which the hook treats as nothing to compute.
159
+ */
160
+ const showsReplacement = !diffText && Boolean(oldString || newString);
161
+ const showsPreview = !diffText && !oldString && !newString && Boolean(newContent);
162
+ const highlightedOld = useDeferredHighlight(
163
+ showsReplacement && truncatedOld ? truncatedOld.text : '',
164
+ lang,
165
+ );
166
+ const highlightedNew = useDeferredHighlight(
167
+ showsReplacement && truncatedNew ? truncatedNew.text : '',
168
+ lang,
169
+ );
170
+ const highlightedPreview = useDeferredHighlight(
171
+ showsPreview && newContent ? newContent.split('\n').slice(0, 80).join('\n') : '',
172
+ lang,
156
173
  );
157
174
 
158
175
  // omp answers an edit with the excerpt it touched, not with prose: numbered
@@ -2,9 +2,9 @@ import { useMemo } from 'preact/hooks';
2
2
  import { AlertCircle, Ban, Clock, Code2, Play } from 'lucide-preact';
3
3
  import type { ToolCallData } from '@/shared/types';
4
4
  import { CopyButton } from '@/client/components/common/CopyButton';
5
- import { highlightCode, highlightJson, tryParseJson } from '@/shared/lib/code/syntax-highlight';
5
+ import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
6
6
  import { MAX_OUTPUT_LINES, truncateTailLines } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/truncate';
7
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
7
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
8
8
 
9
9
  /**
10
10
  * The line box both sides of a code card share — gutter row and code line.
@@ -43,23 +43,20 @@ export function Eval({ tool }: { tool: ToolCallData }) {
43
43
  const isError = tool.status === 'error' || output.includes('ToolError:') || output.includes('Command exited with code');
44
44
  const isAborted = output.includes('Command aborted') || tool.status === 'aborted';
45
45
 
46
- const syntaxReady = useSyntaxReady();
47
-
48
46
  const jsonResult = useMemo(
49
47
  () => (!isError && !isAborted ? tryParseJson(output) : { isValid: false }),
50
48
  [output, isError, isAborted]
51
49
  );
52
50
 
53
51
  const truncatedCode = useMemo(() => truncateTailLines(code, MAX_OUTPUT_LINES), [code]);
54
- const highlightedCode = useMemo(
55
- () => highlightCode(truncatedCode.text, 'javascript'),
56
- [truncatedCode, syntaxReady]
57
- );
52
+ // A cell's script is short but the grammar compile is not, and an eval card
53
+ // opens while its own output streams.
54
+ const highlightedCode = useDeferredHighlight(truncatedCode.text, 'javascript');
58
55
  // The result is shown whole: an eval's output IS the answer, so hiding its
59
56
  // head behind a "… earlier lines hidden" line misreports what the cell returned.
60
- const highlightedJson = useMemo(
61
- () => (jsonResult.isValid && jsonResult.pretty ? highlightJson(jsonResult.pretty) : ''),
62
- [jsonResult, syntaxReady]
57
+ const highlightedJson = useDeferredHighlight(
58
+ jsonResult.isValid && jsonResult.pretty ? jsonResult.pretty : '',
59
+ 'json',
63
60
  );
64
61
 
65
62
  return (
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One file's matches inside a `grep` card.
8
+ *
9
+ * The card used to mount every line of every file group inside a `max-h-80`
10
+ * scroller, and tokenize them all in one pass. Two properties are pinned here,
11
+ * because both were the measured cost of expanding the card:
12
+ *
13
+ * 1. **A file group mounts a bounded number of rows**, with the rest one click
14
+ * away — `MAX_OUTPUT_LINES` caps the whole output, so a single 900-line group
15
+ * used to draw 900 rows to show ~14.
16
+ * 2. **The rows that ARE mounted carry their line numbers and text from the
17
+ * first paint**, before the deferred highlight lands.
18
+ */
19
+
20
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
21
+ import { Window } from 'happy-dom';
22
+ import { h, render } from 'preact';
23
+ import { act } from 'preact/test-utils';
24
+
25
+ import { FileMatches, MAX_MATCH_LINES_PER_FILE } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches';
26
+ import { clearHighlightMemo } from '@/shared/lib/code/highlight-memo';
27
+
28
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
29
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
30
+ let container: HTMLElement;
31
+
32
+ beforeAll(() => {
33
+ const win = new Window({ url: 'http://localhost' });
34
+ const target = globalThis as unknown as Record<string, unknown>;
35
+ for (const key of DOM_GLOBALS) {
36
+ nativeGlobals[key] = target[key];
37
+ target[key] = (win as unknown as Record<string, unknown>)[key];
38
+ }
39
+ container = document.createElement('div');
40
+ document.body.appendChild(container);
41
+ });
42
+
43
+ afterAll(() => {
44
+ const target = globalThis as unknown as Record<string, unknown>;
45
+ for (const key of DOM_GLOBALS) {
46
+ if (nativeGlobals[key] === undefined) delete target[key];
47
+ else target[key] = nativeGlobals[key];
48
+ }
49
+ });
50
+
51
+ beforeEach(() => {
52
+ clearHighlightMemo();
53
+ // A fresh root per test: `render` keeps its vnode tree on the element it was
54
+ // given, so clearing the markup in place would leave preact diffing against a
55
+ // tree that is no longer there and skip the render entirely.
56
+ container = document.createElement('div');
57
+ document.body.appendChild(container);
58
+ });
59
+
60
+ /** Rows the card drew, counted by the gutter numbers it printed. */
61
+ function rowCount(el: HTMLElement): number {
62
+ return el.querySelectorAll('pre.shiki').length;
63
+ }
64
+
65
+ function matches(total: number) {
66
+ return Array.from({ length: total }, (_, index) => ({
67
+ lineNum: index + 1,
68
+ text: `const value${index} = ${index};`,
69
+ isMatch: index % 5 === 0,
70
+ }));
71
+ }
72
+
73
+ describe('FileMatches', () => {
74
+ test('mounts a bounded number of rows for a long file group', async () => {
75
+ await act(async () => {
76
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(300), matchCount: 60, isGlobList: false }), container);
77
+ });
78
+
79
+ expect(rowCount(container)).toBe(MAX_MATCH_LINES_PER_FILE);
80
+ expect(container.textContent).toContain('const value0 = 0;');
81
+ expect(container.textContent).not.toContain('const value299 = 299;');
82
+ });
83
+
84
+ test('offers the hidden rows, and reveals every one of them', async () => {
85
+ await act(async () => {
86
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(300), matchCount: 60, isGlobList: false }), container);
87
+ });
88
+ const reveal = Array.from(container.querySelectorAll('button')).find((b) => (b.textContent ?? '').includes('show all'));
89
+ expect(reveal).toBeDefined();
90
+ expect(reveal?.textContent).toContain('240');
91
+
92
+ await act(async () => {
93
+ reveal?.click();
94
+ });
95
+ expect(rowCount(container)).toBe(300);
96
+ });
97
+
98
+ test('a short group draws no reveal control at all', async () => {
99
+ await act(async () => {
100
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(4), matchCount: 1, isGlobList: false }), container);
101
+ });
102
+ expect(rowCount(container)).toBe(4);
103
+ expect(container.textContent).not.toContain('show all');
104
+ });
105
+
106
+ test('the first paint carries the text, before any highlight is computed', async () => {
107
+ await act(async () => {
108
+ render(h(FileMatches, { path: 'src/a.ts', lines: matches(3), matchCount: 1, isGlobList: false }), container);
109
+ });
110
+ // Nothing tokenized yet, and the rows still read correctly.
111
+ expect(container.textContent).toContain('const value2 = 2;');
112
+ expect(container.querySelectorAll('pre.shiki')).toHaveLength(3);
113
+ });
114
+ });
@@ -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">