ompchamber 1.1.0 → 1.2.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 (192) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.5fe7a2cb64.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/file-icon/index.tsx +4 -1
  36. package/src/client/components/common/image-viewer/index.tsx +224 -0
  37. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  38. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  39. package/src/client/components/settings/Modal.tsx +1 -0
  40. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  41. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  42. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  43. package/src/client/components/workspace/btw-panel/Composer.tsx +183 -0
  44. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  45. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  46. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  47. package/src/client/components/workspace/btw-panel/index.tsx +88 -0
  48. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
  49. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  53. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
  54. package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  56. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  57. package/src/client/components/workspace/editor/index.tsx +45 -36
  58. package/src/client/hooks/chat/btw/index.ts +180 -0
  59. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  60. package/src/client/hooks/chat/btw/mode.ts +179 -0
  61. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  62. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  63. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  64. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  65. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  66. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  67. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  68. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  69. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  70. package/src/client/hooks/chat/composer/index.ts +7 -1
  71. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  72. package/src/client/hooks/chat/omp/index.ts +3 -3
  73. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  74. package/src/client/hooks/chat/timeline/actions.ts +16 -1
  75. package/src/client/hooks/chat/timeline/index.ts +16 -8
  76. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  77. package/src/client/hooks/chat/timeline/send.ts +29 -65
  78. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  79. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  80. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  81. package/src/client/hooks/editor/file-actions.ts +49 -0
  82. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  83. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  84. package/src/client/tailwind.css +22 -0
  85. package/src/server/db.server.ts +21 -287
  86. package/src/server/lib/btw/frames.test.ts +68 -0
  87. package/src/server/lib/btw/frames.ts +56 -0
  88. package/src/server/lib/btw/prompt.ts +28 -0
  89. package/src/server/lib/btw/registry.server.ts +141 -0
  90. package/src/server/lib/btw/runtime.server.ts +324 -0
  91. package/src/server/lib/btw/schema.server.ts +44 -0
  92. package/src/server/lib/btw/service.server.ts +171 -0
  93. package/src/server/lib/btw/session-copy.server.ts +213 -0
  94. package/src/server/lib/btw/session-copy.test.ts +166 -0
  95. package/src/server/lib/btw/store.server.ts +240 -0
  96. package/src/server/lib/db/schema.ts +110 -0
  97. package/src/server/lib/db/seed.ts +160 -0
  98. package/src/server/lib/db/workspace-sync.ts +70 -0
  99. package/src/server/lib/fs/root.ts +37 -0
  100. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  101. package/src/server/lib/omp/rpc/manager.ts +27 -123
  102. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  103. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  104. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  105. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  106. package/src/server/lib/omp/session/file-scan.ts +174 -0
  107. package/src/server/lib/omp/session/files.test.ts +147 -0
  108. package/src/server/lib/omp/session/files.ts +52 -35
  109. package/src/server/lib/omp/session/locator.ts +2 -2
  110. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  111. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  112. package/src/server/lib/omp/session/messages.ts +34 -0
  113. package/src/server/lib/omp/session/reader.ts +2 -2
  114. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  115. package/src/server/lib/queue/delivery.server.test.ts +57 -0
  116. package/src/server/lib/queue/delivery.server.ts +25 -3
  117. package/src/server/plugins/ssr.ts +29 -2
  118. package/src/server/routes/btw/events.ts +70 -0
  119. package/src/server/routes/btw/index.ts +8 -0
  120. package/src/server/routes/btw/topics.ts +87 -0
  121. package/src/server/routes/btw/ws.ts +136 -0
  122. package/src/server/routes/chat/index.ts +2 -0
  123. package/src/server/routes/chat/session.ts +1 -115
  124. package/src/server/routes/chat/turns.ts +87 -0
  125. package/src/server/routes/fs/index.ts +4 -1
  126. package/src/server/routes/fs/raw.ts +85 -0
  127. package/src/server/routes/fs/read.ts +58 -3
  128. package/src/server/routes/index.ts +7 -1
  129. package/src/shared/lib/chat/attachments.test.ts +208 -0
  130. package/src/shared/lib/chat/attachments.ts +200 -15
  131. package/src/shared/lib/chat/btw/client.ts +94 -0
  132. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  133. package/src/shared/lib/chat/composer/client.ts +6 -1
  134. package/src/shared/lib/chat/composer/composer.test.ts +39 -1
  135. package/src/shared/lib/chat/composer/trigger.ts +45 -1
  136. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  137. package/src/shared/lib/chat/omp/socket.ts +11 -6
  138. package/src/shared/lib/chat/omp/sse.ts +11 -6
  139. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  140. package/src/shared/lib/chat/omp/transport.ts +19 -3
  141. package/src/shared/lib/chat/text-file.ts +148 -0
  142. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  143. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  144. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  145. package/src/shared/lib/fs/file-kind.ts +50 -0
  146. package/src/shared/lib/fs/paths.ts +19 -1
  147. package/src/shared/lib/markdown/math.test.ts +20 -3
  148. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  149. package/src/shared/lib/omp/session/mapper.ts +24 -4
  150. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  151. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  152. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  153. package/src/shared/types/btw.ts +65 -0
  154. package/src/shared/types/chat.ts +39 -1
  155. package/src/shared/types/index.ts +1 -0
  156. package/src/shared/types/omp/agent.ts +14 -1
  157. package/src/shared/types/omp/session.ts +4 -1
  158. package/src/shared/types/settings/state.ts +5 -0
  159. package/dist/client/static/css/index.b859cc1cc5.css +0 -2
  160. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  161. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  162. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  163. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  164. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  165. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  166. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  167. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  168. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  169. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  170. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  171. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  172. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  173. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  174. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  175. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  176. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  177. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  178. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  179. package/dist/client/static/js/index.6e065017eb.js +0 -33
  180. package/dist/client/static/js/jp.50817896f3.js +0 -139
  181. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  182. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  183. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  184. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  185. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  186. /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  187. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  188. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  189. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  190. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  191. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  192. /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.5fe7a2cb64.js.LICENSE.txt} +0 -0
@@ -6,6 +6,8 @@ interface EditorToolbarProps {
6
6
  wordWrap: boolean;
7
7
  isMd: boolean;
8
8
  isPreview: boolean;
9
+ /** Raster image: no text to save or wrap, and zoom belongs to the picture. */
10
+ isImage?: boolean;
9
11
  onSave: () => void;
10
12
  onTogglePreview: () => void;
11
13
  onToggleWordWrap: () => void;
@@ -22,6 +24,7 @@ export function EditorToolbar({
22
24
  wordWrap,
23
25
  isMd,
24
26
  isPreview,
27
+ isImage = false,
25
28
  onSave,
26
29
  onTogglePreview,
27
30
  onToggleWordWrap,
@@ -40,22 +43,24 @@ export function EditorToolbar({
40
43
 
41
44
  {/* Right actions */}
42
45
  <div className="flex items-center space-x-3 text-ink/40">
43
- <div className="flex items-center pr-3 border-r border-ink/10">
44
- <button
45
- type="button"
46
- onClick={onSave}
47
- className="flex items-center justify-center rounded hover:bg-ink/5 transition-colors"
48
- title="Save file (Ctrl+S)"
49
- >
50
- {saveStatus === 'saving' ? (
51
- <Save size={14} className="text-amber-500 animate-pulse" />
52
- ) : saveStatus === 'saved' ? (
53
- <Check size={14} className="text-success" />
54
- ) : (
55
- <Save size={14} className="hover:text-ink cursor-pointer" />
56
- )}
57
- </button>
58
- </div>
46
+ {!isImage && (
47
+ <div className="flex items-center pr-3 border-r border-ink/10">
48
+ <button
49
+ type="button"
50
+ onClick={onSave}
51
+ className="flex items-center justify-center rounded hover:bg-ink/5 transition-colors"
52
+ title="Save file (Ctrl+S)"
53
+ >
54
+ {saveStatus === 'saving' ? (
55
+ <Save size={14} className="text-amber-500 animate-pulse" />
56
+ ) : saveStatus === 'saved' ? (
57
+ <Check size={14} className="text-success" />
58
+ ) : (
59
+ <Save size={14} className="hover:text-ink cursor-pointer" />
60
+ )}
61
+ </button>
62
+ </div>
63
+ )}
59
64
 
60
65
  {isMd && (
61
66
  <div onClick={onTogglePreview} className="flex items-center pr-3 border-r border-ink/10">
@@ -67,14 +72,18 @@ export function EditorToolbar({
67
72
  </div>
68
73
  )}
69
74
 
70
- <div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
71
- <WrapText size={14} className={`cursor-pointer ${wordWrap ? 'text-ink' : 'hover:text-ink'}`} onClick={onToggleWordWrap} />
72
- </div>
75
+ {!isImage && (
76
+ <>
77
+ <div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
78
+ <WrapText size={14} className={`cursor-pointer ${wordWrap ? 'text-ink' : 'hover:text-ink'}`} onClick={onToggleWordWrap} />
79
+ </div>
73
80
 
74
- <div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
75
- <ZoomOut size={14} className="hover:text-ink cursor-pointer" onClick={onZoomOut} />
76
- <ZoomIn size={14} className="hover:text-ink cursor-pointer" onClick={onZoomIn} />
77
- </div>
81
+ <div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
82
+ <ZoomOut size={14} className="hover:text-ink cursor-pointer" onClick={onZoomOut} />
83
+ <ZoomIn size={14} className="hover:text-ink cursor-pointer" onClick={onZoomIn} />
84
+ </div>
85
+ </>
86
+ )}
78
87
 
79
88
  <div className="flex items-center space-x-2 pr-3 border-r border-ink/10">
80
89
  <Copy size={14} className="hover:text-ink cursor-pointer" onClick={onCopy} />
@@ -5,6 +5,7 @@ import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
5
5
  import { useScrollbarFade, scrollbarFadeClass } from '@/client/hooks/ui/scrollbar-fade';
6
6
  import { EditorTabs } from '@/client/components/workspace/editor/Tabs';
7
7
  import { EditorToolbar } from '@/client/components/workspace/editor/Toolbar';
8
+ import { ImageViewer } from '@/client/components/common/image-viewer';
8
9
  import { getDefaultContent } from '@/shared/lib/code/editor-utils';
9
10
  import { DiffPanel } from '@/client/components/workspace/diff-panel';
10
11
  import { useSessionState } from '@/client/hooks/workspace/session-state';
@@ -134,6 +135,7 @@ export function Editor({
134
135
  wordWrap={wordWrap}
135
136
  isMd={isMd}
136
137
  isPreview={isPreview}
138
+ isImage={editor.isImage}
137
139
  onSave={editor.saveNow}
138
140
  onTogglePreview={togglePreview}
139
141
  onToggleWordWrap={() => setWordWrap(!wordWrap)}
@@ -144,42 +146,49 @@ export function Editor({
144
146
  onToggleMaximize={() => setIsMaximized(!isMaximized)}
145
147
  />
146
148
 
147
- <div onScroll={handleScroll} className={`flex-1 overflow-auto bg-paper flex ${scrollbarFadeClass(isScrolling)}`}>
148
- {(isMd && isPreview) ? (
149
- <div className="p-6 prose prose-sm max-w-4xl mx-auto font-sans flex-1" style={{ fontSize: `${zoomLevel}px` }}>
150
- <MarkdownRenderer content={currentContent} />
151
- </div>
152
- ) : (
153
- <CodeSurface
154
- value={currentContent}
155
- onValueChange={editor.onChange}
156
- language={editor.language}
157
- wordWrap={wordWrap}
158
- rootClassName="flex"
159
- gutterClassName="flex flex-col text-right pl-4 pr-3 select-none text-ink/30 font-mono border-r border-ink/10 bg-canvas sticky left-0 z-10"
160
- gutterStyle={{
161
- fontSize: zoomLevel,
162
- paddingTop: 16,
163
- paddingBottom: 16,
164
- lineHeight: 1.5,
165
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
166
- }}
167
- gutterLineClassName="min-w-[1.5rem]"
168
- // `min-w-max` keeps a long line intact and lets the panel
169
- // scroll sideways; while wrapping it would instead widen the
170
- // column past the panel, so the toggle had no effect at all.
171
- editorWrapperClassName={`flex-1 code-surface ${wordWrap ? 'min-w-0' : 'min-w-max'}`}
172
- editorPadding={16}
173
- editorClassName="font-mono focus:outline-none"
174
- editorStyle={{
175
- fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
176
- fontSize: zoomLevel,
177
- lineHeight: 1.5,
178
- minHeight: '100%',
179
- }}
180
- />
181
- )}
182
- </div>
149
+ {editor.isImage && editor.imageUrl ? (
150
+ // The picture owns its own pan/zoom, so it must NOT sit inside
151
+ // the scrolling text container — a wheel there would scroll the
152
+ // pane instead of zooming the image.
153
+ <ImageViewer src={editor.imageUrl} name={activeFile.name} className="flex-1 min-h-0" />
154
+ ) : (
155
+ <div onScroll={handleScroll} className={`flex-1 overflow-auto bg-paper flex ${scrollbarFadeClass(isScrolling)}`}>
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} />
159
+ </div>
160
+ ) : (
161
+ <CodeSurface
162
+ value={currentContent}
163
+ onValueChange={editor.onChange}
164
+ language={editor.language}
165
+ wordWrap={wordWrap}
166
+ rootClassName="flex"
167
+ gutterClassName="flex flex-col text-right pl-4 pr-3 select-none text-ink/30 font-mono border-r border-ink/10 bg-canvas sticky left-0 z-10"
168
+ gutterStyle={{
169
+ fontSize: zoomLevel,
170
+ paddingTop: 16,
171
+ paddingBottom: 16,
172
+ lineHeight: 1.5,
173
+ fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
174
+ }}
175
+ gutterLineClassName="min-w-[1.5rem]"
176
+ // `min-w-max` keeps a long line intact and lets the panel
177
+ // scroll sideways; while wrapping it would instead widen the
178
+ // column past the panel, so the toggle had no effect at all.
179
+ editorWrapperClassName={`flex-1 code-surface ${wordWrap ? 'min-w-0' : 'min-w-max'}`}
180
+ editorPadding={16}
181
+ editorClassName="font-mono focus:outline-none"
182
+ editorStyle={{
183
+ fontFamily: '"Fira Code", "JetBrains Mono", "SF Mono", Consolas, monospace',
184
+ fontSize: zoomLevel,
185
+ lineHeight: 1.5,
186
+ minHeight: '100%',
187
+ }}
188
+ />
189
+ )}
190
+ </div>
191
+ )}
183
192
  </>
184
193
  )
185
194
  ) : (
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Live BTW state for one session: the topic list, the answer streaming right
8
+ * now, and the commands that drive them.
9
+ *
10
+ * The server owns the state — every frame is a full snapshot or a delta, and
11
+ * every command answers with the canonical state — so this hook keeps no
12
+ * merge logic of its own and a reload can never disagree with the server.
13
+ * The stream is attached only while the panel is open: a side question is a
14
+ * deliberate action, not background traffic.
15
+ */
16
+
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';
20
+ import type { StreamConnection } from '@/shared/lib/chat/omp/transport';
21
+
22
+ /** Images the side session accepts, already read as base64. */
23
+ export type BtwImage = AgentImage;
24
+
25
+ /** The turn streaming right now; `text` accumulates its deltas. */
26
+ export interface BtwLiveAnswer {
27
+ topicId: string;
28
+ turnIndex: number;
29
+ text: string;
30
+ }
31
+
32
+ export interface BtwSessionHandle {
33
+ state: BtwState | null;
34
+ live: BtwLiveAnswer | null;
35
+ error: string | null;
36
+ /** A command is in flight — the panel disables its actions meanwhile. */
37
+ busy: boolean;
38
+ /** Resolves with the canonical state the command produced, or null when it
39
+ * was refused (the reason is then in `error`). */
40
+ ask: (question: string, images?: BtwImage[], topicId?: string) => Promise<BtwState | null>;
41
+ abort: (topicId: string) => Promise<void>;
42
+ /** Returns the new session id to open, or null when it was refused. */
43
+ promote: (topicId: string) => Promise<string | null>;
44
+ remove: (topicId: string) => Promise<void>;
45
+ clearError: () => void;
46
+ }
47
+
48
+ export interface BtwSessionOptions {
49
+ /** Attach the frame stream (the panel is open). */
50
+ enabled: boolean;
51
+ transport: StreamTransport;
52
+ }
53
+
54
+ export function useBtwSession(sessionId: string | null, options: BtwSessionOptions): BtwSessionHandle {
55
+ const [state, setState] = useState<BtwState | null>(null);
56
+ const [live, setLive] = useState<BtwLiveAnswer | null>(null);
57
+ const [error, setError] = useState<string | null>(null);
58
+ const [busy, setBusy] = useState(false);
59
+ const { enabled, transport } = options;
60
+
61
+ // Commands must not be bound to the render that produced them: `/btw <q>`
62
+ // asks from the same tick that opens the form, before the stream attached and
63
+ // before this hook re-rendered with a session — a captured `sessionId` there
64
+ // would be null and the question would silently go nowhere.
65
+ const sessionIdRef = useRef(sessionId);
66
+ sessionIdRef.current = sessionId;
67
+
68
+ useEffect(() => {
69
+ if (!enabled || !sessionId) return;
70
+
71
+ const connection: StreamConnection = connectBtwStream(sessionId, transport, {
72
+ onOpen: () => {},
73
+ onFrame: (frame) => {
74
+ switch (frame.type) {
75
+ case 'btw_state':
76
+ 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.
79
+ 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;
84
+ });
85
+ break;
86
+ case 'btw_delta':
87
+ 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 },
91
+ );
92
+ break;
93
+ case 'btw_error':
94
+ setError(frame.message);
95
+ break;
96
+ default:
97
+ break;
98
+ }
99
+ },
100
+ onClose: () => {},
101
+ });
102
+
103
+ void fetchBtwState(sessionId)
104
+ .then(setState)
105
+ .catch((cause: unknown) => setError(cause instanceof Error ? cause.message : String(cause)));
106
+
107
+ return () => connection.close();
108
+ }, [enabled, sessionId, transport]);
109
+
110
+ const run = useCallback(
111
+ async (operation: (id: string) => Promise<BtwState>): Promise<BtwState | null> => {
112
+ const id = sessionIdRef.current;
113
+ if (!id) return null;
114
+ setBusy(true);
115
+ try {
116
+ const next = await operation(id);
117
+ setState(next);
118
+ return next;
119
+ } catch (cause) {
120
+ setError(cause instanceof BtwRequestError || cause instanceof Error ? cause.message : String(cause));
121
+ return null;
122
+ } finally {
123
+ setBusy(false);
124
+ }
125
+ },
126
+ [],
127
+ );
128
+
129
+ const ask = useCallback(
130
+ async (question: string, images?: BtwImage[], topicId?: string): Promise<BtwState | null> => {
131
+ setError(null);
132
+ const next = await run((id) => askBtwQuestion(id, { question, images, topicId }));
133
+ setLive(null);
134
+ if (next && !next.runningTopicId) {
135
+ setError('The side question was accepted but did not start running.');
136
+ }
137
+ return next;
138
+ },
139
+ [run],
140
+ );
141
+
142
+ const abort = useCallback(
143
+ async (topicId: string) => {
144
+ setError(null);
145
+ await run((id) => abortBtwQuestion(id, topicId));
146
+ },
147
+ [run],
148
+ );
149
+
150
+ const remove = useCallback(
151
+ async (topicId: string) => {
152
+ setError(null);
153
+ await run((id) => deleteBtwTopic(id, topicId));
154
+ },
155
+ [run],
156
+ );
157
+
158
+ const promote = useCallback(
159
+ async (topicId: string): Promise<string | null> => {
160
+ const id = sessionIdRef.current;
161
+ if (!id) return null;
162
+ setError(null);
163
+ setBusy(true);
164
+ try {
165
+ const created = await promoteBtwTopic(id, topicId);
166
+ return created.sessionId;
167
+ } catch (cause) {
168
+ setError(cause instanceof BtwRequestError || cause instanceof Error ? cause.message : String(cause));
169
+ return null;
170
+ } finally {
171
+ setBusy(false);
172
+ }
173
+ },
174
+ [],
175
+ );
176
+
177
+ const clearError = useCallback(() => setError(null), []);
178
+
179
+ return { state, live, error, busy, ask, abort, promote, remove, clearError };
180
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * `/btw [question]` typed into the main composer is the side-question entry
8
+ * point, not chat text: the panel owns the token (omp's `/btw` is TUI-only, so
9
+ * nothing downstream would understand it). This module is the whole
10
+ * interception — recognize the command, carry the composer's images across as
11
+ * provider payloads, and hand the question to the panel over `omp:btw`.
12
+ *
13
+ * It lives beside the rest of the client-side btw state (`hooks/chat/btw/`)
14
+ * rather than in the send handler: the attachment read goes through the shared
15
+ * accessors, because `Attachment.file` is absent on every replayed attachment.
16
+ */
17
+
18
+ import type { Attachment } from '@/shared/types';
19
+ import { attachmentImage } from '@/shared/lib/chat/attachments';
20
+
21
+ /** A bare `/btw` opens the form on the current history; anything after it is
22
+ * the first question. Case-insensitive, because omp's command tokens are. */
23
+ const BTW_COMMAND_RE = /^\/btw(?:\s+([\s\S]*))?$/i;
24
+
25
+ /**
26
+ * Hand a `/btw` draft to the side-question panel. Returns false when the text
27
+ * is not a btw command, so the caller keeps its normal send path.
28
+ */
29
+ export function dispatchBtwCommand(text: string, attachments: Attachment[]): boolean {
30
+ const match = BTW_COMMAND_RE.exec(text);
31
+ if (!match) return false;
32
+
33
+ const images = attachments
34
+ .map((attachment) => attachmentImage(attachment))
35
+ .filter((image): image is NonNullable<typeof image> => image !== null);
36
+
37
+ window.dispatchEvent(
38
+ new CustomEvent('omp:btw', {
39
+ detail: { question: match[1]?.trim() ?? '', ...(images.length ? { images } : {}) },
40
+ }),
41
+ );
42
+ return true;
43
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * BTW mode: the state behind the side-question form.
8
+ *
9
+ * Entering the mode replaces the chat composer with the form itself — an ask
10
+ * card ("Ask your question", always a new topic) above the side session's
11
+ * composer (follow-ups to the topic in view). The mode flag, both drafts and
12
+ * the selected topic live in per-session state, so a reload lands back in the
13
+ * form the user left and leaving the mode loses nothing.
14
+ *
15
+ * `/btw [question]` in the main composer is the entry point; a question that
16
+ * arrived with the command is asked immediately, which is what the TUI does.
17
+ */
18
+
19
+ import { useCallback } from 'preact/hooks';
20
+ import type { BtwState, BtwTopic } from '@/shared/types';
21
+ import { useBtwSession, type BtwImage, type BtwLiveAnswer } from '@/client/hooks/chat/btw';
22
+ import { useChamberEvent } from '@/client/hooks/ui/window-event';
23
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
24
+ import { useSearchParams } from '@/client/lib/router/search-params';
25
+ import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
26
+
27
+ /** Question sent when a question arrived carrying only images. */
28
+ const ATTACHMENT_ONLY_QUESTION = 'Describe the attached image.';
29
+
30
+ export interface BtwMode {
31
+ open: boolean;
32
+ topics: BtwTopic[];
33
+ activeTopic: BtwTopic | null;
34
+ /** Topic with a turn in flight, if any. */
35
+ runningTopicId: string | null;
36
+ /** A command is still in flight — the panel holds its actions meanwhile. */
37
+ busy: boolean;
38
+ /** Partial answer of the running turn, when it belongs to the active topic. */
39
+ liveAnswer: string;
40
+ error: string | null;
41
+ askDraft: string;
42
+ followUpDraft: string;
43
+ setAskDraft: (value: string) => void;
44
+ setFollowUpDraft: (value: string) => void;
45
+ /** Open the form on the current history (bare `/btw`). */
46
+ enter: () => void;
47
+ /** Leave the form; drafts and history stay. */
48
+ exit: () => void;
49
+ /** ⟳ — clear the question in flight and let the newest topic show. */
50
+ resetQuestion: () => void;
51
+ /** Submit the ask card: a question of its own. */
52
+ submitAsk: () => void;
53
+ /** Submit the side session's composer: a follow-up to the active topic. */
54
+ submitFollowUp: (question: string, images?: BtwImage[]) => void;
55
+ abort: () => void;
56
+ /** Returns the session id to open, or null when promotion was refused. */
57
+ promote: () => Promise<string | null>;
58
+ removeTopic: (topicId: string) => void;
59
+ selectTopic: (topicId: string) => void;
60
+ clearError: () => void;
61
+ }
62
+
63
+ export function useBtwMode(sessionId: string | null, appSettings: Record<string, unknown>): BtwMode {
64
+ const [open, setOpen] = useSessionState<boolean>('chat.btwOpen', false);
65
+ const [askDraft, setAskDraft] = useSessionState<string>('chat.btwAsk', '');
66
+ const [followUpDraft, setFollowUpDraft] = useSessionState<string>('chat.btwFollowUp', '');
67
+ const [selectedTopicId, setSelectedTopicId] = useSessionState<string | null>('chat.btwTopicId', null);
68
+ const [, setSearchParams] = useSearchParams();
69
+
70
+ // `enabled` only gates the stream: the commands must work the moment the form
71
+ // opens, which is before the stream has attached.
72
+ const session = useBtwSession(sessionId, {
73
+ enabled: open && Boolean(sessionId),
74
+ transport: readStreamTransport(appSettings),
75
+ });
76
+
77
+ const topics = session.state?.topics ?? [];
78
+ const activeTopic = topics.find((topic) => topic.id === selectedTopicId) ?? topics[topics.length - 1] ?? null;
79
+ const runningTopicId = session.state?.runningTopicId ?? null;
80
+ const live: BtwLiveAnswer | null = session.live && session.live.topicId === activeTopic?.id ? session.live : null;
81
+
82
+ const selectNewest = useCallback(
83
+ (next: BtwState | null) => {
84
+ if (!next) return;
85
+ setSelectedTopicId(next.topics[next.topics.length - 1]?.id ?? null);
86
+ },
87
+ [setSelectedTopicId],
88
+ );
89
+
90
+ // `/btw [question]` hands the question over; a bare `/btw` only opens the
91
+ // form, which is the history view.
92
+ useChamberEvent('omp:btw', (event) => {
93
+ const detail = (event as CustomEvent<{ question?: string; images?: BtwImage[] }>).detail;
94
+ const question = detail?.question?.trim();
95
+ setOpen(true);
96
+ if (question || detail?.images?.length) {
97
+ setAskDraft('');
98
+ void session.ask(question || ATTACHMENT_ONLY_QUESTION, detail?.images).then(selectNewest);
99
+ }
100
+ });
101
+
102
+ const submitAsk = useCallback(() => {
103
+ const question = askDraft.trim();
104
+ if (!question) return;
105
+ setAskDraft('');
106
+ void session.ask(question).then(selectNewest);
107
+ }, [askDraft, session, setAskDraft, selectNewest]);
108
+
109
+ const submitFollowUp = useCallback(
110
+ (question: string, images?: BtwImage[]) => {
111
+ const text = question.trim();
112
+ if (!text && !images?.length) return;
113
+ setFollowUpDraft('');
114
+ // With no topic in view yet, the side session's first question is the
115
+ // topic — there is nothing to follow up on.
116
+ void session.ask(text || ATTACHMENT_ONLY_QUESTION, images, activeTopic?.id).then((next) => {
117
+ if (!activeTopic) selectNewest(next);
118
+ });
119
+ },
120
+ [activeTopic, session, setFollowUpDraft, selectNewest],
121
+ );
122
+
123
+ const promote = useCallback(async () => {
124
+ if (!activeTopic) return null;
125
+ const created = await session.promote(activeTopic.id);
126
+ if (!created) return null;
127
+ setOpen(false);
128
+ setSelectedTopicId(null);
129
+ setSearchParams((prev) => {
130
+ const next = new URLSearchParams(prev);
131
+ next.set('sessionId', created);
132
+ if (next.has('subagent')) next.delete('subagent');
133
+ return next;
134
+ });
135
+ window.dispatchEvent(new CustomEvent('omp:session-updated', { detail: { sessionId: created } }));
136
+ return created;
137
+ }, [activeTopic, session, setOpen, setSelectedTopicId, setSearchParams]);
138
+
139
+ const removeTopic = useCallback(
140
+ (topicId: string) => {
141
+ if (topicId === selectedTopicId) setSelectedTopicId(null);
142
+ void session.remove(topicId);
143
+ },
144
+ [selectedTopicId, session, setSelectedTopicId],
145
+ );
146
+
147
+ return {
148
+ open,
149
+ topics,
150
+ activeTopic,
151
+ runningTopicId,
152
+ busy: session.busy,
153
+ liveAnswer: live ? live.text : '',
154
+ error: session.error,
155
+ askDraft,
156
+ followUpDraft,
157
+ setAskDraft,
158
+ setFollowUpDraft,
159
+ enter: () => setOpen(true),
160
+ exit: () => setOpen(false),
161
+ resetQuestion: () => {
162
+ setAskDraft('');
163
+ setSelectedTopicId(null);
164
+ session.clearError();
165
+ },
166
+ submitAsk,
167
+ submitFollowUp,
168
+ abort: () => {
169
+ if (runningTopicId) void session.abort(runningTopicId);
170
+ },
171
+ promote,
172
+ removeTopic,
173
+ selectTopic: (topicId: string) => {
174
+ setSelectedTopicId(topicId);
175
+ session.clearError();
176
+ },
177
+ clearError: session.clearError,
178
+ };
179
+ }