ompchamber 3.1.1 → 3.3.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 (151) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +306 -298
  3. package/dist/client/static/js/index-bsxxt89j.css +1 -0
  4. package/dist/client/static/js/{index-qhzsybkn.js → index-qkt4z7x0.js} +658 -658
  5. package/package.json +4 -3
  6. package/public/sw.js +54 -5
  7. package/src/cli/lib/output.js +15 -8
  8. package/src/client/components/common/Modal.tsx +14 -4
  9. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  10. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  11. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  12. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  13. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  14. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  15. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +27 -8
  16. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +67 -29
  17. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.test.ts +84 -0
  18. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +59 -12
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  20. package/src/client/components/settings/Modal.tsx +45 -23
  21. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  22. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  23. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  24. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  25. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  26. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  27. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  28. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  29. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  30. package/src/client/components/settings/categories/appearance-settings/EditorFontSection.tsx +78 -0
  31. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  32. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  33. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  34. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  35. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  36. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  38. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  39. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  40. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  42. package/src/client/components/settings/master-detail/index.tsx +68 -0
  43. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  44. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  45. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +19 -12
  46. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  47. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  55. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  56. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  57. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  58. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  59. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  60. package/src/client/components/workspace/editor/index.tsx +15 -7
  61. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  62. package/src/client/components/workspace/terminal-panel/KeyBar.tsx +194 -0
  63. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +24 -0
  64. package/src/client/components/workspace/terminal-panel/index.tsx +98 -3
  65. package/src/client/components/workspace/terminal-panel/mount.ts +47 -0
  66. package/src/client/data/theme/terminal.ts +25 -8
  67. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  68. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  69. package/src/client/hooks/chat/subagent/index.ts +37 -11
  70. package/src/client/hooks/editor/font.ts +53 -0
  71. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  72. package/src/client/hooks/settings/master-detail.ts +33 -0
  73. package/src/client/hooks/ui/clipboard.ts +17 -0
  74. package/src/client/hooks/ui/keyboard-inset.test.ts +46 -0
  75. package/src/client/hooks/ui/keyboard-inset.ts +113 -0
  76. package/src/client/hooks/ui/text-overflow.test.ts +152 -0
  77. package/src/client/hooks/ui/text-overflow.ts +78 -0
  78. package/src/client/hooks/ui/touch-device.ts +46 -0
  79. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  80. package/src/client/main.tsx +1 -0
  81. package/src/server/index.ts +4 -3
  82. package/src/server/lib/assets/conditional.server.test.ts +74 -0
  83. package/src/server/lib/assets/conditional.server.ts +54 -0
  84. package/src/server/lib/assets/dev-assets.server.test.ts +44 -0
  85. package/src/server/lib/assets/dev-assets.server.ts +121 -0
  86. package/src/server/lib/assets/fonts.server.ts +9 -4
  87. package/src/server/lib/bundler/build-errors.server.ts +136 -0
  88. package/src/server/lib/bundler/build-errors.test.ts +103 -0
  89. package/src/server/lib/bundler/dependencies.test.ts +134 -0
  90. package/src/server/lib/db/client.ts +5 -1
  91. package/src/server/lib/fs/git-diff.ts +34 -10
  92. package/src/server/lib/fs/git-repos.ts +81 -0
  93. package/src/server/lib/lifecycle/flock.ts +199 -0
  94. package/src/server/lib/lifecycle/identity.ts +5 -1
  95. package/src/server/lib/lifecycle/listener.ts +29 -0
  96. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  97. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  98. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  99. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  100. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  101. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  102. package/src/server/lib/observer-ws.ts +150 -0
  103. package/src/server/lib/omp/rpc/constants.ts +9 -0
  104. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  105. package/src/server/lib/omp/session/file-scan.ts +7 -1
  106. package/src/server/lib/omp/session/files.ts +95 -96
  107. package/src/server/lib/omp/session/locator.ts +18 -8
  108. package/src/server/lib/omp/session/messages.ts +7 -7
  109. package/src/server/lib/omp/session/scan.ts +108 -0
  110. package/src/server/lib/omp/session/stats.ts +50 -50
  111. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  112. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  113. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  114. package/src/server/lib/terminal/runtime.server.ts +3 -0
  115. package/src/server/lib/terminal/session.server.ts +30 -3
  116. package/src/server/plugins/shell.server.ts +52 -13
  117. package/src/server/plugins/ssr.ts +43 -7
  118. package/src/server/plugins/static.ts +51 -18
  119. package/src/server/routes/agent/command.test.ts +69 -0
  120. package/src/server/routes/agent/command.ts +14 -0
  121. package/src/server/routes/agent/ws.test.ts +159 -0
  122. package/src/server/routes/agent/ws.ts +22 -97
  123. package/src/server/routes/btw/ws.ts +22 -95
  124. package/src/server/routes/fs/git.ts +61 -70
  125. package/src/server/routes/omp/session-stats.ts +1 -1
  126. package/src/server/routes/telemetry/tokens.ts +6 -4
  127. package/src/server/routes/terminal/run.ts +4 -8
  128. package/src/server/routes/terminal/ws.ts +24 -2
  129. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  130. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  131. package/src/shared/lib/code/editor/typography.test.ts +93 -0
  132. package/src/shared/lib/code/editor/typography.ts +86 -0
  133. package/src/shared/lib/code/language.ts +146 -0
  134. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  135. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  136. package/src/shared/lib/fonts/NOTICE.md +44 -0
  137. package/src/shared/lib/fonts/nerd-symbols.css +55 -0
  138. package/src/shared/lib/fonts/nerd-symbols.test.ts +134 -0
  139. package/src/shared/lib/fonts/symbols-nerd-font-mono.woff2 +0 -0
  140. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  141. package/src/shared/lib/fs/diff-parser.ts +67 -42
  142. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  143. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  144. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  145. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  146. package/src/shared/lib/settings/diff.test.ts +1 -1
  147. package/src/shared/lib/workspace/terminal/keys.test.ts +189 -0
  148. package/src/shared/lib/workspace/terminal/keys.ts +225 -0
  149. package/src/shared/types/omp/subagent.ts +6 -0
  150. package/src/shared/types/settings/state.ts +7 -16
  151. package/dist/client/static/js/index-5yfre1kw.css +0 -1
@@ -0,0 +1,152 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Truncation measurement contract. The bug this hook replaces was a character
8
+ * count standing in for a width: 76 characters fit a desktop timeline and were
9
+ * clipped on a 340px phone card, so the notice rendered an ellipsis and shipped
10
+ * a disabled button — the chevron was dropped with it, and the tap did nothing.
11
+ *
12
+ * happy-dom does no layout, so `scrollWidth`/`clientWidth` are faked on the
13
+ * element exactly as the scroll tests fake container geometry.
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
17
+ import { Window } from 'happy-dom';
18
+ import type { h as PreactH, render as PreactRender } from 'preact';
19
+ import type { act as PreactAct } from 'preact/test-utils';
20
+ import type { RefObject } from 'preact';
21
+ import type { useIsTruncated as UseIsTruncated } from '@/client/hooks/ui/text-overflow';
22
+
23
+ let useIsTruncated: typeof UseIsTruncated;
24
+ let h: typeof PreactH;
25
+ let render: typeof PreactRender;
26
+ let act: typeof PreactAct;
27
+
28
+ let win: Window;
29
+
30
+ /** Box the hook measures. `clientWidth` is faked, so no layout is needed. */
31
+ class StubResizeObserver {
32
+ static latest: StubResizeObserver | null = null;
33
+ constructor(private readonly callback: () => void) {
34
+ StubResizeObserver.latest = this;
35
+ }
36
+ observe() {}
37
+ unobserve() {}
38
+ disconnect() {}
39
+ fire() { this.callback(); }
40
+ }
41
+
42
+ function installBox(el: HTMLElement, scrollWidth: number, clientWidth: number) {
43
+ Object.defineProperty(el, 'scrollWidth', { get: () => scrollWidth, configurable: true });
44
+ Object.defineProperty(el, 'clientWidth', { get: () => clientWidth, configurable: true });
45
+ }
46
+
47
+ interface ProbeProps {
48
+ content: string;
49
+ seen: { current: boolean | null };
50
+ /** Not named `ref`: Preact strips that key from props. */
51
+ targetRef: RefObject<HTMLSpanElement | null>;
52
+ /** When false the truncated row is not rendered — the expanded state. */
53
+ show?: boolean;
54
+ }
55
+
56
+ function Probe(props: ProbeProps) {
57
+ props.seen.current = useIsTruncated(props.targetRef, props.content);
58
+ if (props.show === false) return h('span', null, 'expanded body');
59
+ return h('span', { ref: props.targetRef, className: 'truncate' }, props.content);
60
+ }
61
+
62
+ async function mount(content: string): Promise<{
63
+ seen: { current: boolean | null };
64
+ ref: RefObject<HTMLSpanElement | null>;
65
+ node: HTMLDivElement;
66
+ el: HTMLSpanElement;
67
+ }> {
68
+ const seen: { current: boolean | null } = { current: null };
69
+ const ref: RefObject<HTMLSpanElement | null> = { current: null };
70
+ const node = document.createElement('div');
71
+ document.body.appendChild(node);
72
+ await act(async () => { render(h(Probe, { content, seen, targetRef: ref }), node); });
73
+ return { seen, ref, node, el: ref.current! };
74
+ }
75
+
76
+ beforeAll(async () => {
77
+ win = new Window({ url: 'http://localhost' });
78
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver'] as const) {
79
+ (globalThis as Record<string, unknown>)[key] = (win as unknown as Record<string, unknown>)[key];
80
+ }
81
+ (globalThis as Record<string, unknown>).ResizeObserver = StubResizeObserver;
82
+ // Static imports cannot work here: Preact binds its environment at module
83
+ // evaluation time, so the modules below must load after those DOM globals.
84
+ ({ useIsTruncated } = await import('@/client/hooks/ui/text-overflow'));
85
+ ({ h, render } = await import('preact'));
86
+ ({ act } = await import('preact/test-utils'));
87
+ });
88
+
89
+ afterAll(() => {
90
+ for (const key of ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'ResizeObserver']) {
91
+ delete (globalThis as Record<string, unknown>)[key];
92
+ }
93
+ });
94
+
95
+ describe('useIsTruncated', () => {
96
+ test('reports a clipped line even when it is short', async () => {
97
+ // The regression: 76 characters, clipped on a phone card. A character
98
+ // threshold called this "fits" and the expander was never offered.
99
+ const probe = await mount('Multi-step reasoning: think carefully through the problem before responding.');
100
+ installBox(probe.el, 480, 254);
101
+ await act(async () => { StubResizeObserver.latest!.fire(); });
102
+
103
+ expect(probe.seen.current).toBe(true);
104
+ render(null, probe.node);
105
+ probe.node.remove();
106
+ });
107
+
108
+ test('reports a fitting line as not truncated', async () => {
109
+ const probe = await mount('Late LSP diagnostics arrived after the edit returned:');
110
+ installBox(probe.el, 335, 335);
111
+ await act(async () => { StubResizeObserver.latest!.fire(); });
112
+
113
+ expect(probe.seen.current).toBe(false);
114
+ render(null, probe.node);
115
+ probe.node.remove();
116
+ });
117
+
118
+ test('keeps the last answer when the element has no layout to measure', async () => {
119
+ // An unmeasured element reports 0 for both values, which is not "fits" —
120
+ // treating it as one would retract the expander mid-interaction.
121
+ const probe = await mount('clipped');
122
+ installBox(probe.el, 500, 200);
123
+ await act(async () => { StubResizeObserver.latest!.fire(); });
124
+ expect(probe.seen.current).toBe(true);
125
+
126
+ installBox(probe.el, 0, 0);
127
+ await act(async () => { StubResizeObserver.latest!.fire(); });
128
+ expect(probe.seen.current).toBe(true);
129
+
130
+ render(null, probe.node);
131
+ probe.node.remove();
132
+ });
133
+
134
+ test('keeps its answer when the truncated row is replaced by the expanded body', async () => {
135
+ // Expanding swaps the truncated row out for the body, so the ref detaches
136
+ // while the answer is still the reason the expander is on screen. A reset
137
+ // there would collapse the card the user just opened.
138
+ const probe = await mount('clipped');
139
+ installBox(probe.el, 500, 200);
140
+ await act(async () => { StubResizeObserver.latest!.fire(); });
141
+ expect(probe.seen.current).toBe(true);
142
+
143
+ // Detached AND with new content, so the effect re-runs against a null ref.
144
+ await act(async () => {
145
+ render(h(Probe, { content: 'clipped and expanded', seen: probe.seen, targetRef: probe.ref, show: false }), probe.node);
146
+ });
147
+ expect(probe.seen.current).toBe(true);
148
+
149
+ render(null, probe.node);
150
+ probe.node.remove();
151
+ });
152
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * True when an element's content is visually cut off by CSS truncation.
8
+ *
9
+ * `text-overflow: ellipsis` hides content at a WIDTH, not at a character count,
10
+ * and the two are unrelated: the same 76-character line fits a 970px desktop
11
+ * timeline and is cut on a 340px phone card. A caller that decides "is there
12
+ * more to show?" from `text.length` therefore renders an ellipsis the user can
13
+ * see and no way to reveal it — the exact failure this hook exists to prevent.
14
+ *
15
+ * `scrollWidth > clientWidth` is the measurement the browser itself uses to
16
+ * decide whether to draw the ellipsis, so it cannot disagree with what is on
17
+ * screen. Three details are load-bearing:
18
+ *
19
+ * - A zero-width element reports 0 for BOTH values, which reads as "fits". That
20
+ * is not a measurement, it is the absence of one (a `display: none` ancestor,
21
+ * a detached node), so it is skipped and the previous answer stands.
22
+ * - The measurement is retained when the ref detaches. Callers unmount the
23
+ * truncated row as soon as it expands, and forgetting the answer there would
24
+ * retract the very control the user just used.
25
+ * - A webfont that lands after first paint changes the text's width without
26
+ * resizing the box, so `ResizeObserver` alone never fires. `document.fonts`
27
+ * is the one signal for it.
28
+ */
29
+
30
+ import { useLayoutEffect, useRef, useState } from 'preact/hooks';
31
+ import type { RefObject } from 'preact';
32
+
33
+ /**
34
+ * @param ref Element carrying the truncating class.
35
+ * @param content Deps that change the rendered text; the element's own resize
36
+ * is observed, but new text in an unchanged box is not.
37
+ */
38
+ export function useIsTruncated(
39
+ ref: RefObject<HTMLElement | null>,
40
+ content: unknown,
41
+ ): boolean {
42
+ const [truncated, setTruncated] = useState(false);
43
+ const measuredRef = useRef(false);
44
+
45
+ useLayoutEffect(() => {
46
+ const element = ref.current;
47
+ if (!element) return;
48
+
49
+ let cancelled = false;
50
+ const measure = () => {
51
+ if (cancelled) return;
52
+ // No layout to measure — keep the last real answer rather than report "fits".
53
+ if (element.clientWidth === 0) return;
54
+ const next = element.scrollWidth > element.clientWidth;
55
+ if (next === measuredRef.current) return;
56
+ measuredRef.current = next;
57
+ setTruncated(next);
58
+ };
59
+
60
+ measure();
61
+
62
+ // Fonts swap after first paint and move the text inside a box that never
63
+ // resizes, so no observer fires; `fonts.ready` is the only signal. Absent
64
+ // in a DOM without font loading (a test environment), hence the guard.
65
+ document?.fonts?.ready?.then(measure).catch(() => {});
66
+
67
+ if (typeof ResizeObserver === 'undefined') return;
68
+ const observer = new ResizeObserver(measure);
69
+ observer.observe(element);
70
+
71
+ return () => {
72
+ cancelled = true;
73
+ observer.disconnect();
74
+ };
75
+ }, [ref, content]);
76
+
77
+ return truncated;
78
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Whether this device is typed into with a soft keyboard — the only condition
8
+ * under which a terminal needs on-screen keys.
9
+ *
10
+ * `(pointer: coarse)` is the signal: a phone or tablet reports it, and a
11
+ * touchscreen laptop does not, because its primary pointer is the trackpad.
12
+ * The fallback covers the devices that report a fine pointer anyway (some
13
+ * Android desktop modes): touch points plus `(hover: none)`, which is the pair
14
+ * that means "there is no pointer at all" rather than "there is a screen that
15
+ * can be touched".
16
+ *
17
+ * The queries are subscribed, not read once: a hybrid device that docks a
18
+ * keyboard or leaves tablet mode changes the answer, and a bar of keys that
19
+ * cannot react to that is stuck on a desktop or missing on a phone.
20
+ */
21
+
22
+ import { useEffect, useState } from 'preact/hooks';
23
+
24
+ const COARSE_POINTER_QUERY = '(pointer: coarse)';
25
+ const NO_HOVER_QUERY = '(hover: none)';
26
+
27
+ function detectSoftKeyboardDevice(): boolean {
28
+ if (typeof window === 'undefined') return false;
29
+ if (window.matchMedia(COARSE_POINTER_QUERY).matches) return true;
30
+ return navigator.maxTouchPoints > 0 && window.matchMedia(NO_HOVER_QUERY).matches;
31
+ }
32
+
33
+ export function useTouchDevice(): boolean {
34
+ const [isTouch, setIsTouch] = useState(detectSoftKeyboardDevice);
35
+
36
+ useEffect(() => {
37
+ const queries = [COARSE_POINTER_QUERY, NO_HOVER_QUERY].map((q) => window.matchMedia(q));
38
+ const update = () => setIsTouch(detectSoftKeyboardDevice());
39
+ for (const query of queries) query.addEventListener('change', update);
40
+ return () => {
41
+ for (const query of queries) query.removeEventListener('change', update);
42
+ };
43
+ }, []);
44
+
45
+ return isTouch;
46
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Expanded-session rows, shared by the desktop and mobile session sidebars.
8
+ *
9
+ * A session row's roster (its `SubagentList`) is shown while that row's id is
10
+ * in the expanded set, so both sidebars must read and write ONE set — persisted
11
+ * in `app_settings.omp_sidebar_expanded_sessions` — or a row expanded on the
12
+ * phone would come back collapsed on the desktop.
13
+ *
14
+ * The `?subagent=` deep link auto-expands its session row: a reload on a
15
+ * subagent transcript must leave the roster entry that opened it visible.
16
+ */
17
+
18
+ import { useCallback, useEffect, useState } from 'preact/hooks';
19
+ import { useSearchParams } from '@/client/lib/router/search-params';
20
+ import { loadExpandedSessionIds, saveExpandedSessionIds } from '@/shared/lib/workspace/sidebar-expanded';
21
+
22
+ export interface ExpandedSessionsHandle {
23
+ expandedSessionIds: Set<string>;
24
+ /** Collapse an expanded row, or expand a collapsed one. Persists the result. */
25
+ toggleSession: (sessionId: string) => void;
26
+ }
27
+
28
+ export function useExpandedSessions(): ExpandedSessionsHandle {
29
+ const [searchParams] = useSearchParams();
30
+ const urlSubagentId = searchParams.get('subagent');
31
+ const urlSessionId = searchParams.get('sessionId');
32
+ const [expandedSessionIds, setExpandedSessionIds] = useState<Set<string>>(() => new Set());
33
+
34
+ useEffect(() => {
35
+ const saved = loadExpandedSessionIds();
36
+ if (saved && saved.size > 0) {
37
+ setExpandedSessionIds(saved);
38
+ }
39
+ }, []);
40
+
41
+ useEffect(() => {
42
+ if (!urlSubagentId || !urlSessionId) return;
43
+ setExpandedSessionIds(prev => {
44
+ if (prev.has(urlSessionId)) return prev;
45
+ const next = new Set(prev);
46
+ next.add(urlSessionId);
47
+ return next;
48
+ });
49
+ }, [urlSubagentId, urlSessionId]);
50
+
51
+ const toggleSession = useCallback((sessionId: string) => {
52
+ setExpandedSessionIds((prev) => {
53
+ const next = new Set(prev);
54
+ if (next.has(sessionId)) next.delete(sessionId);
55
+ else next.add(sessionId);
56
+ saveExpandedSessionIds(next);
57
+ return next;
58
+ });
59
+ }, []);
60
+
61
+ return { expandedSessionIds, toggleSession };
62
+ }
@@ -7,6 +7,7 @@ import { initDocumentTheme } from '@/client/hooks/ui/theme';
7
7
  import '@/client/tailwind.css';
8
8
  import '@/shared/lib/markdown/katex-fonts.css';
9
9
  import '@/shared/lib/markdown/fira-code-fonts.css';
10
+ import '@/shared/lib/fonts/nerd-symbols.css';
10
11
 
11
12
  const root = document.getElementById('app');
12
13
  const bootstrap = window.__OMP_BOOTSTRAP__;
@@ -3,7 +3,7 @@ import pkg from '@/../package.json';
3
3
  import shell from '@/../index.html';
4
4
  import { apiRoutes } from '@/server/routes';
5
5
  import { ssrRoutes } from '@/server/plugins/ssr';
6
- import { setShellSource } from '@/server/plugins/shell.server';
6
+ import { setListener } from '@/server/lib/lifecycle/listener';
7
7
  import { getDatabasePath } from '@/server/db.server';
8
8
  import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
9
9
  import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
@@ -67,8 +67,9 @@ try {
67
67
  // an HTML route only while serving, and there is no in-process API for an
68
68
  // `HTMLBundle` (`new Response(bundle)` is "[object HTMLBundle]",
69
69
  // `app.handle` answers 404). Handing the listener over is what makes
70
- // `renderShell()` possible.
71
- setShellSource(app.server);
70
+ // `renderShell()` possible — and the dev asset proxy reaches Bun's own
71
+ // asset routes through it for the same reason.
72
+ setListener(app.server);
72
73
  },
73
74
  });
74
75
  } catch (error) {
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { notModified } from '@/server/lib/assets/conditional.server';
9
+
10
+ const ETAG = '"abc123"';
11
+ const MTIME_MS = Date.parse('2026-01-02T03:04:05.000Z');
12
+
13
+ /** A GET carrying the given conditional headers. */
14
+ const request = (headers: Record<string, string> = {}) => new Request('http://localhost/asset.js', { headers });
15
+
16
+ describe('notModified', () => {
17
+ test('a plain request is never a match', () => {
18
+ expect(notModified(request(), ETAG, MTIME_MS)).toBe(false);
19
+ });
20
+
21
+ test('a matching validator answers 304', () => {
22
+ expect(notModified(request({ 'if-none-match': ETAG }), ETAG, MTIME_MS)).toBe(true);
23
+ });
24
+
25
+ test('a stale validator does not', () => {
26
+ expect(notModified(request({ 'if-none-match': '"other"' }), ETAG, MTIME_MS)).toBe(false);
27
+ });
28
+
29
+ test('a weak or list-form validator still matches', () => {
30
+ expect(notModified(request({ 'if-none-match': `W/${ETAG}` }), ETAG, MTIME_MS)).toBe(true);
31
+ expect(notModified(request({ 'if-none-match': `"other", ${ETAG}` }), ETAG, MTIME_MS)).toBe(true);
32
+ expect(notModified(request({ 'if-none-match': '"other", W/' + ETAG }), ETAG, MTIME_MS)).toBe(true);
33
+ });
34
+
35
+ test('a date at or after the mtime matches, an older one does not', () => {
36
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), ETAG, MTIME_MS)).toBe(true);
37
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS + 60_000).toUTCString() }), ETAG, MTIME_MS)).toBe(true);
38
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS - 1000).toUTCString() }), ETAG, MTIME_MS)).toBe(false);
39
+ });
40
+
41
+ // HTTP dates carry one-second resolution. A file whose mtime has a fractional
42
+ // part must still match the `Last-Modified` this server sent for it, or no
43
+ // request could ever revalidate.
44
+ test('a fractional mtime is floored before comparing', () => {
45
+ const fractional = MTIME_MS + 742;
46
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), ETAG, fractional)).toBe(true);
47
+ });
48
+
49
+ // RFC 9110 §13.1.3: `If-Modified-Since` MUST be ignored when `If-None-Match`
50
+ // is present. A stale validator beside a fresh date is a stale copy.
51
+ test('If-None-Match wins over If-Modified-Since', () => {
52
+ const fresh = new Date(MTIME_MS + 60_000).toUTCString();
53
+ expect(notModified(request({ 'if-none-match': '"stale"', 'if-modified-since': fresh }), ETAG, MTIME_MS)).toBe(false);
54
+ });
55
+
56
+ test('an unparseable date is not a match', () => {
57
+ expect(notModified(request({ 'if-modified-since': 'not-a-date' }), ETAG, MTIME_MS)).toBe(false);
58
+ });
59
+
60
+ // A server that offers only `Last-Modified` revalidates on the date alone —
61
+ // the standard fallback. Neither caller here takes this shape (the file route
62
+ // always mints a validator, the dev proxy never has a date), but the rule is
63
+ // the general one and the branch is reachable if either changes.
64
+ test('without a validator, the date decides', () => {
65
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), null, MTIME_MS)).toBe(true);
66
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS - 1000).toUTCString() }), null, MTIME_MS)).toBe(false);
67
+ });
68
+
69
+ test('with a validator but no mtime, only the validator can match', () => {
70
+ expect(notModified(request({ 'if-none-match': ETAG }), ETAG, null)).toBe(true);
71
+ expect(notModified(request({ 'if-none-match': '"other"' }), ETAG, null)).toBe(false);
72
+ expect(notModified(request({ 'if-modified-since': new Date(MTIME_MS).toUTCString() }), ETAG, null)).toBe(false);
73
+ });
74
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Conditional requests for the assets this server serves itself.
8
+ *
9
+ * Two routes hand out bytes and both need the same answer to "is the client's
10
+ * copy still good?" — `plugins/static.ts`, from a file on disk, and
11
+ * `lib/assets/dev-assets.server.ts`, from bytes it just read back through Bun's
12
+ * dev asset routes. They must agree: a rule applied to one and not the other
13
+ * would make one asset kind revalidate differently from another for no reason a
14
+ * caller could see, so the decision lives here rather than in either caller.
15
+ *
16
+ * The precedence is the load-bearing part. RFC 9110 §13.1.3: a recipient MUST
17
+ * ignore `If-Modified-Since` when the request carries `If-None-Match`. So the
18
+ * presence of `If-None-Match` settles the response on its own — a non-match
19
+ * means "the client's copy is stale" (200), never a reason to consult the date.
20
+ *
21
+ * Every path here errs toward `false`. A `false` costs one full response; a
22
+ * wrong `true` hands the client a stale asset with no way to notice.
23
+ */
24
+
25
+ /**
26
+ * Whether the client's stored copy is still current, so the caller can answer
27
+ * `304` and skip the body.
28
+ *
29
+ * `lastModifiedMs` is the file's own mtime; pass null when there is no file
30
+ * behind the bytes (the dev proxy hashes what it fetched instead).
31
+ */
32
+ export function notModified(request: Request, etag: string | null, lastModifiedMs: number | null): boolean {
33
+ const ifNoneMatch = request.headers.get('if-none-match');
34
+ if (ifNoneMatch !== null) {
35
+ if (!etag) return false;
36
+ // A validator survives a `W/` prefix and arrives as a candidate list.
37
+ const wanted = etag.trim().replace(/^W\//, '');
38
+ return ifNoneMatch.split(',').some((candidate) => candidate.trim().replace(/^W\//, '') === wanted);
39
+ }
40
+
41
+ const ifModifiedSince = request.headers.get('if-modified-since');
42
+ if (ifModifiedSince && lastModifiedMs !== null) {
43
+ const since = Date.parse(ifModifiedSince);
44
+ if (!Number.isNaN(since)) {
45
+ // HTTP dates carry one-second resolution, so the mtime is floored to match
46
+ // before comparing. Without the floor a file whose mtime has a fractional
47
+ // part is always "newer" than the `Last-Modified` this server just sent
48
+ // for it, and no request could ever revalidate.
49
+ return Math.floor(lastModifiedMs / 1000) * 1000 <= since;
50
+ }
51
+ }
52
+
53
+ return false;
54
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { devAssetUpstreamPath, rewriteDevAssetUrls } from '@/server/lib/assets/dev-assets.server';
9
+
10
+ describe('rewriteDevAssetUrls', () => {
11
+ test('moves the bundle and the assets to the proxied prefix', () => {
12
+ const html = '<script src="/_bun/client/index-abc.js"></script><link href="/_bun/asset/x.css">';
13
+ expect(rewriteDevAssetUrls(html)).toBe(
14
+ '<script src="/_dev-assets/client/index-abc.js"></script><link href="/_dev-assets/asset/x.css">',
15
+ );
16
+ });
17
+
18
+ // The HMR client dials `/_bun/hmr` from the bundle and the beacon posts to
19
+ // `/_bun/unref`; both are Bun's own routes and must stay where Bun serves them.
20
+ test('leaves the HMR socket and the unref beacon on Bun', () => {
21
+ const html = '<script>new WebSocket("/_bun/hmr");navigator.sendBeacon("/_bun/unref")</script>';
22
+ expect(rewriteDevAssetUrls(html)).toBe(html);
23
+ });
24
+ });
25
+
26
+ describe('devAssetUpstreamPath', () => {
27
+ test('maps a proxied asset back to the path Bun serves', () => {
28
+ expect(devAssetUpstreamPath('/_dev-assets/client/index-abc.js')).toBe('/_bun/client/index-abc.js');
29
+ expect(devAssetUpstreamPath('/_dev-assets/asset/x.css')).toBe('/_bun/asset/x.css');
30
+ });
31
+
32
+ test('claims nothing outside its prefix', () => {
33
+ expect(devAssetUpstreamPath('/_bun/client/index-abc.js')).toBeNull();
34
+ expect(devAssetUpstreamPath('/fonts.css')).toBeNull();
35
+ expect(devAssetUpstreamPath('/')).toBeNull();
36
+ });
37
+
38
+ // A prefix match alone is not enough: `/_bun/hmr` is a WebSocket upgrade and
39
+ // must never be proxied through a normal request handler.
40
+ test('refuses a Bun path that is not a page-load asset', () => {
41
+ expect(devAssetUpstreamPath('/_dev-assets/hmr')).toBeNull();
42
+ expect(devAssetUpstreamPath('/_dev-assets/unref')).toBeNull();
43
+ });
44
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Dev asset proxy — the one place Bun's own asset routes fall short.
8
+ *
9
+ * In development Bun bundles `index.html` itself and answers the assets it emits
10
+ * from its own routing table (`/_bun/client/*`, `/_bun/asset/*`), before a
11
+ * request can reach Elysia. Those responses carry an `ETag` but ignore
12
+ * `If-None-Match`, carry no `Cache-Control`, and are never compressed — so every
13
+ * page load re-downloads the whole unminified bundle. Measured on Bun 1.4.2, a
14
+ * 19.7 MB dev bundle answered `200` with all 19,755,962 bytes to a request that
15
+ * sent its own ETag back, and again with `Accept-Encoding: gzip, br, zstd` (no
16
+ * `content-encoding`). The same server with `development: false` answers `304`
17
+ * and `Cache-Control: public, max-age=31536000, immutable`, which is exactly why
18
+ * production is cheap and development was not.
19
+ *
20
+ * Bun's table wins for the paths it declares, so a route on `/_bun/*` cannot
21
+ * shadow them (verified: a `routes` entry for `/_bun/client/*` is never
22
+ * reached). The shell is therefore rewritten to a prefix this server owns, and
23
+ * this module serves the bytes back from the listener — adding the two headers
24
+ * Bun omits.
25
+ *
26
+ * Why `no-cache` rather than `immutable`: the JS bundle's name is a build
27
+ * generation id that changes on every rebuild, but the CSS asset names are NOT
28
+ * content-addressed — appending a rule to `tailwind.css` changed the bytes
29
+ * served at the same `/_bun/asset/<hash>.css` URL (verified). `immutable` there
30
+ * would pin stale styles for a year. `no-cache` plus a content `ETag` is correct
31
+ * for both: the browser stores the body and revalidates, so an unchanged asset
32
+ * costs one 304 instead of a re-download.
33
+ *
34
+ * Development only. Production assets are content-hashed and already answered
35
+ * with a working `304` and `immutable` — by Bun's own table in runtime mode and
36
+ * by `plugins/static.ts` from a build — so neither needs this.
37
+ */
38
+
39
+ import { notModified } from '@/server/lib/assets/conditional.server';
40
+ import { maybeCompress } from '@/server/plugins/compress';
41
+
42
+ /** Whether the proxy is active at all. See the module comment. */
43
+ export const DEV_ASSETS_ENABLED = Bun.env.NODE_ENV !== 'production';
44
+
45
+ /** Bun's own dev asset prefix — its routing table owns every path under it. */
46
+ const BUN_ASSET_PREFIX = '/_bun/';
47
+
48
+ /**
49
+ * The asset kinds worth proxying.
50
+ *
51
+ * `/_bun/hmr` (the WebSocket) and `/_bun/unref` (a visibility beacon) stay on
52
+ * Bun's table: neither is a page-load byte, and proxying them would put a socket
53
+ * upgrade and a POST through here for nothing.
54
+ */
55
+ const ASSET_KINDS = ['/_bun/client/', '/_bun/asset/'] as const;
56
+
57
+ /** The prefix this server answers instead. Underscored, so it cannot collide. */
58
+ export const DEV_ASSET_PREFIX = '/_dev-assets/';
59
+
60
+ /** Cache policy: store the body, revalidate every time. See the module comment. */
61
+ const DEV_ASSET_CACHE = 'no-cache';
62
+
63
+ /** `/_bun/client/x.js` -> `/_dev-assets/client/x.js`, in the shell markup. */
64
+ export function rewriteDevAssetUrls(html: string): string {
65
+ let out = html;
66
+ for (const kind of ASSET_KINDS) {
67
+ out = out.replaceAll(kind, DEV_ASSET_PREFIX + kind.slice(BUN_ASSET_PREFIX.length));
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** `/_dev-assets/client/x.js` -> `/_bun/client/x.js`, or null when not ours. */
73
+ export function devAssetUpstreamPath(pathname: string): string | null {
74
+ if (!pathname.startsWith(DEV_ASSET_PREFIX)) return null;
75
+ const upstream = BUN_ASSET_PREFIX + pathname.slice(DEV_ASSET_PREFIX.length);
76
+ return ASSET_KINDS.some((kind) => upstream.startsWith(kind)) ? upstream : null;
77
+ }
78
+
79
+ /**
80
+ * The response for a dev asset request, or null when the path is not one.
81
+ *
82
+ * `base` is the listener's own URL: the bytes are fetched back from it because
83
+ * Bun's bundle is in memory and its routing table — not Elysia's — owns the
84
+ * `/_bun/*` paths.
85
+ */
86
+ export async function serveDevAsset(request: Request, pathname: string, base: URL): Promise<Response | null> {
87
+ const upstream = devAssetUpstreamPath(pathname);
88
+ if (!upstream) return null;
89
+
90
+ const response = await fetch(new URL(upstream, base));
91
+ const type = response.headers.get('content-type') ?? '';
92
+ // An asset name that no longer exists falls through Bun's table to the SSR
93
+ // catch-all and comes back as the shell markup. Serving that as JavaScript
94
+ // would fail far from its cause, so it is reported here instead.
95
+ if (!response.ok || type.startsWith('text/html')) {
96
+ return new Response(`No dev asset at ${upstream}\n`, {
97
+ status: 404,
98
+ headers: { 'content-type': 'text/plain; charset=utf-8' },
99
+ });
100
+ }
101
+
102
+ const bytes = new Uint8Array(await response.arrayBuffer());
103
+ const etag = `"${Bun.hash(bytes).toString(16)}"`;
104
+ // The same decision the file route makes, so a dev asset and a built one
105
+ // revalidate identically. `lastModifiedMs` is null: there is no file behind
106
+ // these bytes, only the hash above, so the date branch does not apply.
107
+ if (notModified(request, etag, null)) {
108
+ return new Response(null, { status: 304, headers: { etag, 'cache-control': DEV_ASSET_CACHE } });
109
+ }
110
+
111
+ return maybeCompress(
112
+ new Response(bytes, {
113
+ headers: {
114
+ 'content-type': type || 'application/octet-stream',
115
+ 'cache-control': DEV_ASSET_CACHE,
116
+ etag,
117
+ },
118
+ }),
119
+ request.headers.get('accept-encoding'),
120
+ );
121
+ }