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
@@ -1,4 +1,4 @@
1
- import type { ComposerPickItem, ComposerTrigger } from '@/shared/types';
1
+ import type { CommandItem, ComposerMatchItem, ComposerPickItem, ComposerTrigger } from '@/shared/types';
2
2
 
3
3
  /** Chars that may legally precede an `@` agent trigger (besides string start). */
4
4
  const AT_PRECEDING_RE = /[\s([{]/;
@@ -9,6 +9,35 @@ export const SKILL_NAMESPACE = 'skill:';
9
9
  /** The collapsed namespace row's token (no trailing space on accept). */
10
10
  export const SKILL_NAMESPACE_TOKEN = `/${SKILL_NAMESPACE}`;
11
11
 
12
+ /**
13
+ * Slash commands the chamber answers itself, reserved exactly like oh-my-pi
14
+ * reserves its own tokens.
15
+ *
16
+ * omp's `/btw` is TUI-only — its registry entry has `handleTui` and no
17
+ * text-mode `handle`, and `get_available_commands` only advertises entries the
18
+ * RPC dispatcher can run — so the composer would offer every command except the
19
+ * one it intercepts on send. The entry is byte-for-byte omp's own metadata
20
+ * (description and inline hint), so the popup reads the same in both clients.
21
+ */
22
+ export const CHAMBER_COMMANDS: CommandItem[] = [
23
+ {
24
+ id: 'chamber-btw',
25
+ name: 'btw',
26
+ description: "Ask a side question, or browse this session's BTW history",
27
+ scope: 'system',
28
+ template: '',
29
+ isBuiltIn: true,
30
+ inputHint: '[question]',
31
+ },
32
+ ];
33
+
34
+ /** True when the chamber intercepts this command instead of forwarding it to
35
+ * omp as chat text. */
36
+ function isChamberOwnedCommand(name: string): boolean {
37
+ const lower = name.toLowerCase();
38
+ return CHAMBER_COMMANDS.some((command) => command.name.toLowerCase() === lower);
39
+ }
40
+
12
41
  /**
13
42
  * Index of a `/` that starts the draft, ignoring leading whitespace —
14
43
  * oh-my-pi's `findLeadingSlashCommandStart`.
@@ -131,6 +160,21 @@ export function tokenForItem(item: ComposerPickItem): string {
131
160
  return item.insertWithoutSpace ? item.token : `${item.token} `;
132
161
  }
133
162
 
163
+ /**
164
+ * Whether a popup accept should stand aside and let Enter mean "send".
165
+ *
166
+ * Only the chamber's own commands qualify: the composer intercepts those on
167
+ * send, so for `/btw` the Enter *is* the action and accept-on-Enter would
168
+ * swallow the very keystroke the user is aiming at. Every command omp owns
169
+ * keeps accepting, which is what opens its argument completions — including the
170
+ * ones that declare no subcommands and take free-form arguments.
171
+ */
172
+ export function sendInsteadOfAccept(trigger: ComposerTrigger, item: ComposerMatchItem | undefined): boolean {
173
+ if (!item || trigger.kind !== 'command' || trigger.phase !== 'name') return false;
174
+ if (item.name.toLowerCase() !== trigger.query.toLowerCase()) return false;
175
+ return isChamberOwnedCommand(item.name);
176
+ }
177
+
134
178
  /** Splice `token` over the trigger span in `value`, returning the new value and caret. */
135
179
  export function insertToken(
136
180
  value: string,
@@ -20,40 +20,20 @@ import { normalizeThinkingLevel } from '@/shared/lib/models/thinking-levels';
20
20
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
21
21
  import { describeAssistantPhase, describeToolActivity } from '@/shared/lib/chat/timeline/tool-verbs';
22
22
  import { FILE_MUTATION_EVENT, isFileMutatingTool } from '@/shared/lib/chat/omp/file-mutations';
23
+ import {
24
+ pairToolOutputs,
25
+ putToolResult,
26
+ recordToolResult,
27
+ refreshToolMessage,
28
+ type ToolResultHost,
29
+ type ToolResultRecord,
30
+ } from '@/shared/lib/chat/omp/tool-results';
23
31
 
24
- /** Tool output accumulated between a `toolCall` block and its result frame. */
25
- export interface ToolResultRecord {
26
- output: string;
27
- isError?: boolean;
28
- details?: Record<string, any>;
29
- }
30
-
31
- /** Per-entry cap on stored tool output; the tail of a build log is what matters. */
32
- const MAX_TOOL_OUTPUT_CHARS = 200_000;
33
- /** Max tool results retained per session; oldest entries are dropped first. */
34
- const MAX_TRACKED_TOOL_RESULTS = 200;
35
-
36
- /** Truncate tool output to the cap, keeping the tail and marking the cut. */
37
- function capToolOutput(output: string): string {
38
- if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output;
39
- return `…[truncated]\n${output.slice(-MAX_TOOL_OUTPUT_CHARS)}`;
40
- }
32
+ // Re-exported so stream.ts and the timeline hook keep their existing import
33
+ // site; the implementation lives in tool-results.ts.
34
+ export type { ToolResultRecord } from '@/shared/lib/chat/omp/tool-results';
41
35
 
42
- /** Write a tool result into the map, enforcing both caps. */
43
- function storeToolResult(
44
- map: Map<string, ToolResultRecord>,
45
- callId: string,
46
- record: ToolResultRecord,
47
- ): void {
48
- map.set(callId, { ...record, output: capToolOutput(record.output) });
49
- while (map.size > MAX_TRACKED_TOOL_RESULTS) {
50
- const oldest = map.keys().next().value;
51
- if (oldest === undefined) break;
52
- map.delete(oldest);
53
- }
54
- }
55
-
56
- export interface OmpAgentFoldDeps {
36
+ export interface OmpAgentFoldDeps extends ToolResultHost {
57
37
  sessionId: string;
58
38
  setState: Dispatch<SetStateAction<OmpAgentState>>;
59
39
  callbacksRef: RefObject<OmpAgentCallbacks>;
@@ -78,45 +58,9 @@ function setActivity(verb: string | undefined, deps: OmpAgentFoldDeps): void {
78
58
  deps.callbacksRef.current?.onActivity?.(verb);
79
59
  }
80
60
 
81
- /** Re-emit the last tool-carrying assistant message with its results paired. */
82
- function pairToolOutputs(
83
- msg: ChatMessageData,
84
- toolResultsRef: RefObject<Map<string, ToolResultRecord>>,
85
- ): ChatMessageData {
86
- if (!msg.toolCalls?.length) return msg;
87
- const toolCalls = msg.toolCalls.map(tc => {
88
- const res = toolResultsRef.current?.get(tc.id);
89
- return res
90
- ? {
91
- ...tc,
92
- output: res.output,
93
- details: tc.details || res.details || undefined,
94
- status: (res.isError ? 'error' : 'success') as ToolCallData['status'],
95
- }
96
- : tc;
97
- });
98
- return { ...msg, toolCalls };
99
- }
100
-
101
- /** Record a toolResult frame's output against its tool call id. */
102
- function recordToolResult(
103
- raw: Record<string, unknown>,
104
- deps: OmpAgentFoldDeps,
105
- ): void {
106
- const callId = typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined;
107
- if (!callId) return;
108
- deps.toolResultsRef.current?.set(callId, {
109
- output: extractTextFromContent(raw.content),
110
- details: (raw.details && typeof raw.details === 'object' ? raw.details : undefined) as ToolResultRecord['details'],
111
- });
112
- }
113
-
114
- /** Re-emit the last tool-carrying message when `callId` belongs to it. */
115
- function refreshToolMessage(callId: string | undefined, deps: OmpAgentFoldDeps): void {
116
- if (!callId) return;
117
- const last = deps.lastToolMessageRef.current;
118
- if (!last?.toolCalls?.some(tc => tc.id === callId)) return;
119
- deps.callbacksRef.current?.onMessageUpdate?.(pairToolOutputs(last, deps.toolResultsRef));
61
+ /** Narrow a deps record to the tool-output host, adding the re-emit sink. */
62
+ function toolHost(deps: OmpAgentFoldDeps): ToolResultHost {
63
+ return { ...deps, onMessageUpdate: deps.callbacksRef.current?.onMessageUpdate };
120
64
  }
121
65
 
122
66
  /** Flush assistant turns that stopped abnormally and were never streamed as
@@ -141,7 +85,7 @@ function materializeTerminalMessages(
141
85
  if (raw.stopReason !== 'aborted' && raw.stopReason !== 'error') continue;
142
86
  const converted = toChatMessage(raw, false);
143
87
  if (!converted) continue;
144
- const paired = pairToolOutputs(converted, deps.toolResultsRef);
88
+ const paired = pairToolOutputs(converted, deps);
145
89
  if (paired.toolCalls?.length) deps.lastToolMessageRef.current = paired;
146
90
  callbacks?.onMessageEnd?.(paired);
147
91
  if (typeof raw.errorMessage === 'string') errorMessage = raw.errorMessage;
@@ -205,7 +149,7 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
205
149
  const completed = data.message as Record<string, unknown> | undefined;
206
150
  if (!completed) break;
207
151
  if (completed.role === 'toolResult') {
208
- recordToolResult(completed, deps);
152
+ recordToolResult(completed, toolHost(deps));
209
153
  break;
210
154
  }
211
155
  if (completed.role === 'custom') break;
@@ -219,7 +163,7 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
219
163
  if (converted.role !== 'user' && deps.currentThinkingLevelRef.current) {
220
164
  converted.thinkingLevel = deps.currentThinkingLevelRef.current;
221
165
  }
222
- const paired = pairToolOutputs(converted, deps.toolResultsRef);
166
+ const paired = pairToolOutputs(converted, deps);
223
167
  if (paired.toolCalls?.length) deps.lastToolMessageRef.current = paired;
224
168
  callbacks?.onMessageEnd?.(paired);
225
169
  break;
@@ -254,9 +198,8 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
254
198
  const partial = toolResultText(data.partialResult);
255
199
  if (!callId || !partial) break;
256
200
  const prev = deps.toolResultsRef.current?.get(callId)?.output ?? '';
257
- const map = deps.toolResultsRef.current;
258
- if (map) storeToolResult(map, callId, { output: prev + partial });
259
- refreshToolMessage(callId, deps);
201
+ putToolResult(deps, callId, { output: prev + partial });
202
+ refreshToolMessage(callId, toolHost(deps));
260
203
  break;
261
204
  }
262
205
 
@@ -264,13 +207,12 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
264
207
  const callId = typeof data.toolCallId === 'string' ? data.toolCallId : undefined;
265
208
  if (!callId) break;
266
209
  setActivity(PHASE_VERBS.thinking, deps);
267
- const map = deps.toolResultsRef.current;
268
- if (map) storeToolResult(map, callId, {
210
+ putToolResult(deps, callId, {
269
211
  output: toolResultText(data.result),
270
212
  isError: data.isError === true,
271
213
  details: (data.details && typeof data.details === 'object' ? data.details : undefined) as ToolResultRecord['details'],
272
214
  });
273
- refreshToolMessage(callId, deps);
215
+ refreshToolMessage(callId, toolHost(deps));
274
216
  // A file-mutating tool just finished — tell the data-bearing right panels
275
217
  // (files / git / context) to re-read now, not on their next poll tick.
276
218
  // Guarded: the fold also runs headless under `bun test` (no window).
@@ -328,6 +270,17 @@ export function foldAgentEvent(data: OmpAgentEvent, deps: OmpAgentFoldDeps): voi
328
270
  break;
329
271
  }
330
272
 
273
+ // omp renamed the session (auto-title generation, /rename, set_session_name).
274
+ // Nothing in the timeline changes, but the sidebar reads titles from the
275
+ // session file — and the rename is a 256-byte in-place slot write that the
276
+ // scan cache's mtime key cannot see — so this frame is the only push
277
+ // signal that the list is stale.
278
+ case 'session_info_update': {
279
+ const title = typeof data.title === 'string' ? data.title.trim() : '';
280
+ if (title) callbacks?.onSessionTitleChanged?.(title);
281
+ break;
282
+ }
283
+
331
284
  case 'notice': {
332
285
  callbacks?.onNotice?.(
333
286
  typeof data.level === 'string' ? data.level : 'info',
@@ -18,8 +18,7 @@
18
18
  * route's observer-only contract.
19
19
  */
20
20
 
21
- import type { OmpAgentEvent } from '@/shared/types';
22
- import type { AgentStreamConnection, AgentStreamHandlers } from '@/shared/lib/chat/omp/transport';
21
+ import type { AgentStreamConnection, AgentStreamHandlers, StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
23
22
  import { agentSocketUrl } from '@/shared/lib/chat/omp/transport';
24
23
 
25
24
  const RECONNECT_BASE_MS = 500;
@@ -27,7 +26,8 @@ const RECONNECT_MAX_MS = 8_000;
27
26
  /** Consecutive re-dials that never opened before the stream is given up on. */
28
27
  const MAX_FAILED_DIALS = 4;
29
28
 
30
- export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
29
+ /** One duplex JSON socket: reconnect with capped backoff, terminal first dial. */
30
+ export function connectSocket<TFrame>(url: string, handlers: StreamHandlers<TFrame>): StreamConnection {
31
31
  let socket: WebSocket | null = null;
32
32
  let retryTimer: ReturnType<typeof setTimeout> | null = null;
33
33
  let failures = 0;
@@ -35,7 +35,7 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
35
35
  let released = false;
36
36
 
37
37
  const dial = () => {
38
- const ws = new WebSocket(agentSocketUrl(sessionId));
38
+ const ws = new WebSocket(url);
39
39
  socket = ws;
40
40
 
41
41
  ws.onopen = () => {
@@ -47,9 +47,9 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
47
47
 
48
48
  ws.onmessage = (event) => {
49
49
  if (released) return;
50
- let data: OmpAgentEvent;
50
+ let data: TFrame;
51
51
  try {
52
- data = JSON.parse(String(event.data)) as OmpAgentEvent;
52
+ data = JSON.parse(String(event.data)) as TFrame;
53
53
  } catch {
54
54
  return;
55
55
  }
@@ -93,3 +93,8 @@ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandl
93
93
  },
94
94
  };
95
95
  }
96
+
97
+ /** WebSocket transport for a session's agent event stream. */
98
+ export function connectAgentSocket(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
99
+ return connectSocket(agentSocketUrl(sessionId), handlers);
100
+ }
@@ -12,12 +12,12 @@
12
12
  * (the server closed the stream) is reported upward.
13
13
  */
14
14
 
15
- import type { OmpAgentEvent } from '@/shared/types';
16
- import type { AgentStreamConnection, AgentStreamHandlers } from '@/shared/lib/chat/omp/transport';
15
+ import type { AgentStreamConnection, AgentStreamHandlers, StreamConnection, StreamHandlers } from '@/shared/lib/chat/omp/transport';
17
16
  import { agentEventsUrl } from '@/shared/lib/chat/omp/transport';
18
17
 
19
- export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
20
- const source = new EventSource(agentEventsUrl(sessionId));
18
+ /** One EventSource stream: the browser owns reconnection on transient drops. */
19
+ export function connectEvents<TFrame>(url: string, handlers: StreamHandlers<TFrame>): StreamConnection {
20
+ const source = new EventSource(url);
21
21
  let released = false;
22
22
 
23
23
  source.onopen = () => {
@@ -27,9 +27,9 @@ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandl
27
27
 
28
28
  source.onmessage = (event) => {
29
29
  if (released) return;
30
- let data: OmpAgentEvent;
30
+ let data: TFrame;
31
31
  try {
32
- data = JSON.parse(event.data) as OmpAgentEvent;
32
+ data = JSON.parse(event.data) as TFrame;
33
33
  } catch {
34
34
  return;
35
35
  }
@@ -51,3 +51,8 @@ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandl
51
51
  },
52
52
  };
53
53
  }
54
+
55
+ /** SSE transport for a session's agent event stream. */
56
+ export function connectAgentEvents(sessionId: string, handlers: AgentStreamHandlers): AgentStreamConnection {
57
+ return connectEvents(agentEventsUrl(sessionId), handlers);
58
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Tool-output bookkeeping for the live agent stream.
8
+ *
9
+ * omp reports a tool call in an assistant message and its output in separate
10
+ * frames (`tool_execution_update` partials, then `tool_execution_end`). The
11
+ * timeline needs the two halves re-joined on the message that carries the call,
12
+ * so this module owns the call-id → output map and the re-emit that pairs them.
13
+ *
14
+ * Split out of agent-events.ts so that file stays under the repo's per-file
15
+ * size ceiling. The host is a narrow structural view of the fold's deps — not
16
+ * the deps type itself — so neither module imports the other.
17
+ */
18
+
19
+ import type { RefObject } from 'preact/compat';
20
+ import type { ChatMessageData, ToolCallData } from '@/shared/types';
21
+ import { extractTextFromContent } from '@/shared/lib/omp/session/mapper';
22
+
23
+ /** Tool output accumulated between a `toolCall` block and its result frame. */
24
+ export interface ToolResultRecord {
25
+ output: string;
26
+ isError?: boolean;
27
+ details?: Record<string, any>;
28
+ }
29
+
30
+ /** Per-entry cap on stored tool output; the tail of a build log is what matters. */
31
+ const MAX_TOOL_OUTPUT_CHARS = 200_000;
32
+ /** Max tool results retained per session; oldest entries are dropped first. */
33
+ const MAX_TRACKED_TOOL_RESULTS = 200;
34
+
35
+ /** The slice of the fold's deps this module needs. */
36
+ export interface ToolResultHost {
37
+ toolResultsRef: RefObject<Map<string, ToolResultRecord>>;
38
+ lastToolMessageRef: RefObject<ChatMessageData>;
39
+ /** Re-emit an updated assistant message into the timeline. */
40
+ onMessageUpdate?: (msg: ChatMessageData) => void;
41
+ }
42
+
43
+ /** Truncate tool output to the cap, keeping the tail and marking the cut. */
44
+ function capToolOutput(output: string): string {
45
+ if (output.length <= MAX_TOOL_OUTPUT_CHARS) return output;
46
+ return `…[truncated]\n${output.slice(-MAX_TOOL_OUTPUT_CHARS)}`;
47
+ }
48
+
49
+ /** Write a tool result into the map, enforcing both caps. */
50
+ function storeToolResult(
51
+ map: Map<string, ToolResultRecord>,
52
+ callId: string,
53
+ record: ToolResultRecord,
54
+ ): void {
55
+ map.set(callId, { ...record, output: capToolOutput(record.output) });
56
+ while (map.size > MAX_TRACKED_TOOL_RESULTS) {
57
+ const oldest = map.keys().next().value;
58
+ if (oldest === undefined) break;
59
+ map.delete(oldest);
60
+ }
61
+ }
62
+
63
+ /** Write a result into the map, enforcing both caps. */
64
+ export function putToolResult(host: ToolResultHost, callId: string, record: ToolResultRecord): void {
65
+ const map = host.toolResultsRef.current;
66
+ if (map) storeToolResult(map, callId, record);
67
+ }
68
+
69
+ /** Record a toolResult frame's output against its tool call id. */
70
+ export function recordToolResult(raw: Record<string, unknown>, host: ToolResultHost): void {
71
+ const callId = typeof raw.toolCallId === 'string' ? raw.toolCallId : undefined;
72
+ if (!callId) return;
73
+ host.toolResultsRef.current?.set(callId, {
74
+ output: extractTextFromContent(raw.content),
75
+ details: (raw.details && typeof raw.details === 'object' ? raw.details : undefined) as ToolResultRecord['details'],
76
+ });
77
+ }
78
+
79
+ /** Re-emit the last tool-carrying assistant message with its results paired. */
80
+ export function pairToolOutputs(msg: ChatMessageData, host: ToolResultHost): ChatMessageData {
81
+ if (!msg.toolCalls?.length) return msg;
82
+ const toolCalls = msg.toolCalls.map(tc => {
83
+ const res = host.toolResultsRef.current?.get(tc.id);
84
+ return res
85
+ ? {
86
+ ...tc,
87
+ output: res.output,
88
+ details: tc.details || res.details || undefined,
89
+ status: (res.isError ? 'error' : 'success') as ToolCallData['status'],
90
+ }
91
+ : tc;
92
+ });
93
+ return { ...msg, toolCalls };
94
+ }
95
+
96
+ /** Re-emit the last tool-carrying message when `callId` belongs to it. */
97
+ export function refreshToolMessage(callId: string | undefined, host: ToolResultHost): void {
98
+ if (!callId) return;
99
+ const last = host.lastToolMessageRef.current;
100
+ if (!last?.toolCalls?.some(tc => tc.id === callId)) return;
101
+ host.onMessageUpdate?.(pairToolOutputs(last, host));
102
+ }
@@ -14,20 +14,25 @@
14
14
  import type { OmpAgentEvent, StreamTransport } from '@/shared/types';
15
15
  import { readChamberSetting } from '@/shared/lib/settings/client';
16
16
 
17
- export interface AgentStreamHandlers {
17
+ export interface StreamHandlers<TFrame> {
18
18
  /** Transport is attached; frames may arrive. */
19
19
  onOpen: () => void;
20
20
  /** One decoded event frame. */
21
- onFrame: (data: OmpAgentEvent) => void;
21
+ onFrame: (data: TFrame) => void;
22
22
  /** Transport is gone for good (no further frames without a re-dial). */
23
23
  onClose: () => void;
24
24
  }
25
25
 
26
- export interface AgentStreamConnection {
26
+ export interface StreamConnection {
27
27
  /** Permanently release the transport; no callbacks fire afterwards. */
28
28
  close: () => void;
29
29
  }
30
30
 
31
+ /** Agent-stream handlers — the socket's frames feed the timeline's fold. */
32
+ export type AgentStreamHandlers = StreamHandlers<OmpAgentEvent>;
33
+
34
+ export type AgentStreamConnection = StreamConnection;
35
+
31
36
  export const DEFAULT_STREAM_TRANSPORT: StreamTransport = 'websocket';
32
37
 
33
38
  /** WebSocket endpoint for a session's agent event stream. */
@@ -41,6 +46,17 @@ export function agentEventsUrl(sessionId: string): string {
41
46
  return `/api/agent/${encodeURIComponent(sessionId)}/events`;
42
47
  }
43
48
 
49
+ /** WebSocket endpoint for a session's BTW frame stream. */
50
+ export function btwSocketUrl(sessionId: string): string {
51
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
52
+ return `${protocol}//${window.location.host}/api/btw/${encodeURIComponent(sessionId)}/ws`;
53
+ }
54
+
55
+ /** SSE endpoint for a session's BTW frame stream. */
56
+ export function btwEventsUrl(sessionId: string): string {
57
+ return `/api/btw/${encodeURIComponent(sessionId)}/events`;
58
+ }
59
+
44
60
  /**
45
61
  * Resolve the configured stream transport from the chamber settings snapshot.
46
62
  */
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Deciding whether a file's contents are text, and what language to fence them
8
+ * under, from the file itself rather than from its metadata.
9
+ *
10
+ * Split out of `attachments.ts` because it is the one part of classification
11
+ * that reads BYTES: everything else there works from `name`/`type`/`size`,
12
+ * which is what a replayed attachment has.
13
+ */
14
+
15
+ /**
16
+ * Extensions treated as inline-able text when the browser reports no useful
17
+ * MIME type. `File.type` is empty for many code files, so the extension is the
18
+ * only signal — a dropped `script.py` used to attach without its contents ever
19
+ * reaching the prompt.
20
+ */
21
+ export const TEXT_FILE_EXTENSIONS: Record<string, true> = {
22
+ txt: true,
23
+ text: true,
24
+ md: true,
25
+ markdown: true,
26
+ mdx: true,
27
+ json: true,
28
+ jsonc: true,
29
+ yaml: true,
30
+ yml: true,
31
+ toml: true,
32
+ ini: true,
33
+ env: true,
34
+ csv: true,
35
+ tsv: true,
36
+ log: true,
37
+ xml: true,
38
+ html: true,
39
+ htm: true,
40
+ css: true,
41
+ scss: true,
42
+ less: true,
43
+ js: true,
44
+ mjs: true,
45
+ cjs: true,
46
+ jsx: true,
47
+ ts: true,
48
+ mts: true,
49
+ cts: true,
50
+ tsx: true,
51
+ py: true,
52
+ rb: true,
53
+ go: true,
54
+ rs: true,
55
+ java: true,
56
+ kt: true,
57
+ kts: true,
58
+ swift: true,
59
+ c: true,
60
+ h: true,
61
+ cc: true,
62
+ cpp: true,
63
+ cxx: true,
64
+ hpp: true,
65
+ hh: true,
66
+ cs: true,
67
+ php: true,
68
+ pl: true,
69
+ lua: true,
70
+ r: true,
71
+ sql: true,
72
+ sh: true,
73
+ bash: true,
74
+ zsh: true,
75
+ fish: true,
76
+ ps1: true,
77
+ bat: true,
78
+ cmd: true,
79
+ dockerfile: true,
80
+ makefile: true,
81
+ gitignore: true,
82
+ editorconfig: true,
83
+ diff: true,
84
+ patch: true,
85
+ };
86
+
87
+ /** MIME types the browser reports for text-ish files it does know. */
88
+ export function isTextMimeType(type: string): boolean {
89
+ if (!type) return false;
90
+ if (type === 'text/plain' || type === 'text/markdown') return true;
91
+ return type.startsWith('text/')
92
+ || type === 'application/json'
93
+ || type === 'application/xml'
94
+ || type === 'application/x-yaml'
95
+ || type === 'application/yaml'
96
+ || type === 'application/javascript'
97
+ || type === 'application/x-sh'
98
+ || type === 'application/sql'
99
+ || type.endsWith('+json')
100
+ || type.endsWith('+xml');
101
+ }
102
+
103
+ /** Bytes sampled to decide whether an unclassified file is text. */
104
+ const SNIFF_BYTES = 4096;
105
+
106
+ /**
107
+ * Whether an unclassified file's own bytes read as text.
108
+ *
109
+ * Some hosts hand over a file whose name and MIME say nothing useful: a
110
+ * promised-file drag from another app arrives as `document` with an empty type,
111
+ * and an opaque download as `application/octet-stream`. Deciding from the name
112
+ * alone left those attached but never inlined — the chip rendered, the model saw
113
+ * nothing.
114
+ *
115
+ * Three signals, because control-character density alone misses a short binary:
116
+ * a NUL byte (no text encoding this app handles contains one), an invalid UTF-8
117
+ * sequence (a lone continuation or lead byte), and the control-character ratio
118
+ * a browser applies to a Blob. `TextDecoder` in fatal mode is the cheapest
119
+ * correct UTF-8 validator available.
120
+ */
121
+ export async function looksLikeTextFile(
122
+ file: Pick<File, 'slice'>,
123
+ /**
124
+ * Bytes already read while the drop's permission was live. A dropped file
125
+ * cannot be `slice()`d after that window closes, so the sniff is served from
126
+ * the primed read where one exists. See `file-reads.ts`.
127
+ */
128
+ primed?: Uint8Array,
129
+ ): Promise<boolean> {
130
+ try {
131
+ const bytes = primed ?? new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer());
132
+ if (bytes.length === 0) return false;
133
+ if (bytes.includes(0)) return false;
134
+ try {
135
+ new TextDecoder('utf-8', { fatal: true }).decode(bytes);
136
+ } catch {
137
+ return false;
138
+ }
139
+ let control = 0;
140
+ for (const byte of bytes) {
141
+ if (byte < 9 || (byte > 13 && byte < 32)) control += 1;
142
+ }
143
+ return control / bytes.length <= 0.3;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+