ompchamber 1.2.1 → 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 +124 -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.5fe7a2cb64.js +0 -34
  214. package/src/client/components/workspace/btw-panel/Composer.tsx +0 -183
  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.5fe7a2cb64.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
@@ -26,6 +26,13 @@ interface AiMessageFooterProps {
26
26
  onRetry?: (msgId: string) => void;
27
27
  onNewChat?: (content: string) => void;
28
28
  isMobile?: boolean;
29
+ /**
30
+ * Whether the row offers Retry / New-chat. Both act on the CHAT (re-run a chat
31
+ * turn, branch a chat session from an answer), so a footer that describes a
32
+ * row the chat does not own — the BTW panel's side answer — turns them off
33
+ * instead of rendering buttons that do nothing.
34
+ */
35
+ showActions?: boolean;
29
36
  }
30
37
 
31
38
  export function AiMessageFooter({
@@ -41,6 +48,7 @@ export function AiMessageFooter({
41
48
  onRetry,
42
49
  onNewChat,
43
50
  isMobile = false,
51
+ showActions = true,
44
52
  }: AiMessageFooterProps) {
45
53
  const [isMenuOpen, setIsMenuOpen] = useState(false);
46
54
 
@@ -198,14 +206,18 @@ export function AiMessageFooter({
198
206
  wrapLabel
199
207
  onClick={() => setIsMenuOpen(false)}
200
208
  />
201
- <button type="button" onClick={handleRetry} className="flex min-h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm text-ink transition-colors hover:bg-ink/5 cursor-pointer">
202
- <RotateCcw size={20} className="shrink-0 text-ink/55" />
203
- <span>Retry response</span>
204
- </button>
205
- <button type="button" onClick={handleNewChat} className="flex min-h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm text-ink transition-colors hover:bg-ink/5 cursor-pointer">
206
- <MessageSquarePlus size={20} className="shrink-0 text-ink/55" />
207
- <span>Start new chat from this answer</span>
208
- </button>
209
+ {showActions && (
210
+ <>
211
+ <button type="button" onClick={handleRetry} className="flex min-h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm text-ink transition-colors hover:bg-ink/5 cursor-pointer">
212
+ <RotateCcw size={20} className="shrink-0 text-ink/55" />
213
+ <span>Retry response</span>
214
+ </button>
215
+ <button type="button" onClick={handleNewChat} className="flex min-h-12 w-full items-center gap-3 rounded-lg px-3 text-left text-sm text-ink transition-colors hover:bg-ink/5 cursor-pointer">
216
+ <MessageSquarePlus size={20} className="shrink-0 text-ink/55" />
217
+ <span>Start new chat from this answer</span>
218
+ </button>
219
+ </>
220
+ )}
209
221
  </div>
210
222
  </div>
211
223
  </div>
@@ -232,18 +244,22 @@ export function AiMessageFooter({
232
244
  </div>
233
245
 
234
246
  <div className="flex items-center space-x-1 shrink-0">
235
- <button type="button" className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer" title="Re-run / Retry generation" onClick={handleRetry}>
236
- <RotateCcw size={12} />
237
- </button>
247
+ {showActions && (
248
+ <button type="button" className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer" title="Re-run / Retry generation" onClick={handleRetry}>
249
+ <RotateCcw size={12} />
250
+ </button>
251
+ )}
238
252
  <CopyButton
239
253
  text={content}
240
254
  className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer"
241
255
  iconSize={12}
242
256
  title="Copy response"
243
257
  />
244
- <button type="button" className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer" title="New Chat from here" onClick={handleNewChat}>
245
- <MessageSquarePlus size={12} />
246
- </button>
258
+ {showActions && (
259
+ <button type="button" className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer" title="New Chat from here" onClick={handleNewChat}>
260
+ <MessageSquarePlus size={12} />
261
+ </button>
262
+ )}
247
263
  </div>
248
264
  </div>
249
265
  );
@@ -118,7 +118,16 @@ export function ComposerDock({
118
118
  isSteering
119
119
  />
120
120
  {btw.open ? (
121
- <BtwForm mode={btw} modelName={modelName} provider={provider} providerNames={providerNames} />
121
+ <BtwForm
122
+ mode={btw}
123
+ appSettings={appSettings}
124
+ composerModelRef={composerModelRef}
125
+ rootPath={rootPath}
126
+ variant={variant}
127
+ modelName={modelName}
128
+ provider={provider}
129
+ providerNames={providerNames}
130
+ />
122
131
  ) : (
123
132
  <ChatInput
124
133
  value={inputValue}
@@ -7,6 +7,12 @@ interface GeneratingIndicatorProps {
7
7
  generatingVerb?: string;
8
8
  provider?: string;
9
9
  providerNames?: Record<string, string>;
10
+ /**
11
+ * DOM id for the docked row. Two indicators can be mounted at once — the chat
12
+ * run and a side question both streaming — so a second caller must pass its
13
+ * own id rather than duplicating the default one.
14
+ */
15
+ id?: string;
10
16
  }
11
17
 
12
18
  const COOL_VERBS = [
@@ -18,7 +24,7 @@ const COOL_VERBS = [
18
24
  'Compiling edge routes'
19
25
  ];
20
26
 
21
- export function GeneratingIndicator({ modelName, generatingVerb, provider, providerNames }: GeneratingIndicatorProps) {
27
+ export function GeneratingIndicator({ modelName, generatingVerb, provider, providerNames, id = 'generating-docked-indicator' }: GeneratingIndicatorProps) {
22
28
  const [activeVerbIndex, setActiveVerbIndex] = useState(0);
23
29
 
24
30
  // Rotate action verb every 2.8s
@@ -38,7 +44,7 @@ export function GeneratingIndicator({ modelName, generatingVerb, provider, provi
38
44
 
39
45
  return (
40
46
  <div
41
- id="generating-docked-indicator"
47
+ id={id}
42
48
  className="w-full flex items-center px-1 py-1 bg-transparent border-0 border-none text-[11px] font-mono select-none animate-in fade-in duration-200"
43
49
  >
44
50
  <style>{`
@@ -23,6 +23,12 @@ interface ChatMessageItemProps {
23
23
  className?: string;
24
24
  /** Whether the message immediately preceding this one was also an assistant message. */
25
25
  isPrevAssistant?: boolean;
26
+ /**
27
+ * Whether a user row offers Undo / New-chat. Both act on the CHAT, so a row
28
+ * that is not a chat turn (the BTW panel's question row) turns them off
29
+ * instead of rendering buttons that do nothing.
30
+ */
31
+ userActions?: boolean;
26
32
  }
27
33
 
28
34
  export const ChatMessageItem = memo(function ChatMessageItem({
@@ -32,6 +38,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
32
38
  onNewChat,
33
39
  className = '',
34
40
  isPrevAssistant = false,
41
+ userActions = true,
35
42
  }: ChatMessageItemProps) {
36
43
  const isUser = msg.role === 'user';
37
44
 
@@ -107,14 +114,16 @@ export const ChatMessageItem = memo(function ChatMessageItem({
107
114
 
108
115
  <div className="flex items-center space-x-1">
109
116
  {/* Undo Button (to the left of copy button) */}
110
- <button
111
- type="button"
112
- className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer"
113
- title="Undo / Edit message"
114
- onClick={handleUndo}
115
- >
116
- <Undo2 size={12} />
117
- </button>
117
+ {userActions && (
118
+ <button
119
+ type="button"
120
+ className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer"
121
+ title="Undo / Edit message"
122
+ onClick={handleUndo}
123
+ >
124
+ <Undo2 size={12} />
125
+ </button>
126
+ )}
118
127
 
119
128
  {/* Copy Button */}
120
129
  <CopyButton
@@ -125,14 +134,16 @@ export const ChatMessageItem = memo(function ChatMessageItem({
125
134
  />
126
135
 
127
136
  {/* New Chat Button */}
128
- <button
129
- type="button"
130
- className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer"
131
- title="New Chat from here"
132
- onClick={handleNewChat}
133
- >
134
- <MessageSquarePlus size={12} />
135
- </button>
137
+ {userActions && (
138
+ <button
139
+ type="button"
140
+ className="flex items-center hover:text-ink transition-colors p-1 rounded hover:bg-ink/5 cursor-pointer"
141
+ title="New Chat from here"
142
+ onClick={handleNewChat}
143
+ >
144
+ <MessageSquarePlus size={12} />
145
+ </button>
146
+ )}
136
147
  </div>
137
148
  </div>
138
149
  </div>
@@ -21,6 +21,14 @@ export interface MessageListProps {
21
21
  onRetry?: (id: string) => void;
22
22
  onNewChat?: (content: string) => void;
23
23
  isMobile?: boolean;
24
+ /**
25
+ * Whether chat-owning actions render: a USER row's Undo / New-chat, and the
26
+ * run footer's Retry / New-chat. All of them act on the CHAT (rewind a chat
27
+ * turn, re-run it, branch a session from it), so a renderer that draws rows
28
+ * the chat does not own — the BTW panel's side question and its answer — turns
29
+ * them off instead of showing buttons that do nothing.
30
+ */
31
+ userActions?: boolean;
24
32
  }
25
33
 
26
34
  /**
@@ -49,6 +57,7 @@ export const MessageList = memo(function MessageList({
49
57
  onRetry,
50
58
  onNewChat,
51
59
  isMobile = false,
60
+ userActions = true,
52
61
  }: MessageListProps) {
53
62
  const { streamingIdx, footers, prevNonNoticeIdx } = useMemo(() => {
54
63
  return {
@@ -79,6 +88,7 @@ export const MessageList = memo(function MessageList({
79
88
  onUndo={onUndo}
80
89
  onNewChat={onNewChat}
81
90
  isPrevAssistant={isPrevAssistant}
91
+ userActions={userActions}
82
92
  className={isNoticeRow(msg) ? 'mt-3 mb-1' : isAiFragment ? 'mt-1' : 'mt-3'}
83
93
  />
84
94
  {footer && (
@@ -93,6 +103,7 @@ export const MessageList = memo(function MessageList({
93
103
  onRetry={onRetry}
94
104
  onNewChat={onNewChat}
95
105
  isMobile={isMobile}
106
+ showActions={userActions}
96
107
  />
97
108
  )}
98
109
  </Fragment>
@@ -19,6 +19,12 @@ interface RunFooterProps {
19
19
  onNewChat?: (content: string) => void;
20
20
  /** Mobile uses a compact provider/model footer with a bottom-sheet menu. */
21
21
  isMobile?: boolean;
22
+ /**
23
+ * Whether the footer offers Retry / New-chat. Both act on the CHAT, so a
24
+ * footer describing a row the chat does not own (the BTW panel's side answer)
25
+ * turns them off rather than rendering dead buttons.
26
+ */
27
+ showActions?: boolean;
22
28
  }
23
29
 
24
30
  /**
@@ -38,6 +44,7 @@ export function RunFooter({
38
44
  onRetry,
39
45
  onNewChat,
40
46
  isMobile = false,
47
+ showActions = true,
41
48
  }: RunFooterProps) {
42
49
  return (
43
50
  <div className="mt-2">
@@ -54,6 +61,7 @@ export function RunFooter({
54
61
  onRetry={onRetry}
55
62
  onNewChat={onNewChat}
56
63
  isMobile={isMobile}
64
+ showActions={showActions}
57
65
  />
58
66
  </div>
59
67
  );
@@ -33,6 +33,8 @@ export function ChatInput({
33
33
  variant = 'desktop',
34
34
  accessMode,
35
35
  onAccessModeChange,
36
+ enablePicker = true,
37
+ placeholder,
36
38
  /** Written on every model/thinking pick so the send path can snapshot the
37
39
  * selection into queued items without lifting ChatInput state. */
38
40
  composerModelRef,
@@ -62,6 +64,14 @@ export function ChatInput({
62
64
  rootPath?: string | null;
63
65
  accessMode: ApprovalMode;
64
66
  onAccessModeChange: (mode: ApprovalMode) => void;
67
+ /**
68
+ * Whether the `@` / `/` / `!` / `#` autocomplete runs. Off for the
69
+ * side-question form, which has no file tree, commands or skills to offer —
70
+ * and whose text is never run through the mention translator.
71
+ */
72
+ enablePicker?: boolean;
73
+ /** Overrides the default hint text (the side-question form names its own). */
74
+ placeholder?: string;
65
75
  /**
66
76
  * `mobile` sizes the composer for a phone: 16px text (iOS Safari zooms the
67
77
  * viewport when focusing an input below that), thumb-sized send/stop
@@ -292,7 +302,8 @@ export function ChatInput({
292
302
  disabled={disabled}
293
303
  appSettings={appSettings}
294
304
  rootPath={rootPath}
295
- placeholder={disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills; ! for shell; # for snippets (Paste images/files here)"}
305
+ enablePicker={enablePicker}
306
+ placeholder={placeholder ?? (disabled ? "Please select a workspace above to start prompting..." : "@ for files/agents; / for commands and skills; ! for shell; # for snippets (Paste images/files here)")}
296
307
  className={`w-full bg-transparent border-none focus:outline-none resize-none text-ink placeholder-ink/40 disabled:opacity-50 disabled:cursor-not-allowed ${
297
308
  isMobile ? 'px-3 py-3 text-base min-h-[68px] max-h-40' : 'px-3 py-3 text-sm min-h-[80px]'
298
309
  }`}
@@ -88,9 +88,14 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
88
88
 
89
89
  // Ask dialogs render on their own tool card; anything else omp is blocked on
90
90
  // (an approval gate, an extension picker) has no card and keeps the modal.
91
+ // The modal also waits for the session's committed history: a dialog replayed
92
+ // on open (the agent probe answers from local flags, long before the JSONL
93
+ // fetch lands) has no card to be matched against yet, so deciding "no card
94
+ // owns this" during that window painted the ask modal and then took it back —
95
+ // the flash on switching to a session parked on a question.
91
96
  const { framesByTool, modalRequest } = useMemo(
92
- () => splitAskFrames(localMessages, extensionDialogs),
93
- [localMessages, extensionDialogs],
97
+ () => splitAskFrames(localMessages, extensionDialogs, !sessionLoading),
98
+ [localMessages, extensionDialogs, sessionLoading],
94
99
  );
95
100
  const respondToFrame = useCallback((request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => {
96
101
  void respondToExtensionUi(request, response);
@@ -4,13 +4,14 @@
4
4
  */
5
5
 
6
6
  /**
7
- * One question of an `ask` call, in whichever of its three states it is in:
8
- * answered (omp already recorded it), asking (its dialog is live) or still
9
- * queued behind an earlier question — omp asks them strictly in order, so the
10
- * later ones can only be previewed until their turn comes.
7
+ * One question of an `ask` call, in whichever of its four states it is in:
8
+ * answered (omp already recorded it), asking (its dialog is live), still queued
9
+ * behind an earlier question (omp asks them strictly in order, so the later
10
+ * ones can only be previewed until their turn comes) or abandoned — the ask
11
+ * ended without reaching it, so no dialog can arrive again.
11
12
  */
12
13
 
13
- import { CheckCircle2, Clock } from 'lucide-preact';
14
+ import { Ban, CheckCircle2, Clock } from 'lucide-preact';
14
15
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
15
16
  import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
16
17
  import type { AskAnswer, AskQuestion } from '@/shared/lib/chat/ask-questions';
@@ -28,6 +29,12 @@ interface QuestionBlockProps {
28
29
  draft?: string;
29
30
  /** Unanswered, but omp has already moved past it — its dialog is gone. */
30
31
  passed: boolean;
32
+ /**
33
+ * The ask's tool call already failed or was aborted: omp withdrew its dialog
34
+ * and cannot raise another, so an unanswered question is not queued behind
35
+ * anything.
36
+ */
37
+ settled: boolean;
31
38
  /** Labels toggled locally on a multi-select question. */
32
39
  multiSelected: string[];
33
40
  onAnswer: (value: string) => void;
@@ -49,14 +56,24 @@ export function QuestionBlock({
49
56
  recorded,
50
57
  draft,
51
58
  passed,
59
+ settled,
52
60
  multiSelected,
53
61
  onAnswer,
54
62
  onToggle,
55
63
  onRespond,
56
64
  }: QuestionBlockProps) {
57
65
  const live = frames.length > 0 ? frames[frames.length - 1] : null;
58
- const answered = recorded ? answerParts(recorded) : draft ? [draft] : multiSelected;
59
- const asking = Boolean(live) && !recorded && (question.multi || !draft);
66
+ // A recorded answer with no labels is still an answer — omp accepts an empty
67
+ // multi-select as "select none" — so presence, never the rendered part count,
68
+ // decides whether a question is answered. Reading the parts made such a
69
+ // question fall through to the queued state and claim a dialog was live.
70
+ const hasAnswer = recorded !== null || draft !== undefined || multiSelected.length > 0;
71
+ const parts = recorded ? answerParts(recorded) : draft ? [draft] : multiSelected;
72
+ const chips = parts.length > 0 ? parts : ['none selected'];
73
+ // A settled ask has no live dialog by construction, even if a frame this
74
+ // client never saw withdrawn is still in its queue: answering it would post
75
+ // a response for a tool call omp has already finished.
76
+ const asking = !settled && Boolean(live) && !recorded && (question.multi || !draft);
60
77
 
61
78
  return (
62
79
  <div className="rounded-lg border border-ink/10 bg-paper p-2.5 shadow-xs">
@@ -94,10 +111,10 @@ export function QuestionBlock({
94
111
  />
95
112
  )}
96
113
 
97
- {answered.length > 0 && (
114
+ {hasAnswer && (
98
115
  <div className="mt-2 flex flex-wrap items-center gap-1.5">
99
116
  <CheckCircle2 size={11} className="shrink-0 text-success" />
100
- {answered.map((part) => (
117
+ {chips.map((part) => (
101
118
  <span
102
119
  key={part}
103
120
  className="rounded-md border border-ink/12 bg-canvas/40 px-2 py-0.5 text-[11.5px] font-medium text-ink select-text"
@@ -113,14 +130,14 @@ export function QuestionBlock({
113
130
  </div>
114
131
  )}
115
132
 
116
- {!asking && answered.length === 0 && passed && (
133
+ {!asking && !hasAnswer && passed && (
117
134
  <div className="mt-2 flex items-center gap-1.5 text-[10.5px] text-ink/45">
118
135
  <CheckCircle2 size={11} className="shrink-0 text-ink/35" />
119
136
  <span>Answered earlier in this run.</span>
120
137
  </div>
121
138
  )}
122
139
 
123
- {!asking && answered.length === 0 && !passed && (
140
+ {!asking && !hasAnswer && !passed && (
124
141
  <div className="mt-2 space-y-1 border-t border-ink/6 pt-2">
125
142
  {question.options.map((option, optionIndex) => (
126
143
  <div key={option.label} className="flex items-start gap-2 px-0.5 text-[11.5px] text-ink/45">
@@ -142,10 +159,20 @@ export function QuestionBlock({
142
159
  </span>
143
160
  </div>
144
161
  ))}
145
- <div className="flex items-center gap-1.5 pt-0.5 text-[10.5px] text-ink/40">
146
- <Clock size={10} className="shrink-0" />
147
- <span>Waiting for the previous answer…</span>
148
- </div>
162
+ {/* "Waiting for the previous answer" is only true while the ask
163
+ is still running: once it settles nothing is being asked, and
164
+ the question was abandoned unanswered. */}
165
+ {settled ? (
166
+ <div className="flex items-center gap-1.5 pt-0.5 text-[10.5px] text-ink/40">
167
+ <Ban size={10} className="shrink-0" />
168
+ <span>Not answered — the ask was cut short.</span>
169
+ </div>
170
+ ) : (
171
+ <div className="flex items-center gap-1.5 pt-0.5 text-[10.5px] text-ink/40">
172
+ <Clock size={10} className="shrink-0" />
173
+ <span>Waiting for the previous answer…</span>
174
+ </div>
175
+ )}
149
176
  </div>
150
177
  )}
151
178
  </div>
@@ -46,7 +46,18 @@ export function AskPanel({ tool }: { tool: ToolCallData }) {
46
46
  return tool.output ? <FallbackOutput text={tool.output} /> : null;
47
47
  }
48
48
 
49
- const waiting = groups.some((frames, index) => frames.length > 0 && !recorded[index]);
49
+ // An ask whose tool call failed or was aborted is OVER: omp cancels the live
50
+ // dialog on abort (the client drops it on that `cancel` frame) and the result
51
+ // records no answer, so after the queue empties every unanswered question
52
+ // falls out of the "passed" branch with nothing left to say it was
53
+ // abandoned — the card read "Waiting for the previous answer…" over a run the
54
+ // user had already stopped. Only a terminal status counts: a session reloaded
55
+ // mid-ask reads its still-blocking tool call back from the JSONL as
56
+ // `success` (omp writes no result until the question is answered), and that
57
+ // question must stay answerable.
58
+ const settled =
59
+ tool.status === 'error' || tool.status === 'aborted' || tool.status === 'skipped';
60
+ const waiting = !settled && groups.some((frames, index) => frames.length > 0 && !recorded[index]);
50
61
  // omp asks in order and blocks on each, so the earliest question holding a
51
62
  // dialog is the only open one: everything before it is answered, whether or
52
63
  // not this client still has the text.
@@ -71,6 +82,7 @@ export function AskPanel({ tool }: { tool: ToolCallData }) {
71
82
  recorded={recorded[index] ?? null}
72
83
  draft={drafts[index]}
73
84
  passed={askingIndex !== -1 && index < askingIndex}
85
+ settled={settled}
74
86
  multiSelected={multiSelected[index] ?? NO_LABELS}
75
87
  onAnswer={(value) => commit(index, value)}
76
88
  onToggle={(label) => toggleMulti(index, label)}
@@ -35,12 +35,21 @@ export function ExtensionDialog({ request, onRespond }: ExtensionDialogProps) {
35
35
  const options = request.options ?? [];
36
36
  const optionDetails = request.optionDetails ?? [];
37
37
 
38
+ // Reset for a NEW dialog only. `request` is the identity to depend on —
39
+ // `options`/`optionDetails` above are derived arrays, re-created on every
40
+ // render, so listing one as a dependency re-ran this effect after every
41
+ // commit and threw away whatever had just been typed: an editor/input frame
42
+ // carries no `options` at all, so the reset fired on every keystroke and the
43
+ // dialog could not be typed into.
38
44
  useEffect(() => {
45
+ const items = request.options ?? [];
39
46
  setValue(request.method === 'editor' ? request.prefill ?? '' : '');
40
- setSelectedOption(options.length > 0 ? options[0] : null);
47
+ setSelectedOption(items.length > 0 ? items[0] : null);
41
48
  setCustomValue('');
42
- }, [request, options]);
49
+ }, [request]);
43
50
 
51
+ // Focus once per dialog, for the same reason: a fresh dependency on every
52
+ // render also pulls the caret back into the field the user just left.
44
53
  useEffect(() => {
45
54
  panelRef.current?.focus();
46
55
  if (request.method === 'input') {
@@ -50,12 +59,12 @@ export function ExtensionDialog({ request, onRespond }: ExtensionDialogProps) {
50
59
  } else if (request.method === 'select') {
51
60
  // When the AI offers an "Other"-style option, the intended interaction
52
61
  // is typing a custom answer — focus the input right away.
53
- const hasOther = options.some((option) => /^other\b/i.test(option.trim()));
62
+ const hasOther = (request.options ?? []).some((option) => /^other\b/i.test(option.trim()));
54
63
  if (hasOther) {
55
64
  customInputRef.current?.focus();
56
65
  }
57
66
  }
58
- }, [request.id, request.method, options]);
67
+ }, [request]);
59
68
 
60
69
  const cancel = useCallback(() => {
61
70
  onRespond(request, { cancelled: true });
@@ -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
@@ -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"