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
@@ -17,7 +17,7 @@
17
17
 
18
18
  import { useCallback } from 'preact/hooks';
19
19
  import type { Dispatch, SetStateAction } from 'preact/compat';
20
- import type { OmpAgentState } from '@/shared/types';
20
+ import type { AgentImage, OmpAgentState } from '@/shared/types';
21
21
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
22
22
 
23
23
  export interface OmpPromptSenderDeps {
@@ -29,11 +29,11 @@ export interface OmpPromptSenderDeps {
29
29
  }
30
30
 
31
31
  export interface OmpPromptSender {
32
- sendPrompt: (message: string, images?: { data: string; mimeType: string }[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
32
+ sendPrompt: (message: string, images?: AgentImage[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
33
33
  sendNewPrompt: (
34
34
  message: string,
35
35
  cwd: string,
36
- images?: { data: string; mimeType: string }[],
36
+ images?: AgentImage[],
37
37
  composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
38
38
  ) => Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null>;
39
39
  }
@@ -44,7 +44,7 @@ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
44
44
  /** Send a prompt to the omp session via the RPC bridge. */
45
45
  const sendPrompt = useCallback(async (
46
46
  message: string,
47
- images?: { data: string; mimeType: string }[],
47
+ images?: AgentImage[],
48
48
  options?: { accessMode?: ApprovalMode },
49
49
  ) => {
50
50
  const sid = sessionIdRef.current;
@@ -105,7 +105,7 @@ export function useOmpPromptSender(deps: OmpPromptSenderDeps): OmpPromptSender {
105
105
  const sendNewPrompt = useCallback(async (
106
106
  message: string,
107
107
  cwd: string,
108
- images?: { data: string; mimeType: string }[],
108
+ images?: AgentImage[],
109
109
  composerOptions?: { model?: { provider: string; modelId: string } | null; thinkingLevel?: string | null; accessMode?: ApprovalMode },
110
110
  ): Promise<{ sessionId: string; model: { provider: string; modelId: string } | null } | null> => {
111
111
  setState((prev) => ({ ...prev, isGenerating: true, error: null }));
@@ -17,6 +17,8 @@ import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel }
17
17
  import type { QueuedMessage } from '@/client/components/workspace/chat-timeline/QueueList';
18
18
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
19
  import { applyComposerPick, consumeComposerPick, stashComposerPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
20
+ import { dispatchBtwCommand } from '@/client/hooks/chat/btw/intercept';
21
+ import { prepareQueuedAttachments } from '@/shared/lib/chat/attachments';
20
22
 
21
23
  export interface ChatTimelineActionsDeps {
22
24
  inputValue: string;
@@ -106,6 +108,14 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
106
108
  // resume delivering after this run ends.
107
109
  stopHoldRef.current = false;
108
110
 
111
+ // `/btw [question]` is the side-question entry point, not chat text: the
112
+ // panel owns it (omp's `/btw` is TUI-only, so nothing downstream would
113
+ // understand the token). A bare `/btw` only opens the panel's history.
114
+ if (dispatchBtwCommand(textToSend, attachments)) {
115
+ setInputValue('');
116
+ return;
117
+ }
118
+
109
119
  if (isGenerating) {
110
120
  if (options?.steering) {
111
121
  // Explicit steering while a run is active.
@@ -140,7 +150,12 @@ export function useChatTimelineActions(deps: ChatTimelineActionsDeps): ChatTimel
140
150
  // panel is a view of it. Delivery happens when the run ends — the
141
151
  // wrapper's terminal `agent_end` claims the head and dispatches it.
142
152
  setInputValue('');
143
- enqueueMessage({ text: textToSend, attachments, model });
153
+ // The queue row is JSON: a `File` does not survive it. Capture the text
154
+ // contents and the display fields now, so delivery can inline the files
155
+ // and render the chips without a live handle.
156
+ void prepareQueuedAttachments(attachments).then((prepared) => {
157
+ enqueueMessage({ text: textToSend, attachments: prepared, model });
158
+ });
144
159
  return;
145
160
  }
146
161
 
@@ -10,8 +10,10 @@ import { useChatTimelineActions } from '@/client/hooks/chat/timeline/actions';
10
10
  import { useChatTimelineSend } from '@/client/hooks/chat/timeline/send';
11
11
  import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
12
12
  import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
13
+ import { useUserTurns } from '@/client/hooks/chat/timeline/user-turns';
13
14
  import { useBrowserPageContextInsert } from '@/client/hooks/chat/timeline/browser-context';
14
- import { cancelStreamingCoalescer, createOmpAgentCallbacks } from '@/shared/lib/chat/timeline/omp-callbacks';
15
+ import { createOmpAgentCallbacks } from '@/shared/lib/chat/timeline/omp-callbacks';
16
+ import { cancelStreamingCoalescer } from '@/shared/lib/chat/timeline/stream-coalescer';
15
17
  import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
16
18
  import { ACCESS_MODE_SETTING_KEY, normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
19
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
@@ -119,14 +121,9 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
119
121
 
120
122
  const {
121
123
  sessionData,
122
- adoptedSessionIdRef,
123
- refreshSessionMeta,
124
- seedSession,
124
+ adoptedSessionIdRef, refreshSessionMeta, seedSession,
125
125
  hasMore,
126
- loadingOlder,
127
- loadOlderError,
128
- sessionLoading,
129
- loadOlder,
126
+ loadingOlder, loadOlderError, sessionLoading, loadOlder, jumpToTurn,
130
127
  } = useSessionLoad({
131
128
  sessionId,
132
129
  setLocalMessages,
@@ -141,6 +138,14 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
141
138
  });
142
139
  loadOlderRef.current = loadOlder;
143
140
 
141
+ // Jump rail: the full-history turn index (the mounted window is only a page
142
+ // of a long session) plus the paging walk that reaches a turn outside it.
143
+ const { turns: userTurns, jumping: jumpingTurn, jumpToTurn: jumpToUserTurn } = useUserTurns({
144
+ sessionId,
145
+ messages: localMessages,
146
+ jumpToTurn,
147
+ });
148
+
144
149
  // Composer picks made BEFORE the omp session exists (pending "new-…" view):
145
150
  // there is no live session to receive the RPC yet, so the selection is held
146
151
  // here and applied to the spawn command on first send.
@@ -303,6 +308,9 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
303
308
  loadOlderError,
304
309
  sessionLoading,
305
310
  loadOlder,
311
+ userTurns,
312
+ jumpingTurn,
313
+ jumpToUserTurn,
306
314
  generatingVerb,
307
315
  messageQueue,
308
316
  removeMessage,
@@ -20,8 +20,13 @@ type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1
20
20
  * Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
21
21
  * task-tool delegation directive at send time (oh-my-pi has no `@agent`
22
22
  * syntax); translation failures fall back to the raw prompt.
23
+ *
24
+ * A file whose bytes could not be read is left OUT rather than inlined empty:
25
+ * an empty fenced block tells the model the file exists and says nothing, which
26
+ * is worse than not mentioning it. The composer reports the omission.
23
27
  */
24
28
  export async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
29
+ const readable = textFiles.filter((file) => !('missing' in file && file.missing));
25
30
  let translated = text;
26
31
  try {
27
32
  const names = await loadAgentNames();
@@ -31,5 +36,5 @@ export async function buildPromptText(text: string, textFiles: TextFileAttachmen
31
36
  }
32
37
  // File mentions are namespaced (`@file:`) by the picker; strip the namespace
33
38
  // only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
34
- return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
39
+ return composeMessageWithTextAttachments(translateFileMentions(translated), readable);
35
40
  }
@@ -12,13 +12,19 @@
12
12
 
13
13
  import { useCallback } from 'preact/hooks';
14
14
  import type { Dispatch, SetStateAction } from 'preact/compat';
15
- import type { Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
15
+ import type { AgentImage, Attachment, ChatMessageData, OmpAgentHandle, QueuedMessageModel } from '@/shared/types';
16
16
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
17
17
  import { streamChatResponse } from '@/client/hooks/chat/stream';
18
18
  import type { SessionSeed } from '@/client/hooks/chat/timeline/session-load';
19
19
  import { buildPromptText } from '@/client/hooks/chat/timeline/prompt-text';
20
20
  import { flushDeferredPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
21
- import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
21
+ import {
22
+ attachmentImage,
23
+ attachmentName,
24
+ attachmentSize,
25
+ attachmentType,
26
+ readTextAttachments,
27
+ } from '@/shared/lib/chat/attachments';
22
28
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
23
29
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
24
30
  import { formatClock } from '@/shared/lib/format/time';
@@ -104,32 +110,13 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
104
110
  const prepareDeliverable = useCallback(async (
105
111
  text: string,
106
112
  attachments: Attachment[],
107
- ): Promise<{ promptText: string; images?: { data: string; mimeType: string }[] }> => {
108
- const textFileContents = new Map<string, string>();
109
- try {
110
- await Promise.all(
111
- attachments
112
- .filter(a => isTextAttachmentFile(a.file))
113
- .map(async a => {
114
- textFileContents.set(a.id, await a.file.text());
115
- })
116
- );
117
- } catch {
118
- // Fall back to prompt without inlined contents if a file cannot be read.
119
- }
120
- const textFiles = attachments
121
- .filter(a => textFileContents.has(a.id))
122
- .map(a => ({
123
- name: a.file.name,
124
- mimeType: a.file.type,
125
- content: textFileContents.get(a.id) as string,
126
- size: a.file.size,
127
- }));
113
+ ): Promise<{ promptText: string; images?: AgentImage[] }> => {
114
+ const textFiles = await readTextAttachments(attachments);
128
115
  const promptText = await buildPromptText(text, textFiles);
129
116
  pendingUserDisplaysRef.current = [...pendingUserDisplaysRef.current.slice(-7), { sent: promptText, display: text }];
130
117
  const images = attachments
131
- .filter(a => a.file.type.startsWith('image/') && a.dataBase64)
132
- .map(a => ({ data: a.dataBase64 as string, mimeType: a.file.type }));
118
+ .map(attachmentImage)
119
+ .filter((image): image is AgentImage => image !== null);
133
120
  return { promptText, images: images.length ? images : undefined };
134
121
  }, []);
135
122
 
@@ -157,19 +144,10 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
157
144
  const aiPlaceholderId = `msg-${Date.now() + 1}-ai`;
158
145
 
159
146
  // Read text-file contents once: used for the editor (attachment.content)
160
- // and for inlining into the prompt (mirror omp-web).
161
- const textFileContents = new Map<string, string>();
162
- try {
163
- await Promise.all(
164
- attachments
165
- .filter(a => isTextAttachmentFile(a.file))
166
- .map(async a => {
167
- textFileContents.set(a.id, await a.file.text());
168
- })
169
- );
170
- } catch {
171
- // Fall back to prompt without inlined contents if a file cannot be read.
172
- }
147
+ // and for inlining into the prompt. Reads a persisted `content` when the
148
+ // attachment is a replay (queue delivery, retry) and has no live File.
149
+ const textFiles = await readTextAttachments(attachments);
150
+ const textContentById = new Map(textFiles.map((file) => [file.id, file.content]));
173
151
 
174
152
  const now = Date.now();
175
153
  const newUserMsg: ChatMessageData = {
@@ -182,11 +160,13 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
182
160
  startedAt: now,
183
161
  content: text,
184
162
  attachments: attachments.map(a => ({
185
- name: a.file.name,
186
- preview: a.dataBase64 ? `data:${a.file.type};base64,${a.dataBase64}` : a.preview,
187
- type: a.file.type,
188
- size: a.file.size,
189
- content: textFileContents.get(a.id),
163
+ name: attachmentName(a),
164
+ // The blob URL only lives as long as this page, so the data URL wins
165
+ // where it exists — a persisted turn re-renders its images from it.
166
+ preview: a.dataBase64 ? `data:${attachmentType(a)};base64,${a.dataBase64}` : a.preview,
167
+ type: attachmentType(a),
168
+ size: attachmentSize(a),
169
+ content: textContentById.get(a.id),
190
170
  }))
191
171
  };
192
172
 
@@ -229,18 +209,10 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
229
209
  }
230
210
  }
231
211
  const images = attachments
232
- .filter(a => a.file.type.startsWith('image/') && a.dataBase64)
233
- .map(a => ({ data: a.dataBase64 as string, mimeType: a.file.type }));
234
- // Inline text-file contents into the prompt (mirror omp-web): the model
235
- // sees the full file content as fenced blocks, not just the filename.
236
- const textFiles = attachments
237
- .filter(a => textFileContents.has(a.id))
238
- .map(a => ({
239
- name: a.file.name,
240
- mimeType: a.file.type,
241
- content: textFileContents.get(a.id) as string,
242
- size: a.file.size,
243
- }));
212
+ .map(attachmentImage)
213
+ .filter((image): image is AgentImage => image !== null);
214
+ // Inline text-file contents into the prompt: the model sees the full file
215
+ // content as fenced blocks, not just the filename.
244
216
  const promptText = await buildPromptText(text, textFiles);
245
217
  const ok = await ompAgent.sendPrompt(promptText, images.length ? images : undefined, { accessMode: effectiveAccessMode });
246
218
  if (!ok) {
@@ -261,16 +233,8 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
261
233
  const cwd = currentFolder?.project_path || currentFolder?.name;
262
234
  if (cwd) {
263
235
  const images = attachments
264
- .filter(a => a.file.type.startsWith('image/') && a.dataBase64)
265
- .map(a => ({ data: a.dataBase64 as string, mimeType: a.file.type }));
266
- const textFiles = attachments
267
- .filter(a => textFileContents.has(a.id))
268
- .map(a => ({
269
- name: a.file.name,
270
- mimeType: a.file.type,
271
- content: textFileContents.get(a.id) as string,
272
- size: a.file.size,
273
- }));
236
+ .map(attachmentImage)
237
+ .filter((image): image is AgentImage => image !== null);
274
238
  const promptText = await buildPromptText(text, textFiles);
275
239
  optimisticUserIdRef.current = userMsgId;
276
240
  const composerModel = pendingComposerModelRef.current;
@@ -94,7 +94,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
94
94
 
95
95
  // Older-window pagination (cursor, retry flag, scroll anchoring) lives in its
96
96
  // own hook so this one stays under the repo's per-file size ceiling.
97
- const { hasMore, loadingOlder, loadOlderError, loadOlder, resetPages, applyWindow } = useSessionPagination({
97
+ const { hasMore, loadingOlder, loadOlderError, loadOlder, jumpToTurn, resetPages, applyWindow } = useSessionPagination({
98
98
  sessionId,
99
99
  sessionIdRef,
100
100
  setLocalMessages,
@@ -268,6 +268,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
268
268
  loadOlderError,
269
269
  sessionLoading,
270
270
  loadOlder,
271
+ jumpToTurn,
271
272
  adoptedSessionIdRef,
272
273
  refreshSessionMeta,
273
274
  seedSession,
@@ -8,6 +8,14 @@
8
8
  * `/api/chat/:id?before=` into the head of the message list and keeps the
9
9
  * viewport anchored on the rows the user was reading. Extracted from
10
10
  * useSessionLoad so that hook stays under the repo's per-file size ceiling.
11
+ *
12
+ * The same cursor serves the jump rail: a turn the user picks from the FULL
13
+ * history is usually not mounted yet (the timeline only holds the newest
14
+ * window plus whatever was paged in), so {@link SessionPagination.jumpToTurn}
15
+ * pages contiguous windows from the head until that turn's row exists. Windows
16
+ * must stay contiguous — a middle window fetched straight to the target would
17
+ * leave a hole in the timeline — so the jump walks the cursor down, one bounded
18
+ * chunk per request instead of the default page size.
11
19
  */
12
20
 
13
21
  import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
@@ -33,12 +41,31 @@ export interface SessionPagination {
33
41
  * dropping hasMore, which made the button vanish with no feedback. */
34
42
  loadOlderError: boolean;
35
43
  loadOlder: () => void;
44
+ /** Reach a user turn by paging history until its row is mounted. Returns
45
+ * whether the row was reached; a turn inside the mounted window (or a live
46
+ * row, `index < 0`) needs no paging and is not this hook's concern. */
47
+ jumpToTurn: (id: string, index: number) => Promise<boolean>;
36
48
  /** Forget the cursor and the retry flag — a window belongs to one session. */
37
49
  resetPages: () => void;
38
50
  /** Adopt the window metadata a session load returned. */
39
51
  applyWindow: (hasMore: unknown, oldestIndex: unknown) => void;
40
52
  }
41
53
 
54
+ /** Messages one jump request may pull. Larger than the default page so a jump
55
+ * is a few round trips instead of dozens, small enough that no single payload
56
+ * mounts an unbounded slice of the session. */
57
+ const JUMP_CHUNK_MESSAGES = 300;
58
+
59
+ /** Ceiling on jump requests: a cursor that refuses to advance (a server window
60
+ * that keeps re-serving the same slice) must end the walk, not spin. */
61
+ const JUMP_MAX_REQUESTS = 40;
62
+
63
+ interface WindowPayload {
64
+ messages: ChatMessageData[];
65
+ hasMore?: boolean;
66
+ oldestIndex?: number;
67
+ }
68
+
42
69
  export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPagination {
43
70
  const { sessionId, sessionIdRef, setLocalMessages, scrollRef } = deps;
44
71
 
@@ -51,6 +78,16 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
51
78
  // explicit change signal per commit).
52
79
  const [prependTick, setPrependTick] = useState(0);
53
80
  const loadingOlderRef = useRef(false);
81
+ // True from a jump's first request until its target scroll has settled. A
82
+ // jump prepends rows above a viewport that is still parked near the top, so
83
+ // the scroll handler would read "the user reached the top" and page an extra
84
+ // window nobody asked for — and that window's own scroll anchor would fight
85
+ // the jump's.
86
+ const jumpActiveRef = useRef(false);
87
+ // Live cursor: a jump walks it across several requests, and state does not
88
+ // update between iterations of that loop.
89
+ const oldestIndexRef = useRef(0);
90
+ oldestIndexRef.current = oldestIndex;
54
91
  // Set while prepending older rows; the layout effect below re-anchors the
55
92
  // viewport to the content the user was reading instead of jumping.
56
93
  const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
@@ -58,6 +95,9 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
58
95
  const resetPages = useCallback(() => {
59
96
  setHasMore(false);
60
97
  setOldestIndex(0);
98
+ // The live cursor too: a jump could otherwise read the previous session's
99
+ // window start before the state change has re-rendered.
100
+ oldestIndexRef.current = 0;
61
101
  loadingOlderRef.current = false;
62
102
  setLoadingOlder(false);
63
103
  setLoadOlderError(false);
@@ -68,6 +108,51 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
68
108
  if (typeof oldestIndexFromLoad === 'number') setOldestIndex(oldestIndexFromLoad);
69
109
  }, []);
70
110
 
111
+ /** One older window ending just before `before`. `limit` of null leaves the
112
+ * server's default window size in place. */
113
+ const fetchWindow = useCallback(async (before: number, limit: number | null): Promise<WindowPayload | null> => {
114
+ if (!sessionId) return null;
115
+ const suffix = limit === null ? '' : `&limit=${limit}`;
116
+ const res = await fetch(`/api/chat/${sessionId}?before=${before}${suffix}`);
117
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
118
+ const data = (await res.json()) as { session?: { messages?: ChatMessageData[] }; hasMore?: boolean; oldestIndex?: number };
119
+ return { messages: data.session?.messages ?? [], hasMore: data.hasMore, oldestIndex: data.oldestIndex };
120
+ }, [sessionId]);
121
+
122
+ /** Record the viewport geometry an older-window prepend is about to shift. */
123
+ const anchorViewport = useCallback(() => {
124
+ const el = scrollRef?.current;
125
+ if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
126
+ }, [scrollRef]);
127
+
128
+ /** Drop the jump's scroll-handler guard once its target scroll has settled.
129
+ * Until then the viewport is still near the top of history (where the paging
130
+ * trigger lives) and the smooth scroll is animating, so a stray "reached the
131
+ * top" read would page a window nobody asked for. */
132
+ const releaseJumpGuard = useCallback(() => {
133
+ const el = scrollRef?.current;
134
+ if (!el) {
135
+ jumpActiveRef.current = false;
136
+ return;
137
+ }
138
+ let lastTop = el.scrollTop;
139
+ let settledFrames = 0;
140
+ const watch = () => {
141
+ if (el.scrollTop === lastTop) settledFrames += 1;
142
+ else {
143
+ settledFrames = 0;
144
+ lastTop = el.scrollTop;
145
+ }
146
+ // A few still frames = the smooth scroll is done (or never started).
147
+ if (settledFrames >= 5) {
148
+ jumpActiveRef.current = false;
149
+ return;
150
+ }
151
+ requestAnimationFrame(watch);
152
+ };
153
+ requestAnimationFrame(watch);
154
+ }, [scrollRef]);
155
+
71
156
  /** Page the next older window into the timeline. Called from the click
72
157
  * handler and the scroll handler when the viewport reaches the top; a no-op
73
158
  * while a page is in flight, when the session has no more history, or while
@@ -77,27 +162,22 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
77
162
  * placeholder, and blocking it made the button feel dead mid-run. */
78
163
  const loadOlder = useCallback(() => {
79
164
  if (!sessionId || sessionId.startsWith('new-')) return;
80
- if (!hasMore || loadingOlderRef.current) return;
165
+ if (!hasMore || loadingOlderRef.current || jumpActiveRef.current) return;
81
166
  loadingOlderRef.current = true;
82
167
  setLoadingOlder(true);
83
168
  setLoadOlderError(false);
84
- const el = scrollRef?.current;
85
- if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
169
+ anchorViewport();
86
170
  // Session id snapshot: prepending a window fetched for the PREVIOUS
87
171
  // session into the freshly-switched timeline is worse than dropping the
88
172
  // page, so the response is discarded if the user switched mid-flight.
89
173
  const requestedSessionId = sessionId;
90
- fetch(`/api/chat/${sessionId}?before=${oldestIndex}`)
91
- .then(res => {
92
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
93
- return res.json();
94
- })
95
- .then((data: { session?: { messages?: ChatMessageData[] }; hasMore?: boolean; oldestIndex?: number }) => {
174
+ fetchWindow(oldestIndex, null)
175
+ .then((page) => {
96
176
  // The main load effect may have cleared/repurposed localMessages for a
97
177
  // new session while this fetch was in flight.
98
- if (sessionIdRef.current !== requestedSessionId) return;
99
- const older = data.session?.messages ?? [];
100
- applyWindow(data.hasMore, data.oldestIndex);
178
+ if (!page || sessionIdRef.current !== requestedSessionId) return;
179
+ const older = page.messages;
180
+ applyWindow(page.hasMore, page.oldestIndex);
101
181
  if (older.length === 0) {
102
182
  // No rows prepended: the recorded anchor is stale — drop it so a
103
183
  // later tick cannot apply it against the wrong geometry.
@@ -124,7 +204,74 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
124
204
  loadingOlderRef.current = false;
125
205
  setLoadingOlder(false);
126
206
  });
127
- }, [sessionId, sessionIdRef, hasMore, oldestIndex, scrollRef, setLocalMessages, applyWindow]);
207
+ }, [sessionId, sessionIdRef, hasMore, oldestIndex, fetchWindow, anchorViewport, setLocalMessages, applyWindow]);
208
+
209
+ /**
210
+ * Walk the cursor down until the row with `id` is mounted, prepending every
211
+ * window on the way so the timeline stays contiguous, then adopt the final
212
+ * window metadata. Shares the in-flight flag with `loadOlder`, so the
213
+ * scroll-triggered loader cannot interleave a second cursor walk.
214
+ *
215
+ * Resolves true when the row is mounted once the call returns — it already
216
+ * was (a live row, or one inside the mounted window) or the walk brought it
217
+ * in. The DOM commit for the last prepend may still be pending; the caller
218
+ * owns waiting for the node.
219
+ */
220
+ const jumpToTurn = useCallback(async (id: string, index: number): Promise<boolean> => {
221
+ if (!sessionId || sessionId.startsWith('new-')) return false;
222
+ // `index < 0` is a live row (an optimistic send the committed history does
223
+ // not carry yet); `index >= oldestIndex` is inside the mounted window.
224
+ // Both are already on screen, so the jump needs no paging.
225
+ if (index < 0 || index >= oldestIndexRef.current) return true;
226
+ if (loadingOlderRef.current) return false;
227
+ const requestedSessionId = sessionId;
228
+ loadingOlderRef.current = true;
229
+ jumpActiveRef.current = true;
230
+ setLoadingOlder(true);
231
+ setLoadOlderError(false);
232
+ // No viewport anchor here, deliberately: the anchor exists to hold the
233
+ // user's reading position when history is paged in while they read, but a
234
+ // jump is an explicit request for a DIFFERENT position. Anchoring would
235
+ // write `scrollTop` on the prepend commit and abort the caller's smooth
236
+ // scroll to the target mid-flight.
237
+
238
+ let cursor = oldestIndexRef.current;
239
+ let lastHasMore: unknown = hasMore;
240
+ let reached = false;
241
+ let requests = 0;
242
+ try {
243
+ while (!reached && cursor > index && requests < JUMP_MAX_REQUESTS) {
244
+ requests += 1;
245
+ // Stop exactly at the target: a window ending before `index` with
246
+ // `cursor - index` rows has that turn as its oldest row.
247
+ const page = await fetchWindow(cursor, Math.min(JUMP_CHUNK_MESSAGES, cursor - index));
248
+ // The user switched sessions mid-walk: prepending this window would
249
+ // inject another session's rows into the fresh timeline.
250
+ if (!page || sessionIdRef.current !== requestedSessionId) return false;
251
+ const older = page.messages;
252
+ if (older.length === 0) break;
253
+ const next = typeof page.oldestIndex === 'number' ? page.oldestIndex : Math.max(0, cursor - older.length);
254
+ setLocalMessages(prev => [...older, ...prev]);
255
+ reached = older.some((message) => message.id === id);
256
+ // A cursor that did not advance would re-serve the same slice forever.
257
+ if (next >= cursor) break;
258
+ cursor = next;
259
+ lastHasMore = page.hasMore;
260
+ }
261
+ } catch {
262
+ if (sessionIdRef.current === requestedSessionId) setLoadOlderError(true);
263
+ return false;
264
+ } finally {
265
+ loadingOlderRef.current = false;
266
+ setLoadingOlder(false);
267
+ }
268
+ applyWindow(lastHasMore, cursor);
269
+ // Bump the prepend tick so the viewport-anchor effect re-runs for the rows
270
+ // this walk prepended (same contract as loadOlder).
271
+ if (requests > 0) setPrependTick(t => t + 1);
272
+ releaseJumpGuard();
273
+ return reached;
274
+ }, [sessionId, sessionIdRef, hasMore, fetchWindow, setLocalMessages, applyWindow, releaseJumpGuard]);
128
275
 
129
276
  // Position preservation: after older rows commit above the viewport, shift
130
277
  // scrollTop by the height delta so the rows the user was reading stay put.
@@ -140,5 +287,5 @@ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPag
140
287
  if (delta > 0) el.scrollTop = anchor.prevTop + delta;
141
288
  }, [prependTick, scrollRef]);
142
289
 
143
- return { hasMore, loadingOlder, loadOlderError, loadOlder, resetPages, applyWindow };
290
+ return { hasMore, loadingOlder, loadOlderError, loadOlder, jumpToTurn, resetPages, applyWindow };
144
291
  }