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.
- package/README.md +6 -3
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +279 -280
- package/dist/client/static/js/index-01dv383b.css +1 -0
- package/dist/client/static/js/{index-8t70jhyj.js → index-1q6165r3.js} +641 -641
- package/package.json +1 -1
- package/src/cli/lib/args.js +4 -3
- package/src/cli/lib/commands/restart.js +7 -6
- package/src/cli/lib/commands/stop.js +38 -31
- package/src/cli/lib/commands/update.js +3 -2
- package/src/cli/lib/output.js +15 -8
- package/src/cli/lib/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/client/components/common/Modal.tsx +14 -4
- package/src/client/components/{layout/session-sidebar/SubagentList.tsx → common/subagent-list/index.tsx} +8 -10
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +6 -30
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +11 -5
- package/src/client/components/layout/session-sidebar/index.tsx +4 -2
- package/src/client/components/mobile/LayoutWrapper.tsx +6 -0
- package/src/client/components/mobile/SessionSidebar.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +66 -29
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +50 -12
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +21 -1
- package/src/client/components/settings/Modal.tsx +35 -6
- package/src/client/components/settings/SettingsSidebarList.tsx +1 -1
- package/src/client/components/settings/categories/AgentSettings.tsx +45 -28
- package/src/client/components/settings/categories/CommandSettings.tsx +45 -28
- package/src/client/components/settings/categories/McpSettings.tsx +48 -31
- package/src/client/components/settings/categories/ProjectSettings.tsx +34 -19
- package/src/client/components/settings/categories/ProviderSettings.tsx +77 -53
- package/src/client/components/settings/categories/SkillSettings.tsx +34 -17
- package/src/client/components/settings/categories/UsageSettings.tsx +53 -39
- package/src/client/components/settings/categories/behavior-settings/Editor.tsx +7 -7
- package/src/client/components/settings/categories/mcp-settings/ImportModal.tsx +1 -1
- package/src/client/components/settings/categories/mcp-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/project-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AuthSection.tsx +8 -3
- package/src/client/components/settings/categories/provider-settings/DeleteProviderModal.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/ModelsList.tsx +9 -4
- package/src/client/components/settings/categories/provider-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/skill-catalog/AddSourceModal.tsx +1 -1
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/categories/usage-settings/SidebarList.tsx +1 -1
- package/src/client/components/settings/master-detail/index.tsx +68 -0
- package/src/client/components/workspace/SearchPanel.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +7 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +2 -1
- package/src/client/components/workspace/diff-panel/SplitView.tsx +31 -5
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +77 -6
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +18 -5
- package/src/client/components/workspace/diff-panel/index.tsx +36 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +11 -4
- package/src/client/components/workspace/git-panel/commit-modal/CommitDiffViewer.tsx +2 -1
- package/src/client/hooks/chat/omp/session-list.tsx +31 -1
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +7 -1
- package/src/client/hooks/chat/subagent/index.ts +37 -11
- package/src/client/hooks/editor/use-file-editor.ts +1 -1
- package/src/client/hooks/settings/master-detail.ts +33 -0
- package/src/client/hooks/workspace/expanded-sessions.ts +62 -0
- package/src/server/lib/bundler/css.test.ts +87 -0
- package/src/server/lib/bundler/css.ts +17 -2
- package/src/server/lib/bundler/nested-at-rules.test.ts +100 -0
- package/src/server/lib/bundler/nested-at-rules.ts +146 -0
- package/src/server/lib/db/client.ts +5 -1
- package/src/server/lib/fs/git-diff.ts +34 -10
- package/src/server/lib/fs/git-repos.ts +81 -0
- package/src/server/lib/lifecycle/flock.ts +199 -0
- package/src/server/lib/lifecycle/identity.ts +5 -1
- package/src/server/lib/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/port-guard.ts +18 -3
- package/src/server/lib/lifecycle/proc/darwin.ts +52 -17
- package/src/server/lib/lifecycle/proc/linux.ts +29 -7
- package/src/server/lib/lifecycle/proc/ps.ts +7 -0
- package/src/server/lib/lifecycle/proc/types.ts +17 -0
- package/src/server/lib/lifecycle/proc/win32.ts +5 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/src/server/lib/observer-ws.ts +150 -0
- package/src/server/lib/omp/rpc/constants.ts +9 -0
- package/src/server/lib/omp/rpc/subagent-liveness.ts +2 -12
- package/src/server/lib/omp/session/file-scan.ts +7 -1
- package/src/server/lib/omp/session/files.ts +95 -96
- package/src/server/lib/omp/session/locator.ts +18 -8
- package/src/server/lib/omp/session/messages.ts +7 -7
- package/src/server/lib/omp/session/scan.ts +108 -0
- package/src/server/lib/omp/session/stats.ts +50 -50
- package/src/server/lib/omp/session/telemetry/scan.ts +89 -7
- package/src/server/lib/omp/session/telemetry-raw.ts +24 -8
- package/src/server/lib/omp/session/usage/aggregate.ts +170 -55
- package/src/server/lib/terminal/runtime.server.ts +3 -0
- package/src/server/lib/terminal/session.server.ts +30 -3
- package/src/server/routes/agent/command.test.ts +69 -0
- package/src/server/routes/agent/command.ts +14 -0
- package/src/server/routes/agent/ws.test.ts +159 -0
- package/src/server/routes/agent/ws.ts +22 -97
- package/src/server/routes/btw/ws.ts +22 -95
- package/src/server/routes/fs/git.ts +61 -70
- package/src/server/routes/omp/session-stats.ts +1 -1
- package/src/server/routes/telemetry/tokens.ts +6 -4
- package/src/server/routes/terminal/run.ts +4 -8
- package/src/server/routes/terminal/ws.ts +24 -2
- package/src/shared/lib/chat/omp/agent-events.test.ts +33 -1
- package/src/shared/lib/chat/omp/agent-events.ts +10 -0
- package/src/shared/lib/code/language.ts +146 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +2 -1
- package/src/shared/lib/code/syntax-highlight.ts +0 -122
- package/src/shared/lib/fs/diff-parser.test.ts +5 -5
- package/src/shared/lib/fs/diff-parser.ts +67 -42
- package/src/shared/lib/omp/subagent/parse.test.ts +88 -0
- package/src/shared/lib/omp/subagent/parse.ts +17 -0
- package/src/shared/lib/omp/subagent/transcript-client.test.ts +100 -0
- package/src/shared/lib/omp/subagent/transcript-client.ts +17 -1
- package/src/shared/types/omp/subagent.ts +6 -0
- package/dist/client/static/js/index-n4wkqj7t.css +0 -1
- 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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
107
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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 (
|
|
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/
|
|
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
|
-
|
|
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
|
+
});
|