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,8 @@
1
- import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { useSessionUiState } from '@/client/hooks/workspace/session-state';
3
3
  import { parseUnifiedDiff } from '@/shared/lib/fs/diff-parser';
4
- import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
5
- import { DiffToolbar, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
4
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
5
+ import { DiffToolbar, type DiffContentMode, type DiffViewMode } from '@/client/components/workspace/diff-panel/Toolbar';
6
6
  import { UnifiedView } from '@/client/components/workspace/diff-panel/UnifiedView';
7
7
  import { SplitView } from '@/client/components/workspace/diff-panel/SplitView';
8
8
  import { AlertCircle, RefreshCw } from 'lucide-preact';
@@ -30,6 +30,8 @@ export function DiffPanel({
30
30
  }: DiffPanelProps) {
31
31
  // Read and persist view mode & whitespace preference in session_ui_state
32
32
  const [viewMode, setViewMode] = useSessionUiState<DiffViewMode>('diff.viewMode', 'unified');
33
+ const [contentMode, setContentMode] = useSessionUiState<DiffContentMode>('diff.contentMode', 'full');
34
+ const [wordWrap, setWordWrap] = useSessionUiState<boolean>('diff.wordWrap', true);
33
35
  const [ignoreWhitespace, setIgnoreWhitespace] = useSessionUiState<boolean>('diff.ignoreWhitespace', false);
34
36
 
35
37
  const [rawDiff, setRawDiff] = useState<string>('');
@@ -66,6 +68,11 @@ export function DiffPanel({
66
68
  staged: staged ? '1' : '0',
67
69
  repo: repo || '.',
68
70
  });
71
+ // `fullContext=1` makes git emit the whole file rather than the hunks.
72
+ // Only the user's own toggle changes it, so it is safe as a callback
73
+ // dependency (unlike the staged/status copies above, which the action
74
+ // path updates while a fetch of its own is already in flight).
75
+ if (contentMode === 'full') params.set('fullContext', '1');
69
76
  if (statusForFetch) params.set('status', statusForFetch);
70
77
  if (root) params.set('root', root);
71
78
 
@@ -99,7 +106,7 @@ export function DiffPanel({
99
106
  } finally {
100
107
  if (seq === fetchSeqRef.current) setIsLoading(false);
101
108
  }
102
- }, [filePath, repo, root]);
109
+ }, [filePath, repo, root, contentMode]);
103
110
 
104
111
  useEffect(() => {
105
112
  setCurrentStatus(status);
@@ -112,13 +119,31 @@ export function DiffPanel({
112
119
  void fetchDiff();
113
120
  }, [fetchDiff]);
114
121
 
115
- const parsed = parseUnifiedDiff(rawDiff, ignoreWhitespace);
122
+ // Full-code mode parses the whole file, not the hunks, so the parse is keyed
123
+ // to its inputs: every render used to re-walk the diff (measured 21 ms on an
124
+ // 80k-line file), for a result that only changes when the diff or the
125
+ // whitespace toggle does.
126
+ // Only the rendered view is built: `views` keeps the shared flush (so the
127
+ // whitespace decision and the counts cannot diverge) while skipping the
128
+ // object graph nobody draws.
129
+ const parsed = useMemo(
130
+ () => parseUnifiedDiff(rawDiff, { ignoreWhitespace, views: viewMode === 'split' ? 'split' : 'unified' }),
131
+ [rawDiff, ignoreWhitespace, viewMode],
132
+ );
116
133
  const language = getLanguageFromPath(filePath);
117
134
 
118
135
  const handleSetViewMode = (mode: DiffViewMode) => {
119
136
  setViewMode(mode);
120
137
  };
121
138
 
139
+ const handleSetContentMode = (mode: DiffContentMode) => {
140
+ setContentMode(mode);
141
+ };
142
+
143
+ const handleToggleWordWrap = () => {
144
+ setWordWrap(!wordWrap);
145
+ };
146
+
122
147
  const handleToggleWhitespace = () => {
123
148
  setIgnoreWhitespace(!ignoreWhitespace);
124
149
  };
@@ -218,6 +243,8 @@ export function DiffPanel({
218
243
  status={currentStatus}
219
244
  isStaged={currentStaged}
220
245
  viewMode={viewMode}
246
+ contentMode={contentMode}
247
+ wordWrap={wordWrap}
221
248
  ignoreWhitespace={ignoreWhitespace}
222
249
  additions={parsed.additions}
223
250
  deletions={parsed.deletions}
@@ -225,6 +252,8 @@ export function DiffPanel({
225
252
  isCopied={isCopied}
226
253
  isBusy={isBusy}
227
254
  onSetViewMode={handleSetViewMode}
255
+ onSetContentMode={handleSetContentMode}
256
+ onToggleWordWrap={handleToggleWordWrap}
228
257
  onToggleWhitespace={handleToggleWhitespace}
229
258
  onStageUnstage={handleStageUnstage}
230
259
  onDiscard={handleDiscard}
@@ -268,9 +297,9 @@ export function DiffPanel({
268
297
  </button>
269
298
  </div>
270
299
  ) : viewMode === 'split' ? (
271
- <SplitView rows={parsed.splitRows} language={language} />
300
+ <SplitView rows={parsed.splitRows} language={language} wordWrap={wordWrap} />
272
301
  ) : (
273
- <UnifiedView lines={parsed.lines} language={language} />
302
+ <UnifiedView lines={parsed.lines} language={language} wordWrap={wordWrap} />
274
303
  )}
275
304
  </div>
276
305
 
@@ -68,14 +68,21 @@ export function EditorToolbar({
68
68
  : 'Save file (Ctrl+S)';
69
69
 
70
70
  return (
71
- <div className="flex items-center justify-between px-3 py-2 border-b border-ink/10 bg-paper flex-shrink-0">
71
+ // `@container`: the path below hides on a NARROW PANEL, not a narrow
72
+ // viewport. At the editor panel's 320px floor the action strip is 273px of
73
+ // `flex-shrink-0`, which left the path a measured 23px — a truncated
74
+ // fragment of a filename is not information, and the tab above already
75
+ // carries it.
76
+ <div className="@container flex items-center justify-between px-3 py-2 border-b border-ink/10 bg-paper flex-shrink-0">
72
77
  {/* Left status */}
73
- <div className="flex items-center space-x-2 text-xs font-mono text-ink/40 min-w-0">
78
+ <div className="hidden @[420px]:flex items-center space-x-2 text-xs font-mono text-ink/40 min-w-0">
74
79
  <span className="truncate max-w-[300px]" title={path}>{path}</span>
75
80
  </div>
76
81
 
77
- {/* Right actions */}
78
- <div className="flex items-center space-x-1 text-ink/40 flex-shrink-0">
82
+ {/* Right actions. `ml-auto` keeps them right-aligned once the path block
83
+ above is hidden, which `justify-between` alone would not do (a single
84
+ child sits at the start). */}
85
+ <div className="flex items-center space-x-1 text-ink/40 flex-shrink-0 ml-auto">
79
86
  {!isImage && (
80
87
  <div className="flex items-center pr-2 mr-1 border-r border-ink/10">
81
88
  <button
@@ -1,6 +1,7 @@
1
1
  import { useMemo, useState } from 'preact/hooks';
2
2
  import { ChevronDown, ChevronUp } from 'lucide-preact';
3
- import { getLanguageFromPath, highlightLines } from '@/shared/lib/code/syntax-highlight';
3
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
4
+ import { highlightLines } from '@/shared/lib/code/syntax-highlight';
4
5
  import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
5
6
 
6
7
  interface DiffLineItem {
@@ -32,6 +32,16 @@ export interface SidebarDataHandle {
32
32
  initializing: boolean;
33
33
  /** Fire a refresh (dedup: while a load is in flight this is a no-op). */
34
34
  refresh: () => void;
35
+ /**
36
+ * User-initiated refresh: the same load as `refresh`, but it also raises
37
+ * `refreshing` until THAT load settles, so a toolbar button can spin while
38
+ * it runs. The background paths (idle poll, stream events, status ack) go
39
+ * through `refresh` and deliberately never raise the flag — a spinner that
40
+ * turns on by itself every 30s reads as a broken list.
41
+ */
42
+ refreshNow: () => void;
43
+ /** True while a `refreshNow` load is in flight. */
44
+ refreshing: boolean;
35
45
  /**
36
46
  * Mark a session's one-shot terminal badge as seen NOW: strips the check
37
47
  * optimistically for this mount and POSTs the server ack. No-op unless the
@@ -74,6 +84,22 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
74
84
  };
75
85
  }, [fetcher]);
76
86
 
87
+ // User-initiated refresh. Unlike `refresh` it does NOT skip an in-flight
88
+ // load — a click is an explicit "read it again now", and the fetcher aborts
89
+ // the superseded request rather than letting two answer. The spinner follows
90
+ // THIS load only: a token marks the newest click, so a background poll
91
+ // settling in between cannot stop a spin that is still running, and a
92
+ // superseded click cannot stop the newer one's.
93
+ const [refreshing, setRefreshing] = useState(false);
94
+ const refreshTokenRef = useRef(0);
95
+ const refreshNow = useCallback(() => {
96
+ const token = ++refreshTokenRef.current;
97
+ setRefreshing(true);
98
+ void fetcher.load('/api/sessions/list').finally(() => {
99
+ if (refreshTokenRef.current === token) setRefreshing(false);
100
+ });
101
+ }, [fetcher]);
102
+
77
103
  // Idle keep-alive: the throttled stream-event hook and useStreamPoll only
78
104
  // fire while THIS client streams, so changes made elsewhere (another tab,
79
105
  // a background omp process finishing, an archive from a second browser)
@@ -142,10 +168,12 @@ export function SidebarDataProvider({ children, initialFolders = [] }: { childre
142
168
  isMock: data?.isMock ?? false,
143
169
  initializing: !hasLoaded,
144
170
  refresh,
171
+ refreshNow,
172
+ refreshing,
145
173
  markSeen,
146
174
  hasSeen: (id) => seen.has(String(id)),
147
175
  };
148
- }, [fetcher.data, initialFolders, hasLoaded, refresh, markSeen]);
176
+ }, [fetcher.data, initialFolders, hasLoaded, refresh, refreshNow, refreshing, markSeen]);
149
177
 
150
178
  return <SidebarDataContext.Provider value={value}>{children}</SidebarDataContext.Provider>;
151
179
  }
@@ -163,6 +191,8 @@ export function useSidebarData(): SidebarDataHandle {
163
191
  isMock: false,
164
192
  initializing: true,
165
193
  refresh: () => {},
194
+ refreshNow: () => {},
195
+ refreshing: false,
166
196
  markSeen: () => {},
167
197
  hasSeen: () => false,
168
198
  };
@@ -51,6 +51,10 @@ export interface SessionSidebarController {
51
51
  folders: WorkspaceFolderData[];
52
52
  initializing: boolean;
53
53
  refresh: () => void;
54
+ /** User-initiated refresh — raises `refreshing` until that load settles. */
55
+ refreshNow: () => void;
56
+ /** True while a `refreshNow` load is in flight (drives the toolbar spinner). */
57
+ refreshing: boolean;
54
58
  activeSessionId: number | string | null;
55
59
  sessionParam: string | null;
56
60
  searchQuery: string;
@@ -71,7 +75,7 @@ export function useSessionSidebarController(
71
75
  options: SessionSidebarControllerOptions = {},
72
76
  ): SessionSidebarController {
73
77
  const { includePendingSessions = false, onSelectSession, onAfterSelect } = options;
74
- const { folders, initializing, refresh, markSeen, hasSeen } = useSidebarData();
78
+ const { folders, initializing, refresh, refreshNow, refreshing, markSeen, hasSeen } = useSidebarData();
75
79
  const [searchParams, setSearchParams] = useSearchParams();
76
80
  const sessionParam = searchParams.get('sessionId');
77
81
  const activeSessionId = sessionParam
@@ -232,6 +236,8 @@ export function useSessionSidebarController(
232
236
  folders,
233
237
  initializing,
234
238
  refresh: revalidate,
239
+ refreshNow,
240
+ refreshing,
235
241
  activeSessionId,
236
242
  sessionParam,
237
243
  searchQuery,
@@ -2,7 +2,7 @@
2
2
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
3
  import type { ChatMessageData, SubagentInfo } from '@/shared/types';
4
4
  import { isRecord } from '@/shared/lib/util/guards';
5
- import { parseSubagentLifecycle, parseSubagentProgress } from '@/shared/lib/omp/subagent/parse';
5
+ import { parseSubagentLifecycle, readSubagentProgressFrame } from '@/shared/lib/omp/subagent/parse';
6
6
  import { convertMessages, mergeMessages, requestHistoryPage, requestSubagentPage } from '@/shared/lib/omp/subagent/transcript-client';
7
7
 
8
8
  /**
@@ -86,6 +86,9 @@ export function useSubagentTranscript(
86
86
  const nextByteRef = useRef(0);
87
87
  const sessionFileRef = useRef<string | undefined>(undefined);
88
88
  const readyRef = useRef(false);
89
+ /** False once the live RPC has refused this subagent (its process is gone).
90
+ * Later tail fetches then read the on-disk transcript directly. */
91
+ const rpcAvailableRef = useRef(true);
89
92
  const flushingRef = useRef(false);
90
93
  const queuedRef = useRef(false);
91
94
  const flushTimerRef = useRef<number | null>(null);
@@ -103,8 +106,25 @@ export function useSubagentTranscript(
103
106
  flushingRef.current = true;
104
107
  const generation = generationRef.current;
105
108
  try {
106
- const page = await requestSubagentPage(sid, active.id, sessionFileRef.current, nextByteRef.current);
107
- if (generation !== generationRef.current || !page) return;
109
+ const fromByte = nextByteRef.current;
110
+ // Once the RPC has answered "not managed", stop probing it: the child is
111
+ // gone for the rest of this view's life, and every later tail would pay a
112
+ // refused round-trip before falling back anyway. The disk path below is
113
+ // the same transcript file the RPC was reading.
114
+ let page = rpcAvailableRef.current
115
+ ? await requestSubagentPage(sid, active.id, sessionFileRef.current, fromByte)
116
+ : null;
117
+ if (generation !== generationRef.current) return;
118
+ if (!page) {
119
+ // The session's omp process is gone (the child finished, the idle
120
+ // reclaim took it, or the chamber restarted) — the RPC is refused for
121
+ // an unmanaged session rather than respawning one for a read. Continue
122
+ // on disk; without this the view froze on whatever the last live page
123
+ // held, which is exactly the final turns of a just-finished subagent.
124
+ rpcAvailableRef.current = false;
125
+ page = await requestHistoryPage(sid, active.id, fromByte);
126
+ if (generation !== generationRef.current || !page) return;
127
+ }
108
128
  sessionFileRef.current = page.sessionFile || sessionFileRef.current;
109
129
  const converted = convertMessages(page.messages, true);
110
130
  if (page.reset) {
@@ -163,6 +183,7 @@ export function useSubagentTranscript(
163
183
  nextByteRef.current = 0;
164
184
  sessionFileRef.current = initial?.sessionFile;
165
185
  readyRef.current = false;
186
+ rpcAvailableRef.current = true;
166
187
 
167
188
  const scheduleTail = () => {
168
189
  if (flushTimerRef.current !== null) return;
@@ -188,8 +209,10 @@ export function useSubagentTranscript(
188
209
  if (!isCurrent()) return;
189
210
  if (!result && !viaHistory && fromByte === 0) {
190
211
  // The parent RPC dropped this subagent (already finished) — the
191
- // on-disk transcript is authoritative from here on.
212
+ // on-disk transcript is authoritative from here on, and the tail
213
+ // fetch must not keep probing a refused RPC.
192
214
  viaHistory = true;
215
+ rpcAvailableRef.current = false;
193
216
  result = await requestHistoryPage(sessionId, subagentId, 0);
194
217
  if (!isCurrent()) return;
195
218
  }
@@ -236,15 +259,18 @@ export function useSubagentTranscript(
236
259
  if (!detail) return;
237
260
  const payload = isRecord(detail.payload) ? detail.payload : null;
238
261
  if (!payload) return;
239
- const payloadId = typeof payload.id === 'string' ? payload.id : null;
240
- if (payloadId && payloadId !== subagentId) return;
241
- if (!payloadId) {
242
- const payloadIndex = typeof payload.index === 'number' ? payload.index : null;
262
+ // `subagent_progress` nests the child's AgentProgress under `progress`
263
+ // (SubagentProgressPayload), and only the NESTED snapshot carries the id
264
+ // plus the resolved model/thinking level — reading the wrapper alone
265
+ // dropped every field it reports.
266
+ const progress = readSubagentProgressFrame(payload);
267
+ if (!progress) return;
268
+ if (progress.id !== undefined) {
269
+ if (progress.id !== subagentId) return;
270
+ } else {
243
271
  // Index is only unique within a spawning batch — require the exact match.
244
- if (payloadIndex === null || payloadIndex !== (subagentRef.current?.index ?? -1)) return;
272
+ if (progress.index === undefined || progress.index !== (subagentRef.current?.index ?? -1)) return;
245
273
  }
246
- const progress = parseSubagentProgress(payload);
247
- if (!progress) return;
248
274
  setStatus(prev => {
249
275
  if (!prev || prev.id !== subagentId) return prev;
250
276
  const next: SubagentInfo = { ...prev, progress: { ...prev.progress, ...progress } };
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
2
- import { getLanguageFromPath } from '@/shared/lib/code/syntax-highlight';
2
+ import { getLanguageFromPath } from '@/shared/lib/code/language';
3
3
  import { getImageMimeType } from '@/shared/lib/fs/file-kind';
4
4
  import { buildFsRawUrl } from '@/shared/lib/fs/paths';
5
5
  import { detectLineEnding, toDiskText, toLf, type LineEnding } from '@/shared/lib/code/line-endings';
@@ -0,0 +1,33 @@
1
+ import { useCallback, useState } from 'preact/hooks';
2
+
3
+ /** Which of a two-pane settings category's screens a phone is showing. */
4
+ export type SettingsPane = 'list' | 'detail';
5
+
6
+ export interface SettingsMasterDetailHandle {
7
+ pane: SettingsPane;
8
+ /** Called by the category's own row handler, never derived from selection. */
9
+ openDetail: () => void;
10
+ back: () => void;
11
+ }
12
+
13
+ /**
14
+ * Pane state for the settings categories that are two panes on a desktop.
15
+ *
16
+ * A phone has no room for a 224px list beside a detail pane: measured on a
17
+ * 412x915 Android viewport, the detail was left 186px wide, and the categories
18
+ * whose container stacked gave it 48px of height. Below `md` the two panes are
19
+ * therefore two screens, and this is which one is up.
20
+ *
21
+ * The pane is opened by an EXPLICIT row choice, never by a selection that
22
+ * merely exists: `useCrudList` selects its first row on load and the provider
23
+ * list falls back to `connectedProviders[0]`, so deriving the pane from
24
+ * `selectedId` would drop the user into a detail pane they never asked for and
25
+ * hide the list they came to browse. Desktop ignores this state entirely —
26
+ * `md:` shows both panes.
27
+ */
28
+ export function useSettingsMasterDetail(): SettingsMasterDetailHandle {
29
+ const [pane, setPane] = useState<SettingsPane>('list');
30
+ const openDetail = useCallback(() => setPane('detail'), []);
31
+ const back = useCallback(() => setPane('list'), []);
32
+ return { pane, openDetail, back };
33
+ }
@@ -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
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdtemp, rm } from 'fs/promises';
8
+ import { tmpdir } from 'os';
9
+ import { join } from 'path';
10
+
11
+ import cssPlugin from '@/server/lib/bundler/css';
12
+
13
+ /**
14
+ * Build one stylesheet through the REAL plugin with the options the client
15
+ * build uses (`target: 'bun'`, `minify: true`), and return the emitted CSS.
16
+ *
17
+ * This is the end-to-end gate for the nesting bug: Bun's minifier re-nests a
18
+ * rule's nested at-rule as `@supports (…) { & { … } }` for a non-browser
19
+ * target, and Chrome then drops it whenever the parent selector names a
20
+ * pseudo-element. Asserting on the flattener's own output cannot catch that —
21
+ * only the bundle the browser is actually served can.
22
+ */
23
+ async function buildThroughPlugin(css: string): Promise<string> {
24
+ const dir = await mkdtemp(join(tmpdir(), 'ompchamber-css-'));
25
+ try {
26
+ await Bun.write(join(dir, 'probe.css'), css);
27
+ const result = await Bun.build({
28
+ entrypoints: [join(dir, 'probe.css')],
29
+ outdir: join(dir, 'out'),
30
+ target: 'bun',
31
+ minify: true,
32
+ plugins: [cssPlugin],
33
+ });
34
+ if (!result.success) throw new Error(result.logs.map((log) => log.message).join('\n'));
35
+ return await Bun.file(join(dir, 'out', 'probe.css')).text();
36
+ } finally {
37
+ await rm(dir, { recursive: true, force: true });
38
+ }
39
+ }
40
+
41
+ describe('css plugin — nested at-rules reach the browser flattened', () => {
42
+ test('a placeholder colour refinement survives as a top-level @supports', async () => {
43
+ const out = await buildThroughPlugin(`.probe::placeholder {
44
+ color: var(--color-ink);
45
+ @supports (color: color-mix(in lab, red, red)) {
46
+ color: color-mix(in oklab, var(--color-ink) 40%, transparent);
47
+ }
48
+ }
49
+ `);
50
+
51
+ // No `&` anywhere: a nested rule under `::placeholder` is inert in Chrome,
52
+ // which leaves the rule's full-opacity fallback as the final colour.
53
+ expect(out).not.toMatch(/(?<!\\)&/);
54
+ expect(out).toContain('@supports');
55
+ expect(out).toContain('::placeholder');
56
+ expect(out).toContain('40%');
57
+ });
58
+
59
+ test('a scrollbar thumb keeps its translucent refinement', async () => {
60
+ const out = await buildThroughPlugin(`::-webkit-scrollbar-thumb {
61
+ background: var(--theme-ink);
62
+ @supports (color: color-mix(in lab, red, red)) {
63
+ background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
64
+ }
65
+ }
66
+ `);
67
+
68
+ expect(out).not.toMatch(/(?<!\\)&/);
69
+ expect(out).toContain('20%');
70
+ });
71
+
72
+ test('declarations written after the at-rule keep their order in the bundle', async () => {
73
+ const out = await buildThroughPlugin(`.probe {
74
+ background: var(--theme-ink);
75
+ @supports (color: color-mix(in lab, red, red)) {
76
+ background: color-mix(in srgb, var(--theme-ink) 20%, transparent);
77
+ }
78
+ border-radius: 3px;
79
+ }
80
+ `);
81
+
82
+ // The trailing declaration must not be hoisted above the at-rule: that
83
+ // would let the at-rule's `background` win over a later one.
84
+ expect(out.indexOf('border-radius')).toBeGreaterThan(out.indexOf('@supports'));
85
+ expect(out).not.toMatch(/(?<!\\)&/);
86
+ });
87
+ });
@@ -42,6 +42,15 @@
42
42
  * never does), so there is no "build finished" moment to flush at. Writing
43
43
  * each source's faces as its `onLoad` returns is order-independent and works
44
44
  * identically under both entry points.
45
+ *
46
+ * 3. **Nested at-rules are flattened** (`flattenNestedAtRules`, registered
47
+ * AFTER Tailwind so it sees the expanded tree). Bun's CSS minifier keeps the
48
+ * nesting it is handed and, for `target: 'bun'`, re-nests a rule's nested
49
+ * at-rule as `@supports (…) { & { … } }` — and Chrome drops a nested rule
50
+ * whose parent selector names a pseudo-element. Tailwind emits its colour
51
+ * fallbacks in exactly that shape, so the whole app's placeholder and
52
+ * scrollbar colours silently collapsed to their fallback. See that module for
53
+ * the measurement; this one only has to run it.
45
54
  */
46
55
 
47
56
  import { basename, dirname, isAbsolute, join, resolve as resolvePath } from 'path';
@@ -51,6 +60,7 @@ import type { BunPlugin } from 'bun';
51
60
 
52
61
  import { FONT_FACE_DIR } from '@/server/lib/assets/font-css.server';
53
62
  import { FONT_ROUTE_PREFIX, packageRoot } from '@/server/lib/assets/fonts.server';
63
+ import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
54
64
 
55
65
  /** A `url(...)` whose target is a font file. */
56
66
  const URL_FUNCTION = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
@@ -100,8 +110,13 @@ const plugin: BunPlugin = {
100
110
  build.onLoad({ filter: /\.css$/ }, async (args) => {
101
111
  const source = await Bun.file(args.path).text();
102
112
  // Tailwind first: it parses `@import "tailwindcss"` and `@theme`, and the
103
- // urls it emits are the ones the extraction below has to recognise.
104
- const expanded = await postcss([tailwind()]).process(source, { from: args.path });
113
+ // urls it emits are the ones the extraction below has to recognise. The
114
+ // flattener runs after it, because the nesting it removes is Tailwind's
115
+ // own output — and before the font extraction, so a hoisted face is still
116
+ // recognised as a face.
117
+ const expanded = await postcss([tailwind(), flattenNestedAtRules()]).process(source, {
118
+ from: args.path,
119
+ });
105
120
  const { rest, faces } = splitFontFaces(expanded.css, dirname(args.path));
106
121
  if (faces.length > 0) {
107
122
  const target = join(packageRoot(), FONT_FACE_DIR, `${basename(args.path)}`);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import postcss from 'postcss';
8
+
9
+ import { flattenNestedAtRules } from '@/server/lib/bundler/nested-at-rules';
10
+
11
+ /** Run the flattener and return the CSS it produced. */
12
+ async function flatten(css: string): Promise<string> {
13
+ const result = await postcss([flattenNestedAtRules()]).process(css, { from: undefined });
14
+ return result.css;
15
+ }
16
+
17
+ /** Every `&` left outside an escaped class name — an unflattened nesting marker. */
18
+ const unescapedAmpersands = (css: string): number => (css.match(/(?<!\\)&/g) ?? []).length;
19
+
20
+ describe('flattenNestedAtRules', () => {
21
+ test('hoists a nested @supports out of a pseudo-element rule', async () => {
22
+ // Tailwind's shape: a fallback declaration, then the color-mix refinement.
23
+ const out = await flatten(
24
+ `.placeholder-ink\\/40::placeholder {
25
+ color: var(--color-ink);
26
+ @supports (color: color-mix(in lab, red, red)) {
27
+ color: color-mix(in oklab, var(--color-ink) 40%, transparent);
28
+ }
29
+ }`,
30
+ );
31
+
32
+ // The at-rule is at the top level and carries the resolved selector itself:
33
+ // a nested rule whose parent names a pseudo-element is dropped by Chrome.
34
+ expect(unescapedAmpersands(out)).toBe(0);
35
+ expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
36
+ const root = postcss.parse(out);
37
+ const atRules = root.nodes.filter((n) => n.type === 'atrule');
38
+ expect(atRules).toHaveLength(1);
39
+ expect(atRules[0].name).toBe('supports');
40
+ expect(atRules[0].toString()).toContain('.placeholder-ink\\/40::placeholder');
41
+ expect(atRules[0].toString()).toContain('color-mix');
42
+ });
43
+
44
+ test('keeps declaration order, so the cascade is unchanged', async () => {
45
+ const out = await flatten(
46
+ `.a { color: red; @supports (display: block) { color: blue; } border-radius: 3px; }`,
47
+ );
48
+
49
+ const root = postcss.parse(out);
50
+ const order = root.nodes.map((node) =>
51
+ node.toString().replace(/\s+/g, ' '),
52
+ );
53
+ // `border-radius` was written AFTER the at-rule and must still follow it —
54
+ // moving it before would let the at-rule's `color` be overridden.
55
+ expect(order).toHaveLength(3);
56
+ expect(order[0]).toBe('.a { color: red; }');
57
+ expect(order[1]).toContain('@supports');
58
+ expect(order[2]).toBe('.a { border-radius: 3px; }');
59
+ });
60
+
61
+ test('hoists inside the layer that contained the rule, not to the root', async () => {
62
+ const out = await flatten(
63
+ `@layer base { .b::placeholder { color: red; @supports (display: block) { color: blue; } } }`,
64
+ );
65
+ expect(out).not.toMatch(/::placeholder\s*\{[^}]*@supports/);
66
+ expect(unescapedAmpersands(out)).toBe(0);
67
+ // The refinement stays inside `@layer base` — hoisting it past the layer
68
+ // boundary would change which layer wins.
69
+ const layer = postcss.parse(out).nodes.find((n) => n.type === 'atrule' && n.name === 'layer');
70
+ expect(layer?.toString()).toContain('@supports');
71
+ });
72
+
73
+ test('resolves an explicit & and leaves other rules alone', async () => {
74
+ const out = await flatten(
75
+ `.card { color: red; @supports (display: block) { & { color: blue; } } }
76
+ .plain { color: green; }`,
77
+ );
78
+ expect(out).not.toContain('&');
79
+ const root = postcss.parse(out);
80
+ const supports = root.nodes.find((n) => n.type === 'atrule');
81
+ expect(supports?.toString()).toContain('.card');
82
+ expect(root.toString()).toContain('.plain');
83
+ });
84
+
85
+ test('flattens a rule nested inside the hoisted at-rule on the next pass', async () => {
86
+ const out = await flatten(
87
+ `.outer { @supports (display: block) { .inner { color: blue; } } }`,
88
+ );
89
+ expect(unescapedAmpersands(out)).toBe(0);
90
+ // The inner rule's selector is resolved against its parent, not left bare.
91
+ expect(postcss.parse(out).toString()).toContain('.outer .inner');
92
+ });
93
+
94
+ test('leaves an already-flat stylesheet byte-identical', async () => {
95
+ const flat = `.a { color: red; }
96
+ @supports (display: block) { .a { color: blue; } }
97
+ @layer base { .b { color: green; } }`;
98
+ expect(await flatten(flat)).toBe(flat);
99
+ });
100
+ });