ompchamber 1.1.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 (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.57b39ffa7f.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 +181 -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 +83 -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.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The ask card — the top card of the side-question form.
8
+ *
9
+ * It is the form's own entry, not the chat's: the question typed here is always
10
+ * a question of its own (a new topic), which is why it carries its own `⟳`
11
+ * (start over) and `✕` (leave the form). The topic history and promotion ride
12
+ * along here because the form replaced the composer that used to hold them.
13
+ */
14
+
15
+ import { useEffect, useRef, useState } from 'preact/hooks';
16
+ import type { TargetedKeyboardEvent } from 'preact';
17
+ import { ChevronDown, ExternalLink, RefreshCw, X } from 'lucide-preact';
18
+ import type { BtwTopic } from '@/shared/types';
19
+ import { BtwTopicMenu } from '@/client/components/workspace/btw-panel/TopicMenu';
20
+
21
+ export interface BtwAskCardProps {
22
+ value: string;
23
+ onChange: (value: string) => void;
24
+ onSubmit: () => void;
25
+ /** `⟳` — clear the question and fall back to the newest topic. */
26
+ onNewQuestion: () => void;
27
+ /** `✕` — leave the side-question form. */
28
+ onClose: () => void;
29
+ /** A side answer is streaming: starting over waits for it. */
30
+ running: boolean;
31
+ topics: BtwTopic[];
32
+ activeTopicId: string | null;
33
+ onSelectTopic: (id: string) => void;
34
+ onDeleteTopic: (id: string) => void;
35
+ canPromote: boolean;
36
+ onPromote: () => void;
37
+ }
38
+
39
+ const GHOST_BUTTON =
40
+ 'p-1.5 rounded text-ink/45 hover:text-ink hover:bg-ink/5 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-ink/45';
41
+
42
+ export function BtwAskCard({
43
+ value,
44
+ onChange,
45
+ onSubmit,
46
+ onNewQuestion,
47
+ onClose,
48
+ running,
49
+ topics,
50
+ activeTopicId,
51
+ onSelectTopic,
52
+ onDeleteTopic,
53
+ canPromote,
54
+ onPromote,
55
+ }: BtwAskCardProps) {
56
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
57
+ const [menuOpen, setMenuOpen] = useState(false);
58
+
59
+ // Grow with the text and collapse again once it is submitted or cleared.
60
+ useEffect(() => {
61
+ const el = textareaRef.current;
62
+ if (!el) return;
63
+ el.style.height = 'auto';
64
+ el.style.height = `${el.scrollHeight}px`;
65
+ }, [value]);
66
+
67
+ const handleKeyDown = (event: TargetedKeyboardEvent<HTMLTextAreaElement>) => {
68
+ if (event.key === 'Escape') {
69
+ event.preventDefault();
70
+ onClose();
71
+ return;
72
+ }
73
+ if (event.key !== 'Enter' || event.shiftKey) return;
74
+ if (event.isComposing || event.keyCode === 229) return;
75
+ event.preventDefault();
76
+ onSubmit();
77
+ };
78
+
79
+ return (
80
+ <div className="bg-paper border border-ink/20 rounded-2xl shadow-sm flex items-center gap-1 pl-1.5 pr-2 py-1 focus-within:border-ink transition-colors">
81
+ <button
82
+ type="button"
83
+ onClick={() => {
84
+ onNewQuestion();
85
+ // The button took the focus; asking again means typing again.
86
+ textareaRef.current?.focus();
87
+ }}
88
+ disabled={running}
89
+ className={GHOST_BUTTON}
90
+ aria-label="New side question"
91
+ title="New side question"
92
+ >
93
+ <RefreshCw size={14} strokeWidth={2} />
94
+ </button>
95
+
96
+ <textarea
97
+ ref={textareaRef}
98
+ rows={1}
99
+ value={value}
100
+ onInput={(event) => onChange(event.currentTarget.value)}
101
+ onKeyDown={handleKeyDown}
102
+ placeholder="Ask your question"
103
+ aria-label="Ask your question"
104
+ className="flex-1 min-w-0 bg-transparent text-[13px] text-ink placeholder-ink/40 resize-none outline-none py-1.5 max-h-32 leading-6"
105
+ />
106
+
107
+ {topics.length > 0 && (
108
+ <div className="relative">
109
+ <button
110
+ type="button"
111
+ onClick={() => setMenuOpen((open) => !open)}
112
+ className={GHOST_BUTTON}
113
+ aria-label="Side question history"
114
+ title="Side question history"
115
+ aria-expanded={menuOpen}
116
+ data-btw-menu-anchor=""
117
+ >
118
+ <ChevronDown size={14} strokeWidth={2} />
119
+ </button>
120
+ {menuOpen && (
121
+ <BtwTopicMenu
122
+ topics={topics}
123
+ activeTopicId={activeTopicId}
124
+ onSelectTopic={onSelectTopic}
125
+ onDeleteTopic={onDeleteTopic}
126
+ onCloseMenu={() => setMenuOpen(false)}
127
+ />
128
+ )}
129
+ </div>
130
+ )}
131
+
132
+ {canPromote && (
133
+ <button
134
+ type="button"
135
+ onClick={onPromote}
136
+ className={GHOST_BUTTON}
137
+ aria-label="Promote answer into chat"
138
+ title="Promote answer into chat"
139
+ >
140
+ <ExternalLink size={14} strokeWidth={2} />
141
+ </button>
142
+ )}
143
+
144
+ <button
145
+ type="button"
146
+ onClick={onClose}
147
+ className={GHOST_BUTTON}
148
+ aria-label="Close side questions"
149
+ title="Close side questions"
150
+ >
151
+ <X size={14} strokeWidth={2} />
152
+ </button>
153
+ </div>
154
+ );
155
+ }
@@ -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
+ }