ompchamber 1.0.0 → 1.2.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 (233) 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.0d6ae50fe8.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.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * BTW panel composer card — the side question's own input.
3
+ *
4
+ * Pure presentation: text, attachments and the running turn are owned by the
5
+ * parent; this file only autosizes the textarea, reads keys and renders.
6
+ */
7
+
8
+ import { useEffect, useRef } from 'preact/hooks';
9
+ import type { TargetedEvent, TargetedKeyboardEvent } from 'preact';
10
+ import { File as FileIcon, Paperclip, Send, Shield, Square, X } from 'lucide-preact';
11
+ import { formatBytes } from '@/shared/lib/format/number';
12
+ import { providerLabel } from '@/shared/lib/models/provider-label';
13
+
14
+ export interface BtwComposerProps {
15
+ value: string;
16
+ onChange: (value: string) => void;
17
+ onSubmit: () => void;
18
+ onStop: () => void;
19
+ running: boolean;
20
+ modelLabel?: string;
21
+ provider?: string;
22
+ attachments: { id: string; name: string; size: number }[];
23
+ onFilesSelected: (files: File[]) => void;
24
+ onRemoveAttachment: (id: string) => void;
25
+ }
26
+
27
+ const ICON_BUTTON = 'p-1.5 rounded text-ink/60 hover:text-ink hover:bg-ink/5 transition-colors cursor-pointer';
28
+
29
+ export function BtwComposer({
30
+ value,
31
+ onChange,
32
+ onSubmit,
33
+ onStop,
34
+ running,
35
+ modelLabel,
36
+ provider,
37
+ attachments,
38
+ onFilesSelected,
39
+ onRemoveAttachment,
40
+ }: BtwComposerProps) {
41
+ const fileInputRef = useRef<HTMLInputElement>(null);
42
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
43
+
44
+ const canSend = value.trim().length > 0 || attachments.length > 0;
45
+ const providerText = provider ? providerLabel(provider) : '';
46
+
47
+ // The form replaces the chat composer that had focus, so the caret is put
48
+ // here — the field the form is for, and the one a restored draft is waiting
49
+ // in — instead of being left on the document body.
50
+ useEffect(() => {
51
+ const textarea = textareaRef.current;
52
+ if (!textarea) return;
53
+ textarea.focus();
54
+ textarea.setSelectionRange(textarea.value.length, textarea.value.length);
55
+ }, []);
56
+
57
+ // Grow with the content and collapse again when the parent clears `value`
58
+ // after a submit — `height: auto` first, otherwise the box can only expand.
59
+ useEffect(() => {
60
+ const textarea = textareaRef.current;
61
+ if (!textarea) return;
62
+ textarea.style.height = 'auto';
63
+ textarea.style.height = `${textarea.scrollHeight}px`;
64
+ }, [value]);
65
+
66
+ const handleKeyDown = (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
67
+ if (event.key === 'Escape') {
68
+ event.currentTarget.blur();
69
+ return;
70
+ }
71
+ if (event.key !== 'Enter' || event.shiftKey) return;
72
+ // IME candidate selection must not submit the question.
73
+ if (event.isComposing || event.keyCode === 229) return;
74
+ event.preventDefault();
75
+ if (canSend) onSubmit();
76
+ };
77
+
78
+ const handleFileSelect = (event: TargetedEvent<HTMLInputElement>) => {
79
+ const files = event.currentTarget.files;
80
+ if (files && files.length > 0) onFilesSelected(Array.from(files));
81
+ if (fileInputRef.current) fileInputRef.current.value = '';
82
+ };
83
+
84
+ return (
85
+ <div className="bg-paper border border-ink/20 rounded-2xl shadow-lg flex flex-col overflow-hidden">
86
+ <textarea
87
+ ref={textareaRef}
88
+ rows={1}
89
+ value={value}
90
+ onInput={(event) => onChange(event.currentTarget.value)}
91
+ onKeyDown={handleKeyDown}
92
+ placeholder="Ask in this btw session..."
93
+ className="w-full bg-transparent text-[13px] text-ink placeholder-ink/40 resize-none outline-none px-3.5 pt-3 pb-1 max-h-32"
94
+ />
95
+
96
+ {attachments.length > 0 && (
97
+ <div className="flex items-center flex-wrap gap-1.5 px-3 pt-2">
98
+ {attachments.map((attachment) => (
99
+ <div
100
+ key={attachment.id}
101
+ className="relative flex items-center bg-canvas border border-ink/10 rounded-md p-0.5 pr-6 text-xs"
102
+ >
103
+ <div className="w-6 h-6 flex items-center justify-center bg-ink/5 rounded-sm mr-1.5 text-ink/60">
104
+ <FileIcon size={12} strokeWidth={2} />
105
+ </div>
106
+ <span className="truncate max-w-[120px] font-mono text-[10px] text-ink/80">{attachment.name}</span>
107
+ {formatBytes(attachment.size) && (
108
+ <span className="ml-1.5 font-mono text-[10px] text-ink/40">{formatBytes(attachment.size)}</span>
109
+ )}
110
+ <button
111
+ type="button"
112
+ onClick={() => onRemoveAttachment(attachment.id)}
113
+ className="absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 text-ink/40 hover:text-error hover:bg-error/10 rounded transition-colors cursor-pointer"
114
+ aria-label={`Remove ${attachment.name}`}
115
+ title="Remove attachment"
116
+ >
117
+ <X size={10} strokeWidth={2} />
118
+ </button>
119
+ </div>
120
+ ))}
121
+ </div>
122
+ )}
123
+
124
+ <div className="flex items-center justify-between px-2 pb-2">
125
+ <div className="flex items-center gap-0.5">
126
+ <button
127
+ type="button"
128
+ onClick={() => fileInputRef.current?.click()}
129
+ className={ICON_BUTTON}
130
+ aria-label="Attach files"
131
+ title="Attach files"
132
+ >
133
+ <Paperclip size={14} strokeWidth={2} />
134
+ </button>
135
+
136
+ <span
137
+ className="p-1.5 text-ink/40"
138
+ aria-label="Side answers never use tools"
139
+ title="Side answers never use tools"
140
+ >
141
+ <Shield size={14} strokeWidth={2} />
142
+ </span>
143
+ </div>
144
+
145
+ <div className="flex items-center gap-2 min-w-0">
146
+ {(providerText || modelLabel) && (
147
+ <div className="flex items-baseline gap-1.5 min-w-0">
148
+ {providerText && <span className="text-[10px] text-ink/40 shrink-0">{providerText}</span>}
149
+ {modelLabel && <span className="text-[11px] text-ink/70 truncate">{modelLabel}</span>}
150
+ </div>
151
+ )}
152
+
153
+ {running ? (
154
+ <button
155
+ type="button"
156
+ onClick={onStop}
157
+ className="bg-ink text-canvas rounded p-1.5 cursor-pointer"
158
+ aria-label="Stop side answer"
159
+ title="Stop side answer"
160
+ >
161
+ <Square size={14} strokeWidth={2} />
162
+ </button>
163
+ ) : (
164
+ <button
165
+ type="button"
166
+ onClick={onSubmit}
167
+ disabled={!canSend}
168
+ className="bg-ink text-canvas hover:bg-ink/80 rounded p-1.5 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
169
+ aria-label="Send side question"
170
+ title="Send side question"
171
+ >
172
+ <Send size={14} strokeWidth={2} />
173
+ </button>
174
+ )}
175
+ </div>
176
+ </div>
177
+
178
+ <input type="file" accept="image/*" multiple className="hidden" ref={fileInputRef} onChange={handleFileSelect} />
179
+ </div>
180
+ );
181
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * BTW panel message body — the scrollable turn list of the active topic.
3
+ *
4
+ * Pure presentation: the parent streams `liveAnswer` in and this file only
5
+ * decides which text is current. No fetch, no session state.
6
+ */
7
+
8
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
9
+ import type { BtwTopic, BtwTurnStatus } from '@/shared/types/btw';
10
+ import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
11
+
12
+ export interface BtwMessagesProps {
13
+ topic: BtwTopic | null;
14
+ /** Partial text of the running turn ('' when none). */
15
+ liveAnswer: string;
16
+ /** Panel-level error line, surfaced above the turns. */
17
+ error: string | null;
18
+ }
19
+
20
+ /** Status line under an answer that is not `complete`. */
21
+ function AnswerStatus({ status }: { status: BtwTurnStatus }) {
22
+ if (status === 'complete') return null;
23
+
24
+ if (status === 'running') {
25
+ return (
26
+ <div className="flex items-center gap-1.5 text-[11px] text-ink/50">
27
+ <span className="w-1.5 h-1.5 rounded-full bg-ink/40 animate-pulse" />
28
+ <span>Thinking…</span>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ if (status === 'cancelled') {
34
+ return <div className="text-[11px] text-ink/50">Cancelled</div>;
35
+ }
36
+
37
+ return <div className="text-[11px] text-error">{status === 'failed' ? 'Failed' : 'Interrupted'}</div>;
38
+ }
39
+
40
+ export function BtwMessages({ topic, liveAnswer, error }: BtwMessagesProps) {
41
+ const scrollRef = useRef<HTMLDivElement>(null);
42
+ const turns = topic?.turns ?? [];
43
+
44
+ // Cheap scroll signal: any visible text growth (stream delta, new question,
45
+ // topic switch) changes this number.
46
+ const renderedLength = useMemo(
47
+ () => turns.reduce((total, turn) => total + turn.question.length + turn.answer.length, 0) + liveAnswer.length,
48
+ [turns, liveAnswer],
49
+ );
50
+
51
+ useEffect(() => {
52
+ const body = scrollRef.current;
53
+ if (body) body.scrollTop = body.scrollHeight;
54
+ }, [renderedLength]);
55
+
56
+ const lastIndex = turns.length - 1;
57
+
58
+ return (
59
+ <div
60
+ ref={scrollRef}
61
+ className="flex-1 min-h-0 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static px-4 py-3 space-y-4"
62
+ >
63
+ {error && (
64
+ <div className="bg-error/10 border border-error/30 text-error rounded-lg px-3 py-2 text-[12px]">{error}</div>
65
+ )}
66
+
67
+ {topic === null ? (
68
+ <div className="flex h-full items-center justify-center px-4 text-center text-[12px] text-ink/50">
69
+ Start a side question — the answer stays out of this chat's transcript.
70
+ </div>
71
+ ) : (
72
+ turns.map((turn, index) => {
73
+ // The streamed text wins while the newest turn is live: either it is
74
+ // still running, or it already outgrew the last flushed answer.
75
+ const streaming =
76
+ index === lastIndex &&
77
+ liveAnswer !== '' &&
78
+ (turn.status === 'running' || liveAnswer.length > turn.answer.length);
79
+ const answer = streaming ? liveAnswer : turn.answer;
80
+
81
+ return (
82
+ <div key={turn.index} className="space-y-2">
83
+ <div className="ml-auto max-w-[85%] w-fit bg-ink/[0.06] border border-ink/10 rounded-xl px-3.5 py-2.5 text-[13px] text-ink break-words">
84
+ <MarkdownRenderer content={turn.question} />
85
+ </div>
86
+
87
+ {answer !== '' && (
88
+ <div className="px-1 text-[13px] text-ink leading-relaxed">
89
+ <MarkdownRenderer content={answer} />
90
+ </div>
91
+ )}
92
+
93
+ <AnswerStatus status={turn.status} />
94
+ </div>
95
+ );
96
+ })
97
+ )}
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * BTW topic history dropdown — the ⌄ menu of the panel header.
3
+ *
4
+ * Pure presentation: it renders the topic list and reports dismissal; the open
5
+ * state lives in `BtwHeader`.
6
+ */
7
+
8
+ import { useEffect, useRef } from 'preact/hooks';
9
+ import { Trash2 } from 'lucide-preact';
10
+ import type { BtwTopic, BtwTurnStatus } from '@/shared/types/btw';
11
+
12
+ export interface BtwTopicMenuProps {
13
+ topics: BtwTopic[];
14
+ activeTopicId: string | null;
15
+ onSelectTopic: (id: string) => void;
16
+ /** Drop a topic and its transcript — a topic copies the parent session, so
17
+ * history nobody will reopen is real disk. */
18
+ onDeleteTopic: (id: string) => void;
19
+ /** Called after a selection and on outside click / Escape. */
20
+ onCloseMenu: () => void;
21
+ }
22
+
23
+ const STATUS_LABEL: Record<BtwTurnStatus, string> = {
24
+ running: 'thinking…',
25
+ complete: 'complete',
26
+ cancelled: 'cancelled',
27
+ failed: 'failed',
28
+ interrupted: 'interrupted',
29
+ };
30
+
31
+ /** `<turn count> · <status of the newest turn>` — the one-line topic summary. */
32
+ export function BtwTopicMenu({ topics, activeTopicId, onSelectTopic, onDeleteTopic, onCloseMenu }: BtwTopicMenuProps) {
33
+ const menuRef = useRef<HTMLDivElement>(null);
34
+
35
+ // Outside dismissal. Pointerdown (not click) so the menu is gone before the
36
+ // underlying target reacts; the header toggle is exempt — it owns its own
37
+ // open state and would otherwise re-open what this handler just closed.
38
+ useEffect(() => {
39
+ const onPointerDown = (event: globalThis.PointerEvent) => {
40
+ const target = event.target instanceof Element ? event.target : null;
41
+ if (menuRef.current?.contains(target)) return;
42
+ if (target?.closest('[data-btw-menu-anchor]')) return;
43
+ onCloseMenu();
44
+ };
45
+ document.addEventListener('pointerdown', onPointerDown);
46
+ return () => document.removeEventListener('pointerdown', onPointerDown);
47
+ }, [onCloseMenu]);
48
+
49
+ useEffect(() => {
50
+ const onKeyDown = (event: globalThis.KeyboardEvent) => {
51
+ if (event.key === 'Escape') onCloseMenu();
52
+ };
53
+ document.addEventListener('keydown', onKeyDown);
54
+ return () => document.removeEventListener('keydown', onKeyDown);
55
+ }, [onCloseMenu]);
56
+
57
+ return (
58
+ <div
59
+ ref={menuRef}
60
+ role="menu"
61
+ aria-label="Side question history"
62
+ className="absolute left-0 top-full mt-1 z-50 bg-paper border border-ink/15 rounded-lg shadow-xl min-w-[280px] max-h-72 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static p-1"
63
+ >
64
+ {topics.length === 0 && (
65
+ <div className="px-2.5 py-2 text-[12px] text-ink/50">No side questions yet.</div>
66
+ )}
67
+
68
+ {topics.map((topic) => {
69
+ const count = topic.turns.length;
70
+ const newest = topic.turns[count - 1];
71
+
72
+ return (
73
+ <div
74
+ key={topic.id}
75
+ className={`group flex items-center rounded ${topic.id === activeTopicId ? 'bg-ink/5' : 'hover:bg-ink/5'}`}
76
+ >
77
+ <button
78
+ type="button"
79
+ role="menuitem"
80
+ onClick={() => {
81
+ onSelectTopic(topic.id);
82
+ onCloseMenu();
83
+ }}
84
+ className="flex-1 min-w-0 text-left px-2.5 py-2 rounded cursor-pointer"
85
+ >
86
+ <div className="truncate text-[12px] text-ink">{topic.title}</div>
87
+ <div className="text-[11px] text-ink/50">
88
+ {count} turn{count === 1 ? '' : 's'}
89
+ {newest ? ` · ${STATUS_LABEL[newest.status]}` : ''}
90
+ </div>
91
+ </button>
92
+ <button
93
+ type="button"
94
+ onClick={() => onDeleteTopic(topic.id)}
95
+ className="p-1.5 mr-1 rounded text-ink/40 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 hover:text-error hover:bg-error/10 transition-colors cursor-pointer"
96
+ aria-label={`Delete side question: ${topic.title}`}
97
+ title="Delete this side question"
98
+ >
99
+ <Trash2 size={12} strokeWidth={2} />
100
+ </button>
101
+ </div>
102
+ );
103
+ })}
104
+ </div>
105
+ );
106
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Attachment handling for the side composer.
8
+ *
9
+ * A side question reaches the model with images only — no text files and no
10
+ * tools (the prompt says so, and the child runs `--no-tools`), so an
11
+ * attachment that cannot be an image payload is not offered in the first
12
+ * place. The file picker is `accept="image/*"`; this reads what it returns.
13
+ */
14
+
15
+ import type { AgentImage } from '@/shared/types';
16
+
17
+ /** An attachment held by the side composer until it is sent. */
18
+ export interface BtwAttachment {
19
+ id: string;
20
+ name: string;
21
+ size: number;
22
+ dataBase64?: string;
23
+ }
24
+
25
+ function readImage(file: File): Promise<string | undefined> {
26
+ const { promise, resolve } = Promise.withResolvers<string | undefined>();
27
+ const reader = new FileReader();
28
+ reader.onload = () => {
29
+ const result = typeof reader.result === 'string' ? reader.result : '';
30
+ const comma = result.indexOf(',');
31
+ resolve(comma >= 0 ? result.slice(comma + 1) : undefined);
32
+ };
33
+ reader.onerror = () => resolve(undefined);
34
+ reader.readAsDataURL(file);
35
+ return promise;
36
+ }
37
+
38
+ /** Read picked files into attachments, keeping only images. */
39
+ export async function readBtwAttachments(files: File[]): Promise<BtwAttachment[]> {
40
+ const images = files.filter((file) => file.type.startsWith('image/'));
41
+ return Promise.all(
42
+ images.map(async (file) => {
43
+ const dataBase64 = await readImage(file);
44
+ return {
45
+ id: `btw-${file.name}-${file.size}-${file.lastModified}`,
46
+ name: file.name,
47
+ size: file.size,
48
+ ...(dataBase64 ? { dataBase64 } : {}),
49
+ };
50
+ }),
51
+ );
52
+ }
53
+
54
+ /** The model payload for an ask — images only, base64 already read. */
55
+ export function btwImagesFrom(attachments: BtwAttachment[]): AgentImage[] {
56
+ return attachments
57
+ .filter((attachment): attachment is BtwAttachment & { dataBase64: string } => typeof attachment.dataBase64 === 'string')
58
+ .map((attachment) => ({
59
+ type: 'image' as const,
60
+ data: attachment.dataBase64,
61
+ mimeType: mimeTypeOf(attachment.name),
62
+ }));
63
+ }
64
+
65
+ const MIME_BY_EXTENSION: Record<string, string> = {
66
+ png: 'image/png',
67
+ jpg: 'image/jpeg',
68
+ jpeg: 'image/jpeg',
69
+ gif: 'image/gif',
70
+ webp: 'image/webp',
71
+ bmp: 'image/bmp',
72
+ svg: 'image/svg+xml',
73
+ avif: 'image/avif',
74
+ };
75
+
76
+ function mimeTypeOf(name: string): string {
77
+ const dot = name.lastIndexOf('.');
78
+ const extension = dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
79
+ return MIME_BY_EXTENSION[extension] ?? 'image/png';
80
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The side-question form — what the chat composer becomes while BTW mode is on.
8
+ *
9
+ * Stacked cards, top to bottom: the topic's turns (only once there is something
10
+ * to show, or an error to explain), the ask card (a question of its own), and
11
+ * the side session's composer (follow-ups, attachments, model chip). Nothing it
12
+ * produces enters the chat transcript — that is the point of a side question —
13
+ * and promotion is the one explicit action that moves an answer into the chat,
14
+ * as a session branched from it.
15
+ */
16
+
17
+ import { useState } from 'preact/hooks';
18
+ import type { BtwMode } from '@/client/hooks/chat/btw/mode';
19
+ import { BtwAskCard } from '@/client/components/workspace/btw-panel/AskCard';
20
+ import { BtwMessages } from '@/client/components/workspace/btw-panel/Messages';
21
+ import { BtwComposer } from '@/client/components/workspace/btw-panel/Composer';
22
+ import { btwImagesFrom, readBtwAttachments, type BtwAttachment } from '@/client/components/workspace/btw-panel/attachments';
23
+
24
+ export interface BtwFormProps {
25
+ mode: BtwMode;
26
+ /** The chat's own model, shown until the topic reports the one it ran on. */
27
+ modelName?: string;
28
+ provider?: string;
29
+ }
30
+
31
+ export function BtwForm({ mode, modelName, provider }: BtwFormProps) {
32
+ const [attachments, setAttachments] = useState<BtwAttachment[]>([]);
33
+ const lastTurn = mode.activeTopic?.turns[mode.activeTopic.turns.length - 1];
34
+ // `busy` is the command in flight (a promote POST, say): promoting again from
35
+ // the same click burst would be a second request for work already running.
36
+ const canPromote = Boolean(mode.activeTopic) && !mode.runningTopicId && !mode.busy && lastTurn?.status === 'complete';
37
+ const showTurns = (mode.activeTopic?.turns.length ?? 0) > 0 || Boolean(mode.error);
38
+
39
+ return (
40
+ <div className="flex flex-col gap-2">
41
+ {showTurns && (
42
+ <div className="bg-paper border border-ink/15 rounded-2xl shadow-xl flex flex-col max-h-[45vh] overflow-hidden">
43
+ <BtwMessages topic={mode.activeTopic} liveAnswer={mode.liveAnswer} error={mode.error} />
44
+ </div>
45
+ )}
46
+
47
+ <BtwAskCard
48
+ value={mode.askDraft}
49
+ onChange={mode.setAskDraft}
50
+ onSubmit={mode.submitAsk}
51
+ onNewQuestion={mode.resetQuestion}
52
+ onClose={mode.exit}
53
+ running={Boolean(mode.runningTopicId)}
54
+ topics={mode.topics}
55
+ activeTopicId={mode.activeTopic?.id ?? null}
56
+ onSelectTopic={mode.selectTopic}
57
+ onDeleteTopic={mode.removeTopic}
58
+ canPromote={canPromote}
59
+ onPromote={() => void mode.promote()}
60
+ />
61
+
62
+ <BtwComposer
63
+ value={mode.followUpDraft}
64
+ onChange={mode.setFollowUpDraft}
65
+ onSubmit={() => {
66
+ // Hand the images over and clear them: leaving them staged would
67
+ // re-send the same picture with every later follow-up.
68
+ mode.submitFollowUp(mode.followUpDraft, btwImagesFrom(attachments));
69
+ setAttachments([]);
70
+ }}
71
+ onStop={mode.abort}
72
+ running={Boolean(mode.runningTopicId)}
73
+ modelLabel={mode.activeTopic?.model?.name ?? modelName}
74
+ provider={mode.activeTopic?.model?.provider ?? provider}
75
+ attachments={attachments}
76
+ onFilesSelected={(files) => {
77
+ void readBtwAttachments(files).then((next) => setAttachments((prev) => [...prev, ...next]));
78
+ }}
79
+ onRemoveAttachment={(id) => setAttachments((prev) => prev.filter((attachment) => attachment.id !== id))}
80
+ />
81
+ </div>
82
+ );
83
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Dispatch, SetStateAction } from 'preact/compat';
2
2
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
3
+ import { BtwForm } from '@/client/components/workspace/btw-panel/index';
4
+ import { useBtwMode } from '@/client/hooks/chat/btw/mode';
3
5
  import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
4
6
  import { QueueList } from '@/client/components/workspace/chat-timeline/QueueList';
5
7
  import type { Attachment, QueuedMessage } from '@/shared/types';
@@ -7,6 +9,8 @@ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
7
9
 
8
10
  interface ComposerDockProps {
9
11
  isMobile: boolean;
12
+ /** Chamber session the side-question panel scopes to. */
13
+ sessionId: string | null;
10
14
  isGenerating: boolean;
11
15
  modelName?: string;
12
16
  generatingVerb: string;
@@ -32,6 +36,7 @@ interface ComposerDockProps {
32
36
  accessMode: ApprovalMode;
33
37
  onAccessModeChange: (mode: ApprovalMode) => void;
34
38
  composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
39
+ deferredComposerPickRef: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
35
40
  sessionModel: { provider: string; modelId: string } | null;
36
41
  sessionThinkingLevel?: string | null;
37
42
  variant: 'desktop' | 'mobile';
@@ -44,6 +49,7 @@ interface ComposerDockProps {
44
49
  */
45
50
  export function ComposerDock({
46
51
  isMobile,
52
+ sessionId,
47
53
  isGenerating,
48
54
  modelName,
49
55
  generatingVerb,
@@ -69,16 +75,24 @@ export function ComposerDock({
69
75
  accessMode,
70
76
  onAccessModeChange,
71
77
  composerModelRef,
78
+ deferredComposerPickRef,
72
79
  sessionModel,
73
80
  sessionThinkingLevel,
74
81
  variant,
75
82
  }: ComposerDockProps) {
83
+ // Side questions take over the composer entirely: the chat's own input is
84
+ // hidden while the form is open, so a stray Enter cannot send to the wrong
85
+ // conversation.
86
+ const btw = useBtwMode(sessionId, appSettings);
87
+
76
88
  return (
77
89
  <div
78
90
  className={`bg-transparent border-t-0 flex-shrink-0 space-y-2 ${isMobile ? 'px-3 pt-1' : 'p-4 pt-1'}`}
79
91
  style={isMobile ? { paddingBottom: 'max(0.75rem, env(safe-area-inset-bottom, 0px))' } : undefined}
80
92
  >
81
- <div className="mx-auto w-full max-w-[970px]">
93
+ {/* `relative` keeps the queue/indicator stack anchored above the input
94
+ while the side-question form takes the composer's place. */}
95
+ <div className="relative mx-auto w-full max-w-[970px]">
82
96
  {isGenerating && (
83
97
  <GeneratingIndicator
84
98
  modelName={modelName}
@@ -103,25 +117,30 @@ export function ComposerDock({
103
117
  })}
104
118
  isSteering
105
119
  />
106
- <ChatInput
107
- value={inputValue}
108
- onChange={setInputValue}
109
- rootPath={rootPath}
110
- attachments={attachments}
111
- onAttachmentsChange={setAttachments}
112
- onSend={onSend}
113
- isGenerating={isGenerating}
114
- onStop={onStop}
115
- appSettings={appSettings}
116
- onThinkingLevelChange={onThinkingLevelChange}
117
- onModelChange={onModelChange}
118
- accessMode={accessMode}
119
- onAccessModeChange={onAccessModeChange}
120
- composerModelRef={composerModelRef}
121
- sessionModel={sessionModel}
122
- sessionThinkingLevel={sessionThinkingLevel}
123
- variant={variant}
124
- />
120
+ {btw.open ? (
121
+ <BtwForm mode={btw} modelName={modelName} provider={provider} />
122
+ ) : (
123
+ <ChatInput
124
+ value={inputValue}
125
+ onChange={setInputValue}
126
+ rootPath={rootPath}
127
+ attachments={attachments}
128
+ onAttachmentsChange={setAttachments}
129
+ onSend={onSend}
130
+ isGenerating={isGenerating}
131
+ onStop={onStop}
132
+ appSettings={appSettings}
133
+ onThinkingLevelChange={onThinkingLevelChange}
134
+ onModelChange={onModelChange}
135
+ accessMode={accessMode}
136
+ onAccessModeChange={onAccessModeChange}
137
+ composerModelRef={composerModelRef}
138
+ deferredComposerPickRef={deferredComposerPickRef}
139
+ sessionModel={sessionModel}
140
+ sessionThinkingLevel={sessionThinkingLevel}
141
+ variant={variant}
142
+ />
143
+ )}
125
144
  </div>
126
145
  </div>
127
146
  );
@@ -40,6 +40,7 @@ interface EmptyWorkspacePromptProps {
40
40
  accessMode: ApprovalMode;
41
41
  onAccessModeChange: (mode: ApprovalMode) => void;
42
42
  composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
43
+ deferredComposerPickRef: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
43
44
  generatingVerb?: string;
44
45
  variant?: 'desktop' | 'mobile';
45
46
  }
@@ -69,6 +70,7 @@ export function EmptyWorkspacePrompt({
69
70
  accessMode,
70
71
  onAccessModeChange,
71
72
  composerModelRef,
73
+ deferredComposerPickRef,
72
74
  generatingVerb,
73
75
  variant = 'desktop',
74
76
  }: EmptyWorkspacePromptProps) {
@@ -226,6 +228,7 @@ export function EmptyWorkspacePrompt({
226
228
  accessMode={accessMode}
227
229
  onAccessModeChange={onAccessModeChange}
228
230
  composerModelRef={composerModelRef}
231
+ deferredComposerPickRef={deferredComposerPickRef}
229
232
  variant={variant}
230
233
  />
231
234
  </div>