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
@@ -10,8 +10,8 @@ import { GitChangesList } from '@/client/components/workspace/git-panel/ChangesL
10
10
  import { ToastStack } from '@/client/components/common/ToastStack';
11
11
  import { useToasts } from '@/client/hooks/ui/toasts';
12
12
  import { useSessionState } from '@/client/hooks/workspace/session-state';
13
+ import { resolveRepoForPanel, useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
13
14
  import { usePanelRefresh, useFileMutationRefresh } from '@/client/hooks/workspace/panel-refresh';
14
- import { REPO_DISCOVERY_POLL_MS } from '@/shared/lib/workspace/refresh-cadence';
15
15
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
16
16
 
17
17
  interface GitPanelProps {
@@ -22,15 +22,46 @@ interface GitPanelProps {
22
22
  }
23
23
 
24
24
  export function GitPanel({ className = '', enabled = true, rootPath, refreshKey = 0 }: GitPanelProps) {
25
- const fetcher = useFetcher<{ changes: GitChange[], branch: string, branches: string[], remoteBranches?: string[], repos: string[], reposPending?: boolean, activeRepo: string, syncCount?: { ahead: number, behind: number } }>();
25
+ const fetcher = useFetcher<{ changes: GitChange[], branch: string, branches: string[], remoteBranches?: string[], syncCount?: { ahead: number, behind: number } }>();
26
26
  const actionFetcher = useFetcher<{ success: boolean, type?: string, data?: any, error?: string }>();
27
27
 
28
- const [storedActiveRepo, setStoredActiveRepo, activeRepoReady] = useSessionState<string>('git.activeRepo', '.');
29
- const activeRepo = fetcher.data?.activeRepo || storedActiveRepo || '.';
28
+ const { activeRepo: pickedRepo, setActiveRepo, ready: activeRepoReady } = useRepoScope(rootPath, 'git.activeRepo');
29
+ const { repos, scanning: reposScanning, rescan: refreshRepos } = useRepoList(rootPath, enabled);
30
+ // When the user has picked nothing and the workspace root is not itself a
31
+ // repo, git falls back to the first repo discovery found — the same choice
32
+ // the loader makes for a request that names no repo. Resolving it here rather
33
+ // than reading it back from the response is what keeps the header label, the
34
+ // picker's checkmark and the requests describing one repo: the response
35
+ // arrives a render later, and by then it may belong to a workspace the user
36
+ // has already left.
37
+ const activeRepo = resolveRepoForPanel(pickedRepo, repos);
38
+ const activeRepoRef = useRef(activeRepo);
39
+ activeRepoRef.current = activeRepo;
40
+
41
+ // `root\0repo` — the working tree the panel is showing right now.
42
+ const scopeRef = useRef('');
43
+ scopeRef.current = `${rootPath ?? ''}\u0000${activeRepo}`;
44
+ // The scope the in-flight read was asked for, and the scope the payload
45
+ // currently in `fetcher.data` was asked for. Only one read is ever in flight
46
+ // (the fetcher aborts its predecessor), so the former is what the latter
47
+ // becomes when a new payload arrives.
48
+ const requestedScopeRef = useRef('');
49
+ const payloadScopeRef = useRef('');
50
+ const lastPayloadRef = useRef<unknown>(undefined);
51
+ if (fetcher.data !== lastPayloadRef.current) {
52
+ lastPayloadRef.current = fetcher.data;
53
+ payloadScopeRef.current = requestedScopeRef.current;
54
+ }
55
+ // A switch leaves the previous repo's branch, changes and branch list in
56
+ // `fetcher.data` until the new read lands. Rendering those under the new
57
+ // repo's header would claim the new working tree is on the old branch with
58
+ // the old changes, so the payload is used only while it describes the scope
59
+ // on screen.
60
+ const data = payloadScopeRef.current === scopeRef.current ? fetcher.data : undefined;
30
61
 
31
62
  const loadRepo = (repo?: string) => {
32
63
  if (!enabled) return;
33
- const targetRepo = repo !== undefined ? repo : storedActiveRepo;
64
+ const targetRepo = repo ?? activeRepoRef.current;
34
65
  const params = new URLSearchParams();
35
66
  if (rootPath) params.set('root', rootPath);
36
67
  if (targetRepo && targetRepo !== '.') params.set('repo', targetRepo);
@@ -39,6 +70,11 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
39
70
  // numbers describe origin rather than the last fetch.
40
71
  params.set('sync', '1');
41
72
  params.set('t', String(Date.now()));
73
+ // Tag the response with the scope it was asked for: a switch leaves the
74
+ // previous repo's branch, changes and branch list in `fetcher.data` until
75
+ // the new read lands, and rendering those under the new repo's header
76
+ // claims the new working tree is on the old branch with the old changes.
77
+ requestedScopeRef.current = scopeRef.current;
42
78
  fetcher.load(`/api/fs/git?${params.toString()}`);
43
79
  };
44
80
 
@@ -66,70 +102,26 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
66
102
 
67
103
  const { toasts, pushToast, dismissToast } = useToasts();
68
104
 
69
- // Background nested-repo discovery polling: the loader returns immediately
70
- // with the root status and `reposPending`; we poll the lightweight
71
- // `?reposOnly=1` endpoint until the discoverer finishes, then refresh.
72
- const [extraRepos, setExtraRepos] = useState<string[] | null>(null);
73
- const [pollingRepos, setPollingRepos] = useState(false);
74
- const [rescanningRepos, setRescanningRepos] = useState(false);
75
-
76
105
  useEffect(() => {
77
106
  setMounted(true);
78
107
  }, []);
79
108
 
109
+ // `activeRepo` is a dependency, not just the root: the repo list that
110
+ // resolves the root-level fallback arrives after the first render, and a
111
+ // workspace switch re-resolves it to '.'.
80
112
  useEffect(() => {
81
113
  if (!activeRepoReady) return;
82
- loadRepo(storedActiveRepo);
83
- }, [refreshKey, rootPath, enabled, activeRepoReady]);
114
+ loadRepo();
115
+ }, [refreshKey, rootPath, enabled, activeRepoReady, activeRepo]);
84
116
 
85
117
  // Auto refresh: keep the change list in step with external mutations
86
118
  // (terminal commits, agent edits) that never bump `refreshKey`. `loadRepo`
87
119
  // re-reads via `fetcher.load`, whose `t` param busts the cache; a load
88
120
  // already in flight is ignored by the loader so polls cannot pile up.
89
- usePanelRefresh(() => loadRepo(storedActiveRepo), enabled && activeRepoReady);
121
+ usePanelRefresh(() => loadRepo(), enabled && activeRepoReady);
90
122
  // Agent edits/commits land between poll ticks: re-read right after a
91
123
  // file-mutating tool finishes so the change list tracks the AI's work.
92
- useFileMutationRefresh(() => loadRepo(storedActiveRepo), enabled && activeRepoReady);
93
-
94
- useEffect(() => {
95
- if (fetcher.data?.activeRepo && fetcher.data.activeRepo !== storedActiveRepo) {
96
- setStoredActiveRepo(fetcher.data.activeRepo);
97
- }
98
- }, [fetcher.data?.activeRepo, storedActiveRepo, setStoredActiveRepo]);
99
-
100
- useEffect(() => {
101
- if (fetcher.data?.reposPending) setPollingRepos(true);
102
- }, [fetcher.data]);
103
-
104
- // A forced rescan may finish before the polling interval kicks in; when that
105
- // happens the loader returns the fresh repo list synchronously.
106
- useEffect(() => {
107
- if (
108
- rescanningRepos &&
109
- fetcher.data &&
110
- !fetcher.data.reposPending &&
111
- Array.isArray(fetcher.data.repos)
112
- ) {
113
- setExtraRepos(fetcher.data.repos);
114
- setRescanningRepos(false);
115
- }
116
- }, [fetcher.data, rescanningRepos]);
117
-
118
- useEffect(() => {
119
- if (!pollingRepos) return;
120
- const params = new URLSearchParams({ reposOnly: '1' });
121
- if (rootPath) params.set('root', rootPath);
122
- const id = setInterval(async () => {
123
- const data = await fetch(`/api/fs/git?${params.toString()}`).then(r => r.json()).catch(() => null);
124
- if (data && !data.reposPending && Array.isArray(data.repos)) {
125
- setExtraRepos(data.repos);
126
- setPollingRepos(false);
127
- setRescanningRepos(false);
128
- loadRepo(storedActiveRepo);
129
- }
130
- }, REPO_DISCOVERY_POLL_MS);
131
- return () => clearInterval(id);
132
- }, [pollingRepos, rootPath, storedActiveRepo]);
124
+ useFileMutationRefresh(() => loadRepo(), enabled && activeRepoReady);
133
125
 
134
126
  // Focus input when branch prompt opens
135
127
  useEffect(() => {
@@ -146,7 +138,7 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
146
138
  } else if (actionFetcher.data.type === 'graph') {
147
139
  setViewingOutput({ title: 'Git Graph', data: actionFetcher.data.data || [] });
148
140
  } else if (actionFetcher.data.success) {
149
- loadRepo(activeRepo);
141
+ loadRepo();
150
142
  } else {
151
143
  pushToast(actionFetcher.data.error || 'Git operation failed');
152
144
  }
@@ -188,11 +180,10 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
188
180
  }
189
181
  };
190
182
 
191
- const branch = fetcher.data?.branch || 'main';
192
- const branches = fetcher.data?.branches || ['main'];
193
- const remoteBranches = fetcher.data?.remoteBranches || [];
194
- const repos = extraRepos ?? (fetcher.data?.repos || ['.']);
195
- const changes = fetcher.data?.changes || [];
183
+ const branch = data?.branch || 'main';
184
+ const branches = data?.branches || ['main'];
185
+ const remoteBranches = data?.remoteBranches || [];
186
+ const changes = data?.changes || [];
196
187
  const isLoading = fetcher.state === 'loading' || actionFetcher.state !== 'idle';
197
188
 
198
189
  const stagedChanges = changes.filter(c => {
@@ -200,17 +191,6 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
200
191
  return status && status[0] !== ' ' && status[0] !== '?';
201
192
  });
202
193
 
203
- const refreshRepos = () => {
204
- if (!enabled || pollingRepos || rescanningRepos) return;
205
- setExtraRepos(repos);
206
- setRescanningRepos(true);
207
- const params = new URLSearchParams({ rescan: '1', sync: '1' });
208
- if (rootPath) params.set('root', rootPath);
209
- if (activeRepo !== '.') params.set('repo', activeRepo);
210
- params.set('t', String(Date.now()));
211
- fetcher.load(`/api/fs/git?${params.toString()}`);
212
- };
213
-
214
194
  if (!enabled) {
215
195
  return (
216
196
  <div className={`flex flex-col h-full bg-paper items-center justify-center text-ink/40 ${className}`}>
@@ -276,12 +256,14 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
276
256
  repos={repos}
277
257
  isLoading={isLoading}
278
258
  rootPath={rootPath}
279
- reposScanning={pollingRepos || rescanningRepos}
259
+ reposScanning={reposScanning}
280
260
  onSelectRepo={(r) => {
281
- setStoredActiveRepo(r);
282
- loadRepo(r);
261
+ // The reload is the scope effect's job: it fires on the resolved
262
+ // `activeRepo` change, so selecting here would fetch the same repo
263
+ // twice.
264
+ if (r !== activeRepo) setActiveRepo(r);
283
265
  }}
284
- onRefresh={() => loadRepo(activeRepo)}
266
+ onRefresh={() => loadRepo()}
285
267
  onRefreshRepos={refreshRepos}
286
268
  />
287
269
 
@@ -303,7 +285,7 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
303
285
  viewMode={viewMode}
304
286
  setViewMode={setViewMode}
305
287
  isBusy={isLoading}
306
- syncCount={fetcher.data?.syncCount}
288
+ syncCount={data?.syncCount}
307
289
  onSync={() => executeAction('sync')}
308
290
  />
309
291
 
@@ -8,6 +8,11 @@ interface TerminalHeaderProps {
8
8
  rootPath?: string;
9
9
  activeRepo: string;
10
10
  onSelectRepo: (repo: string) => void;
11
+ /** Discovered repos of the active root; the panel owns discovery. */
12
+ repos: string[];
13
+ /** Discovery or a forced rescan is in flight. */
14
+ reposScanning: boolean;
15
+ onRefreshRepos: () => void;
11
16
  /** Working directory the shell actually runs in. */
12
17
  cwd?: string;
13
18
  shell?: string;
@@ -35,6 +40,9 @@ export function TerminalHeader({
35
40
  rootPath,
36
41
  activeRepo,
37
42
  onSelectRepo,
43
+ repos,
44
+ reposScanning,
45
+ onRefreshRepos,
38
46
  cwd,
39
47
  shell,
40
48
  gitBranch,
@@ -60,7 +68,14 @@ export function TerminalHeader({
60
68
  <span>{status === 'exited' && exitCode !== null ? `EXIT ${exitCode}` : STATUS_LABEL[status]}</span>
61
69
  </span>
62
70
 
63
- <GitRepoDropdown rootPath={rootPath} activeRepo={activeRepo} onSelectRepo={onSelectRepo} />
71
+ <GitRepoDropdown
72
+ rootPath={rootPath}
73
+ activeRepo={activeRepo}
74
+ onSelectRepo={onSelectRepo}
75
+ repos={repos}
76
+ scanning={reposScanning}
77
+ onRefreshRepos={onRefreshRepos}
78
+ />
64
79
 
65
80
  <button
66
81
  type="button"
@@ -40,7 +40,7 @@ interface RealtimeXtermViewProps {
40
40
  */
41
41
  export const RealtimeXtermView = forwardRef<RealtimeXtermHandle, RealtimeXtermViewProps>(
42
42
  ({ onInput, onBinaryInput, onGridChange, onReady }, ref) => {
43
- const { isDark } = useTheme();
43
+ const { theme } = useTheme();
44
44
  const containerRef = useRef<HTMLDivElement>(null);
45
45
  const mountRef = useRef<XtermMount | null>(null);
46
46
  const [isScrolledUp, setIsScrolledUp] = useState(false);
@@ -53,15 +53,15 @@ export const RealtimeXtermView = forwardRef<RealtimeXtermHandle, RealtimeXtermVi
53
53
  gridChangeRef.current = onGridChange;
54
54
  const readyRef = useRef(onReady);
55
55
  readyRef.current = onReady;
56
- const themeRef = useRef(isDark);
57
- themeRef.current = isDark;
56
+ const themeRef = useRef(theme);
57
+ themeRef.current = theme;
58
58
 
59
59
  useEffect(() => {
60
60
  const term = mountRef.current?.term;
61
61
  if (!term) return;
62
- term.options.theme = getXtermTheme(isDark);
62
+ term.options.theme = getXtermTheme(theme);
63
63
  term.refresh(0, term.rows - 1);
64
- }, [isDark]);
64
+ }, [theme]);
65
65
 
66
66
  useImperativeHandle(ref, () => ({
67
67
  write(bytes: Uint8Array) {
@@ -1,6 +1,6 @@
1
1
  import { useCallback, useRef } from 'preact/hooks';
2
2
  import { useTerminal } from '@/client/hooks/workspace/terminal';
3
- import { useSessionState } from '@/client/hooks/workspace/session-state';
3
+ import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
4
4
  import { useTheme } from '@/client/hooks/ui/theme';
5
5
  import { TerminalHeader } from '@/client/components/workspace/terminal-panel/Header';
6
6
  import { TerminalQuickActions } from '@/client/components/workspace/terminal-panel/QuickActions';
@@ -18,7 +18,8 @@ const encoder = new TextEncoder();
18
18
  export function TerminalPanel({ className = '', enabled = true, rootPath, showHeader = true }: TerminalPanelProps) {
19
19
  const xtermRef = useRef<RealtimeXtermHandle>(null);
20
20
  const { isDark } = useTheme();
21
- const [activeRepo, setActiveRepo] = useSessionState<string>('terminal.activeRepo', '.');
21
+ const { activeRepo, setActiveRepo } = useRepoScope(rootPath, 'terminal.activeRepo');
22
+ const { repos, scanning: reposScanning, rescan: rescanRepos } = useRepoList(rootPath, enabled);
22
23
  const gridRef = useRef<{ cols: number; rows: number } | null>(null);
23
24
 
24
25
  const handleOutput = useCallback((bytes: Uint8Array) => {
@@ -105,6 +106,9 @@ export function TerminalPanel({ className = '', enabled = true, rootPath, showHe
105
106
  rootPath={rootPath}
106
107
  activeRepo={activeRepo}
107
108
  onSelectRepo={setActiveRepo}
109
+ repos={repos}
110
+ reposScanning={reposScanning}
111
+ onRefreshRepos={rescanRepos}
108
112
  cwd={cwd}
109
113
  shell={shell}
110
114
  gitBranch={systemInfo.gitBranch}
@@ -71,7 +71,7 @@ export interface XtermMount {
71
71
  */
72
72
  export async function mountXterm(
73
73
  container: HTMLElement,
74
- isDark: boolean,
74
+ themeId: string,
75
75
  callbacks: XtermMountCallbacks,
76
76
  isCancelled: () => boolean,
77
77
  ): Promise<XtermMount | null> {
@@ -93,7 +93,7 @@ export async function mountXterm(
93
93
  fontSize: 12,
94
94
  lineHeight: 1.25,
95
95
  fontFamily: XTERM_FONT_FAMILY,
96
- theme: getXtermTheme(isDark),
96
+ theme: getXtermTheme(themeId),
97
97
  // No `convertEol`: the PTY already emits CRLF, and converting a lone LF
98
98
  // would re-introduce the line-feed doubling a real terminal avoids.
99
99
  scrollback: 10000,
@@ -1,58 +1,56 @@
1
- export const XTERM_LIGHT_THEME = {
2
- background: '#f4f1ea',
3
- foreground: '#141310',
4
- cursor: '#141310',
5
- cursorAccent: '#f4f1ea',
6
- selectionBackground: '#ded8ce',
7
- selectionForeground: '#141310',
8
- black: '#141310',
9
- red: '#c8321e',
10
- green: '#047857',
11
- yellow: '#b45309',
12
- blue: '#1d4ed8',
13
- magenta: '#9333ea',
14
- cyan: '#0284c7',
15
- white: '#faf8f3',
16
- brightBlack: '#78716c',
17
- brightRed: '#dc2626',
18
- brightGreen: '#059669',
19
- brightYellow: '#d97706',
20
- brightBlue: '#2563eb',
21
- brightMagenta: '#a855f7',
22
- brightCyan: '#0ea5e9',
23
- brightWhite: '#292524',
24
- };
1
+ import { mixHex } from '@/shared/lib/theme/color';
2
+ import { resolveTheme } from '@/shared/lib/theme/catalog';
25
3
 
26
- export const XTERM_DARK_THEME = {
27
- background: '#21252b',
28
- foreground: '#abb2bf',
29
- cursor: '#528bff',
30
- cursorAccent: '#21252b',
31
- selectionBackground: '#3e4451',
32
- selectionForeground: '#abb2bf',
33
- black: '#282c34',
34
- red: '#e06c75',
35
- green: '#98c379',
36
- yellow: '#e5c07b',
37
- blue: '#61afef',
38
- magenta: '#c678dd',
39
- cyan: '#56b6c2',
40
- white: '#abb2bf',
41
- brightBlack: '#5c6370',
42
- brightRed: '#e06c75',
43
- brightGreen: '#98c379',
44
- brightYellow: '#e5c07b',
45
- brightBlue: '#61afef',
46
- brightMagenta: '#c678dd',
47
- brightCyan: '#56b6c2',
48
- brightWhite: '#ffffff',
49
- };
4
+ /**
5
+ * The terminal palette for a theme, derived from the same eight colors the rest
6
+ * of the app paints with.
7
+ *
8
+ * xterm parses its own colors and understands only literal notations (hex,
9
+ * `rgb()`, `hsl()`, named) — `color-mix()` is not among them in either the DOM
10
+ * or the WebGL renderer — so every blend is computed here in hex instead of
11
+ * being left to CSS. The result follows the active theme rather than a fixed
12
+ * light/dark pair: a Nord terminal is Nord, and switching themes repaints the
13
+ * scrollback instead of leaving the previous palette on screen.
14
+ *
15
+ * ANSI slots map onto the semantic colors the palette does carry (red=error,
16
+ * green=success, yellow=warning, blue=info, magenta=accent); `cyan` has no
17
+ * counterpart, so it is a deterministic mix of info and success, and the
18
+ * grayscale slots are mixes of ink over canvas. "Bright" variants mix 20% toward
19
+ * the foreground, which is lighter on a dark theme and darker on a light one —
20
+ * the direction that increases contrast on both.
21
+ */
22
+ export function getXtermTheme(themeId: string) {
23
+ const palette = resolveTheme(themeId);
24
+ const { canvas, ink } = palette;
50
25
 
51
- export function getXtermTheme(isDark: boolean) {
52
- return isDark ? XTERM_DARK_THEME : XTERM_LIGHT_THEME;
53
- }
26
+ const towardInk = (color: string) => mixHex(color, ink, 0.2);
27
+ const grayscale = (ratio: number) => mixHex(canvas, ink, ratio);
54
28
 
55
- export const XTERM_THEME = XTERM_DARK_THEME;
29
+ return {
30
+ background: canvas,
31
+ foreground: ink,
32
+ cursor: palette.meta,
33
+ cursorAccent: canvas,
34
+ selectionBackground: grayscale(0.28),
35
+ selectionForeground: ink,
36
+ black: grayscale(0.2),
37
+ red: palette.error,
38
+ green: palette.success,
39
+ yellow: palette.warning,
40
+ blue: palette.info,
41
+ magenta: palette.meta,
42
+ cyan: mixHex(palette.info, palette.success, 0.5),
43
+ white: grayscale(0.75),
44
+ brightBlack: grayscale(0.5),
45
+ brightRed: towardInk(palette.error),
46
+ brightGreen: towardInk(palette.success),
47
+ brightYellow: towardInk(palette.warning),
48
+ brightBlue: towardInk(palette.info),
49
+ brightMagenta: towardInk(palette.meta),
50
+ brightCyan: towardInk(mixHex(palette.info, palette.success, 0.5)),
51
+ brightWhite: grayscale(0.92),
52
+ };
53
+ }
56
54
 
57
55
  /**
58
56
  * Font stack for the terminal canvas.
@@ -15,18 +15,50 @@
15
15
  */
16
16
 
17
17
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
18
- import type { AgentImage, BtwState, StreamTransport } from '@/shared/types';
19
- import { BtwRequestError, abortBtwQuestion, askBtwQuestion, connectBtwStream, deleteBtwTopic, fetchBtwState, promoteBtwTopic } from '@/shared/lib/chat/btw/client';
18
+ import type { AgentImage, Attachment, BtwState, ChatMessageData, StreamTransport } from '@/shared/types';
19
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
20
+ import { attachmentImage, readTextAttachments } from '@/shared/lib/chat/attachments';
21
+ import {
22
+ BtwRequestError,
23
+ abortBtwQuestion,
24
+ askBtwQuestion,
25
+ connectBtwStream,
26
+ deleteBtwTopic,
27
+ fetchBtwState,
28
+ promoteBtwTopic,
29
+ respondBtwDialog,
30
+ setBtwAccessMode,
31
+ setBtwModel,
32
+ setBtwThinkingLevel,
33
+ } from '@/shared/lib/chat/btw/client';
20
34
  import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
21
35
 
22
36
  /** Images the side session accepts, already read as base64. */
23
37
  export type BtwImage = AgentImage;
24
38
 
25
- /** The turn streaming right now; `text` accumulates its deltas. */
39
+ /**
40
+ * The turn streaming right now. `messages` is the turn's conversation in the
41
+ * chat's own shape, upserted by `message.id` exactly as the chat stream does —
42
+ * so the panel renders a live side answer through the same components the chat
43
+ * uses, tool calls included.
44
+ */
26
45
  export interface BtwLiveAnswer {
27
46
  topicId: string;
28
47
  turnIndex: number;
29
- text: string;
48
+ messages: ChatMessageData[];
49
+ /** Activity phrase for the panel's indicator, from the side child's frames. */
50
+ activity: string;
51
+ }
52
+
53
+ /**
54
+ * Composer picks for a topic's FIRST question. A topic that does not exist yet
55
+ * has no row to write them to, so they ride the ask and reach the spawn; later
56
+ * questions address an existing topic through the dedicated setters.
57
+ */
58
+ export interface BtwFirstQuestionPicks {
59
+ model?: { provider: string; id: string };
60
+ thinkingLevel?: string;
61
+ approvalMode?: ApprovalMode;
30
62
  }
31
63
 
32
64
  export interface BtwSessionHandle {
@@ -37,8 +69,12 @@ export interface BtwSessionHandle {
37
69
  busy: boolean;
38
70
  /** Resolves with the canonical state the command produced, or null when it
39
71
  * was refused (the reason is then in `error`). */
40
- ask: (question: string, images?: BtwImage[], topicId?: string) => Promise<BtwState | null>;
72
+ ask: (question: string, attachments?: Attachment[], topicId?: string, picks?: BtwFirstQuestionPicks) => Promise<BtwState | null>;
41
73
  abort: (topicId: string) => Promise<void>;
74
+ setModel: (topicId: string, provider: string, modelId: string) => Promise<void>;
75
+ setThinkingLevel: (topicId: string, level: string) => Promise<void>;
76
+ setAccessMode: (topicId: string, mode: ApprovalMode) => Promise<void>;
77
+ respondToDialog: (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => Promise<void>;
42
78
  /** Returns the new session id to open, or null when it was refused. */
43
79
  promote: (topicId: string) => Promise<string | null>;
44
80
  remove: (topicId: string) => Promise<void>;
@@ -74,20 +110,39 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
74
110
  switch (frame.type) {
75
111
  case 'btw_state':
76
112
  setState(frame.state);
77
- // A settled turn's text is in the snapshot now; holding the live
78
- // buffer any longer would keep painting the partial answer.
113
+ // The snapshot carries the running turn's conversation, so a client
114
+ // attaching mid-turn (a reload, a second tab) shows the answer the
115
+ // child already produced instead of an empty bubble. A snapshot with
116
+ // no live turn ends the local buffer — the persisted rows own it now.
117
+ setLive(frame.state.live
118
+ ? {
119
+ topicId: frame.state.live.topicId,
120
+ turnIndex: frame.state.live.turnIndex,
121
+ messages: frame.state.live.messages,
122
+ activity: frame.state.live.activity,
123
+ }
124
+ : null);
125
+ break;
126
+ case 'btw_message':
127
+ // Same upsert the chat stream performs: omp emits one frame per
128
+ // segment carrying that segment's full accumulated content, so a
129
+ // same-id frame replaces in place and a new id appends.
79
130
  setLive((current) => {
80
- if (!current) return null;
81
- const topic = frame.state.topics.find((candidate) => candidate.id === current.topicId);
82
- const turn = topic?.turns.find((candidate) => candidate.index === current.turnIndex);
83
- return turn && turn.status === 'running' ? current : null;
131
+ const base =
132
+ current && current.topicId === frame.topicId && current.turnIndex === frame.turnIndex
133
+ ? current
134
+ : { topicId: frame.topicId, turnIndex: frame.turnIndex, messages: [], activity: '' };
135
+ const existing = base.messages.findIndex((message) => message.id === frame.message.id);
136
+ const messages =
137
+ existing === -1
138
+ ? [...base.messages, frame.message]
139
+ : [...base.messages.slice(0, existing), frame.message, ...base.messages.slice(existing + 1)];
140
+ return { ...base, messages };
84
141
  });
85
142
  break;
86
- case 'btw_delta':
143
+ case 'btw_activity':
87
144
  setLive((current) =>
88
- current && current.topicId === frame.topicId && current.turnIndex === frame.turnIndex
89
- ? { ...current, text: current.text + frame.text }
90
- : { topicId: frame.topicId, turnIndex: frame.turnIndex, text: frame.text },
145
+ current && current.topicId === frame.topicId ? { ...current, activity: frame.verb } : current,
91
146
  );
92
147
  break;
93
148
  case 'btw_error':
@@ -127,9 +182,29 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
127
182
  );
128
183
 
129
184
  const ask = useCallback(
130
- async (question: string, images?: BtwImage[], topicId?: string): Promise<BtwState | null> => {
185
+ async (
186
+ question: string,
187
+ attachments: Attachment[] = [],
188
+ topicId?: string,
189
+ picks?: BtwFirstQuestionPicks,
190
+ ): Promise<BtwState | null> => {
131
191
  setError(null);
132
- const next = await run((id) => askBtwQuestion(id, { question, images, topicId }));
192
+ // Same split the chat send path makes: images travel as provider payloads,
193
+ // text files are inlined into the prompt. Both are read here, from the
194
+ // attachments the composer already filled in at attach time.
195
+ const images = attachments
196
+ .map((attachment) => attachmentImage(attachment))
197
+ .filter((image): image is NonNullable<typeof image> => image !== null);
198
+ const textFiles = (await readTextAttachments(attachments)).filter((file) => !file.missing);
199
+ const next = await run((id) => askBtwQuestion(id, {
200
+ question,
201
+ topicId,
202
+ ...(images.length ? { images } : {}),
203
+ ...(textFiles.length ? { textFiles } : {}),
204
+ // Only a first question carries picks; a follow-up addresses a topic
205
+ // that already owns them.
206
+ ...(topicId ? {} : picks ?? {}),
207
+ }));
133
208
  setLive(null);
134
209
  if (next && !next.runningTopicId) {
135
210
  setError('The side question was accepted but did not start running.');
@@ -147,6 +222,38 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
147
222
  [run],
148
223
  );
149
224
 
225
+ const setModel = useCallback(
226
+ async (topicId: string, provider: string, modelId: string) => {
227
+ setError(null);
228
+ await run((id) => setBtwModel(id, topicId, provider, modelId));
229
+ },
230
+ [run],
231
+ );
232
+
233
+ const setThinkingLevel = useCallback(
234
+ async (topicId: string, level: string) => {
235
+ setError(null);
236
+ await run((id) => setBtwThinkingLevel(id, topicId, level));
237
+ },
238
+ [run],
239
+ );
240
+
241
+ const setAccessMode = useCallback(
242
+ async (topicId: string, mode: ApprovalMode) => {
243
+ setError(null);
244
+ await run((id) => setBtwAccessMode(id, topicId, mode));
245
+ },
246
+ [run],
247
+ );
248
+
249
+ const respondToDialog = useCallback(
250
+ async (topicId: string, id: string, response: { value: string } | { confirmed: boolean } | { cancelled: true }) => {
251
+ setError(null);
252
+ await run((requestId) => respondBtwDialog(requestId, topicId, id, response));
253
+ },
254
+ [run],
255
+ );
256
+
150
257
  const remove = useCallback(
151
258
  async (topicId: string) => {
152
259
  setError(null);
@@ -176,5 +283,19 @@ export function useBtwSession(sessionId: string | null, options: BtwSessionOptio
176
283
 
177
284
  const clearError = useCallback(() => setError(null), []);
178
285
 
179
- return { state, live, error, busy, ask, abort, promote, remove, clearError };
286
+ return {
287
+ state,
288
+ live,
289
+ error,
290
+ busy,
291
+ ask,
292
+ abort,
293
+ setModel,
294
+ setThinkingLevel,
295
+ setAccessMode,
296
+ respondToDialog,
297
+ promote,
298
+ remove,
299
+ clearError,
300
+ };
180
301
  }