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,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The mobile session row's status slot.
8
+ *
9
+ * The phone must draw the same cue the desktop row does: a session whose agent
10
+ * is blocked on a question shows the question mark, NOT the run spinner. The
11
+ * child is still mid-run while it waits (`streamStatus` stays `stream`), so a
12
+ * row that only knew the stream state drew a spinner over a turn that was
13
+ * actually waiting for the user — the bug this pins.
14
+ */
15
+
16
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
17
+ import { Window } from 'happy-dom';
18
+ import { h, render } from 'preact';
19
+ import { act } from 'preact/test-utils';
20
+ import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
21
+ import type { MobileSessionRowProps } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
22
+ import type { SessionItemData } from '@/shared/types';
23
+
24
+ let container: HTMLElement;
25
+
26
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
27
+
28
+ beforeAll(() => {
29
+ const win = new Window({ url: 'http://localhost' });
30
+ const target = globalThis as unknown as Record<string, unknown>;
31
+ for (const key of DOM_GLOBALS) target[key] = (win as unknown as Record<string, unknown>)[key];
32
+ });
33
+
34
+ afterAll(() => {
35
+ const target = globalThis as unknown as Record<string, unknown>;
36
+ for (const key of DOM_GLOBALS) delete target[key];
37
+ });
38
+
39
+ const session: SessionItemData = { id: 's1', folder_id: 1, title: 'hello world' };
40
+
41
+ async function mount(overrides: Partial<MobileSessionRowProps>) {
42
+ container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ await act(async () => {
45
+ render(h(MobileSessionRow, {
46
+ session,
47
+ isActive: false,
48
+ timeAgo: '1m',
49
+ onSelect: () => {},
50
+ onArchive: () => {},
51
+ ...overrides,
52
+ }), container);
53
+ });
54
+ return container;
55
+ }
56
+
57
+ function svgClasses(el: HTMLElement) {
58
+ return Array.from(el.querySelectorAll('svg')).map((s) => s.getAttribute('class') ?? '').join('|');
59
+ }
60
+
61
+ describe('mobile session row status slot', () => {
62
+ test('awaiting input beats the stream spinner', async () => {
63
+ const el = await mount({ status: 'stream', awaitingInput: true });
64
+ expect(svgClasses(el)).toContain('circle-question-mark');
65
+ expect(svgClasses(el)).not.toContain('animate-spin');
66
+ });
67
+
68
+ test('stream alone still spins', async () => {
69
+ const el = await mount({ status: 'stream' });
70
+ expect(svgClasses(el)).toContain('animate-spin');
71
+ expect(svgClasses(el)).not.toContain('circle-question-mark');
72
+ });
73
+
74
+ test('terminal badge draws the check', async () => {
75
+ const el = await mount({ status: 'finish' });
76
+ expect(svgClasses(el)).toContain('lucide-check');
77
+ });
78
+
79
+ test('no status draws no glyph', async () => {
80
+ const el = await mount({});
81
+ const slot = el.querySelector('span.w-4');
82
+ expect(slot?.querySelector('svg')).toBeNull();
83
+ });
84
+ });
@@ -1,4 +1,4 @@
1
- import { Archive, ArchiveRestore, Check, Loader2, Pencil } from 'lucide-preact';
1
+ import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
 
@@ -6,9 +6,14 @@ export interface MobileSessionRowProps {
6
6
  session: SessionItemData;
7
7
  isActive: boolean;
8
8
  status?: 'stream' | 'finish' | 'abort';
9
+ /** The session's agent is blocked on a question until the user answers it. */
10
+ awaitingInput?: boolean;
9
11
  /** Relative age of the session, or null when no usable timestamp exists. */
10
12
  timeAgo: string | null;
11
- showTreeGlyph?: boolean;
13
+ /** The session has a subagent roster (on disk or live), so it can be expanded. */
14
+ hasSubagents?: boolean;
15
+ isExpanded?: boolean;
16
+ onToggleExpand?: () => void;
12
17
  onSelect: () => void;
13
18
  onArchive: () => void;
14
19
  onRename?: (name: string) => void;
@@ -18,8 +23,11 @@ export function MobileSessionRow({
18
23
  session,
19
24
  isActive,
20
25
  status,
26
+ awaitingInput = false,
21
27
  timeAgo,
22
- showTreeGlyph = false,
28
+ hasSubagents = false,
29
+ isExpanded = false,
30
+ onToggleExpand,
23
31
  onSelect,
24
32
  onArchive,
25
33
  onRename,
@@ -34,6 +42,18 @@ export function MobileSessionRow({
34
42
  handleBlur,
35
43
  } = useInlineRename(session.title, onRename);
36
44
 
45
+ // The roster toggle is the row's LAST element, so its glyph lands in the same
46
+ // column as the folder header's expand chevron — one vertical line of
47
+ // disclosure controls down the drawer. The toggle is a SIBLING of the select
48
+ // button, never a child: a button inside a button is invalid and the browser
49
+ // reparents it.
50
+ //
51
+ // Touch has no hover to reveal a hidden toggle the way desktop does, so it is
52
+ // pinned whenever the row has a roster; a hidden one would leave the roster
53
+ // unreachable on a phone.
54
+ const showChevron = hasSubagents && Boolean(onToggleExpand);
55
+ const title = session.title.charAt(0).toUpperCase() + session.title.slice(1);
56
+
37
57
  if (isEditing) {
38
58
  return (
39
59
  <div className="w-full rounded-lg flex items-center bg-ink/10">
@@ -59,17 +79,23 @@ export function MobileSessionRow({
59
79
  <button
60
80
  type="button"
61
81
  onClick={onSelect}
62
- className="flex-1 text-left px-3 py-2 flex items-center justify-between min-w-0"
82
+ className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
63
83
  >
64
- <div className="flex items-center space-x-1.5 min-w-0 pr-2">
84
+ <div className="flex items-center min-w-0 pr-2">
85
+ {/* Status slot, 16px at the same x as the folder header's icon slot,
86
+ so the left column reads as one line. Waiting on an answer
87
+ outranks the run spinner — the spinner says work is happening,
88
+ the question mark says it is YOUR turn (desktop parity). */}
65
89
  <span className="w-4 flex-shrink-0 flex items-center justify-center">
66
- {status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
67
- {(status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
68
- </span>
69
- {showTreeGlyph && <span className="text-ink/40 text-xs flex-shrink-0 font-mono">&gt;</span>}
70
- <span className="text-xs truncate leading-snug">
71
- {session.title.charAt(0).toUpperCase() + session.title.slice(1)}
90
+ {awaitingInput ? (
91
+ <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
92
+ ) : status === 'stream' ? (
93
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
94
+ ) : status ? (
95
+ <Check size={13} className="text-ink/50" />
96
+ ) : null}
72
97
  </span>
98
+ <span className="text-xs truncate leading-snug ml-2">{title}</span>
73
99
  </div>
74
100
 
75
101
  <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
@@ -86,14 +112,35 @@ export function MobileSessionRow({
86
112
  </button>
87
113
  )}
88
114
 
115
+ {/* A row WITHOUT a roster toggle still has to keep the toggle's column
116
+ empty, or its archive glyph would sit where the toggle is and the
117
+ trailing icons would step in and out row to row. This `mr-1` is what
118
+ reserves that column: it moves the 14px glyph's centre from x=363 to
119
+ x=359, the exact x the toggle uses on a row that has one. */}
89
120
  <button
90
121
  type="button"
91
122
  onClick={onArchive}
92
123
  title={session.is_archived === 1 ? 'Unarchive session' : 'Archive session'}
93
- className="flex-shrink-0 p-2 mr-1 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
124
+ className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
94
125
  >
95
126
  {session.is_archived === 1 ? <ArchiveRestore size={14} /> : <Archive size={14} />}
96
127
  </button>
128
+
129
+ {/* Last element, so its glyph sits in the folder header's chevron column.
130
+ `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
131
+ touch-sized tap target. */}
132
+ {showChevron && (
133
+ <button
134
+ type="button"
135
+ onClick={onToggleExpand}
136
+ title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
137
+ aria-expanded={isExpanded}
138
+ aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
139
+ className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
140
+ >
141
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
142
+ </button>
143
+ )}
97
144
  </div>
98
145
  );
99
146
  }
@@ -1,5 +1,5 @@
1
1
  import type { RefObject } from 'preact/compat';
2
- import { Archive, ArrowUpDown, Calendar, Check, FolderPlus, Plus, Search, X } from 'lucide-preact';
2
+ import { Archive, ArrowUpDown, Calendar, Check, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
3
3
  import type { SessionSortOption } from '@/shared/types';
4
4
  import { SortMenu } from '@/client/components/common/sort-menu';
5
5
 
@@ -12,6 +12,9 @@ interface MobileSessionToolbarProps {
12
12
  onNewSession: () => void;
13
13
  onNewWorkspace: () => void;
14
14
  onScheduler: () => void;
15
+ onRefresh: () => void;
16
+ /** True while the user-initiated refresh is in flight — spins the icon. */
17
+ refreshing: boolean;
15
18
  onToggleOptions: () => void;
16
19
  onSortChange: (opt: SessionSortOption) => void;
17
20
  onResetSort: () => void;
@@ -30,6 +33,8 @@ export function MobileSessionToolbar({
30
33
  onNewSession,
31
34
  onNewWorkspace,
32
35
  onScheduler,
36
+ onRefresh,
37
+ refreshing,
33
38
  onToggleOptions,
34
39
  onSortChange,
35
40
  onResetSort,
@@ -74,6 +79,21 @@ export function MobileSessionToolbar({
74
79
  <Calendar size={16} strokeWidth={1.8} />
75
80
  </button>
76
81
 
82
+ {/* Refresh sessions button — same action the desktop toolbar's
83
+ RefreshCw performs: refetch the session list from the server.
84
+ The icon spins for the duration of that fetch, so the click has a
85
+ visible end; `refreshing` is raised only by this button's own load. */}
86
+ <button
87
+ type="button"
88
+ onClick={onRefresh}
89
+ className="w-9.5 h-9.5 rounded-xl border border-ink/15 bg-paper hover:bg-ink/5 active:scale-95 text-ink/80 hover:text-ink flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs"
90
+ title="Refresh sessions"
91
+ aria-label="Refresh sessions"
92
+ aria-busy={refreshing}
93
+ >
94
+ <RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
95
+ </button>
96
+
77
97
  {/* Sort & Filter Dropdown */}
78
98
  <div className="relative flex-shrink-0" ref={optionsRef}>
79
99
  <button
@@ -6,6 +6,8 @@ import { diffSettings } from '@/shared/lib/settings/diff';
6
6
  import { useChamberSettingsWriter } from '@/client/hooks/settings/use-chamber-setting';
7
7
  import { applyDocumentTheme } from '@/client/hooks/ui/theme';
8
8
  import { DEFAULT_THEME_ID } from '@/shared/lib/theme/catalog';
9
+ import { EDITOR_DEFAULT_FONT_FAMILY } from '@/shared/lib/code/editor/typography';
10
+ import { EDITOR_FONT_CHANGED_EVENT } from '@/client/hooks/editor/font';
9
11
  import { useChamberEvent } from '@/client/hooks/ui/window-event';
10
12
  import { SETTINGS_CATEGORIES, SettingsSidebar } from '@/client/components/settings/Sidebar';
11
13
  import { AppearanceSettings } from '@/client/components/settings/categories/AppearanceSettings';
@@ -33,28 +35,12 @@ export interface SettingsModalProps {
33
35
  }
34
36
 
35
37
  const DEFAULT_SETTINGS: SettingsState = {
36
- binaryPath: '/Users/you/.bun/bin/omp',
37
- showUpdateNotifications: true,
38
- agentControlTool: true,
39
- ompChamberWebTool: true,
40
38
  theme: DEFAULT_THEME_ID,
41
- fontSize: 'standard',
42
- editorFont: 'JetBrains Mono',
43
- streamResponses: true,
39
+ editorFont: EDITOR_DEFAULT_FONT_FAMILY,
44
40
  streamTransport: 'websocket',
45
41
  autoSessionTitle: true,
46
- expandedThinking: true,
47
- detailedToolCalls: false,
48
- notificationsEnabled: true,
49
- buildFailureAlert: true,
50
42
  soundAlerts: true,
51
43
  chatCompletionSound: true,
52
- defaultWorkspacePath: '~/Projects/ompchamber',
53
- tunnelEnabled: true,
54
- tunnelSubdomain: 'omp-dev-preview',
55
- activeProvider: 'claude',
56
- autoApproveSafeCmds: true,
57
- autoPatchErrors: true,
58
44
  followUpBehavior: 'queue',
59
45
  keybindingSend: 'Enter',
60
46
  keybindingNewLine: 'Shift + Enter',
@@ -84,7 +70,11 @@ export function SettingsModal({
84
70
  if (isOpen) {
85
71
  setActiveCategory(initialCategory);
86
72
  setAutoOpenAdd(autoOpenAddProvider);
87
- setIsMobileDrilled(false);
73
+ // `autoOpenAdd` asks for the provider dialog, which a category renders
74
+ // inside its own detail pane. On a phone the modal would otherwise open
75
+ // inside a `display: none` subtree and never be seen, so the request also
76
+ // lands the user on the category screen it belongs to.
77
+ setIsMobileDrilled(autoOpenAddProvider);
88
78
  setSearchQuery('');
89
79
  }
90
80
  }, [isOpen, initialCategory, autoOpenAddProvider]);
@@ -106,6 +96,13 @@ export function SettingsModal({
106
96
  if (typeof document !== 'undefined' && next.theme) {
107
97
  applyDocumentTheme(next.theme);
108
98
  }
99
+ // The editor font is visual, so an open editor has to hear about the
100
+ // change now — `appSettings` is a boot-time prop and would only show it
101
+ // after a reload. Announced only when the value actually moved, so a
102
+ // re-render of an unrelated setting does not churn every editor's stack.
103
+ if (typeof window !== 'undefined' && next.editorFont !== prev.editorFont) {
104
+ window.dispatchEvent(new CustomEvent(EDITOR_FONT_CHANGED_EVENT, { detail: next.editorFont }));
105
+ }
109
106
  // Only the keys this update actually touched are persisted. The modal
110
107
  // holds a full `SettingsState` snapshot taken when it opened, so writing
111
108
  // the whole object back would also republish every value it read at that
@@ -206,8 +203,19 @@ export function SettingsModal({
206
203
 
207
204
  {/* DESKTOP & MOBILE DETAIL VIEW */}
208
205
  <div className={`flex-1 flex-col h-full bg-paper overflow-hidden ${isMobileDrilled ? 'flex' : 'hidden md:flex'}`}>
209
- {/* Header Bar */}
210
- <div className="h-16 px-6 border-b border-ink/10 flex items-center justify-between flex-shrink-0 bg-paper">
206
+ {/* Header Bar. The height and padding carry the safe-area insets on a
207
+ phone: the modal is full-screen below `md`, so without them the
208
+ back button and the title sit under the status bar / notch, the
209
+ same way the mobile chrome (header, editor, diff) already avoids. */}
210
+ <div
211
+ className="px-6 border-b border-ink/10 flex items-center justify-between flex-shrink-0 bg-paper"
212
+ style={{
213
+ height: 'calc(4rem + env(safe-area-inset-top, 0px))',
214
+ paddingTop: 'env(safe-area-inset-top, 0px)',
215
+ paddingLeft: 'max(1.5rem, env(safe-area-inset-left, 0px))',
216
+ paddingRight: 'max(1.5rem, env(safe-area-inset-right, 0px))',
217
+ }}
218
+ >
211
219
  <div className="flex items-center space-x-3 min-w-0">
212
220
  {/* Mobile Back Button */}
213
221
  <button
@@ -241,7 +249,10 @@ export function SettingsModal({
241
249
  </div>
242
250
 
243
251
  {/* Scrollable Settings Body - Full Width across all categories */}
244
- <div className={`flex-1 scrollbar-overlay-container scrollbar-overlay-static w-full ${['projects', 'providers', 'agents', 'behavior', 'commands', 'mcp', 'skills', 'skills-catalog', 'usage'].includes(activeCategory) ? 'p-0 flex flex-col' : 'p-6 md:p-8 flex flex-col'}`}>
252
+ <div
253
+ className={`flex-1 scrollbar-overlay-container scrollbar-overlay-static w-full ${['projects', 'providers', 'agents', 'behavior', 'commands', 'mcp', 'skills', 'skills-catalog', 'usage'].includes(activeCategory) ? 'p-0 flex flex-col' : 'p-6 md:p-8 flex flex-col'}`}
254
+ style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
255
+ >
245
256
  <div className="w-full h-full flex-1 flex flex-col">
246
257
  {renderCategoryContent()}
247
258
  </div>
@@ -250,7 +261,15 @@ export function SettingsModal({
250
261
 
251
262
  {/* MOBILE CATEGORY LIST VIEW (When not drilled into detail) */}
252
263
  <div className={`flex-1 flex-col h-full bg-canvas md:hidden ${isMobileDrilled ? 'hidden' : 'flex'}`}>
253
- <div className="h-14 px-4 border-b border-ink/10 flex items-center justify-between flex-shrink-0 bg-canvas">
264
+ <div
265
+ className="px-4 border-b border-ink/10 flex items-center justify-between flex-shrink-0 bg-canvas"
266
+ style={{
267
+ height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
268
+ paddingTop: 'env(safe-area-inset-top, 0px)',
269
+ paddingLeft: 'max(1rem, env(safe-area-inset-left, 0px))',
270
+ paddingRight: 'max(1rem, env(safe-area-inset-right, 0px))',
271
+ }}
272
+ >
254
273
  <span className="font-bold text-sm tracking-tight text-ink">Settings</span>
255
274
  <button
256
275
  type="button"
@@ -262,7 +281,10 @@ export function SettingsModal({
262
281
  </button>
263
282
  </div>
264
283
 
265
- <div className="flex-1 overflow-hidden">
284
+ <div
285
+ className="flex-1 overflow-hidden"
286
+ style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
287
+ >
266
288
  <SettingsSidebar
267
289
  activeCategory={activeCategory}
268
290
  onSelectCategory={handleSelectCategory}
@@ -77,7 +77,7 @@ export const SettingsSidebarList: FunctionComponent<SettingsSidebarListProps> =
77
77
  };
78
78
 
79
79
  return (
80
- <div className="w-56 sm:w-64 border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0 select-none">
80
+ <div className="w-full md:w-64 md:border-r border-ink/10 h-full flex flex-col bg-paper/50 flex-shrink-0 select-none">
81
81
  <ProjectSelectorDropdown
82
82
  selectedProject={selectedProject}
83
83
  onChangeProject={onChangeProject}
@@ -5,6 +5,8 @@ import { AgentSidebarList } from '@/client/components/settings/categories/agent-
5
5
  import { AgentDetailPane } from '@/client/components/settings/categories/agent-settings/DetailPane';
6
6
  import { LoadingState } from '@/client/components/settings/LoadingState';
7
7
  import { useCrudList } from '@/client/hooks/settings/crud-list';
8
+ import { useSettingsMasterDetail } from '@/client/hooks/settings/master-detail';
9
+ import { SettingsMasterDetail } from '@/client/components/settings/master-detail';
8
10
 
9
11
  interface AgentSettingsProps {
10
12
  settings: SettingsState;
@@ -12,6 +14,7 @@ interface AgentSettingsProps {
12
14
  }
13
15
 
14
16
  export const AgentSettings: FunctionComponent<AgentSettingsProps> = () => {
17
+ const masterDetail = useSettingsMasterDetail();
15
18
  const [selectedProject, setSelectedProject] = useState('ompchamber');
16
19
  const { items: agents, selectedId, selected: selectedAgent, isCreatingNew, isLoading, select, startCreate, save, remove } =
17
20
  useCrudList<AgentItem>({
@@ -49,36 +52,50 @@ export const AgentSettings: FunctionComponent<AgentSettingsProps> = () => {
49
52
 
50
53
  return (
51
54
  <div className="flex h-full w-full overflow-hidden bg-paper">
52
- <AgentSidebarList
53
- agents={agents}
54
- selectedAgentId={isCreatingNew ? null : selectedId}
55
- onSelectAgent={select}
56
- onAddNewAgent={startCreate}
57
- selectedProject={selectedProject}
58
- onChangeProject={setSelectedProject}
59
- />
60
-
61
- <div className="flex-1 h-full overflow-hidden flex flex-col">
62
- {isCreatingNew ? (
63
- <AgentDetailPane
64
- agent={emptyAgentTemplate}
65
- isNew={true}
66
- onSave={save}
55
+ <SettingsMasterDetail
56
+ pane={masterDetail.pane}
57
+ onBack={masterDetail.back}
58
+ listLabel="Agents"
59
+ list={
60
+ <AgentSidebarList
61
+ agents={agents}
62
+ selectedAgentId={isCreatingNew ? null : selectedId}
63
+ onSelectAgent={(id) => {
64
+ select(id);
65
+ masterDetail.openDetail();
66
+ }}
67
+ onAddNewAgent={() => {
68
+ startCreate();
69
+ masterDetail.openDetail();
70
+ }}
71
+ selectedProject={selectedProject}
72
+ onChangeProject={setSelectedProject}
67
73
  />
68
- ) : selectedAgent ? (
69
- <AgentDetailPane
70
- key={selectedAgent.id}
71
- agent={selectedAgent}
72
- isNew={false}
73
- onSave={save}
74
- onDelete={remove}
75
- />
76
- ) : (
77
- <div className="flex-1 flex items-center justify-center text-xs font-mono text-ink/40">
78
- Select an agent or click + to create one
74
+ }
75
+ detail={
76
+ <div className="flex-1 h-full overflow-hidden flex flex-col">
77
+ {isCreatingNew ? (
78
+ <AgentDetailPane
79
+ agent={emptyAgentTemplate}
80
+ isNew={true}
81
+ onSave={save}
82
+ />
83
+ ) : selectedAgent ? (
84
+ <AgentDetailPane
85
+ key={selectedAgent.id}
86
+ agent={selectedAgent}
87
+ isNew={false}
88
+ onSave={save}
89
+ onDelete={remove}
90
+ />
91
+ ) : (
92
+ <div className="flex-1 flex items-center justify-center text-xs font-mono text-ink/40">
93
+ Select an agent or click + to create one
94
+ </div>
95
+ )}
79
96
  </div>
80
- )}
81
- </div>
97
+ }
98
+ />
82
99
  </div>
83
100
  );
84
101
  };
@@ -2,6 +2,7 @@ import { Palette } from 'lucide-preact';
2
2
  import type { SettingsState } from '@/shared/types';
3
3
  import { resolveThemeId } from '@/shared/lib/theme/catalog';
4
4
  import { ThemeGrid } from '@/client/components/settings/categories/appearance-settings/ThemeGrid';
5
+ import { EditorFontSection } from '@/client/components/settings/categories/appearance-settings/EditorFontSection';
5
6
 
6
7
  interface AppearanceSettingsProps {
7
8
  settings: SettingsState;
@@ -22,6 +23,8 @@ export function AppearanceSettings({ settings, onUpdate }: AppearanceSettingsPro
22
23
  onSelect={(theme) => onUpdate({ theme: resolveThemeId(theme) })}
23
24
  />
24
25
  </section>
26
+
27
+ <EditorFontSection settings={settings} onUpdate={onUpdate} />
25
28
  </div>
26
29
  );
27
30
  }
@@ -5,6 +5,8 @@ import { CommandSidebarList } from '@/client/components/settings/categories/comm
5
5
  import { CommandDetailPane } from '@/client/components/settings/categories/command-settings/DetailPane';
6
6
  import { LoadingState } from '@/client/components/settings/LoadingState';
7
7
  import { useCrudList } from '@/client/hooks/settings/crud-list';
8
+ import { useSettingsMasterDetail } from '@/client/hooks/settings/master-detail';
9
+ import { SettingsMasterDetail } from '@/client/components/settings/master-detail';
8
10
 
9
11
  interface CommandSettingsProps {
10
12
  settings: SettingsState;
@@ -12,6 +14,7 @@ interface CommandSettingsProps {
12
14
  }
13
15
 
14
16
  export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
17
+ const masterDetail = useSettingsMasterDetail();
15
18
  const [selectedProject, setSelectedProject] = useState('ompchamber');
16
19
  const { items: commands, selectedId, selected: selectedCommand, isCreatingNew, isLoading, select, startCreate, save, remove } =
17
20
  useCrudList<CommandItem>({
@@ -46,36 +49,50 @@ export const CommandSettings: FunctionComponent<CommandSettingsProps> = () => {
46
49
 
47
50
  return (
48
51
  <div className="flex h-full w-full overflow-hidden bg-paper">
49
- <CommandSidebarList
50
- commands={commands}
51
- selectedCommandId={isCreatingNew ? null : selectedId}
52
- onSelectCommand={select}
53
- onAddNewCommand={startCreate}
54
- selectedProject={selectedProject}
55
- onChangeProject={setSelectedProject}
56
- />
57
-
58
- <div className="flex-1 h-full overflow-hidden flex flex-col">
59
- {isCreatingNew ? (
60
- <CommandDetailPane
61
- command={emptyCommandTemplate}
62
- isNew={true}
63
- onSave={save}
52
+ <SettingsMasterDetail
53
+ pane={masterDetail.pane}
54
+ onBack={masterDetail.back}
55
+ listLabel="Commands"
56
+ list={
57
+ <CommandSidebarList
58
+ commands={commands}
59
+ selectedCommandId={isCreatingNew ? null : selectedId}
60
+ onSelectCommand={(id) => {
61
+ select(id);
62
+ masterDetail.openDetail();
63
+ }}
64
+ onAddNewCommand={() => {
65
+ startCreate();
66
+ masterDetail.openDetail();
67
+ }}
68
+ selectedProject={selectedProject}
69
+ onChangeProject={setSelectedProject}
64
70
  />
65
- ) : selectedCommand ? (
66
- <CommandDetailPane
67
- key={selectedCommand.id}
68
- command={selectedCommand}
69
- isNew={false}
70
- onSave={save}
71
- onDelete={remove}
72
- />
73
- ) : (
74
- <div className="flex-1 flex items-center justify-center text-xs font-mono text-ink/40">
75
- Select a command or click + to create one
71
+ }
72
+ detail={
73
+ <div className="flex-1 h-full overflow-hidden flex flex-col">
74
+ {isCreatingNew ? (
75
+ <CommandDetailPane
76
+ command={emptyCommandTemplate}
77
+ isNew={true}
78
+ onSave={save}
79
+ />
80
+ ) : selectedCommand ? (
81
+ <CommandDetailPane
82
+ key={selectedCommand.id}
83
+ command={selectedCommand}
84
+ isNew={false}
85
+ onSave={save}
86
+ onDelete={remove}
87
+ />
88
+ ) : (
89
+ <div className="flex-1 flex items-center justify-center text-xs font-mono text-ink/40">
90
+ Select a command or click + to create one
91
+ </div>
92
+ )}
76
93
  </div>
77
- )}
78
- </div>
94
+ }
95
+ />
79
96
  </div>
80
97
  );
81
98
  };