ompchamber 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.md +24 -8
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  4. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  5. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  6. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  7. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  8. package/dist/client/static/js/async/c.797d933882.js +1 -0
  9. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  10. package/dist/client/static/js/async/{g.8515cbc6f7.js → g.d1c2fe09aa.js} +2 -2
  11. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  12. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  13. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  15. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  16. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  17. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  18. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  19. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  20. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  21. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  22. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  23. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  24. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  25. package/package.json +3 -1
  26. package/src/cli/lib/args.js +1 -1
  27. package/src/cli/lib/commands/restart.js +11 -0
  28. package/src/cli/lib/commands/update.js +21 -7
  29. package/src/cli/lib/runtime.js +6 -3
  30. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  31. package/src/client/components/common/code-editor/history.test.ts +118 -0
  32. package/src/client/components/common/code-editor/history.ts +101 -0
  33. package/src/client/components/common/code-editor/index.tsx +56 -66
  34. package/src/client/components/common/code-surface/index.tsx +84 -11
  35. package/src/client/components/common/code-surface/measure.ts +66 -17
  36. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  37. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  38. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  39. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  40. package/src/client/components/settings/LazyModal.tsx +1 -1
  41. package/src/client/components/settings/Modal.tsx +2 -1
  42. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  43. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  44. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  45. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  56. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  57. package/src/client/hooks/chat/omp/index.ts +4 -109
  58. package/src/client/hooks/chat/omp/prompt-send.ts +161 -0
  59. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  60. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  61. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  62. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  63. package/src/client/hooks/chat/timeline/index.ts +6 -8
  64. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  65. package/src/client/hooks/chat/timeline/send.ts +16 -29
  66. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  67. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  68. package/src/client/hooks/editor/use-code-window.ts +118 -0
  69. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  70. package/src/client/hooks/ui/notification-sound.ts +65 -0
  71. package/src/client/hooks/ui/theme.ts +22 -6
  72. package/src/client/tailwind.css +38 -12
  73. package/src/server/index.ts +4 -3
  74. package/src/server/lib/lifecycle/bun.ts +18 -0
  75. package/src/server/lib/lifecycle/launch-mode.test.ts +32 -0
  76. package/src/server/lib/lifecycle/launch-mode.ts +40 -0
  77. package/src/server/lib/lifecycle/restart.test.ts +21 -0
  78. package/src/server/lib/lifecycle/restart.ts +111 -0
  79. package/src/server/lib/omp/rpc/manager.ts +14 -3
  80. package/src/server/lib/omp/rpc/session-commands.ts +15 -1
  81. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  82. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  83. package/src/server/lib/updates/apply.ts +8 -5
  84. package/src/server/lib/updates/install.ts +7 -4
  85. package/src/server/routes/health/probe.ts +2 -1
  86. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  87. package/src/shared/lib/chat/ask-questions.ts +264 -0
  88. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  89. package/src/shared/lib/code/lazy-window.ts +156 -0
  90. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  91. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  92. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  93. package/src/shared/lib/markdown/mermaid.ts +8 -6
  94. package/src/shared/lib/omp/session/stream-state.server.ts +16 -1
  95. package/src/shared/types/omp/agent.ts +2 -2
  96. package/src/shared/types/workspace.ts +4 -0
  97. package/dist/client/static/css/index.51f68210a5.css +0 -2
  98. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  99. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  100. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  101. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  102. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  103. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  104. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  105. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  106. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  107. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  108. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  109. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  110. package/dist/client/static/js/index.47d3b04d5f.js +0 -33
  111. package/dist/client/static/js/index.47d3b04d5f.js.LICENSE.txt +0 -4
  112. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  113. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  114. /package/dist/client/static/js/async/{g.8515cbc6f7.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  115. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  116. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  117. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  118. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  119. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  120. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  121. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  122. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  123. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  124. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  125. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -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
- {status === 'stream' ? (
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/ask-dialog/Lazy.tsx`): the modal is a full-screen overlay
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
- document.documentElement.dataset.theme = next.theme;
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' && hasPanel}
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
- // default: this effect re-runs right after a spawn (sessionModel changes
112
- // while the session's own thinking_level_change entry is not written yet),
113
- // and an `off` fallback here is the "thinking resets to off on send" bug.
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 selectable = selectableThinkingLevels(prev.thinkingLevels ?? []);
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: resolveSessionLevel(selected.thinkingLevels, prev?.thinkingLevel ?? selected.thinkingLevel ?? 'off'),
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
+ }