ompchamber 0.7.0 → 0.8.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.5c0447cefe.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/{g.86e2256a70.js → g.d1c2fe09aa.js} +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +1 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/hooks/chat/omp/session-list.tsx +4 -0
- package/src/client/hooks/chat/timeline/actions.ts +0 -9
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +6 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/tailwind.css +38 -12
- package/src/server/lib/omp/rpc/manager.ts +14 -3
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/types/omp/agent.ts +2 -2
- package/src/shared/types/workspace.ts +4 -0
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Routes omp's pending answerable dialogs to their renderer.
|
|
8
|
+
*
|
|
9
|
+
* `ask` dialogs belong on the tool card that raised them — a modal would hide
|
|
10
|
+
* every question but the one in flight, and the tool's own result (all questions
|
|
11
|
+
* and their answers) is what the user reads afterwards. Everything else
|
|
12
|
+
* (approval gates under `always-ask`, extension pickers) has no card of its own
|
|
13
|
+
* and keeps the modal.
|
|
14
|
+
*
|
|
15
|
+
* The two are told apart by the running ask tool calls: a frame whose title
|
|
16
|
+
* names one of their questions is theirs, and a frame that follows such a frame
|
|
17
|
+
* (the `editor` omp opens after "Other (type your own)") belongs with it too.
|
|
18
|
+
* Any frame left over is modal material — including an ask-shaped frame whose
|
|
19
|
+
* tool call the timeline has not seen, which must stay answerable rather than
|
|
20
|
+
* silently vanish.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { createContext, useContext } from 'preact/compat';
|
|
24
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
25
|
+
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
26
|
+
import { groupAskFrames, isAskToolCall, normalizeAskText, parseAskQuestions, type AskQuestion } from '@/shared/lib/chat/ask-questions';
|
|
27
|
+
import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
|
|
28
|
+
|
|
29
|
+
export interface AskFramesSplit {
|
|
30
|
+
/** The ask's dialogs, bucketed per question, keyed by ask tool-call id. */
|
|
31
|
+
framesByTool: Map<string, ExtensionUiDialogRequest[][]>;
|
|
32
|
+
/** Oldest request no ask card owns — the one the modal renders, if any. */
|
|
33
|
+
modalRequest: ExtensionUiDialogRequest | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface AskFramesHandle {
|
|
37
|
+
framesByTool: AskFramesSplit['framesByTool'];
|
|
38
|
+
/** Answer one ask dialog and drop it from the pending queue. */
|
|
39
|
+
respond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const EMPTY: AskFramesHandle = { framesByTool: new Map(), respond: () => {} };
|
|
43
|
+
|
|
44
|
+
export const AskFramesContext = createContext<AskFramesHandle>(EMPTY);
|
|
45
|
+
|
|
46
|
+
/** Pending ask dialogs for the timeline that provides them. */
|
|
47
|
+
export function useAskFrames(): AskFramesHandle {
|
|
48
|
+
return useContext(AskFramesContext);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function splitAskFrames(
|
|
52
|
+
messages: ChatMessageData[],
|
|
53
|
+
pending: ExtensionUiDialogRequest[],
|
|
54
|
+
): AskFramesSplit {
|
|
55
|
+
const framesByTool = new Map<string, ExtensionUiDialogRequest[][]>();
|
|
56
|
+
if (pending.length === 0) return { framesByTool, modalRequest: null };
|
|
57
|
+
|
|
58
|
+
// Match against EVERY ask the timeline knows, not just the running ones: a
|
|
59
|
+
// reloaded session reads its tool calls back from the JSONL, where a call
|
|
60
|
+
// still parked on a question has no result yet and so cannot be told from a
|
|
61
|
+
// finished one. The pending queue is the part that is authoritative — it only
|
|
62
|
+
// ever holds frames omp is still blocked on — so a title match is enough.
|
|
63
|
+
const openAsks = new Map<string, AskQuestion[]>();
|
|
64
|
+
for (const message of messages) {
|
|
65
|
+
for (const tool of message.toolCalls ?? []) {
|
|
66
|
+
if (!isAskToolCall(tool)) continue;
|
|
67
|
+
const questions = parseAskQuestions(tool);
|
|
68
|
+
if (questions.length > 0) openAsks.set(tool.id, questions);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const owned = new Map<string, ExtensionUiDialogRequest[]>();
|
|
73
|
+
const claimed = new Set<ExtensionUiDialogRequest>();
|
|
74
|
+
let lastOwner: string | null = null;
|
|
75
|
+
|
|
76
|
+
for (const request of pending) {
|
|
77
|
+
const title = typeof request.title === 'string' ? normalizeAskText(request.title) : '';
|
|
78
|
+
let owner: string | null = null;
|
|
79
|
+
if (title) {
|
|
80
|
+
for (const [toolId, questions] of openAsks) {
|
|
81
|
+
if (questions.some((question) => normalizeAskText(question.question) === title)) {
|
|
82
|
+
owner = toolId;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// The dialog omp opens after "Other (type your own)" names the question in
|
|
88
|
+
// its title only loosely; it continues whichever ask spoke last. An
|
|
89
|
+
// approval gate is never part of an ask, so it keeps its own modal.
|
|
90
|
+
if (!owner && request.method !== 'confirm') owner = lastOwner;
|
|
91
|
+
if (!owner) continue;
|
|
92
|
+
claimed.add(request);
|
|
93
|
+
lastOwner = owner;
|
|
94
|
+
owned.set(owner, [...(owned.get(owner) ?? []), request]);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
for (const [toolId, frames] of owned) {
|
|
98
|
+
framesByTool.set(toolId, groupAskFrames(openAsks.get(toolId) ?? [], frames));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { framesByTool, modalRequest: pending.find((request) => !claimed.has(request)) ?? null };
|
|
102
|
+
}
|
|
@@ -13,19 +13,22 @@
|
|
|
13
13
|
* tool call stayed blocked on an approval nobody could answer and the turn never
|
|
14
14
|
* reached `agent_end` — the run looked hung until Stop.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* The queue keeps EVERY pending request rather than the head alone: `ask` raises
|
|
17
|
+
* one frame per question, and those are rendered inline on the ask tool card
|
|
18
|
+
* (see ask-frames.ts) instead of in a modal, so the head is not the only
|
|
19
|
+
* answerable request at any moment. Entries leave the queue by id once their
|
|
20
|
+
* response is on the wire, or when omp withdraws them (`method: 'cancel'`).
|
|
18
21
|
*/
|
|
19
22
|
|
|
20
23
|
import { useCallback, useEffect, useState } from 'preact/hooks';
|
|
21
24
|
import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
|
|
22
25
|
|
|
23
26
|
export interface ExtensionDialogQueue {
|
|
24
|
-
/**
|
|
25
|
-
|
|
27
|
+
/** Still-blocking requests, in omp's arrival order. */
|
|
28
|
+
pending: ExtensionUiDialogRequest[];
|
|
26
29
|
enqueue: (request: ExtensionUiDialogRequest) => void;
|
|
27
|
-
/** Advance past
|
|
28
|
-
|
|
30
|
+
/** Advance past a request once its response has been sent. */
|
|
31
|
+
resolve: (id: string) => void;
|
|
29
32
|
/** Drop a request omp withdrew (`method: 'cancel'`) without an answer. */
|
|
30
33
|
withdraw: (targetId: string) => void;
|
|
31
34
|
}
|
|
@@ -45,12 +48,12 @@ export function useExtensionDialogQueue(sessionId: string | null): ExtensionDial
|
|
|
45
48
|
// entry per omp id keeps the queue answerable exactly once.
|
|
46
49
|
setQueue((current) => (current.some((pending) => pending.id === request.id) ? current : [...current, request]));
|
|
47
50
|
}, []);
|
|
48
|
-
const
|
|
49
|
-
setQueue((current) => current.
|
|
51
|
+
const resolve = useCallback((id: string) => {
|
|
52
|
+
setQueue((current) => current.some((pending) => pending.id === id) ? current.filter((pending) => pending.id !== id) : current);
|
|
50
53
|
}, []);
|
|
51
54
|
const withdraw = useCallback((targetId: string) => {
|
|
52
55
|
setQueue((current) => current.filter((pending) => pending.id !== targetId));
|
|
53
56
|
}, []);
|
|
54
57
|
|
|
55
|
-
return {
|
|
58
|
+
return { pending: queue, enqueue, resolve, withdraw };
|
|
56
59
|
}
|
|
@@ -120,7 +120,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
120
120
|
sessionData,
|
|
121
121
|
adoptedSessionIdRef,
|
|
122
122
|
refreshSessionMeta,
|
|
123
|
-
|
|
123
|
+
seedSession,
|
|
124
124
|
hasMore,
|
|
125
125
|
loadingOlder,
|
|
126
126
|
loadOlderError,
|
|
@@ -183,9 +183,9 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
183
183
|
removeDeliveredFromQueue,
|
|
184
184
|
} = useChatTimelineQueue(sessionId);
|
|
185
185
|
const {
|
|
186
|
-
|
|
186
|
+
pending: extensionDialogs,
|
|
187
187
|
enqueue: enqueueExtensionDialog,
|
|
188
|
-
|
|
188
|
+
resolve: resolveExtensionDialog,
|
|
189
189
|
withdraw: withdrawExtensionDialog,
|
|
190
190
|
} = useExtensionDialogQueue(sessionId);
|
|
191
191
|
|
|
@@ -226,7 +226,7 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
226
226
|
adoptedSessionIdRef,
|
|
227
227
|
optimisticUserIdRef,
|
|
228
228
|
pendingUserDisplaysRef,
|
|
229
|
-
|
|
229
|
+
seedSession,
|
|
230
230
|
pendingComposerModelRef,
|
|
231
231
|
pendingThinkingLevelRef,
|
|
232
232
|
composerModelRef,
|
|
@@ -262,7 +262,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
262
262
|
stopGenerating,
|
|
263
263
|
handleThinkingLevelChange,
|
|
264
264
|
handleModelChange,
|
|
265
|
-
closeExtensionDialog,
|
|
266
265
|
} = useChatTimelineActions({
|
|
267
266
|
inputValue,
|
|
268
267
|
setInputValue,
|
|
@@ -286,7 +285,6 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
286
285
|
composerModelRef,
|
|
287
286
|
accessModeRef,
|
|
288
287
|
setSearchParams,
|
|
289
|
-
dismissExtensionDialog,
|
|
290
288
|
});
|
|
291
289
|
|
|
292
290
|
return {
|
|
@@ -331,8 +329,8 @@ export function useChatTimeline({ folders = [], appSettings = {} }: UseChatTimel
|
|
|
331
329
|
accessMode,
|
|
332
330
|
handleAccessModeChange,
|
|
333
331
|
composerModelRef,
|
|
334
|
-
|
|
335
|
-
|
|
332
|
+
extensionDialogs,
|
|
333
|
+
resolveExtensionDialog,
|
|
336
334
|
respondToExtensionUi: ompAgent.respondToExtensionUi,
|
|
337
335
|
};
|
|
338
336
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Prompt assembly for the chamber composer: rewrite composer-level mentions
|
|
8
|
+
* into what oh-my-pi actually receives, then inline the text attachments the
|
|
9
|
+
* user dropped in. Extracted from the send path so that hook stays under the
|
|
10
|
+
* repo's per-file size ceiling.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { composeMessageWithTextAttachments } from '@/shared/lib/chat/attachments';
|
|
14
|
+
import { loadAgentNames } from '@/shared/lib/chat/composer/client';
|
|
15
|
+
import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
|
|
16
|
+
|
|
17
|
+
type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1][number];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
|
|
21
|
+
* task-tool delegation directive at send time (oh-my-pi has no `@agent`
|
|
22
|
+
* syntax); translation failures fall back to the raw prompt.
|
|
23
|
+
*/
|
|
24
|
+
export async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
|
|
25
|
+
let translated = text;
|
|
26
|
+
try {
|
|
27
|
+
const names = await loadAgentNames();
|
|
28
|
+
if (names.length > 0) translated = translateAgentMentions(text, names).text;
|
|
29
|
+
} catch {
|
|
30
|
+
// keep the raw prompt
|
|
31
|
+
}
|
|
32
|
+
// File mentions are namespaced (`@file:`) by the picker; strip the namespace
|
|
33
|
+
// only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
|
|
34
|
+
return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
|
|
35
|
+
}
|
|
@@ -15,33 +15,13 @@ import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
|
15
15
|
import type { 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
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import type { SessionSeed } from '@/client/hooks/chat/timeline/session-load';
|
|
19
|
+
import { buildPromptText } from '@/client/hooks/chat/timeline/prompt-text';
|
|
20
|
+
import { isTextAttachmentFile } from '@/shared/lib/chat/attachments';
|
|
21
21
|
import { createMockStreamCallbacks } from '@/shared/lib/chat/timeline/stream-callbacks';
|
|
22
22
|
import { PHASE_VERBS } from '@/shared/lib/chat/timeline/tool-phrases';
|
|
23
23
|
import { formatClock } from '@/shared/lib/format/time';
|
|
24
24
|
|
|
25
|
-
type TextFileAttachment = Parameters<typeof composeMessageWithTextAttachments>[1][number];
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Build the outgoing prompt. `@agent` mentions are rewritten into an explicit
|
|
29
|
-
* task-tool delegation directive at send time (oh-my-pi has no `@agent`
|
|
30
|
-
* syntax); translation failures fall back to the raw prompt.
|
|
31
|
-
*/
|
|
32
|
-
async function buildPromptText(text: string, textFiles: TextFileAttachment[]): Promise<string> {
|
|
33
|
-
let translated = text;
|
|
34
|
-
try {
|
|
35
|
-
const names = await loadAgentNames();
|
|
36
|
-
if (names.length > 0) translated = translateAgentMentions(text, names).text;
|
|
37
|
-
} catch {
|
|
38
|
-
// keep the raw prompt
|
|
39
|
-
}
|
|
40
|
-
// File mentions are namespaced (`@file:`) by the picker; strip the namespace
|
|
41
|
-
// only after the agent pass so `@file:<name>` is never mistaken for `@agent`.
|
|
42
|
-
return composeMessageWithTextAttachments(translateFileMentions(translated), textFiles);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
25
|
export interface ChatTimelineSendDeps {
|
|
46
26
|
folders: any[];
|
|
47
27
|
selectedFolderId: number | null;
|
|
@@ -61,7 +41,9 @@ export interface ChatTimelineSendDeps {
|
|
|
61
41
|
adoptedSessionIdRef: { current: string | null };
|
|
62
42
|
optimisticUserIdRef: { current: string | null };
|
|
63
43
|
pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
|
|
64
|
-
|
|
44
|
+
/** Adopt the spawned session's identity (model + the thinking level the first
|
|
45
|
+
* prompt runs with) so the composer replacing the pending view shows it. */
|
|
46
|
+
seedSession: (seed: SessionSeed) => void;
|
|
65
47
|
/** Model/thinking picked in the composer before the session existed —
|
|
66
48
|
* applied to the spawn command so the first prompt runs with them. */
|
|
67
49
|
pendingComposerModelRef: { current: { provider: string; modelId: string } | null };
|
|
@@ -106,7 +88,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
106
88
|
adoptedSessionIdRef,
|
|
107
89
|
optimisticUserIdRef,
|
|
108
90
|
pendingUserDisplaysRef,
|
|
109
|
-
|
|
91
|
+
seedSession,
|
|
110
92
|
pendingComposerModelRef,
|
|
111
93
|
pendingThinkingLevelRef,
|
|
112
94
|
accessModeRef,
|
|
@@ -291,7 +273,11 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
291
273
|
if (spawned) {
|
|
292
274
|
adoptedSessionIdRef.current = spawned.sessionId;
|
|
293
275
|
aiPlaceholderIdRef.current = aiPlaceholderId;
|
|
294
|
-
|
|
276
|
+
// The spawn already applied this level to the session; seed it so the
|
|
277
|
+
// composer taking over from the pending view reports the run's real
|
|
278
|
+
// setting instead of a catalog default (the JSONL that carries it is
|
|
279
|
+
// not locatable yet).
|
|
280
|
+
seedSession({ model: spawned.model, thinkingLevel: composerThinking });
|
|
295
281
|
pendingComposerModelRef.current = null;
|
|
296
282
|
pendingThinkingLevelRef.current = null;
|
|
297
283
|
fetch(`/api/chat/${encodeURIComponent(spawned.sessionId)}`, {
|
|
@@ -343,6 +329,6 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
343
329
|
scrollToBottom,
|
|
344
330
|
})
|
|
345
331
|
);
|
|
346
|
-
}, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages,
|
|
332
|
+
}, [appSettings, folders, isOmpSession, ompAgent, selectedFolderId, sessionId, scrollToBottom, jumpToBottom, persistMessages, seedSession, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
|
|
347
333
|
return { steerOmpAgent, executeSend };
|
|
348
334
|
}
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
|
|
21
|
-
import { useCallback, useEffect,
|
|
21
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
22
22
|
|
|
23
23
|
import type { ChatMessageData } from '@/shared/types';
|
|
24
24
|
import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
25
|
+
import { useSessionPagination } from '@/client/hooks/chat/timeline/session-pagination';
|
|
25
26
|
|
|
26
27
|
export interface SessionDataShape {
|
|
27
28
|
id?: string;
|
|
@@ -31,6 +32,12 @@ export interface SessionDataShape {
|
|
|
31
32
|
messages?: any[];
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
/** Session identity a spawn established before omp's JSONL is readable. */
|
|
36
|
+
export interface SessionSeed {
|
|
37
|
+
model?: { provider: string; modelId: string } | null;
|
|
38
|
+
thinkingLevel?: string | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
34
41
|
export interface UseSessionLoadDeps {
|
|
35
42
|
sessionId: string | null;
|
|
36
43
|
setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
|
|
@@ -59,24 +66,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
59
66
|
const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, aiPlaceholderIdRef, localMessagesRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef } = deps;
|
|
60
67
|
|
|
61
68
|
const [sessionData, setSessionData] = useState<SessionDataShape | null>(null);
|
|
62
|
-
// History pagination state: the first fetch receives the newest window only;
|
|
63
|
-
// scrolling to the top pages further back via ?before=oldestIndex.
|
|
64
|
-
const [hasMore, setHasMore] = useState(false);
|
|
65
|
-
const [oldestIndex, setOldestIndex] = useState(0);
|
|
66
|
-
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
67
|
-
// Sticky failure flag for the "Load earlier messages" affordance: a failed
|
|
68
|
-
// page fetch must keep the button visible (retryable) instead of silently
|
|
69
|
-
// dropping hasMore, which made the button vanish with no feedback.
|
|
70
|
-
const [loadOlderError, setLoadOlderError] = useState(false);
|
|
71
|
-
// Bumped on every successful older-window prepend; the scroll-anchor layout
|
|
72
|
-
// effect keys on it (its other deps have stable identities, so it needs an
|
|
73
|
-
// explicit change signal per commit).
|
|
74
|
-
const [prependTick, setPrependTick] = useState(0);
|
|
75
|
-
const loadingOlderRef = useRef(false);
|
|
76
69
|
const [sessionLoading, setSessionLoading] = useState(false);
|
|
77
|
-
// Set while prepending older rows; the layout effect below re-anchors the
|
|
78
|
-
// viewport to the content the user was reading instead of jumping.
|
|
79
|
-
const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
|
|
80
70
|
const prevSessionIdRef = useRef<string | null>(null);
|
|
81
71
|
// Real session id adopted by a fresh spawn ("new-…" → UUID). onAgentStart
|
|
82
72
|
// may fire before React re-renders with the new URL, so it reads the id
|
|
@@ -84,16 +74,33 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
84
74
|
const adoptedSessionIdRef = useRef<string | null>(null);
|
|
85
75
|
// Model seeded from the spawn response; kept until the JSONL writes model_change.
|
|
86
76
|
const seededModelRef = useRef<{ provider: string; modelId: string } | null>(null);
|
|
77
|
+
// Thinking level seeded from the spawn response. The spawn's
|
|
78
|
+
// `set_thinking_level` IS the level this session runs, and `/api/chat/:id`
|
|
79
|
+
// cannot report it until omp's JSONL is locatable — until then the loader
|
|
80
|
+
// answers from the chamber DB copy, which carries no thinkingLevel at all.
|
|
81
|
+
// Without this seed the composer that takes over the pending view falls back
|
|
82
|
+
// to a catalog default and claims a level the run never used.
|
|
83
|
+
const seededThinkingLevelRef = useRef<string | null>(null);
|
|
87
84
|
|
|
88
85
|
const applySessionData = useCallback((incoming: SessionDataShape) => {
|
|
89
86
|
if (incoming.model && typeof incoming.model === 'object') seededModelRef.current = incoming.model;
|
|
90
87
|
const model = incoming.model ?? (isGeneratingRef.current ? seededModelRef.current ?? undefined : undefined);
|
|
91
|
-
|
|
88
|
+
const thinkingLevel = incoming.thinkingLevel ?? seededThinkingLevelRef.current ?? undefined;
|
|
89
|
+
setSessionData({ ...incoming, model, thinkingLevel });
|
|
92
90
|
}, []);
|
|
93
91
|
|
|
94
92
|
const sessionIdRef = useRef(sessionId);
|
|
95
93
|
sessionIdRef.current = sessionId;
|
|
96
94
|
|
|
95
|
+
// Older-window pagination (cursor, retry flag, scroll anchoring) lives in its
|
|
96
|
+
// own hook so this one stays under the repo's per-file size ceiling.
|
|
97
|
+
const { hasMore, loadingOlder, loadOlderError, loadOlder, resetPages, applyWindow } = useSessionPagination({
|
|
98
|
+
sessionId,
|
|
99
|
+
sessionIdRef,
|
|
100
|
+
setLocalMessages,
|
|
101
|
+
scrollRef,
|
|
102
|
+
});
|
|
103
|
+
|
|
97
104
|
/** Re-fetch the session's title/metadata after the omp JSONL has been
|
|
98
105
|
* written (spawn or agent end) so the navbar and context panel show the
|
|
99
106
|
* real session title instead of the default. Retries on a front-loaded
|
|
@@ -127,9 +134,17 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
127
134
|
tryFetch();
|
|
128
135
|
}, [applySessionData]);
|
|
129
136
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
/** Adopt a spawn's session identity: the model and the thinking level the
|
|
138
|
+
* first prompt is running with. Only the fields a caller supplies are
|
|
139
|
+
* written, so a null model/level leaves the previous value untouched. */
|
|
140
|
+
const seedSession = useCallback((seed: SessionSeed) => {
|
|
141
|
+
if (seed.model) seededModelRef.current = seed.model;
|
|
142
|
+
if (seed.thinkingLevel) seededThinkingLevelRef.current = seed.thinkingLevel;
|
|
143
|
+
setSessionData(prev => ({
|
|
144
|
+
...(prev ?? {}),
|
|
145
|
+
...(seed.model ? { model: seed.model } : {}),
|
|
146
|
+
...(seed.thinkingLevel ? { thinkingLevel: seed.thinkingLevel } : {}),
|
|
147
|
+
}));
|
|
133
148
|
}, []);
|
|
134
149
|
|
|
135
150
|
/** Whether the optimistic bubble set currently owns the timeline tail (a
|
|
@@ -159,6 +174,14 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
159
174
|
setGenerating(false);
|
|
160
175
|
adoptedSessionIdRef.current = null;
|
|
161
176
|
seededModelRef.current = null;
|
|
177
|
+
// Metadata (model / thinking level) belongs to ONE session: leaving it
|
|
178
|
+
// in place let a pending "new-…" composer — and any other session's
|
|
179
|
+
// composer — display a level that session never ran with, until the
|
|
180
|
+
// fetch replaced it.
|
|
181
|
+
setSessionData(null);
|
|
182
|
+
// The thinking seed belongs to the previous session's spawn; keeping it
|
|
183
|
+
// would report that level for the newly opened session.
|
|
184
|
+
seededThinkingLevelRef.current = null;
|
|
162
185
|
// The placeholder/optimistic ids belong to the PREVIOUS session's
|
|
163
186
|
// in-flight send; message_end never arrives after a switch away (the
|
|
164
187
|
// stream is disconnected), so they must be dropped here. A stale
|
|
@@ -174,11 +197,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
174
197
|
}
|
|
175
198
|
// Reset pagination cursor on every session switch — the window belongs
|
|
176
199
|
// to the previous session otherwise.
|
|
177
|
-
|
|
178
|
-
setOldestIndex(0);
|
|
179
|
-
loadingOlderRef.current = false;
|
|
180
|
-
setLoadingOlder(false);
|
|
181
|
-
setLoadOlderError(false);
|
|
200
|
+
resetPages();
|
|
182
201
|
// metaRefreshedRef must NOT survive a session switch — including a
|
|
183
202
|
// spawn adoption. It is the once-per-session guard in onAgentStart
|
|
184
203
|
// (omp-callbacks.ts); keeping the stale value here ate the retrigger,
|
|
@@ -216,8 +235,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
216
235
|
} else if (!sessionId.startsWith('new-') && !optimisticOwnsTail) {
|
|
217
236
|
setLocalMessages([]);
|
|
218
237
|
}
|
|
219
|
-
|
|
220
|
-
if (typeof data.oldestIndex === 'number') setOldestIndex(data.oldestIndex);
|
|
238
|
+
applyWindow(data.hasMore, data.oldestIndex);
|
|
221
239
|
} else {
|
|
222
240
|
setSessionData(null);
|
|
223
241
|
if (!timelineOwnedByOptimistic()) setLocalMessages([]);
|
|
@@ -243,79 +261,6 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
243
261
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
244
262
|
}, [sessionId, applySessionData, timelineOwnedByOptimistic, setLocalMessages, setGenerating]);
|
|
245
263
|
|
|
246
|
-
/** Page the next older window into the timeline. Called from the click
|
|
247
|
-
* handler and the scroll handler when the viewport reaches the top; a no-op
|
|
248
|
-
* while a page is in flight, when the session has no more history, or while
|
|
249
|
-
* the session is a pending optimistic spawn. Prepending older rows only
|
|
250
|
-
* grows the list ABOVE the tail, so it is safe while the agent is
|
|
251
|
-
* generating — unlike a committed refetch, it never clobbers the AI
|
|
252
|
-
* placeholder, and blocking it made the button feel dead mid-run. */
|
|
253
|
-
const loadOlder = useCallback(() => {
|
|
254
|
-
if (!sessionId || sessionId.startsWith('new-')) return;
|
|
255
|
-
if (!hasMore || loadingOlderRef.current) return;
|
|
256
|
-
loadingOlderRef.current = true;
|
|
257
|
-
setLoadingOlder(true);
|
|
258
|
-
setLoadOlderError(false);
|
|
259
|
-
const el = scrollRef?.current;
|
|
260
|
-
if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
|
|
261
|
-
// Session id snapshot: prepending a window fetched for the PREVIOUS
|
|
262
|
-
// session into the freshly-switched timeline is worse than dropping the
|
|
263
|
-
// page, so the response is discarded if the user switched mid-flight.
|
|
264
|
-
const requestedSessionId = sessionId;
|
|
265
|
-
fetch(`/api/chat/${sessionId}?before=${oldestIndex}`)
|
|
266
|
-
.then(res => {
|
|
267
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
268
|
-
return res.json();
|
|
269
|
-
})
|
|
270
|
-
.then((data: { session?: { messages?: ChatMessageData[] }; hasMore?: boolean; oldestIndex?: number }) => {
|
|
271
|
-
// The main load effect may have cleared/repurposed localMessages for a
|
|
272
|
-
// new session while this fetch was in flight.
|
|
273
|
-
if (sessionIdRef.current !== requestedSessionId) return;
|
|
274
|
-
const older = data.session?.messages ?? [];
|
|
275
|
-
setHasMore(Boolean(data.hasMore));
|
|
276
|
-
if (typeof data.oldestIndex === 'number') setOldestIndex(data.oldestIndex);
|
|
277
|
-
if (older.length === 0) {
|
|
278
|
-
// No rows prepended: the recorded anchor is stale — drop it so a
|
|
279
|
-
// later tick cannot apply it against the wrong geometry.
|
|
280
|
-
pendingScrollAnchorRef.current = null;
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
setLocalMessages(prev => {
|
|
284
|
-
// Drop overlap guard: the server window is index-based, so the
|
|
285
|
-
// fetched slice is strictly older than everything already mounted.
|
|
286
|
-
return [...older, ...prev];
|
|
287
|
-
});
|
|
288
|
-
// Bump the prepend tick so the layout effect below re-runs for THIS
|
|
289
|
-
// commit — it previously depended only on stable identities and ran
|
|
290
|
-
// exactly once at mount, so the viewport anchor never fired and every
|
|
291
|
-
// pagination jump snapped the user to the (shifted) top.
|
|
292
|
-
setPrependTick(t => t + 1);
|
|
293
|
-
})
|
|
294
|
-
.catch(() => {
|
|
295
|
-
// Keep the button mounted and marked for retry; a swallowed failure
|
|
296
|
-
// previously also cleared hasMore on the next full reload path.
|
|
297
|
-
if (sessionIdRef.current === requestedSessionId) setLoadOlderError(true);
|
|
298
|
-
})
|
|
299
|
-
.finally(() => {
|
|
300
|
-
loadingOlderRef.current = false;
|
|
301
|
-
setLoadingOlder(false);
|
|
302
|
-
});
|
|
303
|
-
}, [sessionId, hasMore, oldestIndex, scrollRef, setLocalMessages]);
|
|
304
|
-
|
|
305
|
-
// Position preservation: after older rows commit above the viewport, shift
|
|
306
|
-
// scrollTop by the height delta so the rows the user was reading stay put.
|
|
307
|
-
// prependTick changes on every prepend — without it the effect ran only at
|
|
308
|
-
// mount (its other deps are stable) and never re-anchored.
|
|
309
|
-
useLayoutEffect(() => {
|
|
310
|
-
const anchor = pendingScrollAnchorRef.current;
|
|
311
|
-
if (!anchor) return;
|
|
312
|
-
const el = scrollRef?.current;
|
|
313
|
-
if (!el) return;
|
|
314
|
-
pendingScrollAnchorRef.current = null;
|
|
315
|
-
const delta = el.scrollHeight - anchor.prevHeight;
|
|
316
|
-
if (delta > 0) el.scrollTop = anchor.prevTop + delta;
|
|
317
|
-
}, [prependTick, scrollRef]);
|
|
318
|
-
|
|
319
264
|
return {
|
|
320
265
|
sessionData,
|
|
321
266
|
hasMore,
|
|
@@ -324,10 +269,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
324
269
|
sessionLoading,
|
|
325
270
|
loadOlder,
|
|
326
271
|
adoptedSessionIdRef,
|
|
327
|
-
seededModelRef,
|
|
328
|
-
applySessionData,
|
|
329
272
|
refreshSessionMeta,
|
|
330
|
-
|
|
331
|
-
timelineOwnedByOptimistic,
|
|
273
|
+
seedSession,
|
|
332
274
|
};
|
|
333
275
|
}
|