ompchamber 3.1.0 → 3.2.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 (124) hide show
  1. package/README.md +6 -3
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +279 -280
  4. package/dist/client/static/js/index-01dv383b.css +1 -0
  5. package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
  6. package/package.json +1 -1
  7. package/src/cli/lib/args.js +4 -3
  8. package/src/cli/lib/commands/restart.js +7 -6
  9. package/src/cli/lib/commands/stop.js +38 -31
  10. package/src/cli/lib/commands/update.js +3 -2
  11. package/src/cli/lib/output.js +15 -8
  12. package/src/cli/lib/stop-scope.js +23 -0
  13. package/src/cli/lib/stop-scope.test.js +41 -0
  14. package/src/client/components/common/Modal.tsx +14 -4
  15. package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
  16. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
  17. package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
  18. package/src/client/components/layout/session-sidebar/index.tsx +4 -2
  19. package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
  20. package/src/client/components/mobile/SessionSidebar.tsx +4 -0
  21. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
  22. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
  23. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
  24. package/src/client/components/settings/Modal.tsx +35 -6
  25. package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
  26. package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
  27. package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
  28. package/src/client/components/settings/categories/McpSettings.tsx +48 -31
  29. package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
  30. package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
  31. package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
  32. package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
  33. package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
  34. package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
  35. package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
  36. package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
  37. package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
  38. package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
  39. package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
  40. package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
  41. package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
  42. package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
  43. package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
  44. package/src/client/components/settings/master-detail/index.tsx +68 -0
  45. package/src/client/components/workspace/SearchPanel.tsx +2 -1
  46. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
  47. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
  48. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
  49. package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
  57. package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
  58. package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
  59. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
  60. package/src/client/components/workspace/diff-panel/index.tsx +36 -7
  61. package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
  62. package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
  63. package/src/client/hooks/chat/omp/session-list.tsx +31 -1
  64. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
  65. package/src/client/hooks/chat/subagent/index.ts +37 -11
  66. package/src/client/hooks/editor/use-file-editor.ts +1 -1
  67. package/src/client/hooks/settings/master-detail.ts +33 -0
  68. package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
  69. package/src/server/lib/bundler/css.test.ts +87 -0
  70. package/src/server/lib/bundler/css.ts +17 -2
  71. package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
  72. package/src/server/lib/bundler/nested-at-rules.ts +146 -0
  73. package/src/server/lib/db/client.ts +5 -1
  74. package/src/server/lib/fs/git-diff.ts +34 -10
  75. package/src/server/lib/fs/git-repos.ts +81 -0
  76. package/src/server/lib/lifecycle/flock.ts +199 -0
  77. package/src/server/lib/lifecycle/identity.ts +5 -1
  78. package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
  79. package/src/server/lib/lifecycle/launch-mode.ts +26 -0
  80. package/src/server/lib/lifecycle/port-guard.ts +18 -3
  81. package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
  82. package/src/server/lib/lifecycle/proc/linux.ts +29 -7
  83. package/src/server/lib/lifecycle/proc/ps.ts +7 -0
  84. package/src/server/lib/lifecycle/proc/types.ts +17 -0
  85. package/src/server/lib/lifecycle/proc/win32.ts +5 -0
  86. package/src/server/lib/lifecycle/restart.ts +9 -14
  87. package/src/server/lib/observer-ws.ts +150 -0
  88. package/src/server/lib/omp/rpc/constants.ts +9 -0
  89. package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
  90. package/src/server/lib/omp/session/file-scan.ts +7 -1
  91. package/src/server/lib/omp/session/files.ts +95 -96
  92. package/src/server/lib/omp/session/locator.ts +18 -8
  93. package/src/server/lib/omp/session/messages.ts +7 -7
  94. package/src/server/lib/omp/session/scan.ts +108 -0
  95. package/src/server/lib/omp/session/stats.ts +50 -50
  96. package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
  97. package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
  98. package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
  99. package/src/server/lib/terminal/runtime.server.ts +3 -0
  100. package/src/server/lib/terminal/session.server.ts +30 -3
  101. package/src/server/routes/agent/command.test.ts +69 -0
  102. package/src/server/routes/agent/command.ts +14 -0
  103. package/src/server/routes/agent/ws.test.ts +159 -0
  104. package/src/server/routes/agent/ws.ts +22 -97
  105. package/src/server/routes/btw/ws.ts +22 -95
  106. package/src/server/routes/fs/git.ts +61 -70
  107. package/src/server/routes/omp/session-stats.ts +1 -1
  108. package/src/server/routes/telemetry/tokens.ts +6 -4
  109. package/src/server/routes/terminal/run.ts +4 -8
  110. package/src/server/routes/terminal/ws.ts +24 -2
  111. package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
  112. package/src/shared/lib/chat/omp/agent-events.ts +10 -0
  113. package/src/shared/lib/code/language.ts +146 -0
  114. package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
  115. package/src/shared/lib/code/syntax-highlight.ts +0 -122
  116. package/src/shared/lib/fs/diff-parser.test.ts +5 -5
  117. package/src/shared/lib/fs/diff-parser.ts +67 -42
  118. package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
  119. package/src/shared/lib/omp/subagent/parse.ts +17 -0
  120. package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
  121. package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
  122. package/src/shared/types/omp/subagent.ts +6 -0
  123. package/dist/client/static/js/index-n4wkqj7t.css +0 -1
  124. package/src/server/lib/lifecycle/restart.test.ts +0 -21
@@ -1,8 +1,11 @@
1
1
  import { useRef, useState } from 'preact/hooks';
2
2
  import { ChevronDown, ChevronRight, GitBranch, MessageSquare, MoreHorizontal, Plus } from 'lucide-preact';
3
+ import { useSearchParams } from '@/client/lib/router/search-params';
3
4
  import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
5
+ import { SubagentList } from '@/client/components/common/subagent-list';
4
6
  import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
5
7
  import { useShowMore } from '@/client/hooks/ui/show-more';
8
+ import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
6
9
  import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
7
10
  import { getProjectIcon } from '@/shared/lib/workspace/project-icon';
8
11
  import { relativeTimeAgo } from '@/shared/lib/workspace/relative-time';
@@ -33,6 +36,12 @@ export function MobileSessionCategory({
33
36
  }: MobileSessionCategoryProps) {
34
37
  const { visibleCount, showMore } = useShowMore();
35
38
  const { refresh } = useSidebarData();
39
+ const { expandedSessionIds, toggleSession } = useExpandedSessions();
40
+ // The viewed transcript, so its session row dims while a roster entry is
41
+ // open (desktop parity — same params, same rule).
42
+ const [searchParams] = useSearchParams();
43
+ const urlSubagentId = searchParams.get('subagent');
44
+ const urlSessionId = searchParams.get('sessionId');
36
45
  const [showMenu, setShowMenu] = useState(false);
37
46
  const menuRef = useRef<HTMLDivElement>(null);
38
47
  const {
@@ -72,25 +81,32 @@ export function MobileSessionCategory({
72
81
  <div className="mb-4">
73
82
  <div
74
83
  onClick={onToggleExpand}
75
- className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
84
+ className="flex items-center justify-between pl-4 pr-3 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
76
85
  >
77
86
  <div className="flex items-center space-x-2 min-w-0">
78
- {folder.customIconUrl ? (
79
- <img
80
- src={folder.customIconUrl}
81
- alt=""
82
- className="w-[15px] h-[15px] rounded-xs object-contain flex-shrink-0"
83
- referrerPolicy="no-referrer"
84
- />
85
- ) : folder.iconType === 'chat' ? (
86
- <MessageSquare size={15} className="text-ink/80 flex-shrink-0" />
87
- ) : (
88
- <ProjectIcon
89
- size={15}
90
- className="text-ink/80 flex-shrink-0"
91
- style={{ color: folder.accentColor || undefined }}
92
- />
93
- )}
87
+ {/* The folder glyph occupies the same 16px left slot the session rows
88
+ give their roster toggle, so the two columns line up down the
89
+ drawer — the icon, the chevron and every row's status glyph share
90
+ one x. Desktop gets this from one shared slot; the drawer's
91
+ padding is what differs, hence the matching `pl-4`. */}
92
+ <span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
93
+ {folder.customIconUrl ? (
94
+ <img
95
+ src={folder.customIconUrl}
96
+ alt=""
97
+ className="w-[15px] h-[15px] rounded-xs object-contain"
98
+ referrerPolicy="no-referrer"
99
+ />
100
+ ) : folder.iconType === 'chat' ? (
101
+ <MessageSquare size={15} className="text-ink/80" />
102
+ ) : (
103
+ <ProjectIcon
104
+ size={15}
105
+ className="text-ink/80"
106
+ style={{ color: folder.accentColor || undefined }}
107
+ />
108
+ )}
109
+ </span>
94
110
 
95
111
  <span className="text-sm font-semibold text-ink truncate">
96
112
  {folder.name}
@@ -153,18 +169,39 @@ export function MobileSessionCategory({
153
169
 
154
170
  {isExpanded && (
155
171
  <div className="mt-0.5 space-y-0.5 pl-2">
156
- {visibleSessions.map((session) => (
157
- <MobileSessionRow
158
- key={session.id}
159
- session={session}
160
- isActive={String(activeSessionId) === String(session.id)}
161
- status={sessionStatus[String(session.id)]}
162
- timeAgo={relativeTimeAgo(session.updated_at ?? session.created_at)}
163
- onSelect={() => onSelectSession(session.id)}
164
- onArchive={() => handleArchive(session)}
165
- onRename={String(session.id).startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
166
- />
167
- ))}
172
+ {visibleSessions.map((session) => {
173
+ const sessionKey = String(session.id);
174
+ const isSessionActive = String(activeSessionId) === sessionKey;
175
+ // While one of its subagents is being viewed, the parent session
176
+ // row dims so the highlighted roster entry reads as the active one
177
+ // (desktop parity).
178
+ const isViewingSubagent = isSessionActive && urlSessionId === sessionKey && Boolean(urlSubagentId);
179
+ // Same gate as desktop: the omp-side loader flag is authoritative,
180
+ // so a session whose roster exists on disk can be opened without a
181
+ // live process.
182
+ const hasSubagents = Boolean(session.hasSubagents);
183
+ const isRosterOpen = hasSubagents && expandedSessionIds.has(sessionKey);
184
+
185
+ return (
186
+ <div key={session.id} className="space-y-0.5">
187
+ <MobileSessionRow
188
+ session={session}
189
+ isActive={isSessionActive && !isViewingSubagent}
190
+ status={sessionStatus[sessionKey]}
191
+ timeAgo={relativeTimeAgo(session.updated_at ?? session.created_at)}
192
+ hasSubagents={hasSubagents}
193
+ isExpanded={isRosterOpen}
194
+ onToggleExpand={() => toggleSession(sessionKey)}
195
+ onSelect={() => onSelectSession(session.id)}
196
+ onArchive={() => handleArchive(session)}
197
+ onRename={sessionKey.startsWith('new-') ? undefined : (name) => void handleRename(session, name)}
198
+ />
199
+ {isRosterOpen && (
200
+ <SubagentList sessionId={session.id} isActiveSession={isSessionActive} />
201
+ )}
202
+ </div>
203
+ );
204
+ })}
168
205
 
169
206
  {filteredSessions.length > visibleCount && (
170
207
  <button
@@ -1,4 +1,4 @@
1
- import { Archive, ArchiveRestore, Check, Loader2, Pencil } from 'lucide-preact';
1
+ import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, 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
 
@@ -8,7 +8,10 @@ export interface MobileSessionRowProps {
8
8
  status?: 'stream' | 'finish' | 'abort';
9
9
  /** Relative age of the session, or null when no usable timestamp exists. */
10
10
  timeAgo: string | null;
11
- showTreeGlyph?: boolean;
11
+ /** The session has a subagent roster (on disk or live), so it can be expanded. */
12
+ hasSubagents?: boolean;
13
+ isExpanded?: boolean;
14
+ onToggleExpand?: () => void;
12
15
  onSelect: () => void;
13
16
  onArchive: () => void;
14
17
  onRename?: (name: string) => void;
@@ -19,7 +22,9 @@ export function MobileSessionRow({
19
22
  isActive,
20
23
  status,
21
24
  timeAgo,
22
- showTreeGlyph = false,
25
+ hasSubagents = false,
26
+ isExpanded = false,
27
+ onToggleExpand,
23
28
  onSelect,
24
29
  onArchive,
25
30
  onRename,
@@ -34,6 +39,19 @@ export function MobileSessionRow({
34
39
  handleBlur,
35
40
  } = useInlineRename(session.title, onRename);
36
41
 
42
+ // The roster toggle is the row's LAST element, so its glyph lands in the same
43
+ // column as the folder header's expand chevron — one vertical line of
44
+ // disclosure controls down the drawer. The toggle is a SIBLING of the select
45
+ // button, never a child: a button inside a button is invalid and the browser
46
+ // reparents it.
47
+ //
48
+ // Touch has no hover to reveal a hidden toggle the way desktop does, so it is
49
+ // pinned whenever the row has a roster; a hidden one would leave the roster
50
+ // unreachable on a phone.
51
+ const showChevron = hasSubagents && Boolean(onToggleExpand);
52
+ const showStatus = Boolean(status);
53
+ const title = session.title.charAt(0).toUpperCase() + session.title.slice(1);
54
+
37
55
  if (isEditing) {
38
56
  return (
39
57
  <div className="w-full rounded-lg flex items-center bg-ink/10">
@@ -59,17 +77,16 @@ export function MobileSessionRow({
59
77
  <button
60
78
  type="button"
61
79
  onClick={onSelect}
62
- className="flex-1 text-left px-3 py-2 flex items-center justify-between min-w-0"
80
+ className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
63
81
  >
64
- <div className="flex items-center space-x-1.5 min-w-0 pr-2">
82
+ <div className="flex items-center min-w-0 pr-2">
83
+ {/* Status slot, 16px at the same x as the folder header's icon slot,
84
+ so the left column reads as one line. */}
65
85
  <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)}
86
+ {showStatus && status === 'stream' && <Loader2 size={13} className="text-ink/50 animate-spin" />}
87
+ {showStatus && (status === 'finish' || status === 'abort') && <Check size={13} className="text-ink/50" />}
72
88
  </span>
89
+ <span className="text-xs truncate leading-snug ml-2">{title}</span>
73
90
  </div>
74
91
 
75
92
  <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
@@ -86,14 +103,35 @@ export function MobileSessionRow({
86
103
  </button>
87
104
  )}
88
105
 
106
+ {/* A row WITHOUT a roster toggle still has to keep the toggle's column
107
+ empty, or its archive glyph would sit where the toggle is and the
108
+ trailing icons would step in and out row to row. This `mr-1` is what
109
+ reserves that column: it moves the 14px glyph's centre from x=363 to
110
+ x=359, the exact x the toggle uses on a row that has one. */}
89
111
  <button
90
112
  type="button"
91
113
  onClick={onArchive}
92
114
  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"
115
+ className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
94
116
  >
95
117
  {session.is_archived === 1 ? <ArchiveRestore size={14} /> : <Archive size={14} />}
96
118
  </button>
119
+
120
+ {/* Last element, so its glyph sits in the folder header's chevron column.
121
+ `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
122
+ touch-sized tap target. */}
123
+ {showChevron && (
124
+ <button
125
+ type="button"
126
+ onClick={onToggleExpand}
127
+ title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
128
+ aria-expanded={isExpanded}
129
+ aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
130
+ 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"
131
+ >
132
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
133
+ </button>
134
+ )}
97
135
  </div>
98
136
  );
99
137
  }
@@ -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
@@ -84,7 +84,11 @@ export function SettingsModal({
84
84
  if (isOpen) {
85
85
  setActiveCategory(initialCategory);
86
86
  setAutoOpenAdd(autoOpenAddProvider);
87
- setIsMobileDrilled(false);
87
+ // `autoOpenAdd` asks for the provider dialog, which a category renders
88
+ // inside its own detail pane. On a phone the modal would otherwise open
89
+ // inside a `display: none` subtree and never be seen, so the request also
90
+ // lands the user on the category screen it belongs to.
91
+ setIsMobileDrilled(autoOpenAddProvider);
88
92
  setSearchQuery('');
89
93
  }
90
94
  }, [isOpen, initialCategory, autoOpenAddProvider]);
@@ -206,8 +210,19 @@ export function SettingsModal({
206
210
 
207
211
  {/* DESKTOP & MOBILE DETAIL VIEW */}
208
212
  <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">
213
+ {/* Header Bar. The height and padding carry the safe-area insets on a
214
+ phone: the modal is full-screen below `md`, so without them the
215
+ back button and the title sit under the status bar / notch, the
216
+ same way the mobile chrome (header, editor, diff) already avoids. */}
217
+ <div
218
+ className="px-6 border-b border-ink/10 flex items-center justify-between flex-shrink-0 bg-paper"
219
+ style={{
220
+ height: 'calc(4rem + env(safe-area-inset-top, 0px))',
221
+ paddingTop: 'env(safe-area-inset-top, 0px)',
222
+ paddingLeft: 'max(1.5rem, env(safe-area-inset-left, 0px))',
223
+ paddingRight: 'max(1.5rem, env(safe-area-inset-right, 0px))',
224
+ }}
225
+ >
211
226
  <div className="flex items-center space-x-3 min-w-0">
212
227
  {/* Mobile Back Button */}
213
228
  <button
@@ -241,7 +256,10 @@ export function SettingsModal({
241
256
  </div>
242
257
 
243
258
  {/* 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'}`}>
259
+ <div
260
+ 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'}`}
261
+ style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
262
+ >
245
263
  <div className="w-full h-full flex-1 flex flex-col">
246
264
  {renderCategoryContent()}
247
265
  </div>
@@ -250,7 +268,15 @@ export function SettingsModal({
250
268
 
251
269
  {/* MOBILE CATEGORY LIST VIEW (When not drilled into detail) */}
252
270
  <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">
271
+ <div
272
+ className="px-4 border-b border-ink/10 flex items-center justify-between flex-shrink-0 bg-canvas"
273
+ style={{
274
+ height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
275
+ paddingTop: 'env(safe-area-inset-top, 0px)',
276
+ paddingLeft: 'max(1rem, env(safe-area-inset-left, 0px))',
277
+ paddingRight: 'max(1rem, env(safe-area-inset-right, 0px))',
278
+ }}
279
+ >
254
280
  <span className="font-bold text-sm tracking-tight text-ink">Settings</span>
255
281
  <button
256
282
  type="button"
@@ -262,7 +288,10 @@ export function SettingsModal({
262
288
  </button>
263
289
  </div>
264
290
 
265
- <div className="flex-1 overflow-hidden">
291
+ <div
292
+ className="flex-1 overflow-hidden"
293
+ style={{ paddingBottom: 'env(safe-area-inset-bottom, 0px)' }}
294
+ >
266
295
  <SettingsSidebar
267
296
  activeCategory={activeCategory}
268
297
  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
  };
@@ -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
  };