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.
Files changed (100) hide show
  1. package/README.md +24 -5
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/{index-prbmx3yj.js → index-8t70jhyj.js} +643 -617
  5. package/dist/client/static/js/index-n4wkqj7t.css +1 -0
  6. package/package.json +1 -1
  7. package/src/client/components/common/code-editor/handler.test.ts +274 -0
  8. package/src/client/components/common/code-editor/handler.ts +175 -0
  9. package/src/client/components/common/code-editor/index.tsx +185 -220
  10. package/src/client/components/common/code-surface/index.tsx +109 -12
  11. package/src/client/components/common/lazy-panels.tsx +5 -1
  12. package/src/client/components/common/viewport.ts +5 -3
  13. package/src/client/components/layout/RightActivityBar.tsx +2 -1
  14. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +21 -4
  15. package/src/client/components/layout/desktop-layout/index.tsx +24 -4
  16. package/src/client/components/mobile/RightSidebar.tsx +11 -4
  17. package/src/client/components/workspace/browser-panel/AddressBar.tsx +1 -10
  18. package/src/client/components/workspace/browser-panel/Screencast.tsx +4 -5
  19. package/src/client/components/workspace/browser-panel/index.tsx +6 -6
  20. package/src/client/components/workspace/browser-toolbar/index.tsx +22 -12
  21. package/src/client/components/workspace/chat-timeline/ToolCallCard.tsx +1 -1
  22. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Todo.tsx +1 -1
  23. package/src/client/components/workspace/editor/CommandPalette.test.ts +242 -0
  24. package/src/client/components/workspace/editor/CommandPalette.tsx +138 -0
  25. package/src/client/components/workspace/editor/FindWidget.test.ts +261 -0
  26. package/src/client/components/workspace/editor/FindWidget.tsx +219 -0
  27. package/src/client/components/workspace/editor/Toolbar.tsx +19 -1
  28. package/src/client/components/workspace/editor/index.tsx +153 -53
  29. package/src/client/components/workspace/file-explorer/Modals.tsx +79 -2
  30. package/src/client/components/workspace/file-explorer/TreeItem.tsx +43 -113
  31. package/src/client/components/workspace/todo-panel/PhaseGroup.tsx +76 -0
  32. package/src/client/components/workspace/todo-panel/Row.tsx +69 -0
  33. package/src/client/components/workspace/todo-panel/index.tsx +161 -0
  34. package/src/client/hooks/editor/find-harness.test-util.ts +170 -0
  35. package/src/client/hooks/editor/scroll-container.test.ts +102 -0
  36. package/src/client/hooks/editor/scroll-container.ts +26 -0
  37. package/src/client/hooks/editor/use-code-window.ts +2 -20
  38. package/src/client/hooks/editor/use-editor-find-write.test.ts +170 -0
  39. package/src/client/hooks/editor/use-editor-find.test.ts +118 -0
  40. package/src/client/hooks/editor/use-editor-find.ts +321 -0
  41. package/src/client/hooks/editor/use-editor-history.test.ts +216 -0
  42. package/src/client/hooks/editor/use-editor-history.ts +134 -0
  43. package/src/client/hooks/editor/use-occurrences.test.ts +236 -0
  44. package/src/client/hooks/editor/use-occurrences.ts +98 -0
  45. package/src/client/hooks/workspace/file-tree-actions.test.ts +348 -0
  46. package/src/client/hooks/workspace/file-tree-actions.ts +250 -0
  47. package/src/client/hooks/workspace/session-todos.ts +102 -0
  48. package/src/client/tailwind.css +30 -0
  49. package/src/server/lib/omp/session/todos.ts +100 -0
  50. package/src/server/routes/fs/action-create.test.ts +90 -0
  51. package/src/server/routes/fs/action.ts +23 -0
  52. package/src/server/routes/omp/index.ts +2 -0
  53. package/src/server/routes/omp/session-todos.ts +80 -0
  54. package/src/shared/lib/chat/{todo-parser.ts → todo/parser.ts} +17 -0
  55. package/src/shared/lib/chat/todo/progress.ts +97 -0
  56. package/src/shared/lib/chat/todo/snapshot.test.ts +156 -0
  57. package/src/shared/lib/chat/todo/snapshot.ts +215 -0
  58. package/src/shared/lib/code/editor/commands.test.ts +155 -0
  59. package/src/shared/lib/code/editor/commands.ts +260 -0
  60. package/src/shared/lib/code/editor/edits.test.ts +100 -0
  61. package/src/shared/lib/code/editor/edits.ts +122 -0
  62. package/src/shared/lib/code/editor/find-status.test.ts +50 -0
  63. package/src/shared/lib/code/editor/find-status.ts +36 -0
  64. package/src/shared/lib/code/editor/find.test.ts +203 -0
  65. package/src/shared/lib/code/editor/find.ts +224 -0
  66. package/src/{client/components/common/code-editor → shared/lib/code/editor}/history.test.ts +1 -1
  67. package/src/shared/lib/code/editor/keymap.test.ts +109 -0
  68. package/src/shared/lib/code/editor/keymap.ts +151 -0
  69. package/src/shared/lib/code/editor/layers.test.ts +77 -0
  70. package/src/shared/lib/code/editor/layers.ts +67 -0
  71. package/src/shared/lib/code/editor/line-edits.test.ts +131 -0
  72. package/src/shared/lib/code/editor/line-edits.ts +159 -0
  73. package/src/shared/lib/code/editor/lines.test.ts +81 -0
  74. package/src/shared/lib/code/editor/lines.ts +83 -0
  75. package/src/shared/lib/code/editor/marks.test.ts +86 -0
  76. package/src/shared/lib/code/editor/marks.ts +106 -0
  77. package/src/shared/lib/code/editor/occurrences.test.ts +117 -0
  78. package/src/shared/lib/code/editor/occurrences.ts +111 -0
  79. package/src/shared/lib/code/editor/palette.test.ts +91 -0
  80. package/src/shared/lib/code/editor/palette.ts +92 -0
  81. package/src/shared/lib/code/editor/reset-css.test.ts +35 -0
  82. package/src/shared/lib/code/editor/reset-css.ts +32 -0
  83. package/src/shared/lib/code/editor/text-commands.test.ts +178 -0
  84. package/src/shared/lib/code/editor/text-commands.ts +154 -0
  85. package/src/shared/lib/code/syntax-highlight.test.ts +75 -0
  86. package/src/shared/lib/code/syntax-highlight.ts +29 -17
  87. package/src/shared/lib/code/windowed-highlight.ts +52 -1
  88. package/src/shared/lib/ui/key-binding.test.ts +115 -0
  89. package/src/shared/lib/ui/key-binding.ts +143 -0
  90. package/src/shared/lib/util/platform.test.ts +87 -0
  91. package/src/shared/lib/util/platform.ts +30 -0
  92. package/src/shared/lib/workspace/keymap.test.ts +82 -0
  93. package/src/shared/lib/workspace/keymap.ts +30 -0
  94. package/src/shared/lib/workspace/refresh-cadence.ts +9 -0
  95. package/src/shared/lib/workspace/right-panels.ts +8 -0
  96. package/src/shared/lib/workspace/session-state/store.ts +0 -1
  97. package/src/shared/types/index.ts +1 -0
  98. package/src/shared/types/todo.ts +108 -0
  99. package/dist/client/static/js/index-ranztes9.css +0 -1
  100. /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
+ }
@@ -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'),