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.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +7 -1
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.6e065017eb.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /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?:
|
|
32
|
+
sendPrompt: (message: string, images?: AgentImage[], options?: { accessMode?: ApprovalMode }) => Promise<boolean>;
|
|
33
33
|
sendNewPrompt: (
|
|
34
34
|
message: string,
|
|
35
35
|
cwd: string,
|
|
36
|
-
images?:
|
|
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?:
|
|
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?:
|
|
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
|
-
|
|
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 {
|
|
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),
|
|
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 {
|
|
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?:
|
|
108
|
-
const
|
|
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
|
-
.
|
|
132
|
-
.
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
.
|
|
233
|
-
.
|
|
234
|
-
// Inline text-file contents into the prompt
|
|
235
|
-
//
|
|
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
|
-
.
|
|
265
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
.then(
|
|
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 =
|
|
100
|
-
applyWindow(
|
|
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,
|
|
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
|
}
|