@kabmo-labs/kabmo 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -0,0 +1,184 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The code editor's colour theme, as a filterable grid of cards.
8
+ *
9
+ * A second axis beside the app palette (see `themes.ts`): the console keeps its
10
+ * own theme and the editor wears whichever TextMate theme the reader works in.
11
+ * `auto` is the first card and the default, so an install that never opens this
12
+ * picker behaves exactly as before.
13
+ *
14
+ * Three properties are load-bearing and each is a bug if dropped:
15
+ *
16
+ * - **The swatch is drawn from the generated colour rows, not from the theme
17
+ * module.** 65 TextMate definitions is 1.3 MB; a grid that fetched them to
18
+ * draw itself would download the whole catalogue to show a colour. The rows
19
+ * carry the surface, the ink and three syntax accents — the colours that
20
+ * actually tell two themes apart at card size.
21
+ * - **The preview is set in the theme's OWN colours**, so an unselected card
22
+ * still shows what selecting it does.
23
+ * - **Selecting is a write, and the write is announced** (`Modal` dispatches
24
+ * `omp:editor-theme-changed`), which is what reaches an editor that is
25
+ * already open — `appSettings` is a boot-time prop and would only apply on a
26
+ * reload.
27
+ */
28
+
29
+ import { useMemo, useState } from 'preact/hooks';
30
+ import { Check, Moon, Palette, Sun } from 'lucide-preact';
31
+ import type { SettingsState } from '@/shared/types';
32
+ import {
33
+ EDITOR_AUTO_THEME_ID,
34
+ EDITOR_THEMES,
35
+ type EditorTheme,
36
+ } from '@/shared/lib/code/editor/themes';
37
+ import { EDITOR_FONT_CHOICES } from '@/shared/lib/code/editor/typography';
38
+
39
+ interface EditorThemeSectionProps {
40
+ settings: SettingsState;
41
+ onUpdate: (updater: Partial<SettingsState> | ((prev: SettingsState) => SettingsState)) => void;
42
+ }
43
+
44
+ type VariantFilter = 'all' | 'light' | 'dark';
45
+
46
+ const FILTERS: ReadonlyArray<{ id: VariantFilter; label: string }> = [
47
+ { id: 'all', label: 'All' },
48
+ { id: 'light', label: 'Light' },
49
+ { id: 'dark', label: 'Dark' },
50
+ ];
51
+
52
+ /** The face the cards' sample line is set in: the editor's own choice. */
53
+ function sampleFace(font: string): string {
54
+ const choice = EDITOR_FONT_CHOICES.find((entry) => entry.id === font);
55
+ return `"${choice?.id ?? EDITOR_FONT_CHOICES[0].id}", monospace`;
56
+ }
57
+
58
+ function ThemeSwatch({ theme, face }: { theme: EditorTheme; face: string }) {
59
+ return (
60
+ <span
61
+ className="block overflow-hidden rounded border border-ink/10"
62
+ style={{ backgroundColor: theme.bg, color: theme.fg }}
63
+ >
64
+ <span className="block px-1.5 py-1 font-mono text-[10px] leading-[1.4]" style={{ fontFamily: face }}>
65
+ <span style={{ color: theme.keyword }}>const</span> <span>answer</span>{' '}
66
+ <span style={{ color: theme.keyword }}>=</span> <span style={{ color: theme.string }}>42</span>
67
+ <span>{';'}</span>
68
+ <span className="block truncate" style={{ color: theme.comment }}>
69
+ {'// '}
70
+ {theme.label}
71
+ </span>
72
+ </span>
73
+ </span>
74
+ );
75
+ }
76
+
77
+ export function EditorThemeSection({ settings, onUpdate }: EditorThemeSectionProps) {
78
+ const active = settings.editorTheme || EDITOR_AUTO_THEME_ID;
79
+ const [filter, setFilter] = useState<VariantFilter>('all');
80
+ const face = sampleFace(settings.editorFont);
81
+
82
+ const themes = useMemo(
83
+ () => (filter === 'all' ? EDITOR_THEMES : EDITOR_THEMES.filter((theme) => theme.variant === filter)),
84
+ [filter],
85
+ );
86
+
87
+ return (
88
+ <section className="space-y-3">
89
+ <div>
90
+ <h3 className="font-semibold text-sm uppercase tracking-wider text-ink/80 flex items-center gap-2">
91
+ <Palette size={16} className="text-ink/60" />
92
+ Editor Theme
93
+ </h3>
94
+ <p className="text-[11px] text-ink/60 mt-1">
95
+ Colours for the code editor only — the console around it keeps the theme above. <strong>Auto</strong>{' '}
96
+ follows the app palette; any other choice paints the editor's own surface, gutter, selection and
97
+ syntax, and applies to every open editor without a reload.
98
+ </p>
99
+ </div>
100
+
101
+ <div className="flex items-center gap-1.5">
102
+ {FILTERS.map((option) => (
103
+ <button
104
+ key={option.id}
105
+ type="button"
106
+ onClick={() => setFilter(option.id)}
107
+ className={`rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
108
+ filter === option.id
109
+ ? 'border-ink bg-ink text-paper'
110
+ : 'border-ink/20 bg-paper text-ink/70 hover:border-ink/40 hover:text-ink'
111
+ }`}
112
+ >
113
+ {option.id === 'light' && <Sun size={11} className="mr-1 inline align-[-1px]" />}
114
+ {option.id === 'dark' && <Moon size={11} className="mr-1 inline align-[-1px]" />}
115
+ {option.label}
116
+ </button>
117
+ ))}
118
+ <span className="ml-auto font-mono text-[11px] text-ink/50">
119
+ {filter === 'all' ? `${themes.length} themes` : `${themes.length} shown`}
120
+ </span>
121
+ </div>
122
+
123
+ <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
124
+ {/* `Auto` is a card rather than a checkbox: it is one of the choices,
125
+ and a checkbox beside a grid makes the default look like an
126
+ override of the thing it is a peer of. It is drawn under EVERY
127
+ filter — it is neither light nor dark, and a user who filtered to
128
+ Dark must still be able to get back to the app palette without
129
+ first clearing the filter. */}
130
+ {(
131
+ <button
132
+ type="button"
133
+ onClick={() => onUpdate({ editorTheme: EDITOR_AUTO_THEME_ID })}
134
+ aria-pressed={active === EDITOR_AUTO_THEME_ID}
135
+ className={`flex w-full flex-col gap-2 rounded-lg border p-2 text-left transition-colors cursor-pointer ${
136
+ active === EDITOR_AUTO_THEME_ID
137
+ ? 'border-ink bg-ink/5 ring-1 ring-ink/20'
138
+ : 'border-ink/20 bg-paper hover:border-ink/40 hover:bg-ink/5'
139
+ }`}
140
+ >
141
+ <span className="flex items-center gap-2">
142
+ <span className="truncate font-semibold text-ink">Auto</span>
143
+ <span className="shrink-0 rounded-full border border-ink/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-ink/50">
144
+ app palette
145
+ </span>
146
+ {active === EDITOR_AUTO_THEME_ID && <Check size={14} className="ml-auto shrink-0 text-ink" />}
147
+ </span>
148
+ <span className="flex h-8 items-center justify-center rounded border border-dashed border-ink/20 text-[10px] text-ink/50">
149
+ Follows the theme above
150
+ </span>
151
+ </button>
152
+ )}
153
+
154
+ {themes.map((theme) => {
155
+ const selected = active === theme.id;
156
+ return (
157
+ <button
158
+ key={theme.id}
159
+ type="button"
160
+ onClick={() => onUpdate({ editorTheme: theme.id })}
161
+ aria-pressed={selected}
162
+ className={`flex w-full flex-col gap-2 rounded-lg border p-2 text-left transition-colors cursor-pointer ${
163
+ selected
164
+ ? 'border-ink bg-ink/5 ring-1 ring-ink/20'
165
+ : 'border-ink/20 bg-paper hover:border-ink/40 hover:bg-ink/5'
166
+ }`}
167
+ >
168
+ <span className="flex items-center gap-2">
169
+ <span className="truncate font-semibold text-ink" title={theme.label}>
170
+ {theme.label}
171
+ </span>
172
+ <span className="shrink-0 rounded-full border border-ink/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-ink/50">
173
+ {theme.variant}
174
+ </span>
175
+ {selected && <Check size={14} className="ml-auto shrink-0 text-ink" />}
176
+ </span>
177
+ <ThemeSwatch theme={theme} face={face} />
178
+ </button>
179
+ );
180
+ })}
181
+ </div>
182
+ </section>
183
+ );
184
+ }
@@ -34,7 +34,7 @@ export interface WorktreeSettingsProps {
34
34
  }
35
35
 
36
36
  export function WorktreeSettings({ root, folderId, onReport }: WorktreeSettingsProps) {
37
- const { worktrees, base, loading, error, refresh } = useWorktrees({ root, folderId }, Boolean(root));
37
+ const { worktrees, base, loading, error, isRepo, refresh } = useWorktrees({ root, folderId }, Boolean(root));
38
38
  const [directory, setDirectory] = useState(() => {
39
39
  const stored = readKabmoSetting<string>(WORKTREE_DIRECTORY_SETTING);
40
40
  return typeof stored === 'string' ? stored : '';
@@ -62,7 +62,13 @@ export function WorktreeSettings({ root, folderId, onReport }: WorktreeSettingsP
62
62
  Worktrees could not be read: {error}
63
63
  </p>
64
64
  )}
65
- {!loading && !error && removable.length === 0 && (
65
+ {!loading && !error && isRepo === false && (
66
+ // The list is empty for a reason the row has to NAME: a folder that
67
+ // is not a repository can never have a worktree, and "No worktrees
68
+ // yet" would read as one that could be created.
69
+ <p className="text-[11px] text-ink/50">Worktrees need a Git repository.</p>
70
+ )}
71
+ {!loading && !error && isRepo !== false && removable.length === 0 && (
66
72
  <p className="text-[11px] text-ink/50">No worktrees yet.</p>
67
73
  )}
68
74
  {removable.map((worktree) => (
@@ -14,7 +14,8 @@ import {
14
14
  type WaitingCandidate,
15
15
  } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
16
16
  import { Hourglass } from 'lucide-preact';
17
- import { TargetPicker } from '@/client/components/workspace/chat-timeline/target-picker/index';
17
+ import { WorkspacePicker } from '@/client/components/workspace/chat-timeline/target-picker/WorkspacePicker';
18
+ import { WorktreePicker } from '@/client/components/workspace/chat-timeline/target-picker/WorktreePicker';
18
19
  import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
19
20
 
20
21
  interface EmptyWorkspacePromptProps {
@@ -164,19 +165,26 @@ export function EmptyWorkspacePrompt({
164
165
  a target workspace, so the composer is the only focus. */}
165
166
  {messages.length === 0 && (
166
167
  <div className="flex items-center gap-1 flex-wrap">
167
- {/* The target picker asks where the chat runs — the workspace, or one
168
- of its worktrees — so a worktree chat is created here rather than
169
- in a dialog the user has to find first. */}
170
- {onSpawnTargetChange ? (
171
- <TargetPicker
168
+ {/* Two controls, because they answer two questions: the workspace
169
+ (which project) and the worktree (which checkout of it). The
170
+ worktree control draws itself only for a git repository, and it
171
+ reads its list on mount because its own trigger names the branch
172
+ the project root holds. */}
173
+ <WorkspacePicker
174
+ folders={folders as WorkspaceFolderData[]}
175
+ selectedFolderId={selectedFolderId}
176
+ setSelectedFolderId={setSelectedFolderId}
177
+ onClearWorktree={() => onSpawnTargetChange?.(null)}
178
+ />
179
+ {onSpawnTargetChange && (
180
+ <WorktreePicker
172
181
  folders={folders as WorkspaceFolderData[]}
173
182
  selectedFolderId={selectedFolderId}
174
- setSelectedFolderId={setSelectedFolderId}
175
183
  target={spawnTarget}
176
184
  setTarget={onSpawnTargetChange}
177
185
  onReport={onWorktreeReport}
178
186
  />
179
- ) : null}
187
+ )}
180
188
  {/* The wait picker sits BESIDE the workspace picker, because both
181
189
  answer the same question about the chat that has not started:
182
190
  where it runs, and what it waits for. It only appears while there
@@ -1,8 +1,7 @@
1
1
  import { useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { AlertCircle, Bell, Bot, CheckCircle2, ChevronDown, Clock, Info, Layers, Target } from 'lucide-preact';
3
3
  import { isCodeLike } 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 { useIsTruncated } from '@/client/hooks/ui/text-overflow';
7
6
  import { parseTaskNotice } from '@/shared/lib/chat/task-result-parser';
8
7
  import { isReminderTag, stripNoticeTags, unwrapXmlEnvelope } from '@/shared/lib/chat/xml-envelope';
@@ -53,7 +52,6 @@ interface SystemNoticeProps {
53
52
  * injection renders open and has no collapse control (see `forcedOpen`). */
54
53
  export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeProps) {
55
54
  const [userOpen, setUserOpen] = useState(false);
56
- useSyntaxReady();
57
55
 
58
56
  // 1. Check if notice contains a structured <task-result> block
59
57
  const taskNotice = useMemo(() => parseTaskNotice(notice), [notice]);
@@ -115,6 +113,13 @@ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeP
115
113
  };
116
114
  }, [notice]);
117
115
 
116
+ // Deferred and gated: the code branch only renders while the card is open, so
117
+ // a collapsed notice computes nothing and an opened one paints its text first.
118
+ const codeHtml = useDeferredHighlight(
119
+ genericInfo.isCode ? genericInfo.rest || genericInfo.clean : '',
120
+ 'javascript',
121
+ );
122
+
118
123
  const rawTitle = goalNotice
119
124
  ? goalNoticeTitle(goalNotice)
120
125
  : taskNotice?.intro || reminderInfo?.title || genericInfo.firstLine || notice;
@@ -284,7 +289,7 @@ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeP
284
289
  <pre
285
290
  className="max-h-56 overflow-auto font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/75 scrollbar-overlay-container"
286
291
  dangerouslySetInnerHTML={{
287
- __html: highlightCode(genericInfo.rest || genericInfo.clean, 'javascript'),
292
+ __html: codeHtml,
288
293
  }}
289
294
  />
290
295
  ) : (
@@ -4,15 +4,13 @@ import type { ParsedTaskNotice } from '@/shared/lib/chat/task-result-parser';
4
4
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
5
5
  import { CopyButton } from '@/client/components/common/CopyButton';
6
6
  import { isCodeLike } from '@/shared/lib/code/language';
7
- import { highlightCode } from '@/shared/lib/code/syntax-highlight';
8
- import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
7
+ import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
9
8
 
10
9
  interface TaskResultContentProps {
11
10
  task: ParsedTaskNotice;
12
11
  }
13
12
 
14
13
  export function TaskResultContent({ task }: TaskResultContentProps) {
15
- useSyntaxReady();
16
14
  const structured = task.structuredOutput;
17
15
  const isRawCode = !task.formattedJson && isCodeLike(task.rawOutput);
18
16
 
@@ -20,6 +18,13 @@ export function TaskResultContent({ task }: TaskResultContentProps) {
20
18
  structured || !isRawCode ? 'formatted' : 'raw'
21
19
  );
22
20
 
21
+ // Gated on the tab that draws it: the raw view is one of two, and tokenizing
22
+ // it while the formatted view is on screen is work nobody sees.
23
+ const rawHtml = useDeferredHighlight(
24
+ activeTab === 'raw' ? task.formattedJson || task.rawOutput : '',
25
+ task.formattedJson ? 'json' : isRawCode ? 'javascript' : 'markdown',
26
+ );
27
+
23
28
  return (
24
29
  <div className="space-y-3">
25
30
  {/* Top action toolbar / tab toggles */}
@@ -151,10 +156,7 @@ export function TaskResultContent({ task }: TaskResultContentProps) {
151
156
  <pre
152
157
  className="max-h-64 overflow-auto rounded-lg border border-ink/8 bg-paper p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/80 scrollbar-overlay-container"
153
158
  dangerouslySetInnerHTML={{
154
- __html: highlightCode(
155
- task.formattedJson || task.rawOutput,
156
- task.formattedJson ? 'json' : isRawCode ? 'javascript' : 'markdown'
157
- ),
159
+ __html: rawHtml,
158
160
  }}
159
161
  />
160
162
  )}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's WORKSPACE picker, rendered.
8
+ *
9
+ * The cases a reader would otherwise find only in a browser:
10
+ *
11
+ * - the trigger names the selected folder, and says what it is waiting for
12
+ * when nothing is selected;
13
+ * - the menu lists folders and NOTHING about worktrees — the split is the
14
+ * point of this control, and a worktree row here is the regression;
15
+ * - choosing another folder clears the worktree pick, because a checkout of
16
+ * the previous project names a path the new one cannot resolve.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import { h, render } from 'preact';
22
+ import { act } from 'preact/test-utils';
23
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
24
+ import { WorkspacePicker, workspaceLabel } from '@/client/components/workspace/chat-timeline/target-picker/WorkspacePicker';
25
+ import type { WorkspaceFolderData } from '@/shared/types';
26
+
27
+ const folder = (id: number, name: string): WorkspaceFolderData => ({
28
+ id,
29
+ name,
30
+ project_path: `/repo/${name}`,
31
+ isExpanded: true,
32
+ hasMore: false,
33
+ totalSessions: 0,
34
+ sessions: [],
35
+ });
36
+
37
+ const mounted: HTMLElement[] = [];
38
+
39
+ beforeAll(() => {
40
+ installDomGlobals(new Window({ url: 'http://localhost' }));
41
+ });
42
+
43
+ afterEach(() => {
44
+ for (const host of mounted.splice(0)) {
45
+ render(null, host);
46
+ host.remove();
47
+ }
48
+ });
49
+
50
+ afterAll(() => {
51
+ restoreDomGlobals();
52
+ });
53
+
54
+ async function mount(node: h.JSX.Element): Promise<HTMLElement> {
55
+ const host = document.createElement('div');
56
+ document.body.appendChild(host);
57
+ await act(async () => {
58
+ render(node, host);
59
+ });
60
+ mounted.push(host);
61
+ return host;
62
+ }
63
+
64
+ function textOf(host: HTMLElement): string {
65
+ return (host.textContent ?? '').replace(/\s+/g, ' ').trim();
66
+ }
67
+
68
+ describe('workspaceLabel', () => {
69
+ test('the selected folder', () => {
70
+ expect(workspaceLabel([folder(1, 'kabmo')], 1)).toBe('kabmo');
71
+ });
72
+
73
+ test('what it is waiting for, with nothing selected', () => {
74
+ expect(workspaceLabel([], null)).toBe('Select Workspace Context');
75
+ });
76
+ });
77
+
78
+ describe('WorkspacePicker', () => {
79
+ test('the menu lists the workspaces and nothing about worktrees', async () => {
80
+ const host = await mount(
81
+ h(WorkspacePicker, {
82
+ folders: [folder(1, 'kabmo'), folder(2, 'notes')],
83
+ selectedFolderId: 1,
84
+ setSelectedFolderId: () => {},
85
+ }),
86
+ );
87
+
88
+ await act(async () => {
89
+ (host.querySelector('button') as HTMLButtonElement).click();
90
+ });
91
+
92
+ const text = textOf(host);
93
+ expect(text).toContain('Target Workspace');
94
+ expect(text).toContain('kabmo');
95
+ expect(text).toContain('notes');
96
+ // The split: a worktree row here would be the merged menu coming back.
97
+ expect(text).not.toContain('Quick worktree');
98
+ expect(text).not.toContain('New worktree');
99
+ expect(text).not.toContain('Worktrees');
100
+ });
101
+
102
+ test('choosing a folder clears the worktree pick', async () => {
103
+ // A record rather than two locals: a value assigned inside a callback is
104
+ // still narrowed to its initializer by control-flow analysis, so `picked`
105
+ // would be `null` at the assertion.
106
+ const seen: { picked: number | null; cleared: number } = { picked: null, cleared: 0 };
107
+ const host = await mount(
108
+ h(WorkspacePicker, {
109
+ folders: [folder(1, 'kabmo'), folder(2, 'notes')],
110
+ selectedFolderId: 1,
111
+ setSelectedFolderId: (id) => {
112
+ seen.picked = id;
113
+ },
114
+ onClearWorktree: () => {
115
+ seen.cleared += 1;
116
+ },
117
+ }),
118
+ );
119
+
120
+ await act(async () => {
121
+ (host.querySelector('button') as HTMLButtonElement).click();
122
+ });
123
+ const rows = [...host.querySelectorAll('[role="menuitemradio"]')] as HTMLButtonElement[];
124
+ expect(rows.length).toBe(2);
125
+ await act(async () => {
126
+ rows[1]?.click();
127
+ });
128
+
129
+ expect(seen.picked).toBe(2);
130
+ expect(seen.cleared).toBe(1);
131
+ });
132
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * WHERE the chat runs: the workspace folder.
8
+ *
9
+ * One control of the composer's draft-target pair, and the pair exists because
10
+ * the two questions are different: the folder is the project, and the worktree
11
+ * is the checkout inside it (`WorktreePicker`). They were merged into one menu
12
+ * once, and the result read as a workspace picker with worktree rows buried in
13
+ * it — a user looking for another project scrolled past branch names, and a
14
+ * user looking for a worktree could not tell the control was the place to look.
15
+ * OpenChamber draws them as two controls for the same reason.
16
+ *
17
+ * Picking a folder CLEARS the worktree pick: a checkout of the previous project
18
+ * names a path that does not exist under the new one.
19
+ */
20
+
21
+ import { useRef, useState } from 'preact/hooks';
22
+ import { Check, ChevronDown, Folder } from 'lucide-preact';
23
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
24
+ import type { WorkspaceFolderData } from '@/shared/types';
25
+
26
+ export interface WorkspacePickerProps {
27
+ folders: WorkspaceFolderData[];
28
+ selectedFolderId: number | null;
29
+ setSelectedFolderId: (id: number | null) => void;
30
+ /** Called with `null` when the user moves to another project. */
31
+ onClearWorktree?: () => void;
32
+ }
33
+
34
+ /** The trigger's words, exported so a test asserts them without the markup. */
35
+ export function workspaceLabel(folders: WorkspaceFolderData[], selectedFolderId: number | null): string {
36
+ const folder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId));
37
+ return folder?.name ?? 'Select Workspace Context';
38
+ }
39
+
40
+ export function WorkspacePicker({
41
+ folders,
42
+ selectedFolderId,
43
+ setSelectedFolderId,
44
+ onClearWorktree,
45
+ }: WorkspacePickerProps) {
46
+ const [open, setOpen] = useState(false);
47
+ const containerRef = useRef<HTMLDivElement>(null);
48
+ useOnClickOutside(containerRef, () => setOpen(false));
49
+
50
+ const pick = (folder: WorkspaceFolderData) => {
51
+ setSelectedFolderId(folder.id);
52
+ // A worktree belongs to the project it was made from; keeping the pick
53
+ // across a project change would spawn the chat in a path this project
54
+ // cannot resolve.
55
+ onClearWorktree?.();
56
+ setOpen(false);
57
+ };
58
+
59
+ const label = workspaceLabel(folders, selectedFolderId);
60
+
61
+ return (
62
+ <div className="relative shrink-0" ref={containerRef}>
63
+ <button
64
+ type="button"
65
+ // Two triggers sit side by side, so each needs a name that says which
66
+ // question it answers: "kabmo" and "main" alone are both ambiguous.
67
+ aria-label={`Workspace: ${label}`}
68
+ aria-haspopup="menu"
69
+ aria-expanded={open}
70
+ onClick={() => setOpen((value) => !value)}
71
+ className="group flex items-center space-x-1.5 px-1.5 py-1 rounded-md text-xs font-medium text-ink/75 hover:text-ink hover:bg-ink/5 focus-visible:bg-ink/5 transition-all outline-none cursor-pointer select-none"
72
+ >
73
+ <Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
74
+ <span className="max-w-[260px] truncate tracking-tight">{label}</span>
75
+ <ChevronDown
76
+ size={13}
77
+ className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${open ? 'rotate-180 text-ink/70' : ''}`}
78
+ />
79
+ </button>
80
+
81
+ {open && (
82
+ <div
83
+ role="menu"
84
+ className="absolute bottom-full left-0 mb-1.5 w-64 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1"
85
+ >
86
+ <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
87
+ Target Workspace
88
+ </div>
89
+ <div className="max-h-56 scrollbar-overlay-container py-1">
90
+ {folders.length === 0 ? (
91
+ <div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
92
+ ) : (
93
+ folders.map((folder) => {
94
+ const active = String(selectedFolderId) === String(folder.id);
95
+ return (
96
+ <button
97
+ key={folder.id}
98
+ type="button"
99
+ role="menuitemradio"
100
+ aria-checked={active}
101
+ onClick={() => pick(folder)}
102
+ className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
103
+ active ? 'bg-ink/10 text-ink font-semibold' : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
104
+ }`}
105
+ >
106
+ <span className="flex items-center space-x-2 truncate pr-2">
107
+ <Folder size={12} className={active ? 'text-ink flex-shrink-0' : 'text-ink/50 flex-shrink-0'} />
108
+ <span className="truncate font-medium">{folder.name}</span>
109
+ </span>
110
+ {active && <Check size={12} className="text-ink flex-shrink-0" />}
111
+ </button>
112
+ );
113
+ })
114
+ )}
115
+ </div>
116
+ </div>
117
+ )}
118
+ </div>
119
+ );
120
+ }