ompchamber 0.6.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 (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -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
  }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Older-window pagination for the chamber timeline: pages
8
+ * `/api/chat/:id?before=` into the head of the message list and keeps the
9
+ * viewport anchored on the rows the user was reading. Extracted from
10
+ * useSessionLoad so that hook stays under the repo's per-file size ceiling.
11
+ */
12
+
13
+ import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
14
+ import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks';
15
+ import type { ChatMessageData } from '@/shared/types';
16
+
17
+ export interface UseSessionPaginationDeps {
18
+ sessionId: string | null;
19
+ /** Live session id, read when the page response lands: a window fetched for
20
+ * the previous session is worse than a dropped page. */
21
+ sessionIdRef: { current: string | null };
22
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
23
+ /** Scroll container of the timeline: lets loadOlder preserve the viewport
24
+ * position when older rows are prepended above it. */
25
+ scrollRef?: RefObject<HTMLDivElement>;
26
+ }
27
+
28
+ export interface SessionPagination {
29
+ hasMore: boolean;
30
+ loadingOlder: boolean;
31
+ /** Sticky failure flag for the "Load earlier messages" affordance: a failed
32
+ * page fetch must keep the button visible (retryable) instead of silently
33
+ * dropping hasMore, which made the button vanish with no feedback. */
34
+ loadOlderError: boolean;
35
+ loadOlder: () => void;
36
+ /** Forget the cursor and the retry flag — a window belongs to one session. */
37
+ resetPages: () => void;
38
+ /** Adopt the window metadata a session load returned. */
39
+ applyWindow: (hasMore: unknown, oldestIndex: unknown) => void;
40
+ }
41
+
42
+ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPagination {
43
+ const { sessionId, sessionIdRef, setLocalMessages, scrollRef } = deps;
44
+
45
+ const [hasMore, setHasMore] = useState(false);
46
+ const [oldestIndex, setOldestIndex] = useState(0);
47
+ const [loadingOlder, setLoadingOlder] = useState(false);
48
+ const [loadOlderError, setLoadOlderError] = useState(false);
49
+ // Bumped on every successful older-window prepend; the scroll-anchor layout
50
+ // effect keys on it (its other deps have stable identities, so it needs an
51
+ // explicit change signal per commit).
52
+ const [prependTick, setPrependTick] = useState(0);
53
+ const loadingOlderRef = useRef(false);
54
+ // Set while prepending older rows; the layout effect below re-anchors the
55
+ // viewport to the content the user was reading instead of jumping.
56
+ const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
57
+
58
+ const resetPages = useCallback(() => {
59
+ setHasMore(false);
60
+ setOldestIndex(0);
61
+ loadingOlderRef.current = false;
62
+ setLoadingOlder(false);
63
+ setLoadOlderError(false);
64
+ }, []);
65
+
66
+ const applyWindow = useCallback((hasMoreFromLoad: unknown, oldestIndexFromLoad: unknown) => {
67
+ if (typeof hasMoreFromLoad === 'boolean') setHasMore(hasMoreFromLoad);
68
+ if (typeof oldestIndexFromLoad === 'number') setOldestIndex(oldestIndexFromLoad);
69
+ }, []);
70
+
71
+ /** Page the next older window into the timeline. Called from the click
72
+ * handler and the scroll handler when the viewport reaches the top; a no-op
73
+ * while a page is in flight, when the session has no more history, or while
74
+ * the session is a pending optimistic spawn. Prepending older rows only
75
+ * grows the list ABOVE the tail, so it is safe while the agent is
76
+ * generating — unlike a committed refetch, it never clobbers the AI
77
+ * placeholder, and blocking it made the button feel dead mid-run. */
78
+ const loadOlder = useCallback(() => {
79
+ if (!sessionId || sessionId.startsWith('new-')) return;
80
+ if (!hasMore || loadingOlderRef.current) return;
81
+ loadingOlderRef.current = true;
82
+ setLoadingOlder(true);
83
+ setLoadOlderError(false);
84
+ const el = scrollRef?.current;
85
+ if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
86
+ // Session id snapshot: prepending a window fetched for the PREVIOUS
87
+ // session into the freshly-switched timeline is worse than dropping the
88
+ // page, so the response is discarded if the user switched mid-flight.
89
+ const requestedSessionId = sessionId;
90
+ fetch(`/api/chat/${sessionId}?before=${oldestIndex}`)
91
+ .then(res => {
92
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
93
+ return res.json();
94
+ })
95
+ .then((data: { session?: { messages?: ChatMessageData[] }; hasMore?: boolean; oldestIndex?: number }) => {
96
+ // The main load effect may have cleared/repurposed localMessages for a
97
+ // new session while this fetch was in flight.
98
+ if (sessionIdRef.current !== requestedSessionId) return;
99
+ const older = data.session?.messages ?? [];
100
+ applyWindow(data.hasMore, data.oldestIndex);
101
+ if (older.length === 0) {
102
+ // No rows prepended: the recorded anchor is stale — drop it so a
103
+ // later tick cannot apply it against the wrong geometry.
104
+ pendingScrollAnchorRef.current = null;
105
+ return;
106
+ }
107
+ setLocalMessages(prev => {
108
+ // Drop overlap guard: the server window is index-based, so the
109
+ // fetched slice is strictly older than everything already mounted.
110
+ return [...older, ...prev];
111
+ });
112
+ // Bump the prepend tick so the layout effect below re-runs for THIS
113
+ // commit — it previously depended only on stable identities and ran
114
+ // exactly once at mount, so the viewport anchor never fired and every
115
+ // pagination jump snapped the user to the (shifted) top.
116
+ setPrependTick(t => t + 1);
117
+ })
118
+ .catch(() => {
119
+ // Keep the button mounted and marked for retry; a swallowed failure
120
+ // previously also cleared hasMore on the next full reload path.
121
+ if (sessionIdRef.current === requestedSessionId) setLoadOlderError(true);
122
+ })
123
+ .finally(() => {
124
+ loadingOlderRef.current = false;
125
+ setLoadingOlder(false);
126
+ });
127
+ }, [sessionId, sessionIdRef, hasMore, oldestIndex, scrollRef, setLocalMessages, applyWindow]);
128
+
129
+ // Position preservation: after older rows commit above the viewport, shift
130
+ // scrollTop by the height delta so the rows the user was reading stay put.
131
+ // prependTick changes on every prepend — without it the effect ran only at
132
+ // mount (its other deps are stable) and never re-anchored.
133
+ useLayoutEffect(() => {
134
+ const anchor = pendingScrollAnchorRef.current;
135
+ if (!anchor) return;
136
+ const el = scrollRef?.current;
137
+ if (!el) return;
138
+ pendingScrollAnchorRef.current = null;
139
+ const delta = el.scrollHeight - anchor.prevHeight;
140
+ if (delta > 0) el.scrollTop = anchor.prevTop + delta;
141
+ }, [prependTick, scrollRef]);
142
+
143
+ return { hasMore, loadingOlder, loadOlderError, loadOlder, resetPages, applyWindow };
144
+ }
@@ -0,0 +1,118 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
+ import type { RefObject } from 'preact';
3
+
4
+ import {
5
+ codeWindow,
6
+ firstWindow,
7
+ resolveWindow,
8
+ sameWindow,
9
+ type CodeWindow,
10
+ type LineGeometry,
11
+ type LineWindow,
12
+ } from '@/shared/lib/code/lazy-window';
13
+
14
+ export interface UseCodeWindowOptions {
15
+ /** The surface's root box: the frame every line offset in `geometry` is measured from. */
16
+ rootRef: RefObject<HTMLElement | null>;
17
+ /** Lines in the document on screen. */
18
+ lineCount: number;
19
+ /** Document geometry, or null until the wrap/row measurement lands. */
20
+ geometry: LineGeometry | null;
21
+ /** False for short documents (they render whole) and when geometry cannot be measured. */
22
+ enabled: boolean;
23
+ }
24
+
25
+ /** Nearest ancestor the document scrolls inside, or null when the page itself scrolls. */
26
+ function findScrollContainer(element: HTMLElement): HTMLElement | null {
27
+ for (let node = element.parentElement; node; node = node.parentElement) {
28
+ const overflowY = getComputedStyle(node).overflowY;
29
+ if ((overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') && node.scrollHeight > node.clientHeight) {
30
+ return node;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The lines visible inside the scroll container `root` lives in.
38
+ *
39
+ * Both rectangles are read in viewport coordinates, so this needs no knowledge
40
+ * of scroll offsets: the part of the editor's box the container actually shows
41
+ * is exactly the band between the two.
42
+ */
43
+ function visibleWindow(root: HTMLElement, geometry: LineGeometry): LineWindow {
44
+ const frameTop = root.getBoundingClientRect().top;
45
+ const container = findScrollContainer(root);
46
+ const viewTop = container ? container.getBoundingClientRect().top : 0;
47
+ const viewBottom = container
48
+ ? container.getBoundingClientRect().bottom
49
+ : globalThis.innerHeight || geometry.rowHeight;
50
+ return resolveWindow(geometry, viewTop - frameTop, viewBottom - viewTop);
51
+ }
52
+
53
+ /**
54
+ * Rendering window for the lazy editor surface.
55
+ *
56
+ * A long document is highlighted, gutters and all, only where it can be seen;
57
+ * this hook owns which lines those are and re-resolves them on scroll, on
58
+ * resize, and whenever the geometry moves (a keystroke re-wraps a line). The
59
+ * scroll listener is a capturing one on the window: scroll events do not
60
+ * bubble, but they do reach a capturing listener, so the surface follows
61
+ * whichever ancestor scrolls — the desktop editor panel, the phone's
62
+ * full-screen editor, or the page.
63
+ *
64
+ * Returns null for a document that renders whole, which is the caller's cue to
65
+ * keep the plain, unwindowed surface.
66
+ */
67
+ export function useCodeWindow({ rootRef, lineCount, geometry, enabled }: UseCodeWindowOptions): CodeWindow | null {
68
+ const [lineWindow, setLineWindow] = useState<LineWindow | null>(null);
69
+
70
+ const compute = useCallback(() => {
71
+ const root = rootRef.current;
72
+ if (!enabled || !root) return;
73
+ const next = geometry ? visibleWindow(root, geometry) : firstWindow(lineCount);
74
+ setLineWindow((previous) => (sameWindow(previous, next) ? previous : next));
75
+ }, [enabled, geometry, lineCount, rootRef]);
76
+
77
+ // Layout effect: the first window has to be resolved before the browser
78
+ // paints, or a long document flashes its untouched opening lines.
79
+ useLayoutEffect(() => {
80
+ compute();
81
+ }, [compute]);
82
+
83
+ // The listeners follow `enabled`, not `compute`: re-resolving on every
84
+ // keystroke must not tear down and rebuild the observer each time.
85
+ const computeRef = useRef(compute);
86
+ computeRef.current = compute;
87
+ useEffect(() => {
88
+ if (!enabled) return;
89
+ let frame = 0;
90
+ const schedule = () => {
91
+ if (frame) return;
92
+ frame = requestAnimationFrame(() => {
93
+ frame = 0;
94
+ computeRef.current();
95
+ });
96
+ };
97
+ window.addEventListener('scroll', schedule, { passive: true, capture: true });
98
+ window.addEventListener('resize', schedule);
99
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
100
+ const root = rootRef.current;
101
+ if (root) observer?.observe(root);
102
+ return () => {
103
+ if (frame) cancelAnimationFrame(frame);
104
+ window.removeEventListener('scroll', schedule, { capture: true });
105
+ window.removeEventListener('resize', schedule);
106
+ observer?.disconnect();
107
+ };
108
+ }, [enabled, rootRef]);
109
+
110
+ if (!enabled) return null;
111
+ if (!geometry) {
112
+ // First commit: the layout effect above lands the real window in the same
113
+ // frame, so this only bounds how much the opening render is allowed to draw.
114
+ const opening = firstWindow(lineCount);
115
+ return { ...opening, top: 0, bottom: 0, breakAtEnd: opening.end >= lineCount };
116
+ }
117
+ return codeWindow(geometry, lineWindow ?? firstWindow(geometry.lineCount));
118
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * App-wide "an agent is blocked on you" cue.
8
+ *
9
+ * It runs off the shared session list rather than the open timeline, which is
10
+ * the whole point: the agent that needs an answer is usually NOT the session on
11
+ * screen — you sent a prompt in one workspace and moved to another. The list is
12
+ * fed by the sidebar loader (which derives `awaitingInput` from the live omp
13
+ * process registry), so the cue reaches the client even when that process'
14
+ * session was never opened.
15
+ */
16
+
17
+ import { useEffect, useRef } from 'preact/hooks';
18
+ import type { WorkspaceFolderData } from '@/shared/types';
19
+ import { triggerInputRequiredSound } from '@/client/hooks/ui/notification-sound';
20
+
21
+ export function useInputRequiredAlert(folders: WorkspaceFolderData[]): void {
22
+ // Null until the first snapshot: a page load must not replay a cue for
23
+ // questions that were already waiting before it.
24
+ const knownRef = useRef<Set<string> | null>(null);
25
+
26
+ useEffect(() => {
27
+ const waiting = new Set<string>();
28
+ for (const folder of folders) {
29
+ for (const session of folder.sessions ?? []) {
30
+ if (session.awaitingInput) waiting.add(String(session.id));
31
+ }
32
+ }
33
+
34
+ const known = knownRef.current;
35
+ knownRef.current = waiting;
36
+ if (!known) return;
37
+ for (const id of waiting) {
38
+ if (known.has(id)) continue;
39
+ triggerInputRequiredSound();
40
+ return;
41
+ }
42
+ }, [folders]);
43
+ }
@@ -72,6 +72,60 @@ export function playNotificationSound() {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * Web Audio cue for "the agent is blocked on you" — an ask question or an
77
+ * approval gate. Deliberately the mirror image of the completion chime: a
78
+ * triangle wave stepping DOWN (A5 → E5) instead of two sines climbing, so the
79
+ * two are never mistaken for each other when the tab is in the background.
80
+ */
81
+ export function playInputRequiredSound() {
82
+ if (typeof window === 'undefined') return;
83
+
84
+ try {
85
+ const AudioCtx = window.AudioContext || (window as WebkitWindow).webkitAudioContext;
86
+ if (!AudioCtx) return;
87
+
88
+ const ctx = new AudioCtx();
89
+ if (ctx.state === 'suspended') {
90
+ ctx.resume();
91
+ }
92
+
93
+ const now = ctx.currentTime;
94
+ const notes = [
95
+ { at: 0, frequency: 880, duration: 0.18, peak: 0.11 },
96
+ { at: 0.16, frequency: 659.25, duration: 0.46, peak: 0.13 },
97
+ ];
98
+
99
+ for (const note of notes) {
100
+ const osc = ctx.createOscillator();
101
+ const gain = ctx.createGain();
102
+
103
+ osc.type = 'triangle';
104
+ osc.frequency.setValueAtTime(note.frequency, now + note.at);
105
+
106
+ gain.gain.setValueAtTime(0, now + note.at);
107
+ gain.gain.linearRampToValueAtTime(note.peak, now + note.at + 0.015);
108
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + note.at + note.duration);
109
+
110
+ osc.connect(gain);
111
+ gain.connect(ctx.destination);
112
+
113
+ osc.start(now + note.at);
114
+ osc.stop(now + note.at + note.duration);
115
+ }
116
+
117
+ setTimeout(() => {
118
+ try {
119
+ ctx.close();
120
+ } catch {
121
+ // Safe to ignore
122
+ }
123
+ }, 800);
124
+ } catch (err) {
125
+ console.warn('Unable to play input-required audio cue:', err);
126
+ }
127
+ }
128
+
75
129
  /**
76
130
  * Checks whether chat completion sound is enabled in user preferences.
77
131
  */
@@ -91,6 +145,17 @@ export function triggerChatCompletionSound(appSettings?: Record<string, any>) {
91
145
  }
92
146
  }
93
147
 
148
+ /**
149
+ * Triggers the input-required cue, gated by the same notification preference as
150
+ * the completion chime. Reads settings from the primed snapshot: the alert fires
151
+ * from the app-wide session list, which has no settings props to thread.
152
+ */
153
+ export function triggerInputRequiredSound() {
154
+ if (isChatSoundEnabled()) {
155
+ playInputRequiredSound();
156
+ }
157
+ }
158
+
94
159
  export function useNotificationSound() {
95
160
  const play = useCallback(() => {
96
161
  playNotificationSound();
@@ -1,6 +1,25 @@
1
1
  import { useCallback, useEffect, useState } from 'preact/hooks';
2
2
  import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
3
3
 
4
+ /** Broadcast on every theme write. Detail is the new theme id; the
5
+ * `data-theme` attribute on `<html>` is the durable copy of the same fact. */
6
+ export const THEME_CHANGED_EVENT = 'omp:theme-changed';
7
+
8
+ /**
9
+ * Point the document at `theme` and announce it.
10
+ *
11
+ * The single writer path for the theme: the navbar toggle and the settings
12
+ * modal both call this, because a writer that only sets `data-theme` leaves
13
+ * runtime consumers (the mermaid hydrator re-renders diagrams per theme) on the
14
+ * previous palette until the next reload. CSS reads the attribute; listeners
15
+ * watch the attribute too, so this stays correct even if the event is missed.
16
+ */
17
+ export function applyDocumentTheme(theme: string): void {
18
+ if (typeof document === 'undefined') return;
19
+ document.documentElement.dataset.theme = theme;
20
+ window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: theme }));
21
+ }
22
+
4
23
  export function useTheme() {
5
24
  const writeChamberSettings = useChamberSettingsWriter();
6
25
  const [theme, setThemeState] = useState<string>(() => {
@@ -60,12 +79,9 @@ export function useTheme() {
60
79
  }, []);
61
80
 
62
81
  const setTheme = useCallback((newTheme: string) => {
63
- if (typeof document !== 'undefined') {
64
- document.documentElement.dataset.theme = newTheme;
65
- setThemeState(newTheme);
66
- writeChamberSettings({ theme: newTheme });
67
- window.dispatchEvent(new CustomEvent('omp:theme-changed', { detail: newTheme }));
68
- }
82
+ applyDocumentTheme(newTheme);
83
+ setThemeState(newTheme);
84
+ writeChamberSettings({ theme: newTheme });
69
85
  }, [writeChamberSettings]);
70
86
 
71
87
  const toggleTheme = useCallback(() => {