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.
Files changed (107) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  3. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  4. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  5. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  6. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  7. package/dist/client/static/js/async/c.797d933882.js +1 -0
  8. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  9. package/dist/client/static/js/async/{g.86e2256a70.js → g.d1c2fe09aa.js} +2 -2
  10. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  11. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  12. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  13. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  15. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  16. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  17. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  18. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  19. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  20. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  21. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  22. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  23. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  24. package/package.json +1 -1
  25. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  26. package/src/client/components/common/code-editor/history.test.ts +118 -0
  27. package/src/client/components/common/code-editor/history.ts +101 -0
  28. package/src/client/components/common/code-editor/index.tsx +56 -66
  29. package/src/client/components/common/code-surface/index.tsx +84 -11
  30. package/src/client/components/common/code-surface/measure.ts +66 -17
  31. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  32. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  33. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  34. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  35. package/src/client/components/settings/LazyModal.tsx +1 -1
  36. package/src/client/components/settings/Modal.tsx +2 -1
  37. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  38. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  39. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  40. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  41. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  42. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  51. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  52. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  53. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  54. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  55. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  56. package/src/client/hooks/chat/timeline/index.ts +6 -8
  57. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  58. package/src/client/hooks/chat/timeline/send.ts +13 -27
  59. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  60. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  61. package/src/client/hooks/editor/use-code-window.ts +118 -0
  62. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  63. package/src/client/hooks/ui/notification-sound.ts +65 -0
  64. package/src/client/hooks/ui/theme.ts +22 -6
  65. package/src/client/tailwind.css +38 -12
  66. package/src/server/lib/omp/rpc/manager.ts +14 -3
  67. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  68. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  69. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  70. package/src/shared/lib/chat/ask-questions.ts +264 -0
  71. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  72. package/src/shared/lib/code/lazy-window.ts +156 -0
  73. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  74. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  75. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  76. package/src/shared/lib/markdown/mermaid.ts +8 -6
  77. package/src/shared/types/omp/agent.ts +2 -2
  78. package/src/shared/types/workspace.ts +4 -0
  79. package/dist/client/static/css/index.51f68210a5.css +0 -2
  80. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  81. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  82. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  83. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  84. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  85. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  86. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  87. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  88. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  89. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  90. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  91. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  92. package/dist/client/static/js/index.eccad0b70a.js +0 -33
  93. package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
  94. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  95. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  96. /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  97. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  98. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  99. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  100. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  101. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  102. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  103. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  104. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  105. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  106. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  107. /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
- * Requests surface one at a time in arrival order; answering the head advances
17
- * the queue. Extracted from useChatTimeline to stay under the size ceiling.
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
- /** Head of the queue — the single dialog to render. */
25
- dialog: ExtensionUiDialogRequest | null;
27
+ /** Still-blocking requests, in omp's arrival order. */
28
+ pending: ExtensionUiDialogRequest[];
26
29
  enqueue: (request: ExtensionUiDialogRequest) => void;
27
- /** Advance past the head once its response has been sent. */
28
- dismiss: () => void;
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 dismiss = useCallback(() => {
49
- setQueue((current) => current.slice(1));
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 { dialog: queue[0] ?? null, enqueue, dismiss, withdraw };
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
- setSessionModel,
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
- dialog: extensionDialog,
186
+ pending: extensionDialogs,
187
187
  enqueue: enqueueExtensionDialog,
188
- dismiss: dismissExtensionDialog,
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
- setSessionModel,
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
- extensionDialog,
335
- closeExtensionDialog,
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 { composeMessageWithTextAttachments, isTextAttachmentFile } from '@/shared/lib/chat/attachments';
19
- import { loadAgentNames } from '@/shared/lib/chat/composer/client';
20
- import { translateAgentMentions, translateFileMentions } from '@/shared/lib/chat/composer/translate';
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
- setSessionModel: (model: { provider: string; modelId: string } | null) => void;
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
- setSessionModel,
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
- if (spawned.model) setSessionModel(spawned.model);
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, setSessionModel, pendingComposerModelRef, pendingThinkingLevelRef, accessModeRef]);
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, useLayoutEffect, useRef, useState } from 'preact/hooks';
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
- setSessionData({ ...incoming, model });
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
- const setSessionModelWithSeed = useCallback((model: { provider: string; modelId: string } | null) => {
131
- seededModelRef.current = model;
132
- setSessionData(prev => ({ ...(prev ?? {}), model: model ?? undefined }));
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
- setHasMore(false);
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
- if (typeof data.hasMore === 'boolean') setHasMore(data.hasMore);
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
- setSessionModel: setSessionModelWithSeed,
331
- timelineOwnedByOptimistic,
273
+ seedSession,
332
274
  };
333
275
  }