ompchamber 1.2.1 → 2.0.1

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 (237) hide show
  1. package/README.md +143 -147
  2. package/dist/client/index.html +4 -2
  3. package/dist/client/static/css/index.5f630421b7.css +2 -0
  4. package/dist/client/static/js/77.0d81c74cf0.js +139 -0
  5. package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
  6. package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
  7. package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
  8. package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
  9. package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
  10. package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
  11. package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
  12. package/dist/client/static/js/async/e.93214d622b.js +79 -0
  13. package/dist/client/static/js/async/g.cb81980498.js +2 -0
  14. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  15. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  16. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  17. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  18. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  19. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  20. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  21. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  22. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  23. package/dist/client/static/js/async/q.22588845a9.js +21 -0
  24. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  25. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  26. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  27. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  28. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  29. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  31. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  32. package/dist/client/static/js/async/{yx.af2936c368.js → xn1.83a7784fe1.js} +3 -3
  33. package/dist/client/static/js/async/xn1.83a7784fe1.js.LICENSE.txt +11 -0
  34. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  35. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  36. package/dist/client/static/js/index.1fd9e1c543.js +38 -0
  37. package/package.json +3 -3
  38. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  39. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  40. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  41. package/src/client/components/settings/Modal.tsx +11 -2
  42. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  43. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  45. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  46. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  47. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  48. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  49. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  50. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  51. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  52. package/src/client/components/workspace/btw-panel/index.tsx +124 -45
  53. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  54. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  55. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  56. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  57. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  58. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  60. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  64. package/src/client/components/workspace/diff-panel/SplitView.tsx +4 -1
  65. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +70 -65
  66. package/src/client/components/workspace/diff-panel/index.tsx +8 -1
  67. package/src/client/components/workspace/editor/index.tsx +18 -2
  68. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  69. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  70. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  71. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  72. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  73. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  74. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  75. package/src/client/data/theme/terminal.ts +50 -52
  76. package/src/client/hooks/chat/btw/index.ts +139 -18
  77. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  78. package/src/client/hooks/chat/btw/mode.ts +111 -16
  79. package/src/client/hooks/chat/composer/index.ts +10 -4
  80. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  81. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  82. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  83. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  84. package/src/client/hooks/chat/timeline/index.ts +8 -20
  85. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  86. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  87. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  88. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  89. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  90. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  91. package/src/client/hooks/editor/save-file.ts +62 -0
  92. package/src/client/hooks/editor/use-file-editor.test.ts +61 -5
  93. package/src/client/hooks/editor/use-file-editor.ts +38 -29
  94. package/src/client/hooks/editor/use-file-transfer.ts +5 -2
  95. package/src/client/hooks/settings/providers.ts +34 -3
  96. package/src/client/hooks/ui/open-file.ts +7 -1
  97. package/src/client/hooks/ui/theme.ts +82 -57
  98. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  99. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  100. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  101. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  102. package/src/client/hooks/workspace/search-stream.ts +24 -5
  103. package/src/client/main.tsx +6 -0
  104. package/src/client/tailwind.css +19 -56
  105. package/src/server/lib/btw/child.server.ts +41 -0
  106. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  107. package/src/server/lib/btw/prompt.ts +13 -5
  108. package/src/server/lib/btw/registry.server.ts +13 -4
  109. package/src/server/lib/btw/runtime.server.ts +149 -156
  110. package/src/server/lib/btw/schema.server.ts +27 -0
  111. package/src/server/lib/btw/service.server.ts +106 -5
  112. package/src/server/lib/btw/store.server.ts +57 -6
  113. package/src/server/lib/db/schema.ts +4 -0
  114. package/src/server/lib/db/settings-store.test.ts +65 -0
  115. package/src/server/lib/db/settings-store.ts +25 -0
  116. package/src/server/lib/fs/atomic-write.ts +42 -4
  117. package/src/server/lib/fs/config-lock.ts +74 -0
  118. package/src/server/lib/fs/git-ignore.ts +55 -5
  119. package/src/server/lib/fs/ripgrep.ts +119 -0
  120. package/src/server/lib/lifecycle/identity.ts +10 -64
  121. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  122. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  123. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  124. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  125. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  126. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  127. package/src/server/lib/models/provider-registry.server.ts +2 -2
  128. package/src/server/lib/models/registry.server.ts +1 -1
  129. package/src/server/lib/omp/config/behavior.ts +17 -17
  130. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  131. package/src/server/lib/omp/config/document.ts +128 -0
  132. package/src/server/lib/omp/config/extensions.ts +11 -10
  133. package/src/server/lib/omp/config/mcp.ts +4 -57
  134. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  135. package/src/server/lib/omp/config/models-config.ts +92 -0
  136. package/src/server/lib/omp/config/models-validation.ts +159 -0
  137. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  138. package/src/server/lib/omp/config/providers.ts +241 -211
  139. package/src/server/lib/omp/config/roles.ts +6 -14
  140. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  141. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  143. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  144. package/src/server/lib/omp/rpc/manager.ts +21 -21
  145. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  146. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  147. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  148. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  149. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  150. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  151. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  152. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  153. package/src/server/lib/queue/delivery-harness.ts +88 -0
  154. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  155. package/src/server/lib/queue/delivery.server.ts +105 -13
  156. package/src/server/lib/queue/store.server.ts +30 -5
  157. package/src/server/lib/terminal/foreground.test.ts +164 -0
  158. package/src/server/lib/terminal/foreground.ts +34 -6
  159. package/src/server/lib/terminal/history.ts +8 -7
  160. package/src/server/plugins/compress.ts +33 -5
  161. package/src/server/plugins/ssr.ts +14 -7
  162. package/src/server/routes/btw/topics.ts +89 -2
  163. package/src/server/routes/fs/action.test.ts +64 -0
  164. package/src/server/routes/fs/action.ts +10 -1
  165. package/src/server/routes/fs/git.ts +5 -6
  166. package/src/server/routes/fs/search.ts +17 -26
  167. package/src/server/routes/sessions/queue.ts +17 -4
  168. package/src/server/routes/settings/index.ts +2 -0
  169. package/src/server/routes/settings/model-override.ts +69 -0
  170. package/src/server/routes/settings/provider-models.ts +35 -12
  171. package/src/server/routes/settings/providers.ts +31 -1
  172. package/src/server/routes/settings/root.ts +8 -3
  173. package/src/shared/lib/chat/btw/client.ts +34 -0
  174. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  175. package/src/shared/lib/code/line-endings.test.ts +38 -0
  176. package/src/shared/lib/code/line-endings.ts +56 -0
  177. package/src/shared/lib/code/shiki-themes.ts +9 -5
  178. package/src/shared/lib/fs/file-tree.ts +17 -12
  179. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  180. package/src/shared/lib/markdown/autolink.ts +50 -0
  181. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  182. package/src/shared/lib/markdown/document-urls.ts +118 -0
  183. package/src/shared/lib/markdown/marked.ts +27 -15
  184. package/src/shared/lib/markdown/mermaid.ts +2 -2
  185. package/src/shared/lib/models/catalog.ts +1 -1
  186. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  187. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  188. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  189. package/src/shared/lib/settings/client.ts +10 -4
  190. package/src/shared/lib/settings/diff.test.ts +31 -0
  191. package/src/shared/lib/settings/diff.ts +37 -0
  192. package/src/shared/lib/theme/catalog.test.ts +168 -0
  193. package/src/shared/lib/theme/catalog.ts +98 -0
  194. package/src/shared/lib/theme/color.ts +88 -0
  195. package/src/shared/lib/theme/css.ts +70 -0
  196. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  197. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  198. package/src/shared/lib/theme/palettes/light.ts +313 -0
  199. package/src/shared/types/btw.ts +83 -4
  200. package/src/shared/types/index.ts +1 -0
  201. package/src/shared/types/settings/provider.ts +8 -1
  202. package/src/shared/types/settings/state.ts +10 -4
  203. package/src/shared/types/theme.ts +48 -0
  204. package/dist/client/static/css/index.b938f4a693.css +0 -2
  205. package/dist/client/static/js/77.8022a800b5.js +0 -139
  206. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  207. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  208. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  209. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  210. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  211. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  212. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  213. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  214. package/dist/client/static/js/async/m.2e1ea3db9e.js.LICENSE.txt +0 -6
  215. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  216. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  217. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  218. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  219. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  220. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  221. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  222. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  223. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  224. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  225. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  226. package/dist/client/static/js/index.5fe7a2cb64.js +0 -34
  227. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  228. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  229. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  234. /package/dist/client/static/js/async/{yx.af2936c368.js.LICENSE.txt → g.cb81980498.js.LICENSE.txt} +0 -0
  235. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.22588845a9.js.LICENSE.txt} +0 -0
  236. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  237. /package/dist/client/static/js/{index.5fe7a2cb64.js.LICENSE.txt → index.1fd9e1c543.js.LICENSE.txt} +0 -0
@@ -38,80 +38,85 @@ export function UnifiedView({ lines, language }: UnifiedViewProps) {
38
38
 
39
39
  return (
40
40
  <div className="w-full h-full overflow-auto font-mono text-xs select-text bg-paper text-ink code-surface">
41
- <table className="w-max min-w-full border-collapse">
42
- <tbody>
43
- {lines.map((line, idx) => {
44
- if (line.type === 'meta') {
45
- return (
46
- <tr key={idx} className="bg-canvas border-y border-ink/10 text-meta select-none">
47
- <td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
48
- <td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
49
- <td className="py-1 px-3 text-[11px] font-semibold text-meta font-mono tracking-tight">{line.text}</td>
50
- </tr>
51
- );
52
- }
41
+ {/* The room under the last row belongs to the content, not the scroller:
42
+ a scroll container's own bottom padding is not part of its scrollable
43
+ overflow, so the padding has to sit inside it to have any effect. */}
44
+ <div className="pb-4">
45
+ <table className="w-max min-w-full border-collapse">
46
+ <tbody>
47
+ {lines.map((line, idx) => {
48
+ if (line.type === 'meta') {
49
+ return (
50
+ <tr key={idx} className="bg-canvas border-y border-ink/10 text-meta select-none">
51
+ <td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
52
+ <td className="w-12 text-center py-1 px-2 text-[10px] opacity-60 border-r border-ink/10 font-mono">...</td>
53
+ <td className="py-1 px-3 text-[11px] font-semibold text-meta font-mono tracking-tight">{line.text}</td>
54
+ </tr>
55
+ );
56
+ }
53
57
 
54
- const isAdd = line.type === 'add';
55
- const isDel = line.type === 'del';
58
+ const isAdd = line.type === 'add';
59
+ const isDel = line.type === 'del';
56
60
 
57
- return (
58
- <tr
59
- key={idx}
60
- className={`transition-colors group ${
61
- isAdd
62
- ? 'bg-success/10 hover:bg-success/15'
63
- : isDel
64
- ? 'bg-error/10 hover:bg-error/15'
65
- : 'hover:bg-ink/[0.03]'
66
- }`}
67
- >
68
- {/* Old line number */}
69
- <td
70
- className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
71
- isDel
72
- ? 'bg-error/15 text-error font-semibold'
73
- : isAdd
74
- ? 'bg-success/15 text-success/70'
75
- : 'bg-canvas/50 text-ink/40'
76
- }`}
77
- >
78
- {line.oldLineNumber || ''}
79
- </td>
80
-
81
- {/* New line number */}
82
- <td
83
- className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
61
+ return (
62
+ <tr
63
+ key={idx}
64
+ className={`transition-colors group ${
84
65
  isAdd
85
- ? 'bg-success/15 text-success font-semibold'
66
+ ? 'bg-success/10 hover:bg-success/15'
86
67
  : isDel
87
- ? 'bg-error/15 text-error/70'
88
- : 'bg-canvas/50 text-ink/40'
68
+ ? 'bg-error/10 hover:bg-error/15'
69
+ : 'hover:bg-ink/[0.03]'
89
70
  }`}
90
71
  >
91
- {line.newLineNumber || ''}
92
- </td>
72
+ {/* Old line number */}
73
+ <td
74
+ className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
75
+ isDel
76
+ ? 'bg-error/15 text-error font-semibold'
77
+ : isAdd
78
+ ? 'bg-success/15 text-success/70'
79
+ : 'bg-canvas/50 text-ink/40'
80
+ }`}
81
+ >
82
+ {line.oldLineNumber || ''}
83
+ </td>
93
84
 
94
- {/* Change prefix & code text */}
95
- <td className="py-0.5 px-3 whitespace-pre text-ink pr-6">
96
- <span
97
- className={`inline-block w-4 text-center select-none font-bold ${
98
- isAdd ? 'text-success' : isDel ? 'text-error' : 'opacity-0'
85
+ {/* New line number */}
86
+ <td
87
+ className={`w-12 py-0.5 px-2 text-right text-[10px] select-none border-r border-ink/10 font-mono ${
88
+ isAdd
89
+ ? 'bg-success/15 text-success font-semibold'
90
+ : isDel
91
+ ? 'bg-error/15 text-error/70'
92
+ : 'bg-canvas/50 text-ink/40'
99
93
  }`}
100
94
  >
101
- {isAdd ? '+' : isDel ? '-' : ' '}
102
- </span>
103
- <span
104
- className="shiki"
105
- dangerouslySetInnerHTML={{
106
- __html: line.text ? htmlByIndex[idx] || '&nbsp;' : '&nbsp;',
107
- }}
108
- />
109
- </td>
110
- </tr>
111
- );
112
- })}
113
- </tbody>
114
- </table>
95
+ {line.newLineNumber || ''}
96
+ </td>
97
+
98
+ {/* Change prefix & code text */}
99
+ <td className="py-0.5 px-3 whitespace-pre text-ink pr-6">
100
+ <span
101
+ className={`inline-block w-4 text-center select-none font-bold ${
102
+ isAdd ? 'text-success' : isDel ? 'text-error' : 'opacity-0'
103
+ }`}
104
+ >
105
+ {isAdd ? '+' : isDel ? '-' : ' '}
106
+ </span>
107
+ <span
108
+ className="shiki"
109
+ dangerouslySetInnerHTML={{
110
+ __html: line.text ? htmlByIndex[idx] || '&nbsp;' : '&nbsp;',
111
+ }}
112
+ />
113
+ </td>
114
+ </tr>
115
+ );
116
+ })}
117
+ </tbody>
118
+ </table>
119
+ </div>
115
120
  </div>
116
121
  );
117
122
  }
@@ -135,7 +135,14 @@ export function DiffPanel({
135
135
  };
136
136
 
137
137
  return (
138
- <div className={`flex flex-col h-full bg-paper select-text relative font-sans ${className}`}>
138
+ // The panel fills, it does not size itself: the caller owns the height
139
+ // (`flex-1` in the editor's column, `h-full` in the mobile overlay). A
140
+ // hardcoded `h-full` here resolves against the whole editor column rather
141
+ // than the space left under the tabs, so the panel began below the tabs
142
+ // *and* stood the full column tall — its bottom, the toolbar's height,
143
+ // hung past the panel edge and was clipped for good, since the scroll
144
+ // range ends below the visible area.
145
+ <div className={`flex flex-col min-h-0 bg-paper select-text relative font-sans ${className}`}>
139
146
  {/* Top Diff Toolbar */}
140
147
  <DiffToolbar
141
148
  filePath={filePath}
@@ -154,8 +154,17 @@ export function Editor({
154
154
  ) : (
155
155
  <div onScroll={handleScroll} className={`flex-1 overflow-auto bg-paper flex ${scrollbarFadeClass(isScrolling)}`}>
156
156
  {(isMd && isPreview) ? (
157
- <div className="p-6 prose prose-sm max-w-4xl mx-auto font-sans flex-1" style={{ fontSize: `${zoomLevel}px` }}>
158
- <MarkdownRenderer content={currentContent} />
157
+ // No `prose` wrapper: markdown is styled by `.prose-content`
158
+ // alone (the same system the chat timeline uses). The
159
+ // typography plugin fought it — `.prose img` added 2em
160
+ // vertical margins that ballooned a badge row into its own
161
+ // line box, and `--tw-prose-*` colors ignored the theme.
162
+ <div className="p-6 max-w-4xl mx-auto font-sans flex-1" style={{ fontSize: `${zoomLevel}px` }}>
163
+ <MarkdownRenderer
164
+ content={currentContent}
165
+ document
166
+ scope={{ path: activeFile.path, root: activeFile.root, repo: activeFile.repo }}
167
+ />
159
168
  </div>
160
169
  ) : (
161
170
  <CodeSurface
@@ -184,6 +193,13 @@ export function Editor({
184
193
  fontSize: zoomLevel,
185
194
  lineHeight: 1.5,
186
195
  minHeight: '100%',
196
+ // Breathing room under the last line. It has to live on the
197
+ // surface, not on the scroll container: a scroll container's
198
+ // own bottom padding is not part of its scrollable overflow,
199
+ // so `pb-*` on the scroller left the last line flush with the
200
+ // panel's edge (measured: padding-bottom 32px did not change
201
+ // scrollHeight by a single pixel).
202
+ paddingBottom: 16,
187
203
  }}
188
204
  />
189
205
  )}
@@ -1,13 +1,17 @@
1
1
 
2
- import { useEffect, useRef, useState } from 'preact/hooks';
2
+ import { useRef, useState } from 'preact/hooks';
3
3
  import { Check, ChevronDown, FolderGit2, RotateCcw, Search } from 'lucide-preact';
4
4
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
5
- import { REPO_DISCOVERY_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
6
5
 
7
6
  interface GitRepoDropdownProps {
8
7
  rootPath?: string;
9
8
  activeRepo: string;
10
9
  onSelectRepo: (repo: string) => void;
10
+ /** Discovered repos of the active root; the panel owns discovery. */
11
+ repos: string[];
12
+ /** Discovery or a forced rescan is in flight. */
13
+ scanning: boolean;
14
+ onRefreshRepos: () => void;
11
15
  }
12
16
 
13
17
  function workspaceFolderName(rootPath?: string): string {
@@ -16,51 +20,21 @@ function workspaceFolderName(rootPath?: string): string {
16
20
  return parts.length > 0 ? parts[parts.length - 1] : 'workspace root';
17
21
  }
18
22
 
19
- export function GitRepoDropdown({ rootPath, activeRepo, onSelectRepo }: GitRepoDropdownProps) {
23
+ export function GitRepoDropdown({
24
+ rootPath,
25
+ activeRepo,
26
+ onSelectRepo,
27
+ repos,
28
+ scanning,
29
+ onRefreshRepos,
30
+ }: GitRepoDropdownProps) {
20
31
  const [open, setOpen] = useState(false);
21
- const [repos, setRepos] = useState<string[]>(['.']);
22
- const [scanning, setScanning] = useState(false);
23
32
  const [query, setQuery] = useState('');
24
33
  const ref = useRef<HTMLDivElement>(null);
25
34
  const rootLabel = workspaceFolderName(rootPath);
26
35
 
27
36
  const label = (r: string) => (r === '.' ? rootLabel : r);
28
37
 
29
- const fetchRepos = async (rescan = false) => {
30
- const params = new URLSearchParams({ reposOnly: '1' });
31
- if (rootPath) params.set('root', rootPath);
32
- if (rescan) params.set('rescan', '1');
33
- params.set('t', String(Date.now()));
34
- const data = await fetch(`/api/fs/git?${params.toString()}`).then(r => r.json()).catch(() => null);
35
- if (data && Array.isArray(data.repos)) {
36
- setRepos(data.repos);
37
- if (data.reposPending) {
38
- setScanning(true);
39
- } else {
40
- setScanning(false);
41
- }
42
- }
43
- };
44
-
45
- useEffect(() => {
46
- if (!open) return;
47
- void fetchRepos();
48
- }, [open, rootPath]);
49
-
50
- useEffect(() => {
51
- if (!scanning) return;
52
- const params = new URLSearchParams({ reposOnly: '1' });
53
- if (rootPath) params.set('root', rootPath);
54
- const id = setInterval(async () => {
55
- const data = await fetch(`/api/fs/git?${params.toString()}`).then(r => r.json()).catch(() => null);
56
- if (data && !data.reposPending && Array.isArray(data.repos)) {
57
- setRepos(data.repos);
58
- setScanning(false);
59
- }
60
- }, REPO_DISCOVERY_POLL_MS);
61
- return () => clearInterval(id);
62
- }, [scanning, rootPath]);
63
-
64
38
  useOnClickOutside(ref, () => setOpen(false));
65
39
 
66
40
  const filteredRepos = repos.filter(r => {
@@ -106,7 +80,7 @@ export function GitRepoDropdown({ rootPath, activeRepo, onSelectRepo }: GitRepoD
106
80
  )}
107
81
  <button
108
82
  type="button"
109
- onClick={() => void fetchRepos(true)}
83
+ onClick={onRefreshRepos}
110
84
  title="Refresh nested repos"
111
85
  disabled={scanning}
112
86
  className="text-ink/40 hover:text-ink flex-shrink-0 transition-colors disabled:opacity-40 disabled:hover:text-ink/40"
@@ -1,28 +1,71 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
2
  import { RefreshCw, Search } from 'lucide-preact';
3
+ import type { FsNode } from '@/shared/types';
3
4
  import { rehydrateTree, setChildrenAt } from '@/shared/lib/fs/file-tree';
5
+ import { isRecord } from '@/shared/lib/util/guards';
4
6
  import { GitRepoDropdown } from '@/client/components/workspace/file-explorer/GitRepoDropdown';
5
7
  import { FileTreeItem } from '@/client/components/workspace/file-explorer/TreeItem';
6
8
  import { useScrollbarFade, scrollbarFadeClass } from '@/client/hooks/ui/scrollbar-fade';
7
9
  import { useSessionState } from '@/client/hooks/workspace/session-state';
8
10
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
11
+ import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
9
12
  import { usePanelRefresh, useFileMutationRefresh } from '@/client/hooks/workspace/panel-refresh';
10
13
 
14
+ /**
15
+ * The listing on screen, tagged with the `root\0repo` scope it was read for.
16
+ *
17
+ * Tagging it is what makes a workspace switch instant and complete: the panel
18
+ * renders an empty tree until the new root's listing lands, instead of showing
19
+ * the previous workspace's files under the new workspace's header. It also
20
+ * covers what a plain "reset on switch" effect cannot — the children loaded on
21
+ * demand and the expansion set are keyed by paths RELATIVE to the listed root,
22
+ * so `src/` in one workspace would rehydrate into `src/` in the next.
23
+ */
24
+ interface Listing {
25
+ scope: string;
26
+ files: FsNode[];
27
+ /** Absolute base dir reported by `/api/fs/dir`; Copy Path anchors on it. */
28
+ root: string;
29
+ }
30
+
31
+ const EMPTY_LISTING: Listing = { scope: '', files: [], root: '' };
32
+
33
+ /** Directory listing payload from `/api/fs/dir`, validated field by field. */
34
+ function readDirPayload(data: unknown): { files: FsNode[]; root: string } {
35
+ if (!isRecord(data)) return { files: [], root: '' };
36
+ return {
37
+ files: Array.isArray(data.files) ? data.files.filter((f): f is FsNode => isRecord(f)) : [],
38
+ root: typeof data.root === 'string' ? data.root : '',
39
+ };
40
+ }
41
+
11
42
  export function FileExplorer({ className = '', enabled = true, rootPath, onOpenFile, refreshKey = 0, onRefresh }: { className?: string, enabled?: boolean, rootPath?: string, onOpenFile?: (file: any) => void, refreshKey?: number, onRefresh?: () => void }) {
12
- const [tree, setTree] = useState<any[]>([]);
13
- // Absolute base dir reported by `/api/fs/dir` for the current listing — the
14
- // workspace root, or the selected nested repo. Copy Path anchors on it
15
- // because the client's `rootPath` may be rejected/unset.
16
- const [listingRoot, setListingRoot] = useState('');
43
+ const [listing, setListing] = useState<Listing>(EMPTY_LISTING);
17
44
  const [searchQuery, setSearchQuery] = useSessionState<string>('files.searchQuery', '');
18
45
  const [isLoading, setIsLoading] = useState(false);
19
46
  const [storedExpandedPaths, setStoredExpandedPaths, expandedPathsReady] = useSessionState<string[]>('files.expandedPaths', []);
20
47
  const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set(storedExpandedPaths));
21
- const childrenCacheRef = useRef<Record<string, any[]>>({});
22
- const [activeRepo, setActiveRepo] = useSessionState<string>('files.activeRepo', '.');
48
+ const childrenCacheRef = useRef<Record<string, FsNode[]>>({});
49
+ const { activeRepo, setActiveRepo } = useRepoScope(rootPath, 'files.activeRepo');
50
+ const { repos, scanning: reposScanning, rescan: rescanRepos } = useRepoList(rootPath, enabled);
23
51
  const { isScrolling, handleScroll } = useScrollbarFade();
24
52
  const { fileMap: gitFileMap, folderMap: gitFolderMap, refreshGitStatus } = useGitStatus(rootPath, activeRepo, refreshKey, enabled);
25
53
 
54
+ // The scope every request and every cached child path below belongs to. A
55
+ // change to it means the previous workspace's or repo's entries are not this
56
+ // one's, so nothing read under the old value may be rendered.
57
+ const scope = `${rootPath ?? ''}\u0000${activeRepo}`;
58
+ const scopeRef = useRef(scope);
59
+ scopeRef.current = scope;
60
+ // The scope of the listing on screen, read outside the render closure so the
61
+ // async loaders can tell a superseded tree from the current one.
62
+ const listedScopeRef = useRef(EMPTY_LISTING.scope);
63
+
64
+ const commitListing = (next: Listing) => {
65
+ listedScopeRef.current = next.scope;
66
+ setListing(next);
67
+ };
68
+
26
69
  useEffect(() => {
27
70
  if (!expandedPathsReady) return;
28
71
  setExpandedPaths(prev => {
@@ -43,14 +86,23 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
43
86
 
44
87
  const loadFiles = (opts?: { silent?: boolean }) => {
45
88
  if (!enabled) return;
89
+ const requested = scopeRef.current;
46
90
  if (!opts?.silent) setIsLoading(true);
47
91
  fetch(listUrl())
48
92
  .then(r => r.json())
49
93
  .then(data => {
50
- if (typeof data.root === 'string') setListingRoot(data.root);
51
- if (Array.isArray(data.files)) {
52
- setTree(rehydrateTree(data.files, childrenCacheRef.current, expandedPaths));
53
- }
94
+ // A read that was superseded by a workspace switch describes a tree
95
+ // this panel is no longer showing.
96
+ if (scopeRef.current !== requested) return;
97
+ // Children cached under the previous tree are keyed by paths relative
98
+ // to a root that is not this one: drop them before rehydrating.
99
+ if (listedScopeRef.current !== requested) childrenCacheRef.current = {};
100
+ const payload = readDirPayload(data);
101
+ commitListing({
102
+ scope: requested,
103
+ files: rehydrateTree(payload.files, childrenCacheRef.current),
104
+ root: payload.root,
105
+ });
54
106
  })
55
107
  .catch(() => {})
56
108
  .finally(() => {
@@ -85,13 +137,17 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
85
137
  // so a freshly listed row never renders bare next to a status map that
86
138
  // still predates its change (the panel poll alone can lag a fresh edit).
87
139
  refreshGitStatus();
140
+ const requested = scopeRef.current;
88
141
  return fetch(listUrl(path))
89
142
  .then(r => r.json())
90
143
  .then(data => {
91
- if (Array.isArray(data.files)) {
92
- childrenCacheRef.current[path] = data.files;
93
- setTree(prev => setChildrenAt(prev, path, data.files));
94
- }
144
+ if (scopeRef.current !== requested) return;
145
+ const payload = readDirPayload(data);
146
+ if (payload.files.length === 0 && payload.root === '') return;
147
+ childrenCacheRef.current[path] = payload.files;
148
+ setListing(prev => prev.scope === requested
149
+ ? { ...prev, files: setChildrenAt(prev.files, path, payload.files) }
150
+ : prev);
95
151
  })
96
152
  .catch(() => {});
97
153
  };
@@ -106,6 +162,10 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
106
162
 
107
163
  const handleSelectRepo = (repo: string) => {
108
164
  if (repo === activeRepo) return;
165
+ // The expansion set holds paths relative to the repo that was listed, so a
166
+ // repo switch starts from a collapsed tree. (A workspace switch needs no
167
+ // equivalent: the listing is re-read under the new root, and the session's
168
+ // own expansion set is restored with the session.)
109
169
  childrenCacheRef.current = {};
110
170
  setExpandedPaths(new Set());
111
171
  setStoredExpandedPaths([]);
@@ -120,6 +180,9 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
120
180
  );
121
181
  }
122
182
 
183
+ const tree = listing.scope === scope ? listing.files : [];
184
+ const listingRoot = listing.scope === scope ? listing.root : '';
185
+
123
186
  const getFilteredFiles = () => {
124
187
  if (!searchQuery.trim()) return tree;
125
188
 
@@ -158,7 +221,14 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
158
221
  <div className={`flex flex-col h-full bg-paper ${className}`}>
159
222
  <div className="p-3 border-b border-ink/10 flex flex-col space-y-2">
160
223
  <div className="flex items-center justify-between">
161
- <GitRepoDropdown rootPath={rootPath} activeRepo={activeRepo} onSelectRepo={handleSelectRepo} />
224
+ <GitRepoDropdown
225
+ rootPath={rootPath}
226
+ activeRepo={activeRepo}
227
+ onSelectRepo={handleSelectRepo}
228
+ repos={repos}
229
+ scanning={reposScanning}
230
+ onRefreshRepos={rescanRepos}
231
+ />
162
232
  <button
163
233
  onClick={refresh}
164
234
  className="p-1.5 text-ink/40 hover:text-ink hover:bg-ink/5 rounded transition-colors"