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.
Files changed (107) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.5c0447cefe.css +2 -0
  3. package/dist/client/static/js/async/{3y.4c2596f64c.js → 3y.71f2ccf3b6.js} +2 -2
  4. package/dist/client/static/js/async/{xw.ae67ba06fd.js → 5b.0d6ae50fe8.js} +10 -10
  5. package/dist/client/static/js/async/5bh.8b2363424f.js +1 -0
  6. package/dist/client/static/js/async/{vy.bbe114c4e0.js → bi.09688ba75c.js} +1 -2
  7. package/dist/client/static/js/async/c.797d933882.js +1 -0
  8. package/dist/client/static/js/async/{dc.6dc4b0a04b.js → dc.856fda6560.js} +2 -2
  9. package/dist/client/static/js/async/{g.86e2256a70.js → g.d1c2fe09aa.js} +2 -2
  10. package/dist/client/static/js/async/g.d1c2fe09aa.js.LICENSE.txt +11 -0
  11. package/dist/client/static/js/async/{m.4b29d34f93.js → m.0896dab823.js} +2 -2
  12. package/dist/client/static/js/async/ma.2c261b04dd.js +2 -0
  13. package/dist/client/static/js/async/nam.b5663e919f.js +10 -0
  14. package/dist/client/static/js/async/nam.b5663e919f.js.LICENSE.txt +11 -0
  15. package/dist/client/static/js/async/{q.542c184fec.js → q.23af2a358f.js} +3 -3
  16. package/dist/client/static/js/async/{s.413c2a4683.js → s.222ee974f6.js} +2 -2
  17. package/dist/client/static/js/async/t.4dca0e2ba8.js +1 -0
  18. package/dist/client/static/js/async/uf.46ad6209fa.js +2 -0
  19. package/dist/client/static/js/async/x4.69ec69c61b.js +6 -0
  20. package/dist/client/static/js/async/xn.6360379f71.js +2 -0
  21. package/dist/client/static/js/async/xnl.feb0021e50.js +2 -0
  22. package/dist/client/static/js/index.dc5f5fabe8.js +33 -0
  23. package/dist/client/static/js/{uy.2e8929ed0c.js → jp.50817896f3.js} +6 -6
  24. package/package.json +1 -1
  25. package/src/client/components/common/MarkdownRenderer.tsx +69 -27
  26. package/src/client/components/common/code-editor/history.test.ts +118 -0
  27. package/src/client/components/common/code-editor/history.ts +101 -0
  28. package/src/client/components/common/code-editor/index.tsx +56 -66
  29. package/src/client/components/common/code-surface/index.tsx +84 -11
  30. package/src/client/components/common/code-surface/measure.ts +66 -17
  31. package/src/client/components/common/diagram-viewer/index.tsx +258 -0
  32. package/src/client/components/common/diagram-viewer/read-block.ts +53 -0
  33. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -0
  34. package/src/client/components/layout/session-sidebar/SessionItem.tsx +10 -3
  35. package/src/client/components/settings/LazyModal.tsx +1 -1
  36. package/src/client/components/settings/Modal.tsx +2 -1
  37. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +8 -1
  38. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +11 -52
  39. package/src/client/components/workspace/chat-timeline/chat-input/selection.ts +57 -0
  40. package/src/client/components/workspace/chat-timeline/index.tsx +121 -107
  41. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/FrameControl.tsx +172 -0
  42. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +155 -0
  43. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/drafts.ts +113 -0
  44. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +92 -0
  45. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Footer.tsx +3 -3
  46. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Header.tsx +3 -3
  47. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/Lazy.tsx +8 -8
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/SelectBody.tsx +3 -3
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/{ask-dialog → extension-dialog}/index.tsx +16 -10
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/index.tsx +2 -2
  51. package/src/client/components/workspace/file-explorer/index.tsx +18 -5
  52. package/src/client/hooks/chat/omp/session-list.tsx +4 -0
  53. package/src/client/hooks/chat/timeline/actions.ts +0 -9
  54. package/src/client/hooks/chat/timeline/ask-frames.ts +102 -0
  55. package/src/client/hooks/chat/timeline/extension-dialog.ts +12 -9
  56. package/src/client/hooks/chat/timeline/index.ts +6 -8
  57. package/src/client/hooks/chat/timeline/prompt-text.ts +35 -0
  58. package/src/client/hooks/chat/timeline/send.ts +13 -27
  59. package/src/client/hooks/chat/timeline/session-load.ts +48 -106
  60. package/src/client/hooks/chat/timeline/session-pagination.ts +144 -0
  61. package/src/client/hooks/editor/use-code-window.ts +118 -0
  62. package/src/client/hooks/ui/input-required-alert.ts +43 -0
  63. package/src/client/hooks/ui/notification-sound.ts +65 -0
  64. package/src/client/hooks/ui/theme.ts +22 -6
  65. package/src/client/tailwind.css +38 -12
  66. package/src/server/lib/omp/rpc/manager.ts +14 -3
  67. package/src/server/lib/omp/rpc/session-registry.ts +18 -0
  68. package/src/server/lib/omp/session/sidebar-data.server.ts +7 -1
  69. package/src/shared/lib/chat/ask-questions.test.ts +169 -0
  70. package/src/shared/lib/chat/ask-questions.ts +264 -0
  71. package/src/shared/lib/code/lazy-window.test.ts +161 -0
  72. package/src/shared/lib/code/lazy-window.ts +156 -0
  73. package/src/shared/lib/code/syntax-highlight.test.ts +50 -1
  74. package/src/shared/lib/code/syntax-highlight.ts +14 -1
  75. package/src/shared/lib/code/windowed-highlight.ts +63 -0
  76. package/src/shared/lib/markdown/mermaid.ts +8 -6
  77. package/src/shared/types/omp/agent.ts +2 -2
  78. package/src/shared/types/workspace.ts +4 -0
  79. package/dist/client/static/css/index.51f68210a5.css +0 -2
  80. package/dist/client/static/js/async/5b.d88304bc13.js +0 -1
  81. package/dist/client/static/js/async/c.cd45b0ac45.js +0 -1
  82. package/dist/client/static/js/async/fw.6a51998a8e.js +0 -6
  83. package/dist/client/static/js/async/kc.bbdb35b771.js +0 -1
  84. package/dist/client/static/js/async/mw.ca1512563c.js +0 -10
  85. package/dist/client/static/js/async/rf.d626afc367.js +0 -2
  86. package/dist/client/static/js/async/t.cda4891526.js +0 -1
  87. package/dist/client/static/js/async/uf.24ca3d49c3.js +0 -2
  88. package/dist/client/static/js/async/v0.394ed37c6b.js +0 -2
  89. package/dist/client/static/js/async/v0.394ed37c6b.js.LICENSE.txt +0 -6
  90. package/dist/client/static/js/async/vy.bbe114c4e0.js.LICENSE.txt +0 -6
  91. package/dist/client/static/js/async/xn.afecebbbd9.js +0 -2
  92. package/dist/client/static/js/index.eccad0b70a.js +0 -33
  93. package/dist/client/static/js/index.eccad0b70a.js.LICENSE.txt +0 -4
  94. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Ask.tsx +0 -205
  95. /package/dist/client/static/js/async/{3y.4c2596f64c.js.LICENSE.txt → 3y.71f2ccf3b6.js.LICENSE.txt} +0 -0
  96. /package/dist/client/static/js/async/{g.86e2256a70.js.LICENSE.txt → 5b.0d6ae50fe8.js.LICENSE.txt} +0 -0
  97. /package/dist/client/static/js/async/{dc.6dc4b0a04b.js.LICENSE.txt → dc.856fda6560.js.LICENSE.txt} +0 -0
  98. /package/dist/client/static/js/async/{fw.6a51998a8e.js.LICENSE.txt → m.0896dab823.js.LICENSE.txt} +0 -0
  99. /package/dist/client/static/js/async/{m.4b29d34f93.js.LICENSE.txt → ma.2c261b04dd.js.LICENSE.txt} +0 -0
  100. /package/dist/client/static/js/async/{mw.ca1512563c.js.LICENSE.txt → q.23af2a358f.js.LICENSE.txt} +0 -0
  101. /package/dist/client/static/js/async/{q.542c184fec.js.LICENSE.txt → s.222ee974f6.js.LICENSE.txt} +0 -0
  102. /package/dist/client/static/js/async/{rf.d626afc367.js.LICENSE.txt → uf.46ad6209fa.js.LICENSE.txt} +0 -0
  103. /package/dist/client/static/js/async/{s.413c2a4683.js.LICENSE.txt → x4.69ec69c61b.js.LICENSE.txt} +0 -0
  104. /package/dist/client/static/js/async/{uf.24ca3d49c3.js.LICENSE.txt → xn.6360379f71.js.LICENSE.txt} +0 -0
  105. /package/dist/client/static/js/async/{xn.afecebbbd9.js.LICENSE.txt → xnl.feb0021e50.js.LICENSE.txt} +0 -0
  106. /package/dist/client/static/js/{async/xw.ae67ba06fd.js.LICENSE.txt → index.dc5f5fabe8.js.LICENSE.txt} +0 -0
  107. /package/dist/client/static/js/{uy.2e8929ed0c.js.LICENSE.txt → jp.50817896f3.js.LICENSE.txt} +0 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Older-window pagination for the chamber timeline: pages
8
+ * `/api/chat/:id?before=` into the head of the message list and keeps the
9
+ * viewport anchored on the rows the user was reading. Extracted from
10
+ * useSessionLoad so that hook stays under the repo's per-file size ceiling.
11
+ */
12
+
13
+ import type { Dispatch, RefObject, SetStateAction } from 'preact/compat';
14
+ import { useCallback, useLayoutEffect, useRef, useState } from 'preact/hooks';
15
+ import type { ChatMessageData } from '@/shared/types';
16
+
17
+ export interface UseSessionPaginationDeps {
18
+ sessionId: string | null;
19
+ /** Live session id, read when the page response lands: a window fetched for
20
+ * the previous session is worse than a dropped page. */
21
+ sessionIdRef: { current: string | null };
22
+ setLocalMessages: Dispatch<SetStateAction<ChatMessageData[]>>;
23
+ /** Scroll container of the timeline: lets loadOlder preserve the viewport
24
+ * position when older rows are prepended above it. */
25
+ scrollRef?: RefObject<HTMLDivElement>;
26
+ }
27
+
28
+ export interface SessionPagination {
29
+ hasMore: boolean;
30
+ loadingOlder: boolean;
31
+ /** Sticky failure flag for the "Load earlier messages" affordance: a failed
32
+ * page fetch must keep the button visible (retryable) instead of silently
33
+ * dropping hasMore, which made the button vanish with no feedback. */
34
+ loadOlderError: boolean;
35
+ loadOlder: () => void;
36
+ /** Forget the cursor and the retry flag — a window belongs to one session. */
37
+ resetPages: () => void;
38
+ /** Adopt the window metadata a session load returned. */
39
+ applyWindow: (hasMore: unknown, oldestIndex: unknown) => void;
40
+ }
41
+
42
+ export function useSessionPagination(deps: UseSessionPaginationDeps): SessionPagination {
43
+ const { sessionId, sessionIdRef, setLocalMessages, scrollRef } = deps;
44
+
45
+ const [hasMore, setHasMore] = useState(false);
46
+ const [oldestIndex, setOldestIndex] = useState(0);
47
+ const [loadingOlder, setLoadingOlder] = useState(false);
48
+ const [loadOlderError, setLoadOlderError] = useState(false);
49
+ // Bumped on every successful older-window prepend; the scroll-anchor layout
50
+ // effect keys on it (its other deps have stable identities, so it needs an
51
+ // explicit change signal per commit).
52
+ const [prependTick, setPrependTick] = useState(0);
53
+ const loadingOlderRef = useRef(false);
54
+ // Set while prepending older rows; the layout effect below re-anchors the
55
+ // viewport to the content the user was reading instead of jumping.
56
+ const pendingScrollAnchorRef = useRef<{ prevHeight: number; prevTop: number } | null>(null);
57
+
58
+ const resetPages = useCallback(() => {
59
+ setHasMore(false);
60
+ setOldestIndex(0);
61
+ loadingOlderRef.current = false;
62
+ setLoadingOlder(false);
63
+ setLoadOlderError(false);
64
+ }, []);
65
+
66
+ const applyWindow = useCallback((hasMoreFromLoad: unknown, oldestIndexFromLoad: unknown) => {
67
+ if (typeof hasMoreFromLoad === 'boolean') setHasMore(hasMoreFromLoad);
68
+ if (typeof oldestIndexFromLoad === 'number') setOldestIndex(oldestIndexFromLoad);
69
+ }, []);
70
+
71
+ /** Page the next older window into the timeline. Called from the click
72
+ * handler and the scroll handler when the viewport reaches the top; a no-op
73
+ * while a page is in flight, when the session has no more history, or while
74
+ * the session is a pending optimistic spawn. Prepending older rows only
75
+ * grows the list ABOVE the tail, so it is safe while the agent is
76
+ * generating — unlike a committed refetch, it never clobbers the AI
77
+ * placeholder, and blocking it made the button feel dead mid-run. */
78
+ const loadOlder = useCallback(() => {
79
+ if (!sessionId || sessionId.startsWith('new-')) return;
80
+ if (!hasMore || loadingOlderRef.current) return;
81
+ loadingOlderRef.current = true;
82
+ setLoadingOlder(true);
83
+ setLoadOlderError(false);
84
+ const el = scrollRef?.current;
85
+ if (el) pendingScrollAnchorRef.current = { prevHeight: el.scrollHeight, prevTop: el.scrollTop };
86
+ // Session id snapshot: prepending a window fetched for the PREVIOUS
87
+ // session into the freshly-switched timeline is worse than dropping the
88
+ // page, so the response is discarded if the user switched mid-flight.
89
+ const requestedSessionId = sessionId;
90
+ fetch(`/api/chat/${sessionId}?before=${oldestIndex}`)
91
+ .then(res => {
92
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
93
+ return res.json();
94
+ })
95
+ .then((data: { session?: { messages?: ChatMessageData[] }; hasMore?: boolean; oldestIndex?: number }) => {
96
+ // The main load effect may have cleared/repurposed localMessages for a
97
+ // new session while this fetch was in flight.
98
+ if (sessionIdRef.current !== requestedSessionId) return;
99
+ const older = data.session?.messages ?? [];
100
+ applyWindow(data.hasMore, data.oldestIndex);
101
+ if (older.length === 0) {
102
+ // No rows prepended: the recorded anchor is stale — drop it so a
103
+ // later tick cannot apply it against the wrong geometry.
104
+ pendingScrollAnchorRef.current = null;
105
+ return;
106
+ }
107
+ setLocalMessages(prev => {
108
+ // Drop overlap guard: the server window is index-based, so the
109
+ // fetched slice is strictly older than everything already mounted.
110
+ return [...older, ...prev];
111
+ });
112
+ // Bump the prepend tick so the layout effect below re-runs for THIS
113
+ // commit — it previously depended only on stable identities and ran
114
+ // exactly once at mount, so the viewport anchor never fired and every
115
+ // pagination jump snapped the user to the (shifted) top.
116
+ setPrependTick(t => t + 1);
117
+ })
118
+ .catch(() => {
119
+ // Keep the button mounted and marked for retry; a swallowed failure
120
+ // previously also cleared hasMore on the next full reload path.
121
+ if (sessionIdRef.current === requestedSessionId) setLoadOlderError(true);
122
+ })
123
+ .finally(() => {
124
+ loadingOlderRef.current = false;
125
+ setLoadingOlder(false);
126
+ });
127
+ }, [sessionId, sessionIdRef, hasMore, oldestIndex, scrollRef, setLocalMessages, applyWindow]);
128
+
129
+ // Position preservation: after older rows commit above the viewport, shift
130
+ // scrollTop by the height delta so the rows the user was reading stay put.
131
+ // prependTick changes on every prepend — without it the effect ran only at
132
+ // mount (its other deps are stable) and never re-anchored.
133
+ useLayoutEffect(() => {
134
+ const anchor = pendingScrollAnchorRef.current;
135
+ if (!anchor) return;
136
+ const el = scrollRef?.current;
137
+ if (!el) return;
138
+ pendingScrollAnchorRef.current = null;
139
+ const delta = el.scrollHeight - anchor.prevHeight;
140
+ if (delta > 0) el.scrollTop = anchor.prevTop + delta;
141
+ }, [prependTick, scrollRef]);
142
+
143
+ return { hasMore, loadingOlder, loadOlderError, loadOlder, resetPages, applyWindow };
144
+ }
@@ -0,0 +1,118 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
2
+ import type { RefObject } from 'preact';
3
+
4
+ import {
5
+ codeWindow,
6
+ firstWindow,
7
+ resolveWindow,
8
+ sameWindow,
9
+ type CodeWindow,
10
+ type LineGeometry,
11
+ type LineWindow,
12
+ } from '@/shared/lib/code/lazy-window';
13
+
14
+ export interface UseCodeWindowOptions {
15
+ /** The surface's root box: the frame every line offset in `geometry` is measured from. */
16
+ rootRef: RefObject<HTMLElement | null>;
17
+ /** Lines in the document on screen. */
18
+ lineCount: number;
19
+ /** Document geometry, or null until the wrap/row measurement lands. */
20
+ geometry: LineGeometry | null;
21
+ /** False for short documents (they render whole) and when geometry cannot be measured. */
22
+ enabled: boolean;
23
+ }
24
+
25
+ /** Nearest ancestor the document scrolls inside, or null when the page itself scrolls. */
26
+ function findScrollContainer(element: HTMLElement): HTMLElement | null {
27
+ for (let node = element.parentElement; node; node = node.parentElement) {
28
+ const overflowY = getComputedStyle(node).overflowY;
29
+ if ((overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') && node.scrollHeight > node.clientHeight) {
30
+ return node;
31
+ }
32
+ }
33
+ return null;
34
+ }
35
+
36
+ /**
37
+ * The lines visible inside the scroll container `root` lives in.
38
+ *
39
+ * Both rectangles are read in viewport coordinates, so this needs no knowledge
40
+ * of scroll offsets: the part of the editor's box the container actually shows
41
+ * is exactly the band between the two.
42
+ */
43
+ function visibleWindow(root: HTMLElement, geometry: LineGeometry): LineWindow {
44
+ const frameTop = root.getBoundingClientRect().top;
45
+ const container = findScrollContainer(root);
46
+ const viewTop = container ? container.getBoundingClientRect().top : 0;
47
+ const viewBottom = container
48
+ ? container.getBoundingClientRect().bottom
49
+ : globalThis.innerHeight || geometry.rowHeight;
50
+ return resolveWindow(geometry, viewTop - frameTop, viewBottom - viewTop);
51
+ }
52
+
53
+ /**
54
+ * Rendering window for the lazy editor surface.
55
+ *
56
+ * A long document is highlighted, gutters and all, only where it can be seen;
57
+ * this hook owns which lines those are and re-resolves them on scroll, on
58
+ * resize, and whenever the geometry moves (a keystroke re-wraps a line). The
59
+ * scroll listener is a capturing one on the window: scroll events do not
60
+ * bubble, but they do reach a capturing listener, so the surface follows
61
+ * whichever ancestor scrolls — the desktop editor panel, the phone's
62
+ * full-screen editor, or the page.
63
+ *
64
+ * Returns null for a document that renders whole, which is the caller's cue to
65
+ * keep the plain, unwindowed surface.
66
+ */
67
+ export function useCodeWindow({ rootRef, lineCount, geometry, enabled }: UseCodeWindowOptions): CodeWindow | null {
68
+ const [lineWindow, setLineWindow] = useState<LineWindow | null>(null);
69
+
70
+ const compute = useCallback(() => {
71
+ const root = rootRef.current;
72
+ if (!enabled || !root) return;
73
+ const next = geometry ? visibleWindow(root, geometry) : firstWindow(lineCount);
74
+ setLineWindow((previous) => (sameWindow(previous, next) ? previous : next));
75
+ }, [enabled, geometry, lineCount, rootRef]);
76
+
77
+ // Layout effect: the first window has to be resolved before the browser
78
+ // paints, or a long document flashes its untouched opening lines.
79
+ useLayoutEffect(() => {
80
+ compute();
81
+ }, [compute]);
82
+
83
+ // The listeners follow `enabled`, not `compute`: re-resolving on every
84
+ // keystroke must not tear down and rebuild the observer each time.
85
+ const computeRef = useRef(compute);
86
+ computeRef.current = compute;
87
+ useEffect(() => {
88
+ if (!enabled) return;
89
+ let frame = 0;
90
+ const schedule = () => {
91
+ if (frame) return;
92
+ frame = requestAnimationFrame(() => {
93
+ frame = 0;
94
+ computeRef.current();
95
+ });
96
+ };
97
+ window.addEventListener('scroll', schedule, { passive: true, capture: true });
98
+ window.addEventListener('resize', schedule);
99
+ const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule);
100
+ const root = rootRef.current;
101
+ if (root) observer?.observe(root);
102
+ return () => {
103
+ if (frame) cancelAnimationFrame(frame);
104
+ window.removeEventListener('scroll', schedule, { capture: true });
105
+ window.removeEventListener('resize', schedule);
106
+ observer?.disconnect();
107
+ };
108
+ }, [enabled, rootRef]);
109
+
110
+ if (!enabled) return null;
111
+ if (!geometry) {
112
+ // First commit: the layout effect above lands the real window in the same
113
+ // frame, so this only bounds how much the opening render is allowed to draw.
114
+ const opening = firstWindow(lineCount);
115
+ return { ...opening, top: 0, bottom: 0, breakAtEnd: opening.end >= lineCount };
116
+ }
117
+ return codeWindow(geometry, lineWindow ?? firstWindow(geometry.lineCount));
118
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * App-wide "an agent is blocked on you" cue.
8
+ *
9
+ * It runs off the shared session list rather than the open timeline, which is
10
+ * the whole point: the agent that needs an answer is usually NOT the session on
11
+ * screen — you sent a prompt in one workspace and moved to another. The list is
12
+ * fed by the sidebar loader (which derives `awaitingInput` from the live omp
13
+ * process registry), so the cue reaches the client even when that process'
14
+ * session was never opened.
15
+ */
16
+
17
+ import { useEffect, useRef } from 'preact/hooks';
18
+ import type { WorkspaceFolderData } from '@/shared/types';
19
+ import { triggerInputRequiredSound } from '@/client/hooks/ui/notification-sound';
20
+
21
+ export function useInputRequiredAlert(folders: WorkspaceFolderData[]): void {
22
+ // Null until the first snapshot: a page load must not replay a cue for
23
+ // questions that were already waiting before it.
24
+ const knownRef = useRef<Set<string> | null>(null);
25
+
26
+ useEffect(() => {
27
+ const waiting = new Set<string>();
28
+ for (const folder of folders) {
29
+ for (const session of folder.sessions ?? []) {
30
+ if (session.awaitingInput) waiting.add(String(session.id));
31
+ }
32
+ }
33
+
34
+ const known = knownRef.current;
35
+ knownRef.current = waiting;
36
+ if (!known) return;
37
+ for (const id of waiting) {
38
+ if (known.has(id)) continue;
39
+ triggerInputRequiredSound();
40
+ return;
41
+ }
42
+ }, [folders]);
43
+ }
@@ -72,6 +72,60 @@ export function playNotificationSound() {
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * Web Audio cue for "the agent is blocked on you" — an ask question or an
77
+ * approval gate. Deliberately the mirror image of the completion chime: a
78
+ * triangle wave stepping DOWN (A5 → E5) instead of two sines climbing, so the
79
+ * two are never mistaken for each other when the tab is in the background.
80
+ */
81
+ export function playInputRequiredSound() {
82
+ if (typeof window === 'undefined') return;
83
+
84
+ try {
85
+ const AudioCtx = window.AudioContext || (window as WebkitWindow).webkitAudioContext;
86
+ if (!AudioCtx) return;
87
+
88
+ const ctx = new AudioCtx();
89
+ if (ctx.state === 'suspended') {
90
+ ctx.resume();
91
+ }
92
+
93
+ const now = ctx.currentTime;
94
+ const notes = [
95
+ { at: 0, frequency: 880, duration: 0.18, peak: 0.11 },
96
+ { at: 0.16, frequency: 659.25, duration: 0.46, peak: 0.13 },
97
+ ];
98
+
99
+ for (const note of notes) {
100
+ const osc = ctx.createOscillator();
101
+ const gain = ctx.createGain();
102
+
103
+ osc.type = 'triangle';
104
+ osc.frequency.setValueAtTime(note.frequency, now + note.at);
105
+
106
+ gain.gain.setValueAtTime(0, now + note.at);
107
+ gain.gain.linearRampToValueAtTime(note.peak, now + note.at + 0.015);
108
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + note.at + note.duration);
109
+
110
+ osc.connect(gain);
111
+ gain.connect(ctx.destination);
112
+
113
+ osc.start(now + note.at);
114
+ osc.stop(now + note.at + note.duration);
115
+ }
116
+
117
+ setTimeout(() => {
118
+ try {
119
+ ctx.close();
120
+ } catch {
121
+ // Safe to ignore
122
+ }
123
+ }, 800);
124
+ } catch (err) {
125
+ console.warn('Unable to play input-required audio cue:', err);
126
+ }
127
+ }
128
+
75
129
  /**
76
130
  * Checks whether chat completion sound is enabled in user preferences.
77
131
  */
@@ -91,6 +145,17 @@ export function triggerChatCompletionSound(appSettings?: Record<string, any>) {
91
145
  }
92
146
  }
93
147
 
148
+ /**
149
+ * Triggers the input-required cue, gated by the same notification preference as
150
+ * the completion chime. Reads settings from the primed snapshot: the alert fires
151
+ * from the app-wide session list, which has no settings props to thread.
152
+ */
153
+ export function triggerInputRequiredSound() {
154
+ if (isChatSoundEnabled()) {
155
+ playInputRequiredSound();
156
+ }
157
+ }
158
+
94
159
  export function useNotificationSound() {
95
160
  const play = useCallback(() => {
96
161
  playNotificationSound();
@@ -1,6 +1,25 @@
1
1
  import { useCallback, useEffect, useState } from 'preact/hooks';
2
2
  import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
3
3
 
4
+ /** Broadcast on every theme write. Detail is the new theme id; the
5
+ * `data-theme` attribute on `<html>` is the durable copy of the same fact. */
6
+ export const THEME_CHANGED_EVENT = 'omp:theme-changed';
7
+
8
+ /**
9
+ * Point the document at `theme` and announce it.
10
+ *
11
+ * The single writer path for the theme: the navbar toggle and the settings
12
+ * modal both call this, because a writer that only sets `data-theme` leaves
13
+ * runtime consumers (the mermaid hydrator re-renders diagrams per theme) on the
14
+ * previous palette until the next reload. CSS reads the attribute; listeners
15
+ * watch the attribute too, so this stays correct even if the event is missed.
16
+ */
17
+ export function applyDocumentTheme(theme: string): void {
18
+ if (typeof document === 'undefined') return;
19
+ document.documentElement.dataset.theme = theme;
20
+ window.dispatchEvent(new CustomEvent(THEME_CHANGED_EVENT, { detail: theme }));
21
+ }
22
+
4
23
  export function useTheme() {
5
24
  const writeChamberSettings = useChamberSettingsWriter();
6
25
  const [theme, setThemeState] = useState<string>(() => {
@@ -60,12 +79,9 @@ export function useTheme() {
60
79
  }, []);
61
80
 
62
81
  const setTheme = useCallback((newTheme: string) => {
63
- if (typeof document !== 'undefined') {
64
- document.documentElement.dataset.theme = newTheme;
65
- setThemeState(newTheme);
66
- writeChamberSettings({ theme: newTheme });
67
- window.dispatchEvent(new CustomEvent('omp:theme-changed', { detail: newTheme }));
68
- }
82
+ applyDocumentTheme(newTheme);
83
+ setThemeState(newTheme);
84
+ writeChamberSettings({ theme: newTheme });
69
85
  }, [writeChamberSettings]);
70
86
 
71
87
  const toggleTheme = useCallback(() => {
@@ -318,6 +318,11 @@
318
318
  border-radius: 0.5rem;
319
319
  background: var(--theme-paper);
320
320
  padding: 0.75rem;
321
+ /* `applySvg` swaps the `.code-block` shell (the positioned ancestor the copy
322
+ button relied on) for the SVG, so the block owns the anchor itself. */
323
+ position: relative;
324
+ /* The block is the door into the zoom/pan viewer. */
325
+ cursor: zoom-in;
321
326
  }
322
327
  .prose-content .mermaid-block > svg {
323
328
  display: block;
@@ -332,6 +337,27 @@
332
337
  color: var(--theme-error);
333
338
  }
334
339
 
340
+ /* Full-screen mermaid viewer (`common/diagram-viewer/`): a chart opened from
341
+ the timeline. The stage is the fixed-size pan surface, the canvas child
342
+ carries the zoom/pan transform, so the SVG keeps its natural pixel size and
343
+ `100%` on it always means "the size mermaid rendered". */
344
+ .diagram-viewer-stage {
345
+ touch-action: none;
346
+ overscroll-behavior: contain;
347
+ }
348
+ .diagram-viewer-canvas {
349
+ transform-origin: center center;
350
+ will-change: transform;
351
+ background: var(--theme-paper);
352
+ box-shadow: 0 25px 50px -12px color-mix(in srgb, var(--theme-ink) 45%, transparent);
353
+ }
354
+ .diagram-viewer-canvas > svg {
355
+ display: block;
356
+ width: 100% !important;
357
+ height: 100% !important;
358
+ max-width: none !important;
359
+ }
360
+
335
361
  /* PWA Window Controls Overlay and Native Titlebar Styling */
336
362
  .titlebar-drag-region {
337
363
  -webkit-app-region: drag;
@@ -344,31 +370,31 @@
344
370
  }
345
371
 
346
372
 
347
- /* Ask-dialog enter animation — replaces the retired `motion` dependency.
348
- Exit is intentionally instant: a 160ms fade-out on a blocking modal is not
349
- load-bearing, and keeping the element mounted to animate it out would add
350
- state the dialog does not otherwise need. */
351
- @keyframes ask-dialog-backdrop-in {
373
+ /* Blocking extension-dialog enter animation — replaces the retired `motion`
374
+ dependency. Exit is intentionally instant: a 160ms fade-out on a blocking
375
+ modal is not load-bearing, and keeping the element mounted to animate it out
376
+ would add state the dialog does not otherwise need. */
377
+ @keyframes extension-dialog-backdrop-in {
352
378
  from { opacity: 0; }
353
379
  to { opacity: 1; }
354
380
  }
355
381
 
356
- @keyframes ask-dialog-panel-in {
382
+ @keyframes extension-dialog-panel-in {
357
383
  from { opacity: 0; transform: translateY(10px) scale(0.95); }
358
384
  to { opacity: 1; transform: none; }
359
385
  }
360
386
 
361
- .ask-dialog-backdrop {
362
- animation: ask-dialog-backdrop-in 150ms ease-out;
387
+ .extension-dialog-backdrop {
388
+ animation: extension-dialog-backdrop-in 150ms ease-out;
363
389
  }
364
390
 
365
- .ask-dialog-panel {
366
- animation: ask-dialog-panel-in 180ms ease-out;
391
+ .extension-dialog-panel {
392
+ animation: extension-dialog-panel-in 180ms ease-out;
367
393
  }
368
394
 
369
395
  @media (prefers-reduced-motion: reduce) {
370
- .ask-dialog-backdrop,
371
- .ask-dialog-panel {
396
+ .extension-dialog-backdrop,
397
+ .extension-dialog-panel {
372
398
  animation: none;
373
399
  }
374
400
  }
@@ -102,10 +102,21 @@ export class AgentSessionWrapper {
102
102
  }
103
103
 
104
104
  /** Anything in flight that a process reset would destroy: the current turn,
105
- * a compaction, a shell command, or live subagents — which outlive the turn
106
- * that spawned them, so no other flag here can see them. */
105
+ * a compaction, a shell command, live subagents — which outlive the turn that
106
+ * spawned them, so no other flag here can see them — or an ask/approval dialog
107
+ * omp is BLOCKED on.
108
+ *
109
+ * The dialogs are the load-bearing entry: an ask parks the tool call for as
110
+ * long as the user takes to answer, and omp answers RPC handlers one at a
111
+ * time, so every probe (`get_state`) queues behind it and times out. Without
112
+ * this the timeout path read the session as "idle and unresponsive", killed
113
+ * the child, and with it the question the user was still reading. */
107
114
  isBusy(): boolean {
108
- return this.isRunning() || this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0;
115
+ return (
116
+ this.isRunning() ||
117
+ this.pendingUiDialogs.list().length > 0 ||
118
+ this.subagents.liveCount(Date.now(), SUBAGENT_STALE_MS) > 0
119
+ );
109
120
  }
110
121
 
111
122
  start(): void {
@@ -72,6 +72,24 @@ export function getRunningRpcSessionIds(): string[] {
72
72
  return getRunningRpcSessions().map((s) => s.id);
73
73
  }
74
74
 
75
+ /**
76
+ * Sessions whose omp process is parked on a dialog only the user can release —
77
+ * an `ask` question or an approval gate. Deliberately independent of
78
+ * `isRunning()`: a blocked dialog leaves every turn flag exactly as it was, so
79
+ * the pending-dialog registry is the only thing that can tell a session waiting
80
+ * for an answer from an idle one. Feeds the sidebar's "needs input" badge, which
81
+ * has to reach clients that never opened the session.
82
+ */
83
+ export function getAwaitingInputSessionIds(): string[] {
84
+ const ids = new Set<string>();
85
+ for (const [sessionId, session] of getRegistry()) {
86
+ if (session.isAlive() && session.getPendingUiDialogs().length > 0) {
87
+ ids.add(session.sessionId || sessionId);
88
+ }
89
+ }
90
+ return [...ids];
91
+ }
92
+
75
93
  function getRunningListeners(): Set<(update: RunningSessionUpdate) => void> {
76
94
  if (!globalThis.__ompRunningListeners) globalThis.__ompRunningListeners = new Set();
77
95
  return globalThis.__ompRunningListeners;
@@ -22,7 +22,7 @@ import { isValidSessionSortOption, sortFolders } from '@/shared/lib/workspace/si
22
22
  import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
23
23
  import { sessionHasSubagents } from '@/server/lib/omp/session/subagent-presence';
24
24
  import { healStaleStreamStatuses, loadStreamStatuses } from '@/shared/lib/omp/session/stream-state.server';
25
- import { getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
25
+ import { getAwaitingInputSessionIds, getRunningRpcSessionIds } from '@/server/lib/omp/rpc/session-registry';
26
26
  import type { SessionItemData, SessionSortOption, WorkspaceFolderData } from '@/shared/types';
27
27
  import type { OmpSession } from '@/shared/types/omp/session';
28
28
 
@@ -115,15 +115,21 @@ export async function loadSidebarData(): Promise<SessionListPayload> {
115
115
  // `finish` right here — the authoritative status travels with the same
116
116
  // fetch that refreshes the sidebar list.
117
117
  const streamStatuses: Record<string, 'stream' | 'finish' | 'abort' | 'error'> = {};
118
+ // Sessions blocked on a dialog nobody has answered yet. Read live from the
119
+ // process registry (never persisted): the child that owns the question is the
120
+ // same thing that owns the flag, so a restart cannot leave a stale badge.
121
+ const awaitingInput = new Set<string>();
118
122
  if (!mock) {
119
123
  await healStaleStreamStatuses(new Set(getRunningRpcSessionIds()));
120
124
  Object.assign(streamStatuses, await loadStreamStatuses());
125
+ for (const id of getAwaitingInputSessionIds()) awaitingInput.add(id);
121
126
  }
122
127
  const foldersWithStatus = groupedFolders.map((folder) => ({
123
128
  ...folder,
124
129
  sessions: (folder.sessions ?? []).map((s) => ({
125
130
  ...s,
126
131
  streamStatus: streamStatuses[String(s.id)],
132
+ ...(awaitingInput.has(String(s.id)) ? { awaitingInput: true } : {}),
127
133
  })),
128
134
  }));
129
135