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.
- package/README.md +24 -8
- 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.8515cbc6f7.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 +3 -1
- package/src/cli/lib/args.js +1 -1
- package/src/cli/lib/commands/restart.js +11 -0
- package/src/cli/lib/commands/update.js +21 -7
- package/src/cli/lib/runtime.js +6 -3
- 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/index.ts +4 -109
- package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
- 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 +16 -29
- 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/index.ts +4 -3
- package/src/server/lib/lifecycle/bun.ts +18 -0
- package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
- package/src/server/lib/lifecycle/launch-mode.ts +40 -0
- package/src/server/lib/lifecycle/restart.test.ts +21 -0
- package/src/server/lib/lifecycle/restart.ts +111 -0
- package/src/server/lib/omp/rpc/manager.ts +14 -3
- package/src/server/lib/omp/rpc/session-commands.ts +15 -1
- 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/server/lib/updates/apply.ts +8 -5
- package/src/server/lib/updates/install.ts +7 -4
- package/src/server/routes/health/probe.ts +2 -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/lib/omp/session/stream-state.server.ts +16 -1
- 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.47d3b04d5f.js +0 -33
- package/dist/client/static/js/index.47d3b04d5f.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.8515cbc6f7.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
|
@@ -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
|
}
|
|
@@ -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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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(() => {
|