ompchamber 3.0.0 → 3.1.1
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 +30 -8
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-5yfre1kw.css +1 -0
- package/dist/client/static/js/{index-prbmx3yj.js → index-qhzsybkn.js} +643 -617
- 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/stop-scope.js +23 -0
- package/src/cli/lib/stop-scope.test.js +41 -0
- package/src/client/components/common/code-editor/handler.test.ts +274 -0
- package/src/client/components/common/code-editor/handler.ts +175 -0
- package/src/client/components/common/code-editor/index.tsx +185 -220
- package/src/client/components/common/code-surface/index.tsx +109 -12
- package/src/client/components/common/lazy-panels.tsx +5 -1
- package/src/client/components/common/viewport.ts +5 -3
- package/src/client/components/layout/RightActivityBar.tsx +2 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
- package/src/client/components/layout/desktop-layout/index.tsx +24 -4
- package/src/client/components/mobile/RightSidebar.tsx +11 -4
- package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
- package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
- package/src/client/components/workspace/browser-panel/index.tsx +6 -6
- package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
- package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
- package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
- package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
- package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
- package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
- package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
- package/src/client/components/workspace/editor/index.tsx +153 -53
- package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
- package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
- package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
- package/src/client/components/workspace/todo-panel/index.tsx +161 -0
- package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
- package/src/client/hooks/editor/scroll-container.test.ts +102 -0
- package/src/client/hooks/editor/scroll-container.ts +26 -0
- package/src/client/hooks/editor/use-code-window.ts +2 -20
- package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
- package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
- package/src/client/hooks/editor/use-editor-find.ts +321 -0
- package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
- package/src/client/hooks/editor/use-editor-history.ts +134 -0
- package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
- package/src/client/hooks/editor/use-occurrences.ts +98 -0
- package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
- package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
- package/src/client/hooks/workspace/session-todos.ts +102 -0
- package/src/client/tailwind.css +30 -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/lifecycle/launch-mode.test.ts +20 -1
- package/src/server/lib/lifecycle/launch-mode.ts +26 -0
- package/src/server/lib/lifecycle/restart.ts +9 -14
- package/src/server/lib/omp/session/todos.ts +100 -0
- package/src/server/routes/fs/action-create.test.ts +90 -0
- package/src/server/routes/fs/action.ts +23 -0
- package/src/server/routes/omp/index.ts +2 -0
- package/src/server/routes/omp/session-todos.ts +80 -0
- package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
- package/src/shared/lib/chat/todo/progress.ts +97 -0
- package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
- package/src/shared/lib/chat/todo/snapshot.ts +215 -0
- package/src/shared/lib/code/editor/commands.test.ts +155 -0
- package/src/shared/lib/code/editor/commands.ts +260 -0
- package/src/shared/lib/code/editor/edits.test.ts +100 -0
- package/src/shared/lib/code/editor/edits.ts +122 -0
- package/src/shared/lib/code/editor/find-status.test.ts +50 -0
- package/src/shared/lib/code/editor/find-status.ts +36 -0
- package/src/shared/lib/code/editor/find.test.ts +203 -0
- package/src/shared/lib/code/editor/find.ts +224 -0
- package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
- package/src/shared/lib/code/editor/keymap.test.ts +109 -0
- package/src/shared/lib/code/editor/keymap.ts +151 -0
- package/src/shared/lib/code/editor/layers.test.ts +77 -0
- package/src/shared/lib/code/editor/layers.ts +67 -0
- package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
- package/src/shared/lib/code/editor/line-edits.ts +159 -0
- package/src/shared/lib/code/editor/lines.test.ts +81 -0
- package/src/shared/lib/code/editor/lines.ts +83 -0
- package/src/shared/lib/code/editor/marks.test.ts +86 -0
- package/src/shared/lib/code/editor/marks.ts +106 -0
- package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
- package/src/shared/lib/code/editor/occurrences.ts +111 -0
- package/src/shared/lib/code/editor/palette.test.ts +91 -0
- package/src/shared/lib/code/editor/palette.ts +92 -0
- package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
- package/src/shared/lib/code/editor/reset-css.ts +32 -0
- package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
- package/src/shared/lib/code/editor/text-commands.ts +154 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
- package/src/shared/lib/code/syntax-highlight.ts +29 -17
- package/src/shared/lib/code/windowed-highlight.ts +52 -1
- package/src/shared/lib/ui/key-binding.test.ts +115 -0
- package/src/shared/lib/ui/key-binding.ts +143 -0
- package/src/shared/lib/util/platform.test.ts +87 -0
- package/src/shared/lib/util/platform.ts +30 -0
- package/src/shared/lib/workspace/keymap.test.ts +82 -0
- package/src/shared/lib/workspace/keymap.ts +30 -0
- package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
- package/src/shared/lib/workspace/right-panels.ts +8 -0
- package/src/shared/lib/workspace/session-state/store.ts +0 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/todo.ts +108 -0
- package/dist/client/static/js/index-ranztes9.css +0 -1
- package/src/server/lib/lifecycle/restart.test.ts +0 -21
- /package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.ts +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Everything a file-tree row does that is not rendering: the context menu's
|
|
8
|
+
* action dispatch, the create/rename/delete dialogs and their server calls.
|
|
9
|
+
*
|
|
10
|
+
* Extracted from the row component because it is a stateful cluster with one
|
|
11
|
+
* shared dependency (`/api/fs/action` and the fetcher that submits to it), and
|
|
12
|
+
* because the row is at the repository's line ceiling. The component keeps the
|
|
13
|
+
* markup and the handlers that need its own DOM node; this keeps the behavior.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
17
|
+
import type { FormEvent, RefObject } from 'preact/compat';
|
|
18
|
+
import { useFetcher } from '@/client/lib/router/fetcher';
|
|
19
|
+
import { toAbsolutePath } from '@/shared/lib/fs/paths';
|
|
20
|
+
import { copyToClipboard } from '@/client/hooks/ui/clipboard';
|
|
21
|
+
|
|
22
|
+
/** The shape `/api/fs/action` answers with, as far as a row reads it. */
|
|
23
|
+
interface FsActionResponse {
|
|
24
|
+
success?: boolean;
|
|
25
|
+
/** Present on the git-history payload, which the history modal reads. */
|
|
26
|
+
type?: string;
|
|
27
|
+
/** Relative path of a freshly created file. */
|
|
28
|
+
path?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FileActionTarget {
|
|
33
|
+
path: string;
|
|
34
|
+
name: string;
|
|
35
|
+
/** Absolute base dir of the listing, anchoring Copy Path. */
|
|
36
|
+
basePath?: string;
|
|
37
|
+
rootPath?: string;
|
|
38
|
+
repo?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface FileActionDeps {
|
|
42
|
+
file: FileActionTarget;
|
|
43
|
+
/** Which diff to open, when the row is a changed file. */
|
|
44
|
+
diff?: { staged?: boolean; status?: string };
|
|
45
|
+
onOpenFile?: (file: { path: string; name: string; root?: string; repo?: string }) => void;
|
|
46
|
+
onActionComplete: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FileActions {
|
|
50
|
+
contextMenu: { x: number; y: number } | null;
|
|
51
|
+
contextMenuRef: RefObject<HTMLDivElement>;
|
|
52
|
+
openContextMenu: (x: number, y: number) => void;
|
|
53
|
+
closeContextMenu: () => void;
|
|
54
|
+
showRenameModal: boolean;
|
|
55
|
+
showDeleteModal: boolean;
|
|
56
|
+
showHistoryModal: boolean;
|
|
57
|
+
showCreateModal: boolean;
|
|
58
|
+
/** The server's refusal for the create request, rendered inside its dialog. */
|
|
59
|
+
createError: string | null;
|
|
60
|
+
createName: string;
|
|
61
|
+
setCreateName: (value: string) => void;
|
|
62
|
+
renameValue: string;
|
|
63
|
+
setRenameValue: (value: string) => void;
|
|
64
|
+
/** True while a request is in flight; every dialog's buttons read it. */
|
|
65
|
+
busy: boolean;
|
|
66
|
+
/** The fetcher's own state/data, for the history modal. */
|
|
67
|
+
fetcherState: string;
|
|
68
|
+
fetcherData: FsActionResponse | undefined;
|
|
69
|
+
handleAction: (actionType: string) => void;
|
|
70
|
+
submitCreate: (e: FormEvent) => void;
|
|
71
|
+
submitRename: (e: FormEvent) => void;
|
|
72
|
+
submitDelete: () => void;
|
|
73
|
+
closeCreate: () => void;
|
|
74
|
+
closeDelete: () => void;
|
|
75
|
+
closeRename: () => void;
|
|
76
|
+
closeHistory: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function useFileActions({ file, diff, onOpenFile, onActionComplete }: FileActionDeps): FileActions {
|
|
80
|
+
const actionFetcher = useFetcher<FsActionResponse>();
|
|
81
|
+
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
82
|
+
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
83
|
+
const [showRenameModal, setShowRenameModal] = useState(false);
|
|
84
|
+
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
|
85
|
+
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
|
86
|
+
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
87
|
+
const [createName, setCreateName] = useState('');
|
|
88
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
89
|
+
const [renameValue, setRenameValue] = useState(file.path);
|
|
90
|
+
/** Which dialog the in-flight request belongs to, so its result is routed. */
|
|
91
|
+
const pendingActionRef = useRef<'create' | null>(null);
|
|
92
|
+
|
|
93
|
+
const repo = file.repo;
|
|
94
|
+
const rootPath = file.rootPath;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The payload already acted on.
|
|
98
|
+
*
|
|
99
|
+
* The fetcher keeps `data` until the next request, and the callbacks below
|
|
100
|
+
* (`onActionComplete` → the panel's refresh → a re-render with a fresh
|
|
101
|
+
* callback identity) would otherwise re-run this effect for the SAME
|
|
102
|
+
* response forever: an unguarded effect here is an infinite refresh loop that
|
|
103
|
+
* pins the tab the moment a file is created.
|
|
104
|
+
*/
|
|
105
|
+
const handledRef = useRef<FsActionResponse | null>(null);
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
if (actionFetcher.state !== 'idle' || !actionFetcher.data) return;
|
|
109
|
+
const data = actionFetcher.data;
|
|
110
|
+
if (handledRef.current === data) return;
|
|
111
|
+
handledRef.current = data;
|
|
112
|
+
// The fetcher parses the body whatever the status, so a refusal arrives as
|
|
113
|
+
// `data.error` rather than as a throw — which is what lets the create
|
|
114
|
+
// dialog stay open and put the reason beside the field.
|
|
115
|
+
if (pendingActionRef.current === 'create') {
|
|
116
|
+
pendingActionRef.current = null;
|
|
117
|
+
if (data.success && data.path) {
|
|
118
|
+
// The user asked for a file to work on, so it opens: closing the dialog
|
|
119
|
+
// and refreshing the tree alone would leave them hunting for the empty
|
|
120
|
+
// row they just created.
|
|
121
|
+
setShowCreateModal(false);
|
|
122
|
+
setCreateName('');
|
|
123
|
+
setCreateError(null);
|
|
124
|
+
onActionComplete();
|
|
125
|
+
onOpenFile?.({
|
|
126
|
+
path: data.path,
|
|
127
|
+
name: data.path.split('/').pop() || data.path,
|
|
128
|
+
root: rootPath,
|
|
129
|
+
repo: repo || '.',
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
setCreateError(data.error || 'Could not create the file');
|
|
133
|
+
}
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (data.success && !data.type) {
|
|
137
|
+
setShowRenameModal(false);
|
|
138
|
+
setShowDeleteModal(false);
|
|
139
|
+
onActionComplete();
|
|
140
|
+
}
|
|
141
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
142
|
+
}, [actionFetcher.state, actionFetcher.data]);
|
|
143
|
+
|
|
144
|
+
const submitAction = (formData: FormData) => {
|
|
145
|
+
if (repo && repo !== '.') formData.append('repo', repo);
|
|
146
|
+
if (rootPath) formData.append('root', rootPath);
|
|
147
|
+
void actionFetcher.submit(formData, { method: 'post', action: '/api/fs/action' });
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const openFile = () => {
|
|
151
|
+
onOpenFile?.({ path: file.path, name: file.name, root: rootPath, repo: repo || '.' });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const handleAction = (actionType: string) => {
|
|
155
|
+
setContextMenu(null);
|
|
156
|
+
if (actionType === 'view') {
|
|
157
|
+
openFile();
|
|
158
|
+
} else if (actionType === 'diff') {
|
|
159
|
+
window.dispatchEvent(new CustomEvent('omp:open-diff', {
|
|
160
|
+
detail: {
|
|
161
|
+
file: file.path,
|
|
162
|
+
staged: diff?.staged,
|
|
163
|
+
status: diff?.status || 'M',
|
|
164
|
+
repo: repo || '.',
|
|
165
|
+
root: rootPath,
|
|
166
|
+
},
|
|
167
|
+
}));
|
|
168
|
+
} else if (actionType === 'explorer') {
|
|
169
|
+
const fd = new FormData();
|
|
170
|
+
fd.append('actionType', 'open_explorer');
|
|
171
|
+
fd.append('path', file.path);
|
|
172
|
+
submitAction(fd);
|
|
173
|
+
} else if (actionType === 'copy_path') {
|
|
174
|
+
void copyToClipboard(toAbsolutePath(file.basePath, file.path));
|
|
175
|
+
} else if (actionType === 'copy_relative') {
|
|
176
|
+
void copyToClipboard(file.path);
|
|
177
|
+
} else if (actionType === 'history') {
|
|
178
|
+
setShowHistoryModal(true);
|
|
179
|
+
const fd = new FormData();
|
|
180
|
+
fd.append('actionType', 'git_history');
|
|
181
|
+
fd.append('path', file.path);
|
|
182
|
+
submitAction(fd);
|
|
183
|
+
} else if (actionType === 'rename') {
|
|
184
|
+
setRenameValue(file.path);
|
|
185
|
+
setShowRenameModal(true);
|
|
186
|
+
} else if (actionType === 'new_file') {
|
|
187
|
+
setCreateName('');
|
|
188
|
+
setCreateError(null);
|
|
189
|
+
setShowCreateModal(true);
|
|
190
|
+
} else if (actionType === 'delete') {
|
|
191
|
+
setShowDeleteModal(true);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const submitCreate = (e: FormEvent) => {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
const name = createName.trim();
|
|
198
|
+
if (!name) return;
|
|
199
|
+
setCreateError(null);
|
|
200
|
+
pendingActionRef.current = 'create';
|
|
201
|
+
const fd = new FormData();
|
|
202
|
+
fd.append('actionType', 'create');
|
|
203
|
+
fd.append('path', file.path);
|
|
204
|
+
fd.append('name', name);
|
|
205
|
+
submitAction(fd);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const submitRename = (e: FormEvent) => {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
const fd = new FormData();
|
|
211
|
+
fd.append('actionType', 'rename');
|
|
212
|
+
fd.append('path', file.path);
|
|
213
|
+
fd.append('newPath', renameValue);
|
|
214
|
+
submitAction(fd);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const submitDelete = () => {
|
|
218
|
+
const fd = new FormData();
|
|
219
|
+
fd.append('actionType', 'delete');
|
|
220
|
+
fd.append('path', file.path);
|
|
221
|
+
submitAction(fd);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
contextMenu,
|
|
226
|
+
contextMenuRef,
|
|
227
|
+
openContextMenu: (x, y) => setContextMenu({ x, y }),
|
|
228
|
+
closeContextMenu: () => setContextMenu(null),
|
|
229
|
+
showRenameModal,
|
|
230
|
+
showDeleteModal,
|
|
231
|
+
showHistoryModal,
|
|
232
|
+
showCreateModal,
|
|
233
|
+
createError,
|
|
234
|
+
createName,
|
|
235
|
+
setCreateName,
|
|
236
|
+
renameValue,
|
|
237
|
+
setRenameValue,
|
|
238
|
+
busy: actionFetcher.state !== 'idle',
|
|
239
|
+
fetcherState: actionFetcher.state,
|
|
240
|
+
fetcherData: actionFetcher.data,
|
|
241
|
+
handleAction,
|
|
242
|
+
submitCreate,
|
|
243
|
+
submitRename,
|
|
244
|
+
submitDelete,
|
|
245
|
+
closeCreate: () => setShowCreateModal(false),
|
|
246
|
+
closeDelete: () => setShowDeleteModal(false),
|
|
247
|
+
closeRename: () => setShowRenameModal(false),
|
|
248
|
+
closeHistory: () => setShowHistoryModal(false),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reads a session's live todo list for the right-panel view.
|
|
8
|
+
*
|
|
9
|
+
* The list belongs to omp and its only durable home is the session transcript,
|
|
10
|
+
* so this hook is a reader: it fetches `GET /api/omp/session-todos` and re-reads
|
|
11
|
+
* on two triggers —
|
|
12
|
+
*
|
|
13
|
+
* - a poll (`usePanelRefresh`, paused while hidden), which covers a list the
|
|
14
|
+
* agent changed while this client was not the one driving it;
|
|
15
|
+
* - `omp:session-updated`, which the chat dispatches at every turn boundary
|
|
16
|
+
* and therefore right after each `todo` tool call, so the panel follows a
|
|
17
|
+
* running agent without waiting out the poll.
|
|
18
|
+
*
|
|
19
|
+
* The event path is throttled: one run emits the signal several times around a
|
|
20
|
+
* single tool call, and the read is a disk parse. A read already in flight is
|
|
21
|
+
* not cancelled by a newer trigger — the server answers an unchanged session
|
|
22
|
+
* from a stat, so the extra request is cheap, and dropping the newer one would
|
|
23
|
+
* leave the panel showing the list from before the call that just finished.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
27
|
+
import { useChamberEvent } from '@/client/hooks/ui/window-event';
|
|
28
|
+
import { usePanelRefresh } from '@/client/hooks/workspace/panel-refresh';
|
|
29
|
+
import { TODO_REFRESH_EVENT_THROTTLE_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
30
|
+
import type { SessionTodosPayload } from '@/shared/types/todo';
|
|
31
|
+
|
|
32
|
+
export interface SessionTodosState {
|
|
33
|
+
data: SessionTodosPayload | null;
|
|
34
|
+
isLoading: boolean;
|
|
35
|
+
/** Set when the last read failed; the previous payload is kept meanwhile. */
|
|
36
|
+
error: string | null;
|
|
37
|
+
reload: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useSessionTodos(sessionId: string | null, enabled: boolean): SessionTodosState {
|
|
41
|
+
const [data, setData] = useState<SessionTodosPayload | null>(null);
|
|
42
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
43
|
+
const [error, setError] = useState<string | null>(null);
|
|
44
|
+
|
|
45
|
+
// Latest session id for the fetcher, so switching sessions does not have to
|
|
46
|
+
// rebuild the callback (and with it the poll interval) on every render.
|
|
47
|
+
const sessionIdRef = useRef(sessionId);
|
|
48
|
+
sessionIdRef.current = sessionId;
|
|
49
|
+
// Guards against a response for a session the user has already left being
|
|
50
|
+
// written into the panel that now shows a different one.
|
|
51
|
+
const requestRef = useRef(0);
|
|
52
|
+
|
|
53
|
+
const load = useCallback(() => {
|
|
54
|
+
const id = sessionIdRef.current;
|
|
55
|
+
if (!id) {
|
|
56
|
+
setData(null);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const request = ++requestRef.current;
|
|
60
|
+
setIsLoading(true);
|
|
61
|
+
fetch(`/api/omp/session-todos?sessionId=${encodeURIComponent(id)}`)
|
|
62
|
+
.then((response) => {
|
|
63
|
+
if (!response.ok) throw new Error(`Todo request failed (${response.status})`);
|
|
64
|
+
return response.json() as Promise<SessionTodosPayload>;
|
|
65
|
+
})
|
|
66
|
+
.then((payload) => {
|
|
67
|
+
if (requestRef.current !== request) return;
|
|
68
|
+
setData(payload);
|
|
69
|
+
setError(null);
|
|
70
|
+
})
|
|
71
|
+
.catch((err: unknown) => {
|
|
72
|
+
if (requestRef.current !== request) return;
|
|
73
|
+
setError(err instanceof Error ? err.message : 'Failed to load todos');
|
|
74
|
+
})
|
|
75
|
+
.finally(() => {
|
|
76
|
+
if (requestRef.current === request) setIsLoading(false);
|
|
77
|
+
});
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
// A session switch must not render the previous session's list under the new
|
|
81
|
+
// title: drop the payload and re-read immediately.
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
setData(null);
|
|
84
|
+
setError(null);
|
|
85
|
+
if (sessionId) load();
|
|
86
|
+
}, [sessionId, load]);
|
|
87
|
+
|
|
88
|
+
usePanelRefresh(load, enabled);
|
|
89
|
+
|
|
90
|
+
const throttleRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
91
|
+
useChamberEvent('omp:session-updated', () => {
|
|
92
|
+
if (!enabled || throttleRef.current !== undefined) return;
|
|
93
|
+
throttleRef.current = setTimeout(() => {
|
|
94
|
+
throttleRef.current = undefined;
|
|
95
|
+
load();
|
|
96
|
+
}, TODO_REFRESH_EVENT_THROTTLE_MS);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
useEffect(() => () => clearTimeout(throttleRef.current), []);
|
|
100
|
+
|
|
101
|
+
return { data, isLoading, error, reload: load };
|
|
102
|
+
}
|
package/src/client/tailwind.css
CHANGED
|
@@ -246,6 +246,36 @@
|
|
|
246
246
|
* The selector keys off the variant attribute, not a list of theme ids. */
|
|
247
247
|
.shiki span { color: var(--shiki-light); }
|
|
248
248
|
html[data-theme-variant="dark"] .shiki span { color: var(--shiki-dark); }
|
|
249
|
+
|
|
250
|
+
/* Find matches, painted inside the token spans the highlighter emits. An
|
|
251
|
+
* inline <mark> colours a background without moving a glyph, which is what
|
|
252
|
+
* keeps the transparent <textarea> above aligned with this layer — a block or
|
|
253
|
+
* a padding would shift the text under the caret. `color: inherit` is required:
|
|
254
|
+
* a <mark> would otherwise take the UA's own black-on-yellow, ignoring the
|
|
255
|
+
* dual-theme token colour it sits inside.
|
|
256
|
+
*
|
|
257
|
+
* No `border-radius`, and the current match is marked with TOP/BOTTOM borders
|
|
258
|
+
* rather than an outline: a match spanning several tokens (`const alpha` is
|
|
259
|
+
* three) is emitted as one <mark> per token, so any per-fragment decoration
|
|
260
|
+
* that draws vertical edges — radius, outline, left/right border — paints a
|
|
261
|
+
* seam in the middle of the match. Horizontal edges join seamlessly. */
|
|
262
|
+
.shiki .find-match {
|
|
263
|
+
color: inherit;
|
|
264
|
+
background: color-mix(in srgb, var(--theme-warning) 32%, transparent);
|
|
265
|
+
}
|
|
266
|
+
/* An extra ⌘D selection: same geometry as a find match, different ink. The
|
|
267
|
+
* caret can only live in the primary range, so the two must not look alike —
|
|
268
|
+
* the user has to see where their keystrokes will land. */
|
|
269
|
+
.shiki .find-match[data-occurrence] {
|
|
270
|
+
background: color-mix(in srgb, var(--theme-info) 30%, transparent);
|
|
271
|
+
border-top: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
|
|
272
|
+
border-bottom: 1px solid color-mix(in srgb, var(--theme-info) 55%, transparent);
|
|
273
|
+
}
|
|
274
|
+
.shiki .find-match[data-find-current] {
|
|
275
|
+
background: color-mix(in srgb, var(--theme-warning) 55%, transparent);
|
|
276
|
+
border-top: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
|
|
277
|
+
border-bottom: 1px solid color-mix(in srgb, var(--theme-warning) 75%, transparent);
|
|
278
|
+
}
|
|
249
279
|
.prose-content .katex-display { overflow-x: auto; overflow-y: hidden; }
|
|
250
280
|
|
|
251
281
|
/* Math placeholder: `marked.ts` emits a `.math-pending` span and KaTeX is
|
|
@@ -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
|
+
});
|