ompchamber 3.0.0 → 3.1.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 +24 -5
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
- package/dist/client/static/js/index-n4wkqj7t.css +1 -0
- package/package.json +1 -1
- 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/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/{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,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Read one session's todo state from its transcript.
|
|
8
|
+
*
|
|
9
|
+
* The list is omp's, and its only durable home is the session JSONL — there is
|
|
10
|
+
* no separate todo file and no chamber-side copy to fall out of date. So the
|
|
11
|
+
* read is: locate the session file, parse it, and take the deepest committed
|
|
12
|
+
* snapshot on the active branch (`latestTodoSnapshot`).
|
|
13
|
+
*
|
|
14
|
+
* Two properties of this read are deliberate:
|
|
15
|
+
*
|
|
16
|
+
* - **It is a full-file parse, cached by size+mtime.** Measured on real
|
|
17
|
+
* sessions (7.2 MB / 1905 entries) a full parse is ~15 ms, and the todos
|
|
18
|
+
* may sit megabytes from EOF — a `todo` call near the start of a long
|
|
19
|
+
* session stays the live list for the rest of it, so a bounded tail window
|
|
20
|
+
* would report "no todos" for exactly the sessions that have the most. omp
|
|
21
|
+
* flushes each entry as it appends, so a poll sees a new list within the
|
|
22
|
+
* second it was written.
|
|
23
|
+
* - **It never creates an omp process.** The session is read-only here; a
|
|
24
|
+
* panel that spawned a child per poll would fight the chat's own session
|
|
25
|
+
* wrapper for the same session file.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { findSessionFileById } from '@/server/lib/omp/session/locator';
|
|
29
|
+
import { parseJsonlLenient } from '@/shared/lib/omp/session/jsonl';
|
|
30
|
+
import { latestTodoSnapshot, todoProgress } from '@/shared/lib/chat/todo/snapshot';
|
|
31
|
+
import type { SessionTodoState } from '@/shared/types/todo';
|
|
32
|
+
|
|
33
|
+
/** Sessions larger than this are refused rather than parsed; a transcript past
|
|
34
|
+
* it is pathological, and the read runs behind an HTTP request. */
|
|
35
|
+
const MAX_TODO_SESSION_BYTES = 512 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
interface TodoCacheEntry {
|
|
38
|
+
size: number;
|
|
39
|
+
mtimeMs: number;
|
|
40
|
+
state: SessionTodoState;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cache keyed by session id. Hangs off `globalThis` for the same reason the
|
|
45
|
+
* session-file caches do: a `bun --hot` reload re-evaluates modules but keeps
|
|
46
|
+
* `globalThis`, and a cache that vanished on every edit would re-parse a
|
|
47
|
+
* multi-megabyte file per poll during development.
|
|
48
|
+
*/
|
|
49
|
+
interface TodoCacheHost {
|
|
50
|
+
entries: Map<string, TodoCacheEntry>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
declare global {
|
|
54
|
+
// eslint-disable-next-line no-var
|
|
55
|
+
var __ompChamberTodoCache: TodoCacheHost | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function cache(): TodoCacheHost {
|
|
59
|
+
globalThis.__ompChamberTodoCache ??= { entries: new Map() };
|
|
60
|
+
return globalThis.__ompChamberTodoCache;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const EMPTY_TODO_STATE: SessionTodoState = { snapshot: null, progress: todoProgress([]) };
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The todo state of one session. Returns an empty state (never a throw) when
|
|
67
|
+
* the transcript cannot be read: a session whose file is gone is a normal
|
|
68
|
+
* condition for a sidebar entry the user has not opened yet, and the panel
|
|
69
|
+
* reports "no todos" rather than an error for it.
|
|
70
|
+
*/
|
|
71
|
+
export async function readSessionTodos(sessionId: string): Promise<SessionTodoState> {
|
|
72
|
+
const filePath = await findSessionFileById(sessionId);
|
|
73
|
+
if (!filePath) return EMPTY_TODO_STATE;
|
|
74
|
+
|
|
75
|
+
let size: number;
|
|
76
|
+
let mtimeMs: number;
|
|
77
|
+
try {
|
|
78
|
+
const stats = await Bun.file(filePath).stat();
|
|
79
|
+
if (stats.size > MAX_TODO_SESSION_BYTES) return EMPTY_TODO_STATE;
|
|
80
|
+
size = stats.size;
|
|
81
|
+
mtimeMs = stats.mtimeMs;
|
|
82
|
+
} catch {
|
|
83
|
+
return EMPTY_TODO_STATE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const slot = cache().entries.get(sessionId);
|
|
87
|
+
if (slot && slot.size === size && slot.mtimeMs === mtimeMs) return slot.state;
|
|
88
|
+
|
|
89
|
+
let body: string;
|
|
90
|
+
try {
|
|
91
|
+
body = await Bun.file(filePath).text();
|
|
92
|
+
} catch {
|
|
93
|
+
return EMPTY_TODO_STATE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const snapshot = latestTodoSnapshot(parseJsonlLenient<Record<string, unknown>>(body));
|
|
97
|
+
const state: SessionTodoState = { snapshot, progress: todoProgress(snapshot?.phases ?? []) };
|
|
98
|
+
cache().entries.set(sessionId, { size, mtimeMs, state });
|
|
99
|
+
return state;
|
|
100
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Files panel's "New file" branch.
|
|
8
|
+
*
|
|
9
|
+
* Two rules are load-bearing and neither is visible from the happy path: the
|
|
10
|
+
* name may not carry a path separator (the folder is named by `path`, and a
|
|
11
|
+
* separator would turn a name into an arbitrary write under the root), and an
|
|
12
|
+
* existing file is REFUSED rather than truncated. The second one is the one a
|
|
13
|
+
* naive `Bun.write` gets wrong: `Bun.write` creates or truncates, so a name
|
|
14
|
+
* that collides with a real file would silently blank it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import path from 'path';
|
|
20
|
+
import { action } from '@/server/routes/fs/action';
|
|
21
|
+
|
|
22
|
+
/** Probes live in the repo root — the one directory the root allow-list always accepts. */
|
|
23
|
+
const DIR = path.join(process.cwd(), 'tmp-create-probe');
|
|
24
|
+
const NESTED = path.join(DIR, 'sub');
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
fs.rmSync(DIR, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function create(folderPath: string, name: string) {
|
|
31
|
+
const form = new FormData();
|
|
32
|
+
form.append('actionType', 'create');
|
|
33
|
+
form.append('root', process.cwd());
|
|
34
|
+
form.append('path', folderPath);
|
|
35
|
+
form.append('name', name);
|
|
36
|
+
const res = await action({ request: new Request('http://localhost/api/fs/action', { method: 'POST', body: form }) } as never);
|
|
37
|
+
return { status: res.status, body: (await res.json()) as { success?: boolean; path?: string; error?: string } };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('fs create', () => {
|
|
41
|
+
test('creates an empty file in the named folder and reports its relative path', async () => {
|
|
42
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
43
|
+
const { status, body } = await create('tmp-create-probe', 'notes.md');
|
|
44
|
+
|
|
45
|
+
expect(status).toBe(200);
|
|
46
|
+
expect(body).toEqual({ success: true, path: 'tmp-create-probe/notes.md' });
|
|
47
|
+
expect(fs.readFileSync(path.join(DIR, 'notes.md'), 'utf8')).toBe('');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('creates inside a nested folder path, which is how a subfolder is targeted', async () => {
|
|
51
|
+
fs.mkdirSync(NESTED, { recursive: true });
|
|
52
|
+
const { status, body } = await create('tmp-create-probe/sub', 'index.ts');
|
|
53
|
+
|
|
54
|
+
expect(status).toBe(200);
|
|
55
|
+
expect(body.path).toBe('tmp-create-probe/sub/index.ts');
|
|
56
|
+
expect(fs.existsSync(path.join(NESTED, 'index.ts'))).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('refuses an existing name instead of truncating it', async () => {
|
|
60
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
61
|
+
fs.writeFileSync(path.join(DIR, 'keep.txt'), 'important');
|
|
62
|
+
|
|
63
|
+
const { status, body } = await create('tmp-create-probe', 'keep.txt');
|
|
64
|
+
|
|
65
|
+
expect(status).toBe(409);
|
|
66
|
+
expect(body.error).toContain('already exists');
|
|
67
|
+
expect(fs.readFileSync(path.join(DIR, 'keep.txt'), 'utf8')).toBe('important');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('refuses a name carrying a path separator', async () => {
|
|
71
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
72
|
+
for (const name of ['../escape.txt', 'sub/escape.txt', 'sub\\escape.txt']) {
|
|
73
|
+
const { status } = await create('tmp-create-probe', name);
|
|
74
|
+
expect(status).toBe(400);
|
|
75
|
+
}
|
|
76
|
+
expect(fs.existsSync(path.join(process.cwd(), 'escape.txt'))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('refuses an empty or whitespace-only name', async () => {
|
|
80
|
+
fs.mkdirSync(DIR, { recursive: true });
|
|
81
|
+
expect((await create('tmp-create-probe', ' ')).status).toBe(400);
|
|
82
|
+
expect((await create('tmp-create-probe', '.')).status).toBe(400);
|
|
83
|
+
expect((await create('tmp-create-probe', '..')).status).toBe(400);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('refuses a folder that resolves outside the root', async () => {
|
|
87
|
+
const { status } = await create('../outside', 'x.txt');
|
|
88
|
+
expect(status).toBe(403);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -37,6 +37,29 @@ export async function action({ request }: ActionFunctionArgs) {
|
|
|
37
37
|
await fs.promises.mkdir(path.dirname(fullPath), { recursive: true });
|
|
38
38
|
await Bun.write(fullPath, content);
|
|
39
39
|
return json({ success: true });
|
|
40
|
+
} else if (actionType === 'create') {
|
|
41
|
+
// The Files panel's "New file": an empty file beside the folder that was
|
|
42
|
+
// right-clicked. Only a NAME is accepted, never a path — a separator
|
|
43
|
+
// would let the panel write anywhere under the root, and the folder is
|
|
44
|
+
// already named by `path`.
|
|
45
|
+
const name = ((formData.get('name') as string) ?? '').trim();
|
|
46
|
+
if (!name || name === '.' || name === '..' || /[/\\\0]/.test(name)) {
|
|
47
|
+
return json({ error: 'Enter a file name without slashes' }, { status: 400 });
|
|
48
|
+
}
|
|
49
|
+
const target = resolveWithinRoot(scopedRoot, path.join(filePath || '.', name));
|
|
50
|
+
if (!target) {
|
|
51
|
+
return json({ error: 'Invalid path' }, { status: 403 });
|
|
52
|
+
}
|
|
53
|
+
// Creating must never overwrite: a name that already exists is reported,
|
|
54
|
+
// not truncated — the panel offers Rename for that, and an empty write
|
|
55
|
+
// here would destroy the file the user meant to keep.
|
|
56
|
+
if ((await Bun.file(target).stat().catch(() => null)) !== null) {
|
|
57
|
+
return json({ error: `${name} already exists` }, { status: 409 });
|
|
58
|
+
}
|
|
59
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true });
|
|
60
|
+
await Bun.write(target, '');
|
|
61
|
+
// Relative to the LISTED root, which is the identity the tree renders by.
|
|
62
|
+
return json({ success: true, path: path.relative(scopedRoot, target).split(path.sep).join('/') });
|
|
40
63
|
} else if (actionType === 'delete') {
|
|
41
64
|
await fs.promises.rm(fullPath, { recursive: true, force: true });
|
|
42
65
|
return json({ success: true });
|
|
@@ -2,6 +2,7 @@ import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
|
|
|
2
2
|
import * as ompState from '@/server/routes/omp/state';
|
|
3
3
|
import * as ompSidebar from '@/server/routes/omp/sidebar';
|
|
4
4
|
import * as ompSessionStats from '@/server/routes/omp/session-stats';
|
|
5
|
+
import * as ompSessionTodos from '@/server/routes/omp/session-todos';
|
|
5
6
|
import * as ompCommands from '@/server/routes/omp/commands';
|
|
6
7
|
import * as ompExtensions from '@/server/routes/omp/extensions';
|
|
7
8
|
import * as ompLogin from '@/server/routes/omp/login';
|
|
@@ -12,6 +13,7 @@ export const ompBindings: HandlerBinding[] = [
|
|
|
12
13
|
...bindingsFor(ompState, '/api/omp/state'),
|
|
13
14
|
...bindingsFor(ompSidebar, '/api/omp/sidebar'),
|
|
14
15
|
...bindingsFor(ompSessionStats, '/api/omp/session-stats'),
|
|
16
|
+
...bindingsFor(ompSessionTodos, '/api/omp/session-todos'),
|
|
15
17
|
...bindingsFor(ompCommands, '/api/omp/commands'),
|
|
16
18
|
...bindingsFor(ompExtensions, '/api/omp/extensions'),
|
|
17
19
|
...bindingsFor(ompLogin, '/api/omp/login'),
|