ompchamber 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.html +1 -1
- package/dist/client/static/css/index.5c0447cefe.css +2 -0
- package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
- package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
- package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
- package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
- package/dist/client/static/js/async/c.797d933882.js +1 -0
- package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
- package/dist/client/static/js/async/{g.86e2256a70.js → g.d1c2fe09aa.js} +2 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
- package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
- package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
- package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
- package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
- package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
- package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
- package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
- package/dist/client/static/js/async/xn.6360379f71.js +2 -0
- package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
- package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
- package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
- package/package.json +1 -1
- package/src/client/components/common/MarkdownRenderer.tsx +69 -27
- package/src/client/components/common/code-editor/history.test.ts +118 -0
- package/src/client/components/common/code-editor/history.ts +101 -0
- package/src/client/components/common/code-editor/index.tsx +56 -66
- package/src/client/components/common/code-surface/index.tsx +84 -11
- package/src/client/components/common/code-surface/measure.ts +66 -17
- package/src/client/components/common/diagram-viewer/index.tsx +258 -0
- package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
- package/src/client/components/settings/LazyModal.tsx +1 -1
- package/src/client/components/settings/Modal.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
- package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
- package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
- package/src/client/components/workspace/file-explorer/index.tsx +18 -5
- package/src/client/hooks/chat/omp/session-list.tsx +4 -0
- package/src/client/hooks/chat/timeline/actions.ts +0 -9
- package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
- package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
- package/src/client/hooks/chat/timeline/index.ts +6 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
- package/src/client/hooks/chat/timeline/send.ts +13 -27
- package/src/client/hooks/chat/timeline/session-load.ts +48 -106
- package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
- package/src/client/hooks/editor/use-code-window.ts +118 -0
- package/src/client/hooks/ui/input-required-alert.ts +43 -0
- package/src/client/hooks/ui/notification-sound.ts +65 -0
- package/src/client/hooks/ui/theme.ts +22 -6
- package/src/client/tailwind.css +38 -12
- package/src/server/lib/omp/rpc/manager.ts +14 -3
- package/src/server/lib/omp/rpc/session-registry.ts +18 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
- package/src/shared/lib/chat/ask-questions.test.ts +169 -0
- package/src/shared/lib/chat/ask-questions.ts +264 -0
- package/src/shared/lib/code/lazy-window.test.ts +161 -0
- package/src/shared/lib/code/lazy-window.ts +156 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
- package/src/shared/lib/code/syntax-highlight.ts +14 -1
- package/src/shared/lib/code/windowed-highlight.ts +63 -0
- package/src/shared/lib/markdown/mermaid.ts +8 -6
- package/src/shared/types/omp/agent.ts +2 -2
- package/src/shared/types/workspace.ts +4 -0
- package/dist/client/static/css/index.51f68210a5.css +0 -2
- package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
- package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
- package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
- package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
- package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
- package/dist/client/static/js/async/rf.d626afc367.js +0 -2
- package/dist/client/static/js/async/t.cda4891526.js +0 -1
- package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
- package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
- package/dist/client/static/js/index.eccad0b70a.js +0 -33
- package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
- /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
|
@@ -0,0 +1,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(() => {
|
package/src/client/tailwind.css
CHANGED
|
@@ -318,6 +318,11 @@
|
|
|
318
318
|
border-radius: 0.5rem;
|
|
319
319
|
background: var(--theme-paper);
|
|
320
320
|
padding: 0.75rem;
|
|
321
|
+
/* `applySvg` swaps the `.code-block` shell (the positioned ancestor the copy
|
|
322
|
+
button relied on) for the SVG, so the block owns the anchor itself. */
|
|
323
|
+
position: relative;
|
|
324
|
+
/* The block is the door into the zoom/pan viewer. */
|
|
325
|
+
cursor: zoom-in;
|
|
321
326
|
}
|
|
322
327
|
.prose-content .mermaid-block > svg {
|
|
323
328
|
display: block;
|
|
@@ -332,6 +337,27 @@
|
|
|
332
337
|
color: var(--theme-error);
|
|
333
338
|
}
|
|
334
339
|
|
|
340
|
+
/* Full-screen mermaid viewer (`common/diagram-viewer/`): a chart opened from
|
|
341
|
+
the timeline. The stage is the fixed-size pan surface, the canvas child
|
|
342
|
+
carries the zoom/pan transform, so the SVG keeps its natural pixel size and
|
|
343
|
+
`100%` on it always means "the size mermaid rendered". */
|
|
344
|
+
.diagram-viewer-stage {
|
|
345
|
+
touch-action: none;
|
|
346
|
+
overscroll-behavior: contain;
|
|
347
|
+
}
|
|
348
|
+
.diagram-viewer-canvas {
|
|
349
|
+
transform-origin: center center;
|
|
350
|
+
will-change: transform;
|
|
351
|
+
background: var(--theme-paper);
|
|
352
|
+
box-shadow: 0 25px 50px -12px color-mix(in srgb, var(--theme-ink) 45%, transparent);
|
|
353
|
+
}
|
|
354
|
+
.diagram-viewer-canvas > svg {
|
|
355
|
+
display: block;
|
|
356
|
+
width: 100% !important;
|
|
357
|
+
height: 100% !important;
|
|
358
|
+
max-width: none !important;
|
|
359
|
+
}
|
|
360
|
+
|
|
335
361
|
/* PWA Window Controls Overlay and Native Titlebar Styling */
|
|
336
362
|
.titlebar-drag-region {
|
|
337
363
|
-webkit-app-region: drag;
|
|
@@ -344,31 +370,31 @@
|
|
|
344
370
|
}
|
|
345
371
|
|
|
346
372
|
|
|
347
|
-
/*
|
|
348
|
-
Exit is intentionally instant: a 160ms fade-out on a blocking
|
|
349
|
-
load-bearing, and keeping the element mounted to animate it out
|
|
350
|
-
state the dialog does not otherwise need. */
|
|
351
|
-
@keyframes
|
|
373
|
+
/* Blocking extension-dialog enter animation — replaces the retired `motion`
|
|
374
|
+
dependency. Exit is intentionally instant: a 160ms fade-out on a blocking
|
|
375
|
+
modal is not load-bearing, and keeping the element mounted to animate it out
|
|
376
|
+
would add state the dialog does not otherwise need. */
|
|
377
|
+
@keyframes extension-dialog-backdrop-in {
|
|
352
378
|
from { opacity: 0; }
|
|
353
379
|
to { opacity: 1; }
|
|
354
380
|
}
|
|
355
381
|
|
|
356
|
-
@keyframes
|
|
382
|
+
@keyframes extension-dialog-panel-in {
|
|
357
383
|
from { opacity: 0; transform: translateY(10px) scale(0.95); }
|
|
358
384
|
to { opacity: 1; transform: none; }
|
|
359
385
|
}
|
|
360
386
|
|
|
361
|
-
.
|
|
362
|
-
animation:
|
|
387
|
+
.extension-dialog-backdrop {
|
|
388
|
+
animation: extension-dialog-backdrop-in 150ms ease-out;
|
|
363
389
|
}
|
|
364
390
|
|
|
365
|
-
.
|
|
366
|
-
animation:
|
|
391
|
+
.extension-dialog-panel {
|
|
392
|
+
animation: extension-dialog-panel-in 180ms ease-out;
|
|
367
393
|
}
|
|
368
394
|
|
|
369
395
|
@media (prefers-reduced-motion: reduce) {
|
|
370
|
-
.
|
|
371
|
-
.
|
|
396
|
+
.extension-dialog-backdrop,
|
|
397
|
+
.extension-dialog-panel {
|
|
372
398
|
animation: none;
|
|
373
399
|
}
|
|
374
400
|
}
|
|
@@ -102,10 +102,21 @@ export class AgentSessionWrapper {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
/** Anything in flight that a process reset would destroy: the current turn,
|
|
105
|
-
* a compaction, a shell command,
|
|
106
|
-
*
|
|
105
|
+
* a compaction, a shell command, live subagents — which outlive the turn that
|
|
106
|
+
* spawned them, so no other flag here can see them — or an ask/approval dialog
|
|
107
|
+
* omp is BLOCKED on.
|
|
108
|
+
*
|
|
109
|
+
* The dialogs are the load-bearing entry: an ask parks the tool call for as
|
|
110
|
+
* long as the user takes to answer, and omp answers RPC handlers one at a
|
|
111
|
+
* time, so every probe (`get_state`) queues behind it and times out. Without
|
|
112
|
+
* this the timeout path read the session as "idle and unresponsive", killed
|
|
113
|
+
* the child, and with it the question the user was still reading. */
|
|
107
114
|
isBusy(): boolean {
|
|
108
|
-
return
|
|
115
|
+
return (
|
|
116
|
+
this.isRunning() ||
|
|
117
|
+
this.pendingUiDialogs.list().length > 0 ||
|
|
118
|
+
this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0
|
|
119
|
+
);
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
start(): void {
|
|
@@ -72,6 +72,24 @@ export function getRunningRpcSessionIds(): string[] {
|
|
|
72
72
|
return getRunningRpcSessions().map((s) => s.id);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Sessions whose omp process is parked on a dialog only the user can release —
|
|
77
|
+
* an `ask` question or an approval gate. Deliberately independent of
|
|
78
|
+
* `isRunning()`: a blocked dialog leaves every turn flag exactly as it was, so
|
|
79
|
+
* the pending-dialog registry is the only thing that can tell a session waiting
|
|
80
|
+
* for an answer from an idle one. Feeds the sidebar's "needs input" badge, which
|
|
81
|
+
* has to reach clients that never opened the session.
|
|
82
|
+
*/
|
|
83
|
+
export function getAwaitingInputSessionIds(): string[] {
|
|
84
|
+
const ids = new Set<string>();
|
|
85
|
+
for (const [sessionId, session] of getRegistry()) {
|
|
86
|
+
if (session.isAlive() && session.getPendingUiDialogs().length > 0) {
|
|
87
|
+
ids.add(session.sessionId || sessionId);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return [...ids];
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
function getRunningListeners(): Set<(update: RunningSessionUpdate) => void> {
|
|
76
94
|
if (!globalThis.__ompRunningListeners) globalThis.__ompRunningListeners = new Set();
|
|
77
95
|
return globalThis.__ompRunningListeners;
|
|
@@ -22,7 +22,7 @@ import { isValidSessionSortOption, sortFolders } from '@/shared/lib/workspace/si
|
|
|
22
22
|
import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
23
23
|
import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
|
|
24
24
|
import { healStaleStreamStatuses, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
|
|
25
|
-
import { getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
|
|
25
|
+
import { getAwaitingInputSessionIds, getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
|
|
26
26
|
import type { SessionItemData, SessionSortOption, WorkspaceFolderData } from '@/shared/types';
|
|
27
27
|
import type { OmpSession } from '@/shared/types/omp/session';
|
|
28
28
|
|
|
@@ -115,15 +115,21 @@ export async function loadSidebarData(): Promise<SessionListPayload> {
|
|
|
115
115
|
// `finish` right here — the authoritative status travels with the same
|
|
116
116
|
// fetch that refreshes the sidebar list.
|
|
117
117
|
const streamStatuses: Record<string, 'stream' | 'finish' | 'abort' | 'error'> = {};
|
|
118
|
+
// Sessions blocked on a dialog nobody has answered yet. Read live from the
|
|
119
|
+
// process registry (never persisted): the child that owns the question is the
|
|
120
|
+
// same thing that owns the flag, so a restart cannot leave a stale badge.
|
|
121
|
+
const awaitingInput = new Set<string>();
|
|
118
122
|
if (!mock) {
|
|
119
123
|
await healStaleStreamStatuses(new Set(getRunningRpcSessionIds()));
|
|
120
124
|
Object.assign(streamStatuses, await loadStreamStatuses());
|
|
125
|
+
for (const id of getAwaitingInputSessionIds()) awaitingInput.add(id);
|
|
121
126
|
}
|
|
122
127
|
const foldersWithStatus = groupedFolders.map((folder) => ({
|
|
123
128
|
...folder,
|
|
124
129
|
sessions: (folder.sessions ?? []).map((s) => ({
|
|
125
130
|
...s,
|
|
126
131
|
streamStatus: streamStatuses[String(s.id)],
|
|
132
|
+
...(awaitingInput.has(String(s.id)) ? { awaitingInput: true } : {}),
|
|
127
133
|
})),
|
|
128
134
|
}));
|
|
129
135
|
|