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
|
@@ -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;
|
|
@@ -13,6 +13,7 @@ import { FallbackOutput } from '@/client/components/workspace/chat-timeline/tool
|
|
|
13
13
|
import { tryParseJson } from '@/shared/lib/code/syntax-highlight';
|
|
14
14
|
import { JsonCodeBlock } from '@/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock';
|
|
15
15
|
import { getTodoSummary } from '@/shared/lib/chat/todo-parser';
|
|
16
|
+
import { parseAskQuestions } from '@/shared/lib/chat/ask-questions';
|
|
16
17
|
|
|
17
18
|
interface ToolCallCardProps {
|
|
18
19
|
tool: ToolCallData;
|
|
@@ -199,6 +200,12 @@ export const ToolCallCard = memo(function ToolCallCard({ tool, isOpen, onToggle,
|
|
|
199
200
|
.join(' · ');
|
|
200
201
|
displaySubtitle = cleaned || sub;
|
|
201
202
|
}
|
|
203
|
+
} else if (toolKey === 'ask') {
|
|
204
|
+
// The question itself is the card's body; the header says how many were
|
|
205
|
+
// asked, matching the summary the same card shows once they are answered.
|
|
206
|
+
displayTitle = 'Question';
|
|
207
|
+
const asked = parseAskQuestions(tool).length;
|
|
208
|
+
if (asked > 0) displaySubtitle = `Asked ${asked} question${asked === 1 ? '' : 's'}`;
|
|
202
209
|
} else if (mcpToolNameOf(tool)) {
|
|
203
210
|
// MCP call: `write xd://mcp__<tool>` (or a direct `mcp__<tool>` name) —
|
|
204
211
|
// the MCP tool names the action, never the transport `write`.
|
|
@@ -258,7 +265,7 @@ export const ToolCallCard = memo(function ToolCallCard({ tool, isOpen, onToggle,
|
|
|
258
265
|
isOpen={isOpen}
|
|
259
266
|
onToggle={onToggle ? handleToggle : undefined}
|
|
260
267
|
defaultExpanded={defaultExpanded}
|
|
261
|
-
alwaysExpanded={toolKey === 'yield'
|
|
268
|
+
alwaysExpanded={hasPanel && (toolKey === 'yield' || toolKey === 'ask')}
|
|
262
269
|
>
|
|
263
270
|
<ToolDetailsPanel tool={tool} />
|
|
264
271
|
|
|
@@ -7,6 +7,7 @@ import { ComposerTextarea } from '@/client/components/common/ComposerTextarea';
|
|
|
7
7
|
import { AttachmentToolbar } from '@/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar';
|
|
8
8
|
import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
|
|
9
9
|
import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
|
|
10
|
+
import { resolveThinkingLevel, selectionFor } from '@/client/components/workspace/chat-timeline/chat-input/selection';
|
|
10
11
|
|
|
11
12
|
export function ChatInput({
|
|
12
13
|
value,
|
|
@@ -96,21 +97,11 @@ export function ChatInput({
|
|
|
96
97
|
const sessionModelRef = useRef<{ provider: string; modelId: string } | null>(sessionModel ?? null);
|
|
97
98
|
sessionModelRef.current = sessionModel ?? null;
|
|
98
99
|
|
|
99
|
-
// Preserve the session's last-used thinking level when applying a model
|
|
100
|
-
// picked from the catalog: the session level is authoritative and an async
|
|
101
|
-
// fetch resolving later must not reset it to the ladder default.
|
|
102
|
-
const resolveSessionLevel = (ladder: readonly string[] | undefined, fallback: string): string => {
|
|
103
|
-
const sessionLevel = sessionThinkingLevelRef.current;
|
|
104
|
-
if (!sessionLevel) return fallback;
|
|
105
|
-
const selectable = selectableThinkingLevels(ladder ?? []);
|
|
106
|
-
return selectable.length === 0 || selectable.includes(sessionLevel) ? sessionLevel : fallback;
|
|
107
|
-
};
|
|
108
|
-
|
|
109
100
|
// Adopt the active session's last-used model as the selected model. The
|
|
110
|
-
// thinking level must fall back to the CURRENT one, never the ladder
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
101
|
+
// thinking level must fall back to the CURRENT one, never the ladder default:
|
|
102
|
+
// this effect re-runs right after a spawn (sessionModel changes while the
|
|
103
|
+
// session's own thinking_level_change entry is not readable yet), and any
|
|
104
|
+
// concrete fallback here claims a level the run never used.
|
|
114
105
|
useEffect(() => {
|
|
115
106
|
if (!sessionModel?.provider || !sessionModel.modelId) return;
|
|
116
107
|
let active = true;
|
|
@@ -121,14 +112,7 @@ export function ChatInput({
|
|
|
121
112
|
(m) => m.id === sessionModel.modelId && m.provider === sessionModel.provider
|
|
122
113
|
);
|
|
123
114
|
if (match) {
|
|
124
|
-
setSelectedModel(prev => (
|
|
125
|
-
id: match.id,
|
|
126
|
-
name: match.name,
|
|
127
|
-
provider: match.provider,
|
|
128
|
-
contextWindow: match.contextWindow,
|
|
129
|
-
thinkingLevels: match.thinkingLevels,
|
|
130
|
-
thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
|
|
131
|
-
}));
|
|
115
|
+
setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
|
|
132
116
|
}
|
|
133
117
|
})
|
|
134
118
|
.catch(() => {});
|
|
@@ -143,14 +127,7 @@ export function ChatInput({
|
|
|
143
127
|
if (!sessionThinkingLevel) return;
|
|
144
128
|
setSelectedModel(prev => {
|
|
145
129
|
if (!prev) return prev;
|
|
146
|
-
const
|
|
147
|
-
// The session level is authoritative (omp recorded it for this model).
|
|
148
|
-
// Only reject it when the ladder is KNOWN and explicitly excludes it;
|
|
149
|
-
// an empty ladder (catalog not resolved / model exposes none) must not
|
|
150
|
-
// fall back to 'off' and erase the session's actual level.
|
|
151
|
-
const level = selectable.length === 0 || selectable.includes(sessionThinkingLevel)
|
|
152
|
-
? sessionThinkingLevel
|
|
153
|
-
: prev.thinkingLevel;
|
|
130
|
+
const level = resolveThinkingLevel(prev.thinkingLevels, sessionThinkingLevel, prev.thinkingLevel);
|
|
154
131
|
return level === prev.thinkingLevel ? prev : { ...prev, thinkingLevel: level };
|
|
155
132
|
});
|
|
156
133
|
}, [sessionThinkingLevel]);
|
|
@@ -169,13 +146,7 @@ export function ChatInput({
|
|
|
169
146
|
if (sm?.provider && sm.modelId) {
|
|
170
147
|
const match = data.modelList.find((m: ModelEntry) => m.id === sm.modelId && m.provider === sm.provider);
|
|
171
148
|
if (match) {
|
|
172
|
-
setSelectedModel(prev => (
|
|
173
|
-
id: match.id,
|
|
174
|
-
name: match.name,
|
|
175
|
-
provider: match.provider,
|
|
176
|
-
thinkingLevels: match.thinkingLevels,
|
|
177
|
-
thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
|
|
178
|
-
}));
|
|
149
|
+
setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
|
|
179
150
|
}
|
|
180
151
|
return;
|
|
181
152
|
}
|
|
@@ -184,13 +155,7 @@ export function ChatInput({
|
|
|
184
155
|
if (persisted?.provider && persisted.id) {
|
|
185
156
|
const match = data.modelList.find((m: ModelEntry) => m.id === persisted.id && m.provider === persisted.provider);
|
|
186
157
|
if (match) {
|
|
187
|
-
setSelectedModel(prev => (
|
|
188
|
-
id: match.id,
|
|
189
|
-
name: match.name,
|
|
190
|
-
provider: match.provider,
|
|
191
|
-
thinkingLevels: match.thinkingLevels,
|
|
192
|
-
thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
|
|
193
|
-
}));
|
|
158
|
+
setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
|
|
194
159
|
return;
|
|
195
160
|
}
|
|
196
161
|
}
|
|
@@ -199,20 +164,14 @@ export function ChatInput({
|
|
|
199
164
|
if (defaultModel) {
|
|
200
165
|
const match = data.modelList.find((m: ModelEntry) => m.id === defaultModel.modelId && m.provider === defaultModel.provider);
|
|
201
166
|
if (match) {
|
|
202
|
-
setSelectedModel(prev => (
|
|
203
|
-
id: match.id,
|
|
204
|
-
name: match.name,
|
|
205
|
-
provider: match.provider,
|
|
206
|
-
thinkingLevels: match.thinkingLevels,
|
|
207
|
-
thinkingLevel: resolveSessionLevel(match.thinkingLevels, prev?.thinkingLevel ?? match.thinkingLevels?.[0] ?? 'off'),
|
|
208
|
-
}));
|
|
167
|
+
setSelectedModel(prev => selectionFor(match, sessionThinkingLevelRef.current, prev));
|
|
209
168
|
}
|
|
210
169
|
}
|
|
211
170
|
} else if (data.selectedModel) {
|
|
212
171
|
const selected = data.selectedModel;
|
|
213
172
|
setSelectedModel(prev => ({
|
|
214
173
|
...selected,
|
|
215
|
-
thinkingLevel:
|
|
174
|
+
thinkingLevel: resolveThinkingLevel(selected.thinkingLevels, sessionThinkingLevelRef.current, prev?.thinkingLevel ?? selected.thinkingLevel),
|
|
216
175
|
}));
|
|
217
176
|
}
|
|
218
177
|
} catch {}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Catalog → composer mapping for the model/thinking pickers: one definition of
|
|
8
|
+
* how a catalog entry becomes the selected model, and of which thinking level
|
|
9
|
+
* that selection is allowed to claim. Shared by every resolution site in
|
|
10
|
+
* ChatInput so the level rule cannot drift between them.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { AIModelOption, ModelEntry } from '@/shared/types';
|
|
14
|
+
import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Level reported while none is known. A catalog ladder's first entry is NOT a
|
|
18
|
+
* fact about the session — every model's ladder starts at `off` — and the send
|
|
19
|
+
* path pushes any non-`auto` value onto the live session (queue replay), so an
|
|
20
|
+
* unresolved level must stay indistinguishable from "leave omp alone".
|
|
21
|
+
*/
|
|
22
|
+
export const UNKNOWN_THINKING_LEVEL = 'auto';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Level the composer may select for a model. The session's last-used level is
|
|
26
|
+
* authoritative whenever the model offers it — an async fetch resolving later
|
|
27
|
+
* must not reset it to a ladder default. Only a ladder that is KNOWN and
|
|
28
|
+
* excludes the session level rejects it; an empty ladder (catalog not resolved
|
|
29
|
+
* / model exposes none) must not erase the session's actual level. With no
|
|
30
|
+
* session level to go on, the current selection is kept, and a composer that
|
|
31
|
+
* has none yet reports {@link UNKNOWN_THINKING_LEVEL}.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveThinkingLevel(
|
|
34
|
+
ladder: readonly string[] | null | undefined,
|
|
35
|
+
sessionLevel: string | null | undefined,
|
|
36
|
+
currentLevel: string | null | undefined,
|
|
37
|
+
): string {
|
|
38
|
+
const selectable = selectableThinkingLevels(ladder ?? []);
|
|
39
|
+
if (sessionLevel && (selectable.length === 0 || selectable.includes(sessionLevel))) return sessionLevel;
|
|
40
|
+
return currentLevel ?? UNKNOWN_THINKING_LEVEL;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The composer's selection for a catalog model, carrying its thinking level. */
|
|
44
|
+
export function selectionFor(
|
|
45
|
+
match: ModelEntry,
|
|
46
|
+
sessionLevel: string | null | undefined,
|
|
47
|
+
current: AIModelOption | null,
|
|
48
|
+
): AIModelOption {
|
|
49
|
+
return {
|
|
50
|
+
id: match.id,
|
|
51
|
+
name: match.name,
|
|
52
|
+
provider: match.provider,
|
|
53
|
+
contextWindow: match.contextWindow,
|
|
54
|
+
thinkingLevels: match.thinkingLevels,
|
|
55
|
+
thinkingLevel: resolveThinkingLevel(match.thinkingLevels, sessionLevel, current?.thinkingLevel),
|
|
56
|
+
};
|
|
57
|
+
}
|