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
|
@@ -1,13 +1,30 @@
|
|
|
1
|
-
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import type { CSSProperties } from 'preact';
|
|
3
|
+
|
|
3
4
|
import { CodeEditor } from '@/client/components/common/code-editor';
|
|
4
|
-
import { measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
|
|
5
|
-
import {
|
|
5
|
+
import { measureRowHeight, measureWrappedLines, type WrappedLines } from '@/client/components/common/code-surface/measure';
|
|
6
|
+
import { useCodeWindow } from '@/client/hooks/editor/use-code-window';
|
|
6
7
|
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
8
|
+
import {
|
|
9
|
+
measuredGeometry,
|
|
10
|
+
shouldWindow,
|
|
11
|
+
uniformGeometry,
|
|
12
|
+
type LineGeometry,
|
|
13
|
+
type LineWindow,
|
|
14
|
+
} from '@/shared/lib/code/lazy-window';
|
|
15
|
+
import { highlightCode } from '@/shared/lib/code/syntax-highlight';
|
|
16
|
+
import { highlightCodeWindow } from '@/shared/lib/code/windowed-highlight';
|
|
7
17
|
|
|
8
18
|
const WRAP_ON = '!whitespace-pre-wrap !break-words';
|
|
9
19
|
const WRAP_OFF = '!whitespace-pre !break-normal';
|
|
10
20
|
|
|
21
|
+
/** Line count without materializing every line — a 20 000-line value per render is a megabyte of strings. */
|
|
22
|
+
function countLines(text: string): number {
|
|
23
|
+
let count = 1;
|
|
24
|
+
for (let index = text.indexOf('\n'); index !== -1; index = text.indexOf('\n', index + 1)) count++;
|
|
25
|
+
return count;
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
interface CodeSurfaceProps {
|
|
12
29
|
value: string;
|
|
13
30
|
onValueChange: (value: string) => void;
|
|
@@ -47,6 +64,12 @@ function sameWrappedLines(previous: WrappedLines | null, next: WrappedLines | nu
|
|
|
47
64
|
* The gutter spaces its numbers from the editor's measured wrap layout: with
|
|
48
65
|
* word wrap on, a long line covers several rows and every number below it has
|
|
49
66
|
* to move down by the same amount.
|
|
67
|
+
*
|
|
68
|
+
* A long document is drawn lazily: only the lines inside the scroll container's
|
|
69
|
+
* viewport are highlighted and numbered, and the space of everything else is
|
|
70
|
+
* reserved from the same geometry the caret is aligned to. That keeps opening a
|
|
71
|
+
* 20 000-line file from spending minutes in the tokenizer, and a keystroke in it
|
|
72
|
+
* from re-tokenizing the file at all.
|
|
50
73
|
*/
|
|
51
74
|
export function CodeSurface({
|
|
52
75
|
value,
|
|
@@ -64,16 +87,29 @@ export function CodeSurface({
|
|
|
64
87
|
}: CodeSurfaceProps) {
|
|
65
88
|
const wrapClass = wordWrap ? WRAP_ON : WRAP_OFF;
|
|
66
89
|
const preRef = useRef<HTMLPreElement | null>(null);
|
|
90
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
67
91
|
const [wrapped, setWrapped] = useState<WrappedLines | null>(null);
|
|
92
|
+
const [rowHeight, setRowHeight] = useState<number | null>(null);
|
|
93
|
+
/** Lines the last measurement pass described; -1 before the first one runs. */
|
|
94
|
+
const [measuredLines, setMeasuredLines] = useState(-1);
|
|
68
95
|
useSyntaxReady();
|
|
69
96
|
|
|
97
|
+
const lineCount = useMemo(() => countLines(value), [value]);
|
|
98
|
+
|
|
70
99
|
// Re-measured on the inputs that move a wrap point: the text, the wrap
|
|
71
100
|
// preference, and the editor's font size / padding.
|
|
72
101
|
const measure = useCallback(() => {
|
|
73
102
|
const pre = preRef.current;
|
|
74
|
-
|
|
103
|
+
if (!pre) return;
|
|
104
|
+
// Wrapped heights are only needed with word wrap on; without it every line
|
|
105
|
+
// is one row and the row height alone describes the document.
|
|
106
|
+
const next = wordWrap ? measureWrappedLines(pre, value) : null;
|
|
75
107
|
setWrapped((previous) => (sameWrappedLines(previous, next) ? previous : next));
|
|
76
|
-
|
|
108
|
+
const height = next?.rowHeight ?? measureRowHeight(pre);
|
|
109
|
+
setRowHeight((previous) => (previous === height ? previous : height));
|
|
110
|
+
setMeasuredLines(lineCount);
|
|
111
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
112
|
+
}, [value, lineCount, wordWrap, editorPadding, editorStyle?.fontSize]);
|
|
77
113
|
|
|
78
114
|
useLayoutEffect(() => {
|
|
79
115
|
measure();
|
|
@@ -92,16 +128,52 @@ export function CodeSurface({
|
|
|
92
128
|
return () => observer.disconnect();
|
|
93
129
|
}, []);
|
|
94
130
|
|
|
131
|
+
const geometry = useMemo<LineGeometry | null>(() => {
|
|
132
|
+
if (rowHeight === null) return null;
|
|
133
|
+
return wrapped
|
|
134
|
+
? measuredGeometry(wrapped.heights, wrapped.rowHeight, lineCount)
|
|
135
|
+
: uniformGeometry(rowHeight, lineCount);
|
|
136
|
+
}, [rowHeight, wrapped, lineCount]);
|
|
137
|
+
|
|
138
|
+
// Windowing costs a scroll listener and a spacer; a document short enough to
|
|
139
|
+
// draw in one pass keeps the plain surface.
|
|
140
|
+
const lazy = shouldWindow(lineCount, value.length);
|
|
141
|
+
const virtual = useCodeWindow({
|
|
142
|
+
rootRef,
|
|
143
|
+
lineCount,
|
|
144
|
+
geometry,
|
|
145
|
+
// A document whose geometry could not be measured still falls back to the
|
|
146
|
+
// plain surface — but one whose measurement has not run *yet* must not:
|
|
147
|
+
// that path would draw the whole file, which is what windowing exists to
|
|
148
|
+
// avoid. `measuredLines` is why switching files (which leaves a geometry
|
|
149
|
+
// for the previous document behind) does not take it either.
|
|
150
|
+
enabled: lazy && (measuredLines !== lineCount || geometry !== null),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const highlight = useCallback(
|
|
154
|
+
(code: string, range: LineWindow | null) =>
|
|
155
|
+
range ? highlightCodeWindow(code, language, range) : highlightCode(code, language),
|
|
156
|
+
[language],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const firstLine = virtual ? virtual.start : 0;
|
|
160
|
+
const lastLine = virtual ? Math.min(virtual.end, lineCount) : lineCount;
|
|
161
|
+
const rows: number[] = [];
|
|
162
|
+
for (let i = firstLine; i < lastLine; i++) rows.push(i);
|
|
163
|
+
// Row heights are only usable for the document they were measured against.
|
|
164
|
+
const lineHeights = wrapped && wrapped.heights.length === lineCount ? wrapped.heights : null;
|
|
165
|
+
|
|
95
166
|
return (
|
|
96
|
-
<div className={rootClassName}>
|
|
167
|
+
<div className={rootClassName} ref={rootRef}>
|
|
97
168
|
<div className={gutterClassName} style={gutterStyle}>
|
|
98
|
-
{
|
|
169
|
+
{virtual && virtual.top > 0 ? <div style={{ height: virtual.top, flexShrink: 0 }} aria-hidden="true" /> : null}
|
|
170
|
+
{rows.map((index) => (
|
|
99
171
|
<div
|
|
100
|
-
key={
|
|
172
|
+
key={index + 1}
|
|
101
173
|
className={gutterLineClassName}
|
|
102
|
-
style={
|
|
174
|
+
style={lineHeights ? { height: lineHeights[index], flexShrink: 0 } : undefined}
|
|
103
175
|
>
|
|
104
|
-
{
|
|
176
|
+
{index + 1}
|
|
105
177
|
</div>
|
|
106
178
|
))}
|
|
107
179
|
</div>
|
|
@@ -109,13 +181,14 @@ export function CodeSurface({
|
|
|
109
181
|
<CodeEditor
|
|
110
182
|
value={value}
|
|
111
183
|
onValueChange={onValueChange}
|
|
112
|
-
highlight={
|
|
184
|
+
highlight={highlight}
|
|
113
185
|
padding={editorPadding}
|
|
114
186
|
preRef={preRef}
|
|
115
187
|
textareaClassName={`focus:outline-none ${wrapClass}`}
|
|
116
188
|
preClassName={wrapClass}
|
|
117
189
|
style={editorStyle}
|
|
118
190
|
className={editorClassName}
|
|
191
|
+
virtual={virtual}
|
|
119
192
|
/>
|
|
120
193
|
</div>
|
|
121
194
|
</div>
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* width the editor actually got — so it is measured rather than estimated: the
|
|
9
9
|
* same lines are laid out once in a hidden mirror that copies the editor's
|
|
10
10
|
* typography, and each line's height is read back.
|
|
11
|
+
*
|
|
12
|
+
* Measuring every line on every keystroke is linear in the document, which no
|
|
13
|
+
* long file can afford, so heights are cached per line *text*: an edit re-reads
|
|
14
|
+
* the one line it touched, and a document whose lines all fit on one row is
|
|
15
|
+
* never mirrored at all.
|
|
11
16
|
*/
|
|
12
17
|
|
|
13
18
|
/**
|
|
@@ -34,6 +39,13 @@ const MIRRORED_PROPERTIES = [
|
|
|
34
39
|
'wordSpacing',
|
|
35
40
|
] as const;
|
|
36
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Newest-first ceiling on the height cache. A long editing session mints one
|
|
44
|
+
* entry per keystroke, so the oldest entries are dropped once it is reached;
|
|
45
|
+
* whatever leaves the cache is simply measured again on the next pass.
|
|
46
|
+
*/
|
|
47
|
+
const HEIGHT_CACHE_LIMIT = 100_000;
|
|
48
|
+
|
|
37
49
|
export interface WrappedLines {
|
|
38
50
|
/** Height in px of a single visual row. */
|
|
39
51
|
rowHeight: number;
|
|
@@ -43,6 +55,11 @@ export interface WrappedLines {
|
|
|
43
55
|
|
|
44
56
|
let mirrorHost: HTMLDivElement | null = null;
|
|
45
57
|
let mirrorLines: HTMLDivElement[] = [];
|
|
58
|
+
/** The column the cached heights were measured in; a new one re-wraps every line. */
|
|
59
|
+
let mirrorSignature = '';
|
|
60
|
+
const heightByLine = new Map<string, number>();
|
|
61
|
+
/** Line indexes awaiting a mirror read this pass; reused to keep the pass allocation-free. */
|
|
62
|
+
const pendingLines: number[] = [];
|
|
46
63
|
|
|
47
64
|
/** Off-screen mirror, reused across measurements (one per page). */
|
|
48
65
|
function getMirrorHost(): HTMLDivElement {
|
|
@@ -57,16 +74,22 @@ function getMirrorHost(): HTMLDivElement {
|
|
|
57
74
|
return mirrorHost;
|
|
58
75
|
}
|
|
59
76
|
|
|
77
|
+
/** Height in px of `source`'s line box, or null while it cannot be measured (SSR, `line-height: normal`). */
|
|
78
|
+
export function measureRowHeight(source: HTMLPreElement): number | null {
|
|
79
|
+
if (typeof document === 'undefined' || !source.isConnected) return null;
|
|
80
|
+
const rowHeight = Number.parseFloat(getComputedStyle(source).lineHeight);
|
|
81
|
+
return Number.isFinite(rowHeight) && rowHeight > 0 ? rowHeight : null;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/**
|
|
61
85
|
* Height each line of `value` occupies in `source`'s wrap layout, or null when
|
|
62
86
|
* the layout is not measurable yet (SSR, zero width, unresolved line-height) —
|
|
63
87
|
* callers then fall back to one row per line.
|
|
64
88
|
*/
|
|
65
89
|
export function measureWrappedLines(source: HTMLPreElement, value: string): WrappedLines | null {
|
|
66
|
-
|
|
90
|
+
const rowHeight = measureRowHeight(source);
|
|
91
|
+
if (rowHeight === null) return null;
|
|
67
92
|
const computed = getComputedStyle(source);
|
|
68
|
-
const rowHeight = Number.parseFloat(computed.lineHeight);
|
|
69
|
-
if (!Number.isFinite(rowHeight) || rowHeight <= 0) return null;
|
|
70
93
|
// The editor's text column: its padding is the gutter's offset, not text area.
|
|
71
94
|
const width =
|
|
72
95
|
source.clientWidth -
|
|
@@ -79,21 +102,47 @@ export function measureWrappedLines(source: HTMLPreElement, value: string): Wrap
|
|
|
79
102
|
host.style.lineHeight = `${rowHeight}px`;
|
|
80
103
|
host.style.width = `${width}px`;
|
|
81
104
|
|
|
105
|
+
const signature = `${width}|${rowHeight}|${MIRRORED_PROPERTIES.map((property) => computed[property]).join('|')}`;
|
|
106
|
+
if (signature !== mirrorSignature) {
|
|
107
|
+
mirrorSignature = signature;
|
|
108
|
+
heightByLine.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
82
111
|
const lines = value.split('\n');
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
const heights = new Array<number>(lines.length);
|
|
113
|
+
let pending = 0;
|
|
114
|
+
for (let i = 0; i < lines.length; i++) {
|
|
115
|
+
const cached = heightByLine.get(lines[i]);
|
|
116
|
+
if (cached === undefined) pendingLines[pending++] = i;
|
|
117
|
+
else heights[i] = cached;
|
|
88
118
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
|
|
120
|
+
if (pending > 0) {
|
|
121
|
+
while (mirrorLines.length < pending) {
|
|
122
|
+
const line = document.createElement('div');
|
|
123
|
+
host.appendChild(line);
|
|
124
|
+
mirrorLines.push(line);
|
|
125
|
+
}
|
|
126
|
+
// Write the whole batch first, then read the whole layout: interleaving the
|
|
127
|
+
// two would force a re-layout per line.
|
|
128
|
+
for (let k = 0; k < pending; k++) mirrorLines[k].textContent = lines[pendingLines[k]];
|
|
129
|
+
for (let k = 0; k < pending; k++) {
|
|
130
|
+
const index = pendingLines[k];
|
|
131
|
+
const height = mirrorLines[k].getBoundingClientRect().height;
|
|
132
|
+
// An empty line has no line box at all, and sub-pixel wrapping would
|
|
133
|
+
// otherwise leave the gutter fractions of a row out of step.
|
|
134
|
+
const rows = height > rowHeight ? Math.round(height / rowHeight) * rowHeight : rowHeight;
|
|
135
|
+
heights[index] = rows;
|
|
136
|
+
heightByLine.set(lines[index], rows);
|
|
137
|
+
}
|
|
138
|
+
if (heightByLine.size > HEIGHT_CACHE_LIMIT) {
|
|
139
|
+
let drop = heightByLine.size - HEIGHT_CACHE_LIMIT / 2;
|
|
140
|
+
for (const key of heightByLine.keys()) {
|
|
141
|
+
if (drop-- <= 0) break;
|
|
142
|
+
heightByLine.delete(key);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
98
147
|
return { rowHeight, heights };
|
|
99
148
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Full-screen viewer for a hydrated mermaid diagram.
|
|
8
|
+
*
|
|
9
|
+
* Opened by clicking a rendered diagram in the chat timeline (see
|
|
10
|
+
* `MarkdownRenderer`), which hands over the SVG snapshot it read from the
|
|
11
|
+
* inline block — the viewer never re-runs mermaid, so it shows exactly the
|
|
12
|
+
* pixels the message shows. Opens fitted to the viewport and never upscales
|
|
13
|
+
* past 100%: a small chart stays readable at its natural size.
|
|
14
|
+
*
|
|
15
|
+
* Interaction: wheel zooms around the cursor, drag pans, double-click toggles
|
|
16
|
+
* fit/100%, the footer bar is the pointer-free equivalent, and Esc closes.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ComponentChildren, CSSProperties, TargetedPointerEvent, TargetedWheelEvent } from 'preact';
|
|
20
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
21
|
+
import { createPortal } from 'preact/compat';
|
|
22
|
+
import { Maximize2, Minus, Plus, X } from 'lucide-preact';
|
|
23
|
+
import type { DiagramSnapshot } from '@/client/components/common/diagram-viewer/read-block';
|
|
24
|
+
|
|
25
|
+
const MIN_SCALE = 0.1;
|
|
26
|
+
const MAX_SCALE = 8;
|
|
27
|
+
/** Viewport pixels kept clear around a fitted diagram (chrome + breathing room). */
|
|
28
|
+
const FIT_MARGIN = 112;
|
|
29
|
+
/** One button press / keyboard notch. */
|
|
30
|
+
const ZOOM_STEP = 1.4;
|
|
31
|
+
/** Wheel `deltaY` → scale exponent, so zooming feels the same on every device. */
|
|
32
|
+
const WHEEL_SENSITIVITY = 0.0015;
|
|
33
|
+
|
|
34
|
+
interface View {
|
|
35
|
+
scale: number;
|
|
36
|
+
/** Translation of the canvas centre from the stage centre, in stage px. */
|
|
37
|
+
x: number;
|
|
38
|
+
y: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface Drag {
|
|
42
|
+
pointerId: number;
|
|
43
|
+
x: number;
|
|
44
|
+
y: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const clampScale = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
|
|
48
|
+
|
|
49
|
+
/** Zoom `view` to `scale` keeping the screen point (`anchorX`,`anchorY`) under
|
|
50
|
+
* the cursor, which is what makes wheel-zoom feel anchored rather than
|
|
51
|
+
* centre-locked. The canvas grows from its centre, so the anchor is converted
|
|
52
|
+
* to a centre-relative point before solving. */
|
|
53
|
+
function zoomTo(view: View, scale: number, anchorX: number, anchorY: number, stage: DOMRect | undefined): View {
|
|
54
|
+
const next = clampScale(scale);
|
|
55
|
+
if (next === view.scale) return view;
|
|
56
|
+
const px = stage ? anchorX - (stage.left + stage.width / 2) : 0;
|
|
57
|
+
const py = stage ? anchorY - (stage.top + stage.height / 2) : 0;
|
|
58
|
+
const k = next / view.scale;
|
|
59
|
+
return { scale: next, x: px - (px - view.x) * k, y: py - (py - view.y) * k };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface DiagramViewerProps extends DiagramSnapshot {
|
|
63
|
+
onClose: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function DiagramViewer({ svg, width, height, onClose }: DiagramViewerProps) {
|
|
67
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
68
|
+
const dragRef = useRef<Drag | null>(null);
|
|
69
|
+
const [view, setView] = useState<View>({ scale: 1, x: 0, y: 0 });
|
|
70
|
+
const [fitScale, setFitScale] = useState(1);
|
|
71
|
+
const [dragging, setDragging] = useState(false);
|
|
72
|
+
|
|
73
|
+
/** Scale that fits the whole diagram in the stage without upscaling it. */
|
|
74
|
+
const measureFit = useCallback(() => {
|
|
75
|
+
const stage = stageRef.current?.getBoundingClientRect();
|
|
76
|
+
if (!stage) return 1;
|
|
77
|
+
return clampScale(
|
|
78
|
+
Math.min(1, (stage.width - FIT_MARGIN) / width, (stage.height - FIT_MARGIN) / height),
|
|
79
|
+
);
|
|
80
|
+
}, [width, height]);
|
|
81
|
+
|
|
82
|
+
const fitToView = useCallback(() => {
|
|
83
|
+
const scale = measureFit();
|
|
84
|
+
setFitScale(scale);
|
|
85
|
+
setView({ scale, x: 0, y: 0 });
|
|
86
|
+
}, [measureFit]);
|
|
87
|
+
|
|
88
|
+
// Fit once per diagram size: re-measuring on every render would fight an
|
|
89
|
+
// in-progress zoom, and only a size change (a re-render under a new theme
|
|
90
|
+
// that produced a differently sized SVG) invalidates the current fit.
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
fitToView();
|
|
93
|
+
}, [fitToView]);
|
|
94
|
+
|
|
95
|
+
// Modal focus: the stage owns the keyboard so the shortcuts below (and Esc)
|
|
96
|
+
// never land in the composer still focused behind the overlay, and closing
|
|
97
|
+
// hands focus back to whoever had it.
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const previouslyFocused = document.activeElement as HTMLElement | null;
|
|
100
|
+
stageRef.current?.focus();
|
|
101
|
+
return () => previouslyFocused?.focus();
|
|
102
|
+
}, []);
|
|
103
|
+
|
|
104
|
+
const zoomBy = useCallback((factor: number, anchorX?: number, anchorY?: number) => {
|
|
105
|
+
setView((current) => {
|
|
106
|
+
const stage = stageRef.current?.getBoundingClientRect();
|
|
107
|
+
const x = anchorX ?? (stage ? stage.left + stage.width / 2 : 0);
|
|
108
|
+
const y = anchorY ?? (stage ? stage.top + stage.height / 2 : 0);
|
|
109
|
+
return zoomTo(current, current.scale * factor, x, y, stage);
|
|
110
|
+
});
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const handleWheel = useCallback(
|
|
114
|
+
(event: TargetedWheelEvent<HTMLDivElement>) => {
|
|
115
|
+
// Without this the timeline behind the overlay scrolls while zooming.
|
|
116
|
+
event.preventDefault();
|
|
117
|
+
zoomBy(Math.exp(-event.deltaY * WHEEL_SENSITIVITY), event.clientX, event.clientY);
|
|
118
|
+
},
|
|
119
|
+
[zoomBy],
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const handlePointerDown = useCallback((event: TargetedPointerEvent<HTMLDivElement>) => {
|
|
123
|
+
if (event.button !== 0) return;
|
|
124
|
+
dragRef.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
|
|
125
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
126
|
+
setDragging(true);
|
|
127
|
+
}, []);
|
|
128
|
+
|
|
129
|
+
const handlePointerMove = useCallback((event: TargetedPointerEvent<HTMLDivElement>) => {
|
|
130
|
+
const drag = dragRef.current;
|
|
131
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
132
|
+
const dx = event.clientX - drag.x;
|
|
133
|
+
const dy = event.clientY - drag.y;
|
|
134
|
+
drag.x = event.clientX;
|
|
135
|
+
drag.y = event.clientY;
|
|
136
|
+
setView((current) => ({ ...current, x: current.x + dx, y: current.y + dy }));
|
|
137
|
+
}, []);
|
|
138
|
+
|
|
139
|
+
const endDrag = useCallback((event: TargetedPointerEvent<HTMLDivElement>) => {
|
|
140
|
+
if (dragRef.current?.pointerId !== event.pointerId) return;
|
|
141
|
+
dragRef.current = null;
|
|
142
|
+
setDragging(false);
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const handleDoubleClick = useCallback(() => {
|
|
146
|
+
setView((current) => (Math.abs(current.scale - 1) < 0.01 ? { scale: fitScale, x: 0, y: 0 } : { scale: 1, x: 0, y: 0 }));
|
|
147
|
+
}, [fitScale]);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const onKeyDown = (event: globalThis.KeyboardEvent) => {
|
|
151
|
+
if (event.key === 'Escape') {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
onClose();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.key === '+' || event.key === '=') {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
zoomBy(ZOOM_STEP);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (event.key === '-' || event.key === '_') {
|
|
162
|
+
event.preventDefault();
|
|
163
|
+
zoomBy(1 / ZOOM_STEP);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (event.key === '0') {
|
|
167
|
+
event.preventDefault();
|
|
168
|
+
fitToView();
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
window.addEventListener('keydown', onKeyDown);
|
|
172
|
+
return () => window.removeEventListener('keydown', onKeyDown);
|
|
173
|
+
}, [onClose, zoomBy, fitToView]);
|
|
174
|
+
|
|
175
|
+
// The overlay is the only scroller-free surface on screen; leaving `body`
|
|
176
|
+
// scrollable lets a wheel outside the stage scroll the timeline behind it.
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const previous = document.body.style.overflow;
|
|
179
|
+
document.body.style.overflow = 'hidden';
|
|
180
|
+
return () => {
|
|
181
|
+
document.body.style.overflow = previous;
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const canvasStyle: CSSProperties = {
|
|
186
|
+
width: `${width}px`,
|
|
187
|
+
height: `${height}px`,
|
|
188
|
+
transform: `translate3d(${view.x}px, ${view.y}px, 0) scale(${view.scale})`,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
return createPortal(
|
|
192
|
+
<div
|
|
193
|
+
className="fixed inset-0 z-[200] bg-ink/85 backdrop-blur-sm"
|
|
194
|
+
role="dialog"
|
|
195
|
+
aria-modal="true"
|
|
196
|
+
aria-label="Diagram viewer"
|
|
197
|
+
onWheel={handleWheel}
|
|
198
|
+
>
|
|
199
|
+
<div
|
|
200
|
+
ref={stageRef}
|
|
201
|
+
tabIndex={-1}
|
|
202
|
+
className={`diagram-viewer-stage absolute inset-0 overflow-hidden outline-none ${dragging ? 'cursor-grabbing' : 'cursor-grab'}`}
|
|
203
|
+
onPointerDown={handlePointerDown}
|
|
204
|
+
onPointerMove={handlePointerMove}
|
|
205
|
+
onPointerUp={endDrag}
|
|
206
|
+
onPointerCancel={endDrag}
|
|
207
|
+
onDblClick={handleDoubleClick}
|
|
208
|
+
>
|
|
209
|
+
<div className="absolute inset-0 flex items-center justify-center">
|
|
210
|
+
<div className="diagram-viewer-canvas" style={canvasStyle} dangerouslySetInnerHTML={{ __html: svg }} />
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center pb-4">
|
|
215
|
+
<div className="pointer-events-auto flex items-center gap-0.5 rounded-full border border-canvas/15 bg-paper/10 px-1.5 py-1 text-canvas backdrop-blur-sm">
|
|
216
|
+
<ViewerButton label="Zoom out (-)" onClick={() => zoomBy(1 / ZOOM_STEP)}>
|
|
217
|
+
<Minus size={14} />
|
|
218
|
+
</ViewerButton>
|
|
219
|
+
<span className="w-12 text-center font-mono text-[11px] tabular-nums text-canvas/80">
|
|
220
|
+
{Math.round(view.scale * 100)}%
|
|
221
|
+
</span>
|
|
222
|
+
<ViewerButton label="Zoom in (+)" onClick={() => zoomBy(ZOOM_STEP)}>
|
|
223
|
+
<Plus size={14} />
|
|
224
|
+
</ViewerButton>
|
|
225
|
+
<ViewerButton label="Fit to view (0)" onClick={fitToView}>
|
|
226
|
+
<Maximize2 size={14} />
|
|
227
|
+
</ViewerButton>
|
|
228
|
+
<ViewerButton label="Close (Esc)" onClick={onClose}>
|
|
229
|
+
<X size={14} />
|
|
230
|
+
</ViewerButton>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
</div>,
|
|
234
|
+
document.body,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function ViewerButton({
|
|
239
|
+
label,
|
|
240
|
+
onClick,
|
|
241
|
+
children,
|
|
242
|
+
}: {
|
|
243
|
+
label: string;
|
|
244
|
+
onClick: () => void;
|
|
245
|
+
children: ComponentChildren;
|
|
246
|
+
}) {
|
|
247
|
+
return (
|
|
248
|
+
<button
|
|
249
|
+
type="button"
|
|
250
|
+
onClick={onClick}
|
|
251
|
+
title={label}
|
|
252
|
+
aria-label={label}
|
|
253
|
+
className="flex h-7 w-7 cursor-pointer items-center justify-center rounded-full text-canvas/80 transition-colors hover:bg-canvas/10 hover:text-canvas"
|
|
254
|
+
>
|
|
255
|
+
{children}
|
|
256
|
+
</button>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read a hydrated `.mermaid-block` (see `@/shared/lib/markdown/mermaid`) back
|
|
8
|
+
* out as a self-contained snapshot: the sanitized SVG markup plus the natural
|
|
9
|
+
* pixel size mermaid gave it. Markup + size is all the viewer needs, so it
|
|
10
|
+
* renders its own copy of the diagram instead of moving the timeline node — the
|
|
11
|
+
* inline block keeps its theme, its copy button, and its place in the layout.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface DiagramSnapshot {
|
|
15
|
+
/** Sanitized `<svg …>` markup, exactly as the inline block renders it. */
|
|
16
|
+
svg: string;
|
|
17
|
+
/** Natural diagram width in px (the transform's 100% reference). */
|
|
18
|
+
width: number;
|
|
19
|
+
/** Natural diagram height in px. */
|
|
20
|
+
height: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `null` while the block is pending/rendering/failed — it has no direct SVG
|
|
24
|
+
* child until `mermaid.ts` has swapped one in. */
|
|
25
|
+
export function readDiagramBlock(block: HTMLElement): DiagramSnapshot | null {
|
|
26
|
+
const svg = block.querySelector<SVGSVGElement>(':scope > svg');
|
|
27
|
+
if (!svg) return null;
|
|
28
|
+
return { svg: svg.outerHTML, ...measure(svg) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mermaid writes numeric px `width`/`height` attributes (the app configures
|
|
33
|
+
* `useMaxWidth: false`), but a `viewBox`-only diagram is valid SVG too, and a
|
|
34
|
+
* detached SVG reports a 0×0 border box — hence the fallback chain.
|
|
35
|
+
*/
|
|
36
|
+
function measure(svg: SVGSVGElement): { width: number; height: number } {
|
|
37
|
+
const box = svg.viewBox?.baseVal;
|
|
38
|
+
const rendered = svg.getBoundingClientRect();
|
|
39
|
+
return {
|
|
40
|
+
width: positive(svg.getAttribute('width'), box?.width, rendered.width),
|
|
41
|
+
height: positive(svg.getAttribute('height'), box?.height, rendered.height),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** First candidate that resolves to a usable positive number; 1 otherwise, so
|
|
46
|
+
* callers can never divide by a zero-sized diagram. */
|
|
47
|
+
function positive(...candidates: (string | number | null | undefined)[]): number {
|
|
48
|
+
for (const candidate of candidates) {
|
|
49
|
+
const value = typeof candidate === 'number' ? candidate : Number.parseFloat(candidate ?? '');
|
|
50
|
+
if (Number.isFinite(value) && value > 0) return value;
|
|
51
|
+
}
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
@@ -224,6 +224,7 @@ export function Category({
|
|
|
224
224
|
isActive={isActive && !isViewingSubagent}
|
|
225
225
|
isArchived={session.is_archived === 1}
|
|
226
226
|
status={sessionStatus[sessionKey]}
|
|
227
|
+
awaitingInput={Boolean(session.awaitingInput)}
|
|
227
228
|
onClick={() => onSelectSession(session.id)}
|
|
228
229
|
onArchive={() => handleArchive(session)}
|
|
229
230
|
onRename={String(session.id).startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TargetedMouseEvent } from 'preact';
|
|
2
|
-
import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, Loader2, Pencil } from 'lucide-preact';
|
|
2
|
+
import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
|
|
3
3
|
import { useInlineRename } from '@/client/hooks/ui/inline-rename';
|
|
4
4
|
|
|
5
5
|
export interface SessionItemProps {
|
|
@@ -7,6 +7,8 @@ export interface SessionItemProps {
|
|
|
7
7
|
isActive?: boolean;
|
|
8
8
|
isArchived?: boolean;
|
|
9
9
|
status?: 'stream' | 'finish' | 'abort' | 'error';
|
|
10
|
+
/** The session's agent is blocked on a question until the user answers it. */
|
|
11
|
+
awaitingInput?: boolean;
|
|
10
12
|
onClick?: () => void;
|
|
11
13
|
onArchive?: () => void;
|
|
12
14
|
onRename?: (name: string) => void;
|
|
@@ -21,6 +23,7 @@ export function SessionItem({
|
|
|
21
23
|
isActive = false,
|
|
22
24
|
isArchived = false,
|
|
23
25
|
status,
|
|
26
|
+
awaitingInput = false,
|
|
24
27
|
onClick,
|
|
25
28
|
onArchive,
|
|
26
29
|
onRename,
|
|
@@ -73,13 +76,17 @@ export function SessionItem({
|
|
|
73
76
|
{isExpanded ? <ChevronDown size={13} className="text-ink/70" /> : <ChevronRight size={13} />}
|
|
74
77
|
</button>
|
|
75
78
|
)}
|
|
76
|
-
{status && !(showChevron && isExpanded) && (
|
|
79
|
+
{(awaitingInput || status) && !(showChevron && isExpanded) && (
|
|
77
80
|
<span
|
|
78
81
|
className={`absolute inset-0 flex items-center justify-center text-ink/60 transition-opacity ${
|
|
79
82
|
showChevron ? 'group-hover/item:opacity-0 peer-focus-visible:opacity-0' : ''
|
|
80
83
|
}`}
|
|
81
84
|
>
|
|
82
|
-
{
|
|
85
|
+
{/* Waiting on an answer outranks the run spinner: the spinner says
|
|
86
|
+
work is happening, the question mark says it is YOUR turn. */}
|
|
87
|
+
{awaitingInput ? (
|
|
88
|
+
<CircleQuestionMark size={12} className="animate-pulse text-ink/80" />
|
|
89
|
+
) : status === 'stream' ? (
|
|
83
90
|
<Loader2 size={12} className="animate-spin" />
|
|
84
91
|
) : (
|
|
85
92
|
<Check size={12} />
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* the stale bootstrap payload.
|
|
25
25
|
*
|
|
26
26
|
* `fallback={null}` matches the other lazy dialog boundary
|
|
27
|
-
* (`tool-renderers/
|
|
27
|
+
* (`tool-renderers/extension-dialog/Lazy.tsx`): the modal is a full-screen overlay
|
|
28
28
|
* that appears on user intent, so a brief absence reads as the click landing
|
|
29
29
|
* rather than as a broken surface.
|
|
30
30
|
*/
|
|
@@ -3,6 +3,7 @@ import { Check, ChevronLeft, X } from 'lucide-preact';
|
|
|
3
3
|
import type { SettingsCategoryId, SettingsState } from '@/shared/types';
|
|
4
4
|
import { mergeChamberSettings } from '@/shared/lib/settings/client';
|
|
5
5
|
import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
|
|
6
|
+
import { applyDocumentTheme } from '@/client/hooks/ui/theme';
|
|
6
7
|
import { useChamberEvent } from '@/client/hooks/ui/window-event';
|
|
7
8
|
import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
|
|
8
9
|
import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
|
|
@@ -100,7 +101,7 @@ export function SettingsModal({
|
|
|
100
101
|
setSettings(prev => {
|
|
101
102
|
const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater };
|
|
102
103
|
if (typeof document !== 'undefined' && next.theme) {
|
|
103
|
-
|
|
104
|
+
applyDocumentTheme(next.theme);
|
|
104
105
|
}
|
|
105
106
|
writeChamberSettings(next);
|
|
106
107
|
return next;
|