ompchamber 1.2.0 → 2.0.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 (223) hide show
  1. package/README.md +131 -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.74ff36419f.js +2 -0
  14. package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
  15. package/dist/client/static/js/async/h1.393f066d09.js +1 -0
  16. package/dist/client/static/js/async/jl.874d352278.js +1 -0
  17. package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
  18. package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
  19. package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
  20. package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
  21. package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
  22. package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
  23. package/dist/client/static/js/async/px.fc6877924b.js +1 -0
  24. package/dist/client/static/js/async/q.33d6779f98.js +21 -0
  25. package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
  26. package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
  27. package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
  28. package/dist/client/static/js/async/vt.42416b707d.js +1 -0
  29. package/dist/client/static/js/async/x1.da8687159b.js +90 -0
  30. package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
  31. package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
  32. package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
  33. package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
  34. package/dist/client/static/js/async/yv.d99d829136.js +1 -0
  35. package/dist/client/static/js/index.c05535dbde.js +38 -0
  36. package/package.json +3 -3
  37. package/src/client/components/common/ComposerTextarea.tsx +9 -1
  38. package/src/client/components/common/MarkdownRenderer.tsx +50 -12
  39. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
  40. package/src/client/components/settings/Modal.tsx +11 -2
  41. package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
  42. package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
  43. package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
  44. package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
  45. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
  46. package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
  47. package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
  48. package/src/client/components/workspace/SearchPanel.tsx +16 -3
  49. package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
  50. package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
  51. package/src/client/components/workspace/btw-panel/index.tsx +129 -45
  52. package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
  53. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
  54. package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
  55. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
  56. package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
  57. package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
  59. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
  63. package/src/client/components/workspace/editor/index.tsx +11 -2
  64. package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
  65. package/src/client/components/workspace/file-explorer/index.tsx +86 -16
  66. package/src/client/components/workspace/git-panel/index.tsx +60 -78
  67. package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
  68. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
  69. package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
  70. package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
  71. package/src/client/data/theme/terminal.ts +50 -52
  72. package/src/client/hooks/chat/btw/index.ts +139 -18
  73. package/src/client/hooks/chat/btw/intercept.ts +8 -8
  74. package/src/client/hooks/chat/btw/mode.ts +111 -16
  75. package/src/client/hooks/chat/composer/index.ts +10 -4
  76. package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
  77. package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
  78. package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
  79. package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
  80. package/src/client/hooks/chat/timeline/index.ts +8 -20
  81. package/src/client/hooks/chat/timeline/queue.ts +66 -9
  82. package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
  83. package/src/client/hooks/chat/timeline/scroll.ts +93 -20
  84. package/src/client/hooks/chat/timeline/session-load.ts +7 -1
  85. package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
  86. package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
  87. package/src/client/hooks/settings/providers.ts +34 -3
  88. package/src/client/hooks/ui/open-file.ts +7 -1
  89. package/src/client/hooks/ui/theme.ts +82 -57
  90. package/src/client/hooks/workspace/file-tabs.ts +5 -1
  91. package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
  92. package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
  93. package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
  94. package/src/client/hooks/workspace/search-stream.ts +24 -5
  95. package/src/client/main.tsx +6 -0
  96. package/src/client/tailwind.css +19 -56
  97. package/src/server/lib/btw/child.server.ts +41 -0
  98. package/src/server/lib/btw/lifecycle.server.ts +254 -0
  99. package/src/server/lib/btw/prompt.ts +13 -5
  100. package/src/server/lib/btw/registry.server.ts +13 -4
  101. package/src/server/lib/btw/runtime.server.ts +149 -156
  102. package/src/server/lib/btw/schema.server.ts +27 -0
  103. package/src/server/lib/btw/service.server.ts +106 -5
  104. package/src/server/lib/btw/store.server.ts +57 -6
  105. package/src/server/lib/db/schema.ts +4 -0
  106. package/src/server/lib/db/settings-store.test.ts +65 -0
  107. package/src/server/lib/db/settings-store.ts +25 -0
  108. package/src/server/lib/fs/atomic-write.ts +42 -4
  109. package/src/server/lib/fs/config-lock.ts +74 -0
  110. package/src/server/lib/fs/git-ignore.ts +55 -5
  111. package/src/server/lib/fs/ripgrep.ts +119 -0
  112. package/src/server/lib/lifecycle/identity.ts +10 -64
  113. package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
  114. package/src/server/lib/lifecycle/proc/index.ts +38 -0
  115. package/src/server/lib/lifecycle/proc/linux.ts +80 -0
  116. package/src/server/lib/lifecycle/proc/ps.ts +86 -0
  117. package/src/server/lib/lifecycle/proc/types.ts +33 -0
  118. package/src/server/lib/lifecycle/proc/win32.ts +53 -0
  119. package/src/server/lib/models/provider-registry.server.ts +2 -2
  120. package/src/server/lib/models/registry.server.ts +1 -1
  121. package/src/server/lib/omp/config/behavior.ts +17 -17
  122. package/src/server/lib/omp/config/disabled-providers.ts +84 -35
  123. package/src/server/lib/omp/config/document.ts +128 -0
  124. package/src/server/lib/omp/config/extensions.ts +11 -10
  125. package/src/server/lib/omp/config/mcp.ts +4 -57
  126. package/src/server/lib/omp/config/model-overrides.ts +123 -0
  127. package/src/server/lib/omp/config/models-config.ts +92 -0
  128. package/src/server/lib/omp/config/models-validation.ts +159 -0
  129. package/src/server/lib/omp/config/path-scoped.ts +74 -0
  130. package/src/server/lib/omp/config/providers.ts +241 -211
  131. package/src/server/lib/omp/config/roles.ts +6 -14
  132. package/src/server/lib/omp/core/auth-credentials.ts +1 -1
  133. package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
  134. package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
  135. package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
  136. package/src/server/lib/omp/rpc/manager.ts +21 -21
  137. package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
  138. package/src/server/lib/omp/rpc/session-commands.ts +17 -29
  139. package/src/server/lib/omp/rpc/session-registry.ts +5 -66
  140. package/src/server/lib/omp/rpc/web-state.ts +0 -6
  141. package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
  142. package/src/server/lib/omp/session/auto-title.server.ts +95 -20
  143. package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
  144. package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
  145. package/src/server/lib/queue/delivery-harness.ts +88 -0
  146. package/src/server/lib/queue/delivery.server.test.ts +78 -149
  147. package/src/server/lib/queue/delivery.server.ts +105 -13
  148. package/src/server/lib/queue/store.server.ts +30 -5
  149. package/src/server/lib/terminal/foreground.test.ts +164 -0
  150. package/src/server/lib/terminal/foreground.ts +34 -6
  151. package/src/server/lib/terminal/history.ts +8 -7
  152. package/src/server/plugins/compress.ts +33 -5
  153. package/src/server/plugins/ssr.ts +14 -7
  154. package/src/server/routes/btw/topics.ts +89 -2
  155. package/src/server/routes/fs/git.ts +5 -6
  156. package/src/server/routes/fs/search.ts +17 -26
  157. package/src/server/routes/sessions/queue.ts +17 -4
  158. package/src/server/routes/settings/index.ts +2 -0
  159. package/src/server/routes/settings/model-override.ts +69 -0
  160. package/src/server/routes/settings/provider-models.ts +35 -12
  161. package/src/server/routes/settings/providers.ts +31 -1
  162. package/src/server/routes/settings/root.ts +8 -3
  163. package/src/shared/lib/chat/btw/client.ts +34 -0
  164. package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
  165. package/src/shared/lib/code/shiki-themes.ts +9 -5
  166. package/src/shared/lib/fs/file-tree.ts +17 -12
  167. package/src/shared/lib/markdown/autolink.test.ts +32 -0
  168. package/src/shared/lib/markdown/autolink.ts +50 -0
  169. package/src/shared/lib/markdown/document-urls.test.ts +68 -0
  170. package/src/shared/lib/markdown/document-urls.ts +118 -0
  171. package/src/shared/lib/markdown/marked.ts +27 -15
  172. package/src/shared/lib/markdown/mermaid.ts +2 -2
  173. package/src/shared/lib/models/catalog.ts +1 -1
  174. package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
  175. package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
  176. package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
  177. package/src/shared/lib/settings/client.ts +10 -4
  178. package/src/shared/lib/settings/diff.test.ts +31 -0
  179. package/src/shared/lib/settings/diff.ts +37 -0
  180. package/src/shared/lib/theme/catalog.test.ts +168 -0
  181. package/src/shared/lib/theme/catalog.ts +98 -0
  182. package/src/shared/lib/theme/color.ts +88 -0
  183. package/src/shared/lib/theme/css.ts +70 -0
  184. package/src/shared/lib/theme/palettes/chamber.ts +61 -0
  185. package/src/shared/lib/theme/palettes/dark.ts +313 -0
  186. package/src/shared/lib/theme/palettes/light.ts +313 -0
  187. package/src/shared/types/btw.ts +83 -4
  188. package/src/shared/types/index.ts +1 -0
  189. package/src/shared/types/settings/provider.ts +8 -1
  190. package/src/shared/types/settings/state.ts +10 -4
  191. package/src/shared/types/theme.ts +48 -0
  192. package/dist/client/static/css/index.b938f4a693.css +0 -2
  193. package/dist/client/static/js/77.8022a800b5.js +0 -139
  194. package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
  195. package/dist/client/static/js/async/66.86544ad741.js +0 -93
  196. package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
  197. package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
  198. package/dist/client/static/js/async/e.6443e739a2.js +0 -79
  199. package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
  200. package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
  201. package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
  202. package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
  203. package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
  204. package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
  205. package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
  206. package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
  207. package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
  208. package/dist/client/static/js/async/vt.20406a5604.js +0 -1
  209. package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
  210. package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
  211. package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
  212. package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
  213. package/dist/client/static/js/index.57b39ffa7f.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
  215. package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
  216. /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
  217. /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
  218. /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
  219. /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
  220. /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
  221. /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
  222. /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -1,92 +1,117 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The single writer for the document's theme.
8
+ *
9
+ * `data-theme` (the palette id) and `data-theme-variant` (light/dark) are
10
+ * written together, and both are read from the catalog: the variant used to be
11
+ * a hardcoded id list here, in the Shiki selector, in the mermaid hydrator and
12
+ * in the SSR shell — four copies that a new theme would have silently missed.
13
+ *
14
+ * The `data-theme` attribute is the durable copy of the palette; the
15
+ * `omp:theme-changed` event is the notification. CSS reads the attribute, and
16
+ * the mermaid hydrator observes it, so a write that skips the event still
17
+ * recolors — but a write that skips the attribute does not.
18
+ */
19
+
1
20
  import { useCallback, useEffect, useState } from 'preact/hooks';
2
21
  import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
22
+ import { DEFAULT_THEME_ID, resolveTheme } from '@/shared/lib/theme/catalog';
23
+ import { THEME_STYLE_ELEMENT_ID, themeStyleSheet } from '@/shared/lib/theme/css';
3
24
 
4
- /** Broadcast on every theme write. Detail is the new theme id; the
5
- * `data-theme` attribute on `<html>` is the durable copy of the same fact. */
25
+ /** Broadcast on every theme write. Detail is the new theme id. */
6
26
  export const THEME_CHANGED_EVENT = 'omp:theme-changed';
7
27
 
28
+ /** The palette id currently on `<html>`, falling back to the shipped default. */
29
+ export function currentThemeId(): string {
30
+ if (typeof document === 'undefined') return DEFAULT_THEME_ID;
31
+ return document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme') || DEFAULT_THEME_ID;
32
+ }
33
+
34
+ /**
35
+ * Put the catalog on the document before the first render.
36
+ *
37
+ * The SSR shell already inlines the stylesheet and both attributes; this is the
38
+ * guard for a document that predates them — a cached page, or `dist/client/
39
+ * index.html` left over from an older build while only the server was restarted.
40
+ * Without it the page keeps `:root`'s default palette and no variant attribute,
41
+ * which is a wrong palette, not merely a stale one.
42
+ *
43
+ * Called once from `main.tsx` rather than from a hook: the only components that
44
+ * read the theme are lazily mounted panels, so a hook would not run until the
45
+ * user opened one.
46
+ */
47
+ export function initDocumentTheme(): void {
48
+ if (typeof document === 'undefined') return;
49
+ if (!document.getElementById(THEME_STYLE_ELEMENT_ID)) {
50
+ const style = document.createElement('style');
51
+ style.id = THEME_STYLE_ELEMENT_ID;
52
+ style.textContent = themeStyleSheet();
53
+ document.head.appendChild(style);
54
+ }
55
+ applyDocumentTheme(currentThemeId());
56
+ }
57
+
8
58
  /**
9
59
  * Point the document at `theme` and announce it.
10
60
  *
11
- * The single writer path for the theme: the navbar toggle and the settings
12
- * modal both call this, because a writer that only sets `data-theme` leaves
13
- * runtime consumers (the mermaid hydrator re-renders diagrams per theme) on the
14
- * previous palette until the next reload. CSS reads the attribute; listeners
15
- * watch the attribute too, so this stays correct even if the event is missed.
61
+ * The single writer path for the theme: the settings modal and every future
62
+ * picker call this, because a writer that only sets `data-theme` leaves runtime
63
+ * consumers (the mermaid hydrator re-renders diagrams per palette) on the
64
+ * previous colors until the next reload.
16
65
  */
17
66
  export function applyDocumentTheme(theme: string): void {
18
67
  if (typeof document === 'undefined') return;
19
- document.documentElement.dataset.theme = theme;
20
- window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: theme }));
68
+ const palette = resolveTheme(theme);
69
+ const root = document.documentElement;
70
+ root.dataset.theme = palette.id;
71
+ root.dataset.themeVariant = palette.variant;
72
+ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', palette.canvas);
73
+ window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: palette.id }));
21
74
  }
22
75
 
23
76
  export function useTheme() {
24
77
  const writeChamberSettings = useChamberSettingsWriter();
25
- const [theme, setThemeState] = useState<string>(() => {
26
- if (typeof document !== 'undefined') {
27
- return document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme') || 'paper';
28
- }
29
- return 'paper';
30
- });
78
+ const [theme, setThemeState] = useState<string>(currentThemeId);
31
79
 
32
- const isDark = theme === 'one-dark-pro-soft' || theme === 'noir' || theme === 'dark';
80
+ const isDark = resolveTheme(theme).variant === 'dark';
33
81
 
34
82
  useEffect(() => {
35
83
  if (typeof document === 'undefined') return;
36
84
 
37
- const updateTheme = () => {
38
- const current = document.documentElement.dataset.theme || document.documentElement.getAttribute('data-theme') || 'paper';
39
- setThemeState(current);
40
- const metaThemeColor = document.querySelector('meta[name="theme-color"]');
41
- if (metaThemeColor) {
42
- metaThemeColor.setAttribute('content', current === 'one-dark-pro-soft' ? '#282c34' : '#faf8f3');
43
- }
44
- };
45
-
46
- updateTheme();
47
-
48
85
  const observer = new MutationObserver((mutations) => {
49
86
  for (const mutation of mutations) {
50
- if (
51
- mutation.type === 'attributes' &&
52
- (mutation.attributeName === 'data-theme' || mutation.attributeName === 'class')
53
- ) {
54
- updateTheme();
87
+ if (mutation.type === 'attributes' && mutation.attributeName === 'data-theme') {
88
+ setThemeState(currentThemeId());
55
89
  }
56
90
  }
57
91
  });
92
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
58
93
 
59
- observer.observe(document.documentElement, {
60
- attributes: true,
61
- attributeFilter: ['data-theme', 'class'],
62
- });
63
-
64
- const handleCustomChange = (e: Event) => {
65
- const customEvent = e as CustomEvent<string>;
66
- if (customEvent.detail) {
67
- setThemeState(customEvent.detail);
68
- } else {
69
- updateTheme();
70
- }
94
+ const handleCustomChange = (event: Event) => {
95
+ const detail = (event as CustomEvent<string>).detail;
96
+ setThemeState(detail ? resolveTheme(detail).id : currentThemeId());
71
97
  };
72
-
73
- window.addEventListener('omp:theme-changed', handleCustomChange);
98
+ window.addEventListener(THEME_CHANGED_EVENT, handleCustomChange);
74
99
 
75
100
  return () => {
76
101
  observer.disconnect();
77
- window.removeEventListener('omp:theme-changed', handleCustomChange);
102
+ window.removeEventListener(THEME_CHANGED_EVENT, handleCustomChange);
78
103
  };
79
104
  }, []);
80
105
 
81
- const setTheme = useCallback((newTheme: string) => {
82
- applyDocumentTheme(newTheme);
83
- setThemeState(newTheme);
84
- writeChamberSettings({ theme: newTheme });
85
- }, [writeChamberSettings]);
86
-
87
- const toggleTheme = useCallback(() => {
88
- setTheme(isDark ? 'paper' : 'one-dark-pro-soft');
89
- }, [isDark, setTheme]);
106
+ const setTheme = useCallback(
107
+ (newTheme: string) => {
108
+ const id = resolveTheme(newTheme).id;
109
+ applyDocumentTheme(id);
110
+ setThemeState(id);
111
+ writeChamberSettings({ theme: id });
112
+ },
113
+ [writeChamberSettings],
114
+ );
90
115
 
91
- return { theme, isDark, setTheme, toggleTheme };
116
+ return { theme, isDark, setTheme };
92
117
  }
@@ -93,7 +93,7 @@ export function useFileTabs(
93
93
 
94
94
  // Global listeners for omp:open-file and omp:open-diff
95
95
  useChamberEvent('omp:open-file', (e) => {
96
- const customEvent = e as CustomEvent<{ path: string; name?: string; id?: number; content?: string }>;
96
+ const customEvent = e as CustomEvent<{ path: string; name?: string; id?: number; content?: string; root?: string; repo?: string }>;
97
97
  if (!customEvent.detail || !customEvent.detail.path) return;
98
98
 
99
99
  const rawPath = customEvent.detail.path.replace(/^\/+/, '');
@@ -110,6 +110,10 @@ export function useFileTabs(
110
110
  name,
111
111
  path: rawPath,
112
112
  content: customEvent.detail.content,
113
+ // A caller that knows its own scope (a preview's relative link) keeps it;
114
+ // otherwise the file belongs to the active project.
115
+ root: customEvent.detail.root,
116
+ repo: customEvent.detail.repo,
113
117
  });
114
118
  });
115
119
 
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Nested-repo scope for the right-panel views (files, search, git, terminal).
8
+ *
9
+ * Every one of them needs the same two things, and both are meaningless without
10
+ * the workspace root they describe:
11
+ *
12
+ * - WHICH repo the view operates on (`useRepoScope`). A repo is a path
13
+ * relative to the active root — `projects/jns6_5/token` only names a
14
+ * directory under `~/JatisMobile/Workspace` — so a choice is only valid
15
+ * next to the root it was made under.
16
+ * - WHAT repos exist under that root (`useRepoList`), discovered server-side
17
+ * and stored with the root it was discovered for (see `./store`).
18
+ *
19
+ * Kept as bare per-panel values, both outlived the workspace they belonged to:
20
+ * after a workspace switch the picker still listed the previous workspace's
21
+ * repos, still showed one of them as the selection, and every request the panel
22
+ * made named a path that did not exist under the new root. Pairing each value
23
+ * with its root invalidates it by construction — no reset effect to order
24
+ * against the per-session state restore — while a session returned to inside
25
+ * the same workspace keeps the repo it was left on.
26
+ */
27
+
28
+ import { useCallback, useRef, useSyncExternalStore } from 'preact/compat';
29
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
30
+ import { repoStore, UNKNOWN_LIST } from '@/client/hooks/workspace/repo-scope/store';
31
+
32
+ /** A repo choice, kept next to the workspace root it was made under. */
33
+ interface RepoPick {
34
+ root: string;
35
+ repo: string;
36
+ }
37
+
38
+ /**
39
+ * The repo a git panel works on: the user's pick, or — when they have none and
40
+ * the workspace root is not itself a repo — the first repo discovery found.
41
+ *
42
+ * The git loader applies the same fallback when it is sent no repo at all
43
+ * (`repo = repos.includes('.') ? '.' : repos[0]`), so resolving it here is what
44
+ * keeps the header label, the picker's checkmark and the requests all naming
45
+ * the repo git is actually operating on. Only git resolves it: browsing the
46
+ * workspace root is legitimate even when that root is not a repo — a folder of
47
+ * nested repos is a normal workspace — so the files, search and terminal panels
48
+ * keep `'.'` as the root itself.
49
+ */
50
+ export function resolveRepoForPanel(pick: string, repos: readonly string[]): string {
51
+ if (pick !== '.' || repos.includes('.')) return pick;
52
+ return repos[0] ?? '.';
53
+ }
54
+
55
+ /**
56
+ * The repo this panel operates on, restored per session but dropped the moment
57
+ * the active workspace root changes.
58
+ */
59
+ export function useRepoScope(
60
+ rootPath: string | undefined,
61
+ stateKey: string,
62
+ ): { activeRepo: string; setActiveRepo: (repo: string) => void; ready: boolean } {
63
+ const [pick, setPick, ready] = useSessionState<RepoPick | null>(stateKey, null);
64
+ const scope = rootPath ?? '';
65
+
66
+ // Read through refs so the setter keeps ONE identity: it is handed to child
67
+ // components and used in effect dependencies, and `useSessionState` mints a
68
+ // fresh setter per render.
69
+ const scopeRef = useRef(scope);
70
+ scopeRef.current = scope;
71
+ const setPickRef = useRef(setPick);
72
+ setPickRef.current = setPick;
73
+ const setActiveRepo = useCallback((repo: string) => {
74
+ setPickRef.current({ root: scopeRef.current, repo });
75
+ }, []);
76
+
77
+ // A pick made under another root (or stored before the root was recorded
78
+ // alongside it) names a directory that need not exist here: fall back to the
79
+ // workspace root.
80
+ const activeRepo =
81
+ pick && typeof pick === 'object' && pick.root === scope && typeof pick.repo === 'string' && pick.repo
82
+ ? pick.repo
83
+ : '.';
84
+
85
+ return { activeRepo, setActiveRepo, ready };
86
+ }
87
+
88
+ export interface RepoListHandle {
89
+ /** Repos of the active root; the workspace root alone until discovery answers. */
90
+ repos: string[];
91
+ /** Discovery is still running — the server said so, or a rescan is in flight. */
92
+ scanning: boolean;
93
+ /** Re-run discovery from scratch (the picker's refresh button). */
94
+ rescan: () => void;
95
+ }
96
+
97
+ /**
98
+ * Discovered repos of the active root.
99
+ *
100
+ * @param active - when false nothing is subscribed; a hidden panel has no use
101
+ * for a list that costs the server a `find` over the workspace.
102
+ */
103
+ export function useRepoList(rootPath: string | undefined, active: boolean): RepoListHandle {
104
+ const scope = rootPath ?? '';
105
+ const subscribe = useCallback(
106
+ (listener: () => void) => (active ? repoStore.subscribe(scope, listener) : () => {}),
107
+ [scope, active],
108
+ );
109
+ const getSnapshot = useCallback(
110
+ () => (active ? repoStore.snapshot(scope) : UNKNOWN_LIST),
111
+ [scope, active],
112
+ );
113
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot);
114
+ const rescan = useCallback(() => {
115
+ if (active) repoStore.rescan(scope);
116
+ }, [scope, active]);
117
+
118
+ return { repos: snapshot.repos, scanning: snapshot.scanning, rescan };
119
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { createRepoStore, type RepoFetch, type RepoScheduler } from '@/client/hooks/workspace/repo-scope/store';
9
+
10
+ /** Resolves each request from a per-root table, recording the roots asked for. */
11
+ function fakeFetch(byRoot: Record<string, { repos: string[]; reposPending?: boolean }>) {
12
+ const requested: string[] = [];
13
+ const fetchImpl: RepoFetch = async (url) => {
14
+ const root = new URL(url, 'http://localhost').searchParams.get('root') ?? '';
15
+ requested.push(root);
16
+ return byRoot[root] ?? { repos: ['.'] };
17
+ };
18
+ return { fetchImpl, requested };
19
+ }
20
+
21
+ /** Retries the caller arms, so a test can fire them instead of waiting them out. */
22
+ function fakeScheduler() {
23
+ const armed: (() => void)[] = [];
24
+ const scheduler: RepoScheduler = (run) => {
25
+ armed.push(run);
26
+ return () => {
27
+ const index = armed.indexOf(run);
28
+ if (index >= 0) armed.splice(index, 1);
29
+ };
30
+ };
31
+ return { scheduler, armed };
32
+ }
33
+
34
+ describe('repo list invalidation on workspace switch', () => {
35
+ test('a new root never reports the previous root\'s repos', async () => {
36
+ const { fetchImpl } = fakeFetch({
37
+ '/ws/a': { repos: ['.', 'projects/a'] },
38
+ '/ws/b': { repos: ['.', 'projects/b'] },
39
+ });
40
+ const store = createRepoStore({ fetch: fetchImpl });
41
+
42
+ const seenA: string[][] = [];
43
+ const unsubscribeA = store.subscribe('/ws/a', () => seenA.push(store.snapshot('/ws/a').repos));
44
+ await store.settled('/ws/a');
45
+ expect(store.snapshot('/ws/a').repos).toEqual(['.', 'projects/a']);
46
+
47
+ // Switching workspaces: root B has no entry, so it starts from the root
48
+ // alone — not from A's list.
49
+ expect(store.snapshot('/ws/b').repos).toEqual(['.']);
50
+ unsubscribeA();
51
+
52
+ const seenB: string[][] = [];
53
+ const unsubscribeB = store.subscribe('/ws/b', () => seenB.push(store.snapshot('/ws/b').repos));
54
+ await store.settled('/ws/b');
55
+ expect(store.snapshot('/ws/b').repos).toEqual(['.', 'projects/b']);
56
+ // Every notification for B reported B's list — A's list was never published
57
+ // under B, not even for the render before the read landed.
58
+ expect(seenB).toEqual([['.'], ['.', 'projects/b']]);
59
+ expect(seenA).toEqual([['.'], ['.', 'projects/a']]);
60
+ unsubscribeB();
61
+ });
62
+
63
+ test('a list already discovered for a root is reused without re-asking', async () => {
64
+ const { fetchImpl, requested } = fakeFetch({ '/ws/a': { repos: ['.', 'projects/a'] } });
65
+ const store = createRepoStore({ fetch: fetchImpl });
66
+
67
+ const unsubscribeFirst = store.subscribe('/ws/a', () => {});
68
+ await store.settled('/ws/a');
69
+ unsubscribeFirst();
70
+ expect(requested).toEqual(['/ws/a']);
71
+
72
+ // A right-panel view switch re-subscribes to the same root.
73
+ const unsubscribeSecond = store.subscribe('/ws/a', () => {});
74
+ await store.settled('/ws/a');
75
+ expect(requested).toEqual(['/ws/a']);
76
+ expect(store.snapshot('/ws/a').repos).toEqual(['.', 'projects/a']);
77
+ unsubscribeSecond();
78
+ });
79
+
80
+ test('an unsettled discovery is resumed, not abandoned, when a subscriber returns', async () => {
81
+ const { fetchImpl, requested } = fakeFetch({ '/ws/a': { repos: ['.'], reposPending: true } });
82
+ const { scheduler, armed } = fakeScheduler();
83
+ const store = createRepoStore({ fetch: fetchImpl, scheduler });
84
+
85
+ const unsubscribe = store.subscribe('/ws/a', () => {});
86
+ await store.settled('/ws/a');
87
+ expect(store.snapshot('/ws/a').scanning).toBe(true);
88
+ expect(armed).toHaveLength(1);
89
+ unsubscribe();
90
+ // The retry is dropped with the last subscriber rather than left armed.
91
+ expect(armed).toHaveLength(0);
92
+
93
+ // Re-subscribing resumes the scan instead of inheriting a spinner that
94
+ // nothing drives.
95
+ const unsubscribeAgain = store.subscribe('/ws/a', () => {});
96
+ await store.settled('/ws/a');
97
+ expect(requested).toEqual(['/ws/a', '/ws/a']);
98
+ expect(armed).toHaveLength(1);
99
+ unsubscribeAgain();
100
+ });
101
+
102
+ test('a failed discovery leaves the root unlisted so the next subscriber retries', async () => {
103
+ let calls = 0;
104
+ const store = createRepoStore({
105
+ fetch: async () => {
106
+ calls += 1;
107
+ if (calls === 1) throw new Error('offline');
108
+ return { repos: ['.', 'projects/a'] };
109
+ },
110
+ });
111
+
112
+ const unsubscribe = store.subscribe('/ws/a', () => {});
113
+ await store.settled('/ws/a');
114
+ expect(store.snapshot('/ws/a').repos).toEqual(['.']);
115
+ unsubscribe();
116
+
117
+ const unsubscribeAgain = store.subscribe('/ws/a', () => {});
118
+ await store.settled('/ws/a');
119
+ expect(calls).toBe(2);
120
+ expect(store.snapshot('/ws/a').repos).toEqual(['.', 'projects/a']);
121
+ unsubscribeAgain();
122
+ });
123
+
124
+ test('a response that lands after its root lost every subscriber is dropped', async () => {
125
+ const gate = Promise.withResolvers<unknown>();
126
+ const store = createRepoStore({ fetch: () => gate.promise });
127
+
128
+ const unsubscribe = store.subscribe('/ws/a', () => {});
129
+ unsubscribe();
130
+ gate.resolve({ repos: ['.', 'projects/a'] });
131
+ await store.settled('/ws/a');
132
+
133
+ // The read was abandoned with the subscription: nothing is reported for a
134
+ // root nobody is showing.
135
+ expect(store.snapshot('/ws/a').repos).toEqual(['.']);
136
+ });
137
+
138
+ test('a rescan reports the fresh list and clears the pending flag', async () => {
139
+ let repos = ['.'];
140
+ const requested: string[] = [];
141
+ const store = createRepoStore({
142
+ fetch: async (url) => {
143
+ requested.push(new URL(url, 'http://localhost').searchParams.get('rescan') ?? '');
144
+ return { repos };
145
+ },
146
+ });
147
+
148
+ const unsubscribe = store.subscribe('/ws/a', () => {});
149
+ await store.settled('/ws/a');
150
+ expect(store.snapshot('/ws/a')).toEqual({ repos: ['.'], scanning: false });
151
+
152
+ // A nested repo that appeared after the first discovery.
153
+ repos = ['.', 'projects/late'];
154
+ store.rescan('/ws/a');
155
+ await store.settled('/ws/a');
156
+
157
+ expect(requested).toEqual(['', '1']);
158
+ expect(store.snapshot('/ws/a')).toEqual({ repos: ['.', 'projects/late'], scanning: false });
159
+ unsubscribe();
160
+ });
161
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Nested-repo discovery store, keyed by workspace root.
8
+ *
9
+ * The leak this module exists to prevent: a repo is a path RELATIVE to a root
10
+ * (`projects/jns6_5/token` only names a directory under `~/JatisMobile/Workspace`),
11
+ * so a list discovered for one workspace is meaningless under another. Keying
12
+ * every entry by the root it was discovered for makes that structural — a new
13
+ * root has no entry and starts from {@link UNKNOWN_LIST} instead of inheriting
14
+ * the previous list.
15
+ *
16
+ * A store rather than a fetch per hook: the right-panel views ask the same
17
+ * question about the same root, and independent copies meant one request per
18
+ * panel per poll interval plus lists that could disagree.
19
+ *
20
+ * Both platform dependencies are injected — `fetch` and the retry scheduler —
21
+ * so the invalidation rules are testable without a DOM and without wall-clock
22
+ * timers.
23
+ */
24
+
25
+ import { REPO_DISCOVERY_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
26
+ import { isRecord } from '@/shared/lib/util/guards';
27
+
28
+ export interface RepoListSnapshot {
29
+ repos: string[];
30
+ /** Discovery is still running server-side, or a forced rescan is. */
31
+ scanning: boolean;
32
+ }
33
+
34
+ /** The list before discovery has answered: the workspace root alone. */
35
+ export const UNKNOWN_LIST: RepoListSnapshot = { repos: ['.'], scanning: false };
36
+
37
+ /** `fetch` seam: resolves the parsed `?reposOnly=1` payload, rejects on error. */
38
+ export type RepoFetch = (url: string) => Promise<unknown>;
39
+
40
+ /**
41
+ * Retry seam. Runs `run` once after `delayMs` and returns a cancel; the default
42
+ * is a one-shot timer, and the caller re-arms it per attempt.
43
+ */
44
+ export type RepoScheduler = (run: () => void, delayMs: number) => () => void;
45
+
46
+ interface RootEntry {
47
+ snapshot: RepoListSnapshot;
48
+ /** True once a discovery response was accepted for this root. Drives the
49
+ * "start discovery on first subscriber" decision — object identity of
50
+ * `snapshot` cannot, because clearing the spinner mints a new one. */
51
+ loaded: boolean;
52
+ listeners: Set<() => void>;
53
+ controller: AbortController | null;
54
+ cancelRetry: (() => void) | null;
55
+ /** The discovery currently in flight, so a caller can await its settlement. */
56
+ pending: Promise<void> | null;
57
+ }
58
+
59
+ export interface RepoStore {
60
+ /** Repos discovered for `root`; {@link UNKNOWN_LIST} until one is. */
61
+ snapshot: (root: string) => RepoListSnapshot;
62
+ /** Await the discovery in flight for `root` (a no-op when there is none). */
63
+ settled: (root: string) => Promise<void>;
64
+ /** Observe `root`; the first subscriber starts discovery. Returns a cancel. */
65
+ subscribe: (root: string, listener: () => void) => () => void;
66
+ /** Re-run discovery for `root` from scratch, ignoring any cached result. */
67
+ rescan: (root: string) => void;
68
+ }
69
+
70
+ export interface RepoStoreOptions {
71
+ fetch: RepoFetch;
72
+ scheduler?: RepoScheduler;
73
+ }
74
+
75
+ const defaultScheduler: RepoScheduler = (run, delayMs) => {
76
+ const id = setTimeout(run, delayMs);
77
+ return () => clearTimeout(id);
78
+ };
79
+
80
+ export function createRepoStore({ fetch: fetchImpl, scheduler = defaultScheduler }: RepoStoreOptions): RepoStore {
81
+ // Per-store, not module-level: two stores must not share state, and a test
82
+ // store must not see the app store's roots.
83
+ const entries = new Map<string, RootEntry>();
84
+
85
+ function entryFor(root: string): RootEntry {
86
+ let entry = entries.get(root);
87
+ if (!entry) {
88
+ entry = {
89
+ snapshot: UNKNOWN_LIST,
90
+ loaded: false,
91
+ listeners: new Set(),
92
+ controller: null,
93
+ cancelRetry: null,
94
+ pending: null,
95
+ };
96
+ entries.set(root, entry);
97
+ }
98
+ return entry;
99
+ }
100
+
101
+ function publish(root: string, patch: Partial<RepoListSnapshot>): void {
102
+ const entry = entries.get(root);
103
+ if (!entry) return;
104
+ const next = { ...entry.snapshot, ...patch };
105
+ if (next.repos === entry.snapshot.repos && next.scanning === entry.snapshot.scanning) return;
106
+ entry.snapshot = next;
107
+ for (const listener of entry.listeners) listener();
108
+ }
109
+
110
+ function cancelRetry(entry: RootEntry): void {
111
+ entry.cancelRetry?.();
112
+ entry.cancelRetry = null;
113
+ }
114
+
115
+ function load(root: string, rescan: boolean): Promise<void> {
116
+ const entry = entryFor(root);
117
+ // A newer load supersedes this one: its response must not overwrite what
118
+ // the newer request is already about to replace.
119
+ entry.controller?.abort();
120
+ const controller = new AbortController();
121
+ entry.controller = controller;
122
+ publish(root, { scanning: true });
123
+
124
+ const params = new URLSearchParams({ reposOnly: '1' });
125
+ if (root) params.set('root', root);
126
+ if (rescan) params.set('rescan', '1');
127
+ params.set('t', String(Date.now()));
128
+
129
+ const pending = (async () => {
130
+ const data = await fetchImpl(`/api/fs/git?${params.toString()}`).catch(() => null);
131
+ if (entry.controller !== controller) return;
132
+ entry.controller = null;
133
+
134
+ const repos = isRecord(data) && Array.isArray(data.repos) ? data.repos : null;
135
+ if (!repos) {
136
+ // A failed read leaves the snapshot as it was — for a root nobody has
137
+ // successfully listed yet that is still `UNKNOWN_LIST`, so the next
138
+ // subscriber retries instead of sitting on an empty list.
139
+ publish(root, { scanning: false });
140
+ return;
141
+ }
142
+ const scanning = isRecord(data) && data.reposPending === true;
143
+ entry.loaded = true;
144
+ publish(root, { repos: repos.filter((r): r is string => typeof r === 'string'), scanning });
145
+
146
+ // Discovery runs in the background server-side — the loader returns at
147
+ // once with `reposPending` — so retry until it settles.
148
+ if (scanning) {
149
+ cancelRetry(entry);
150
+ entry.cancelRetry = scheduler(() => {
151
+ entry.cancelRetry = null;
152
+ if (entry.listeners.size === 0) return;
153
+ void load(root, false);
154
+ }, REPO_DISCOVERY_POLL_MS);
155
+ } else {
156
+ cancelRetry(entry);
157
+ }
158
+ })();
159
+
160
+ entry.pending = pending;
161
+ return pending;
162
+ }
163
+
164
+ function subscribe(root: string, listener: () => void): () => void {
165
+ const entry = entryFor(root);
166
+ entry.listeners.add(listener);
167
+ // First subscriber for this root — or the first since everyone left —
168
+ // starts (or resumes) discovery, unless a settled list is already known for
169
+ // this same root: a right-panel view switch must not re-run the server's
170
+ // `find`.
171
+ if (entry.controller === null && entry.cancelRetry === null && (!entry.loaded || entry.snapshot.scanning)) {
172
+ void load(root, false);
173
+ }
174
+ return () => {
175
+ entry.listeners.delete(listener);
176
+ if (entry.listeners.size > 0) return;
177
+ // Nobody is showing this root's list any more: abandon the read in flight
178
+ // and drop the retry. The snapshot keeps its `scanning` flag, so the next
179
+ // subscriber resumes discovery rather than inheriting a spinner that
180
+ // nothing is driving.
181
+ entry.controller?.abort();
182
+ entry.controller = null;
183
+ cancelRetry(entry);
184
+ };
185
+ }
186
+
187
+ return {
188
+ snapshot: (root) => entries.get(root)?.snapshot ?? UNKNOWN_LIST,
189
+ settled: (root) => entries.get(root)?.pending ?? Promise.resolve(),
190
+ subscribe,
191
+ rescan: (root) => void load(root, true),
192
+ };
193
+ }
194
+
195
+ /** The app's single store, over the browser's `fetch` and real timers. */
196
+ export const repoStore = createRepoStore({
197
+ fetch: async (url) => {
198
+ const response = await fetch(url);
199
+ return response.json();
200
+ },
201
+ });