ompchamber 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (233) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b938f4a693.css +2 -0
  3. package/dist/client/static/js/77.8022a800b5.js +139 -0
  4. package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
  5. package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
  6. package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
  7. package/dist/client/static/js/async/66.86544ad741.js +93 -0
  8. package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
  9. package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
  10. package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
  11. package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
  12. package/dist/client/static/js/async/e.6443e739a2.js +79 -0
  13. package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
  14. package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
  15. package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
  16. package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
  17. package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
  18. package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
  19. package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
  20. package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
  21. package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
  22. package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
  23. package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
  24. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → q.b453193538.js} +10 -10
  25. package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
  26. package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
  27. package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
  28. package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
  29. package/dist/client/static/js/async/vt.20406a5604.js +1 -0
  30. package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
  31. package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
  32. package/dist/client/static/js/async/yx.af2936c368.js +10 -0
  33. package/dist/client/static/js/index.57b39ffa7f.js +34 -0
  34. package/package.json +1 -1
  35. package/src/client/components/common/ComposerPicker.tsx +16 -4
  36. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  37. package/src/client/components/common/file-icon/index.tsx +4 -1
  38. package/src/client/components/common/image-viewer/index.tsx +224 -0
  39. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  40. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  41. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  42. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  43. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  44. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  45. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  46. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  47. package/src/client/components/layout/session-sidebar/SessionItem.tsx +26 -12
  48. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  49. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
  50. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
  51. package/src/client/components/settings/Modal.tsx +1 -0
  52. package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
  53. package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
  54. package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
  55. package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
  56. package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
  57. package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
  58. package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
  59. package/src/client/components/workspace/btw-panel/index.tsx +83 -0
  60. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +39 -20
  61. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  62. package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
  63. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  64. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
  65. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
  66. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
  67. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +67 -43
  68. package/src/client/components/workspace/chat-timeline/index.tsx +14 -9
  69. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  70. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
  71. package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
  72. package/src/client/components/workspace/editor/index.tsx +45 -36
  73. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  74. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  75. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  76. package/src/client/hooks/chat/btw/index.ts +180 -0
  77. package/src/client/hooks/chat/btw/intercept.ts +43 -0
  78. package/src/client/hooks/chat/btw/mode.ts +179 -0
  79. package/src/client/hooks/chat/composer/attachments.ts +198 -0
  80. package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
  81. package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
  82. package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
  83. package/src/client/hooks/chat/composer/drop-references.ts +106 -0
  84. package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
  85. package/src/client/hooks/chat/composer/file-drop.ts +226 -0
  86. package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
  87. package/src/client/hooks/chat/composer/file-reads.ts +238 -0
  88. package/src/client/hooks/chat/composer/index.ts +35 -9
  89. package/src/client/hooks/chat/composer/pipeline.ts +181 -0
  90. package/src/client/hooks/chat/omp/index.ts +3 -3
  91. package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
  92. package/src/client/hooks/chat/timeline/actions.ts +40 -4
  93. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  94. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  95. package/src/client/hooks/chat/timeline/index.ts +29 -9
  96. package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
  97. package/src/client/hooks/chat/timeline/send.ts +45 -70
  98. package/src/client/hooks/chat/timeline/session-load.ts +2 -1
  99. package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
  100. package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
  101. package/src/client/hooks/editor/file-actions.ts +49 -0
  102. package/src/client/hooks/editor/use-file-editor.ts +38 -27
  103. package/src/client/hooks/editor/use-file-transfer.ts +78 -0
  104. package/src/client/hooks/workspace/available-width.ts +33 -0
  105. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  106. package/src/client/tailwind.css +22 -0
  107. package/src/server/db.server.ts +21 -287
  108. package/src/server/lib/btw/frames.test.ts +68 -0
  109. package/src/server/lib/btw/frames.ts +56 -0
  110. package/src/server/lib/btw/prompt.ts +28 -0
  111. package/src/server/lib/btw/registry.server.ts +141 -0
  112. package/src/server/lib/btw/runtime.server.ts +324 -0
  113. package/src/server/lib/btw/schema.server.ts +44 -0
  114. package/src/server/lib/btw/service.server.ts +171 -0
  115. package/src/server/lib/btw/session-copy.server.ts +213 -0
  116. package/src/server/lib/btw/session-copy.test.ts +166 -0
  117. package/src/server/lib/btw/store.server.ts +240 -0
  118. package/src/server/lib/db/schema.ts +110 -0
  119. package/src/server/lib/db/seed.ts +160 -0
  120. package/src/server/lib/db/workspace-sync.ts +70 -0
  121. package/src/server/lib/fs/root.ts +37 -0
  122. package/src/server/lib/models/preferences.server.ts +110 -0
  123. package/src/server/lib/models/registry.server.ts +206 -0
  124. package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
  125. package/src/server/lib/omp/rpc/manager.ts +27 -123
  126. package/src/server/lib/omp/rpc/utility.ts +7 -1
  127. package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
  128. package/src/server/lib/omp/session/auto-title.server.ts +152 -0
  129. package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
  130. package/src/server/lib/omp/session/blobs.server.ts +76 -0
  131. package/src/server/lib/omp/session/file-scan.ts +174 -0
  132. package/src/server/lib/omp/session/files.test.ts +147 -0
  133. package/src/server/lib/omp/session/files.ts +52 -35
  134. package/src/server/lib/omp/session/locator.ts +2 -2
  135. package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
  136. package/src/server/lib/omp/session/merge-stored.ts +171 -0
  137. package/src/server/lib/omp/session/messages.ts +34 -0
  138. package/src/server/lib/omp/session/reader.ts +2 -2
  139. package/src/server/lib/omp/session/subagent-presence.ts +9 -6
  140. package/src/server/lib/queue/delivery.server.test.ts +260 -0
  141. package/src/server/lib/queue/delivery.server.ts +63 -11
  142. package/src/server/plugins/ssr.ts +29 -2
  143. package/src/server/routes/btw/events.ts +70 -0
  144. package/src/server/routes/btw/index.ts +8 -0
  145. package/src/server/routes/btw/topics.ts +87 -0
  146. package/src/server/routes/btw/ws.ts +136 -0
  147. package/src/server/routes/chat/index.ts +2 -0
  148. package/src/server/routes/chat/session.ts +1 -115
  149. package/src/server/routes/chat/turns.ts +87 -0
  150. package/src/server/routes/fs/index.ts +4 -1
  151. package/src/server/routes/fs/raw.ts +85 -0
  152. package/src/server/routes/fs/read.ts +58 -3
  153. package/src/server/routes/index.ts +7 -1
  154. package/src/server/routes/models/root.ts +88 -215
  155. package/src/server/routes/settings/commands.ts +59 -18
  156. package/src/shared/lib/chat/attachments.test.ts +208 -0
  157. package/src/shared/lib/chat/attachments.ts +200 -15
  158. package/src/shared/lib/chat/btw/client.ts +94 -0
  159. package/src/shared/lib/chat/composer/client.test.ts +48 -0
  160. package/src/shared/lib/chat/composer/client.ts +23 -9
  161. package/src/shared/lib/chat/composer/commands.ts +290 -0
  162. package/src/shared/lib/chat/composer/composer.test.ts +195 -0
  163. package/src/shared/lib/chat/composer/filter.ts +21 -7
  164. package/src/shared/lib/chat/composer/trigger.ts +154 -21
  165. package/src/shared/lib/chat/omp/agent-events.ts +33 -80
  166. package/src/shared/lib/chat/omp/socket.ts +11 -6
  167. package/src/shared/lib/chat/omp/sse.ts +11 -6
  168. package/src/shared/lib/chat/omp/tool-results.ts +102 -0
  169. package/src/shared/lib/chat/omp/transport.ts +19 -3
  170. package/src/shared/lib/chat/text-file.ts +148 -0
  171. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
  172. package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
  173. package/src/shared/lib/chat/timeline/turns.ts +7 -0
  174. package/src/shared/lib/fs/file-kind.ts +50 -0
  175. package/src/shared/lib/fs/paths.ts +19 -1
  176. package/src/shared/lib/markdown/math.test.ts +20 -3
  177. package/src/shared/lib/models/preferences.test.ts +131 -0
  178. package/src/shared/lib/models/preferences.ts +88 -0
  179. package/src/shared/lib/models/provider-models.ts +8 -1
  180. package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
  181. package/src/shared/lib/omp/session/mapper.ts +24 -4
  182. package/src/shared/lib/omp/session/messages-map.ts +16 -3
  183. package/src/shared/lib/omp/session/messages-parse.ts +2 -2
  184. package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
  185. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  186. package/src/shared/lib/omp/subagent/label.ts +84 -0
  187. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  188. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  189. package/src/shared/lib/workspace/right-panels.ts +50 -11
  190. package/src/shared/lib/workspace/session-state/store.ts +3 -0
  191. package/src/shared/types/btw.ts +65 -0
  192. package/src/shared/types/chat.ts +77 -66
  193. package/src/shared/types/index.ts +2 -0
  194. package/src/shared/types/models.ts +101 -0
  195. package/src/shared/types/omp/agent.ts +14 -1
  196. package/src/shared/types/omp/session.ts +4 -1
  197. package/src/shared/types/omp/subagent.ts +4 -0
  198. package/src/shared/types/settings/command.ts +13 -0
  199. package/src/shared/types/settings/state.ts +5 -0
  200. package/dist/client/static/css/index.e880517fcd.css +0 -2
  201. package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
  202. package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
  203. package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
  204. package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
  205. package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
  206. package/dist/client/static/js/async/e.93214d622b.js +0 -79
  207. package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
  208. package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
  209. package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
  210. package/dist/client/static/js/async/jl.874d352278.js +0 -1
  211. package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
  212. package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
  213. package/dist/client/static/js/async/q.23af2a358f.js +0 -21
  214. package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
  215. package/dist/client/static/js/async/s.222ee974f6.js +0 -2
  216. package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
  217. package/dist/client/static/js/async/vt.42416b707d.js +0 -1
  218. package/dist/client/static/js/async/x1.da8687159b.js +0 -90
  219. package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
  220. package/dist/client/static/js/index.0de15c5e17.js +0 -33
  221. package/dist/client/static/js/jp.50817896f3.js +0 -139
  222. /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
  223. /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
  224. /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
  225. /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
  226. /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
  227. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
  228. /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
  229. /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
  230. /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
  231. /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
  232. /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
  233. /package/dist/client/static/js/{index.0de15c5e17.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
@@ -8,9 +8,12 @@ import { useChatTimelineScroll } from '@/client/hooks/chat/timeline/scroll';
8
8
  import { useTimelineAutoScroll } from '@/client/hooks/chat/timeline/auto-scroll';
9
9
  import { useChatTimelineActions } from '@/client/hooks/chat/timeline/actions';
10
10
  import { useChatTimelineSend } from '@/client/hooks/chat/timeline/send';
11
+ import type { ComposerModelPick } from '@/client/hooks/chat/timeline/deferred-model';
11
12
  import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
13
+ import { useUserTurns } from '@/client/hooks/chat/timeline/user-turns';
12
14
  import { useBrowserPageContextInsert } from '@/client/hooks/chat/timeline/browser-context';
13
- 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';
14
17
  import { readStreamTransport } from '@/shared/lib/chat/omp/transport';
15
18
  import { ACCESS_MODE_SETTING_KEY, normalizeApprovalMode } from '@/shared/lib/omp/config/access-mode';
16
19
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
@@ -118,14 +121,9 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
118
121
 
119
122
  const {
120
123
  sessionData,
121
- adoptedSessionIdRef,
122
- refreshSessionMeta,
123
- seedSession,
124
+ adoptedSessionIdRef, refreshSessionMeta, seedSession,
124
125
  hasMore,
125
- loadingOlder,
126
- loadOlderError,
127
- sessionLoading,
128
- loadOlder,
126
+ loadingOlder, loadOlderError, sessionLoading, loadOlder, jumpToTurn,
129
127
  } = useSessionLoad({
130
128
  sessionId,
131
129
  setLocalMessages,
@@ -140,6 +138,14 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
140
138
  });
141
139
  loadOlderRef.current = loadOlder;
142
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
+
143
149
  // Composer picks made BEFORE the omp session exists (pending "new-…" view):
144
150
  // there is no live session to receive the RPC yet, so the selection is held
145
151
  // here and applied to the spawn command on first send.
@@ -148,6 +154,15 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
148
154
  // Live mirror of the composer's model/thinking pick: the enqueue path
149
155
  // snapshots it onto queued items so auto-delivery replays those settings.
150
156
  const composerModelRef = useRef<{ provider: string; modelId: string; thinkingLevel: string } | null>(null);
157
+ // Picks made WHILE a turn streams: held here so they cannot re-target the
158
+ // answer in flight (omp applies set_model to the running turn), and pushed
159
+ // onto the session right before the next prompt.
160
+ const deferredComposerPickRef = useRef<ComposerModelPick | null>(null);
161
+ // A stashed pick belongs to ONE session's next prompt: carrying it across a
162
+ // switch would silently re-target the session the user just opened.
163
+ useEffect(() => {
164
+ deferredComposerPickRef.current = null;
165
+ }, [sessionId]);
151
166
 
152
167
  // Access-control mode is a global, persisted user preference (unlike the
153
168
  // per-session model/thinking picks): it hydrates from appSettings at first
@@ -229,7 +244,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
229
244
  seedSession,
230
245
  pendingComposerModelRef,
231
246
  pendingThinkingLevelRef,
232
- composerModelRef,
247
+ deferredComposerPickRef,
233
248
  accessModeRef,
234
249
  abortControllerRef,
235
250
  setInputValue,
@@ -274,6 +289,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
274
289
  pendingComposerModelRef,
275
290
  pendingThinkingLevelRef,
276
291
  composerModelRef,
292
+ deferredComposerPickRef,
277
293
  accessModeRef,
278
294
  setSearchParams,
279
295
  });
@@ -292,6 +308,9 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
292
308
  loadOlderError,
293
309
  sessionLoading,
294
310
  loadOlder,
311
+ userTurns,
312
+ jumpingTurn,
313
+ jumpToUserTurn,
295
314
  generatingVerb,
296
315
  messageQueue,
297
316
  removeMessage,
@@ -321,6 +340,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
321
340
  accessMode,
322
341
  handleAccessModeChange,
323
342
  composerModelRef,
343
+ deferredComposerPickRef,
324
344
  extensionDialogs,
325
345
  resolveExtensionDialog,
326
346
  respondToExtensionUi: ompAgent.respondToExtensionUi,
@@ -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,12 +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
- import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
20
+ import { flushDeferredPick, type DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
21
+ import {
22
+ attachmentImage,
23
+ attachmentName,
24
+ attachmentSize,
25
+ attachmentType,
26
+ readTextAttachments,
27
+ } from '@/shared/lib/chat/attachments';
21
28
  import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
22
29
  import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
23
30
  import { formatClock } from '@/shared/lib/format/time';
@@ -51,9 +58,9 @@ export interface ChatTimelineSendDeps {
51
58
  /** Global access-control mode (persisted user preference), read at send time
52
59
  * so the spawn-capable requests carry the latest value. */
53
60
  accessModeRef: { current: ApprovalMode };
54
- /** Live composer model/thinking mirror (written by ChatInput) so enqueue can
55
- * snapshot the selection onto queued items. */
56
- composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
61
+ /** Picks the composer made while a turn was streaming. Pushed onto the
62
+ * session here, immediately before the prompt they were meant for. */
63
+ deferredComposerPickRef: DeferredModelStore;
57
64
  abortControllerRef: { current: AbortController | null };
58
65
  setInputValue: (v: string) => void;
59
66
  setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, opts?: { replace?: boolean }) => void;
@@ -91,6 +98,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
91
98
  seedSession,
92
99
  pendingComposerModelRef,
93
100
  pendingThinkingLevelRef,
101
+ deferredComposerPickRef,
94
102
  accessModeRef,
95
103
  abortControllerRef,
96
104
  setInputValue,
@@ -102,41 +110,25 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
102
110
  const prepareDeliverable = useCallback(async (
103
111
  text: string,
104
112
  attachments: Attachment[],
105
- ): Promise<{ promptText: string; images?: { data: string; mimeType: string }[] }> => {
106
- const textFileContents = new Map<string, string>();
107
- try {
108
- await Promise.all(
109
- attachments
110
- .filter(a => isTextAttachmentFile(a.file))
111
- .map(async a => {
112
- textFileContents.set(a.id, await a.file.text());
113
- })
114
- );
115
- } catch {
116
- // Fall back to prompt without inlined contents if a file cannot be read.
117
- }
118
- const textFiles = attachments
119
- .filter(a => textFileContents.has(a.id))
120
- .map(a => ({
121
- name: a.file.name,
122
- mimeType: a.file.type,
123
- content: textFileContents.get(a.id) as string,
124
- size: a.file.size,
125
- }));
113
+ ): Promise<{ promptText: string; images?: AgentImage[] }> => {
114
+ const textFiles = await readTextAttachments(attachments);
126
115
  const promptText = await buildPromptText(text, textFiles);
127
116
  pendingUserDisplaysRef.current = [...pendingUserDisplaysRef.current.slice(-7), { sent: promptText, display: text }];
128
117
  const images = attachments
129
- .filter(a => a.file.type.startsWith('image/') && a.dataBase64)
130
- .map(a => ({ data: a.dataBase64 as string, mimeType: a.file.type }));
118
+ .map(attachmentImage)
119
+ .filter((image): image is AgentImage => image !== null);
131
120
  return { promptText, images: images.length ? images : undefined };
132
121
  }, []);
133
122
 
134
123
  // Steer the running omp agent with a fresh prompt (interrupt-and-reply).
135
124
  const steerOmpAgent = useCallback(async (text: string, attachments: Attachment[]) => {
125
+ // A steer IS a new prompt: a pick made during the interrupted turn belongs
126
+ // to it, so it is pushed before the interrupt-and-reply starts the turn.
127
+ await flushDeferredPick(ompAgent, deferredComposerPickRef);
136
128
  const { promptText, images } = await prepareDeliverable(text, attachments);
137
129
  const ok = await ompAgent.sendInterruptAndReply(promptText, images);
138
130
  if (!ok) setInputValue(text);
139
- }, [ompAgent, prepareDeliverable, setInputValue]);
131
+ }, [ompAgent, prepareDeliverable, setInputValue, deferredComposerPickRef]);
140
132
 
141
133
  const executeSend = useCallback(async (
142
134
  text: string,
@@ -152,19 +144,10 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
152
144
  const aiPlaceholderId = `msg-${Date.now() + 1}-ai`;
153
145
 
154
146
  // Read text-file contents once: used for the editor (attachment.content)
155
- // and for inlining into the prompt (mirror omp-web).
156
- const textFileContents = new Map<string, string>();
157
- try {
158
- await Promise.all(
159
- attachments
160
- .filter(a => isTextAttachmentFile(a.file))
161
- .map(async a => {
162
- textFileContents.set(a.id, await a.file.text());
163
- })
164
- );
165
- } catch {
166
- // Fall back to prompt without inlined contents if a file cannot be read.
167
- }
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]));
168
151
 
169
152
  const now = Date.now();
170
153
  const newUserMsg: ChatMessageData = {
@@ -177,11 +160,13 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
177
160
  startedAt: now,
178
161
  content: text,
179
162
  attachments: attachments.map(a => ({
180
- name: a.file.name,
181
- preview: a.dataBase64 ? `data:${a.file.type};base64,${a.dataBase64}` : a.preview,
182
- type: a.file.type,
183
- size: a.file.size,
184
- 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),
185
170
  }))
186
171
  };
187
172
 
@@ -209,6 +194,12 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
209
194
  if (isOmpSession) {
210
195
  aiPlaceholderIdRef.current = aiPlaceholderId;
211
196
  optimisticUserIdRef.current = userMsgId;
197
+ // A model/thinking pick made while the previous turn streamed was held
198
+ // back so it could not re-target that answer. A queued delivery does NOT
199
+ // own it: that item carries its own snapshot, so the pick stays pending
200
+ // for the composer's own next prompt instead of being spent — and then
201
+ // overwritten — by a delivery it was never meant for.
202
+ if (!modelOverride) await flushDeferredPick(ompAgent, deferredComposerPickRef);
212
203
  // Replay the queued snapshot first so the prompt runs on the exact
213
204
  // model/thinking the item was queued with.
214
205
  if (modelOverride) {
@@ -218,18 +209,10 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
218
209
  }
219
210
  }
220
211
  const images = attachments
221
- .filter(a => a.file.type.startsWith('image/') && a.dataBase64)
222
- .map(a => ({ data: a.dataBase64 as string, mimeType: a.file.type }));
223
- // Inline text-file contents into the prompt (mirror omp-web): the model
224
- // sees the full file content as fenced blocks, not just the filename.
225
- const textFiles = attachments
226
- .filter(a => textFileContents.has(a.id))
227
- .map(a => ({
228
- name: a.file.name,
229
- mimeType: a.file.type,
230
- content: textFileContents.get(a.id) as string,
231
- size: a.file.size,
232
- }));
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.
233
216
  const promptText = await buildPromptText(text, textFiles);
234
217
  const ok = await ompAgent.sendPrompt(promptText, images.length ? images : undefined, { accessMode: effectiveAccessMode });
235
218
  if (!ok) {
@@ -250,16 +233,8 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
250
233
  const cwd = currentFolder?.project_path || currentFolder?.name;
251
234
  if (cwd) {
252
235
  const images = attachments
253
- .filter(a => a.file.type.startsWith('image/') && a.dataBase64)
254
- .map(a => ({ data: a.dataBase64 as string, mimeType: a.file.type }));
255
- const textFiles = attachments
256
- .filter(a => textFileContents.has(a.id))
257
- .map(a => ({
258
- name: a.file.name,
259
- mimeType: a.file.type,
260
- content: textFileContents.get(a.id) as string,
261
- size: a.file.size,
262
- }));
236
+ .map(attachmentImage)
237
+ .filter((image): image is AgentImage => image !== null);
263
238
  const promptText = await buildPromptText(text, textFiles);
264
239
  optimisticUserIdRef.current = userMsgId;
265
240
  const composerModel = pendingComposerModelRef.current;
@@ -329,6 +304,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
329
304
  scrollToBottom,
330
305
  })
331
306
  );
332
- }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
307
+ }, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, deferredComposerPickRef, accessModeRef]);
333
308
  return { steerOmpAgent, executeSend };
334
309
  }
@@ -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
  }