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
@@ -1,14 +1,11 @@
1
- import { useEffect, useRef, useState } from 'preact/hooks';
2
- import type { FormEvent } from 'preact/compat';
1
+ import { useEffect, useState } from 'preact/hooks';
3
2
  import { ChevronDown, ChevronRight } from 'lucide-preact';
4
- import { useFetcher } from '@/client/lib/router/fetcher';
5
3
  import { FileIcon } from '@/client/components/common/file-icon';
6
- import { FileContextMenu, FileDeleteModal, FileHistoryModal, FileRenameModal } from '@/client/components/workspace/file-explorer/Modals';
4
+ import { FileContextMenu, FileCreateModal, FileDeleteModal, FileHistoryModal, FileRenameModal } from '@/client/components/workspace/file-explorer/Modals';
7
5
  import { getGitStatusInfo, type FolderGitStatusInfo } from '@/shared/lib/fs/git-status';
8
- import { toAbsolutePath } from '@/shared/lib/fs/paths';
9
- import { copyToClipboard } from '@/client/hooks/ui/clipboard';
10
- import type { GitChange } from '@/shared/types/git';
6
+ import { useFileActions } from '@/client/hooks/workspace/file-tree-actions';
11
7
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
8
+ import type { GitChange } from '@/shared/types/git';
12
9
 
13
10
  interface FileTreeItemProps {
14
11
  file: any;
@@ -41,16 +38,8 @@ export function FileTreeItem({
41
38
  const isFolder = file.type === 'folder';
42
39
  const [isOpen, setIsOpen] = useState(false);
43
40
  const [isLoadingChildren, setIsLoadingChildren] = useState(false);
44
- const actionFetcher = useFetcher<any>();
41
+ /** Portals need a real document; the first client render is the gate. */
45
42
  const [mounted, setMounted] = useState(false);
46
-
47
- const [contextMenu, setContextMenu] = useState<{ x: number, y: number } | null>(null);
48
- const contextMenuRef = useRef<HTMLDivElement>(null);
49
- const [showRenameModal, setShowRenameModal] = useState(false);
50
- const [showDeleteModal, setShowDeleteModal] = useState(false);
51
- const [showHistoryModal, setShowHistoryModal] = useState(false);
52
- const [renameValue, setRenameValue] = useState(file.path);
53
-
54
43
  const children = Array.isArray(file.children) ? file.children : [];
55
44
 
56
45
  const normalizedPath = (file.path || '').replace(/^\/+/, '');
@@ -80,19 +69,13 @@ export function FileTreeItem({
80
69
  }
81
70
  }, [isFolder, actualIsOpen, file.children, file.path, onLoadChildren]);
82
71
 
83
- useOnClickOutside(contextMenuRef, () => setContextMenu(null));
84
-
85
- useEffect(() => {
86
- if (actionFetcher.state === 'idle' && actionFetcher.data) {
87
- if (actionFetcher.data.success) {
88
- if (!actionFetcher.data.type) {
89
- setShowRenameModal(false);
90
- setShowDeleteModal(false);
91
- onActionComplete();
92
- }
93
- }
94
- }
95
- }, [actionFetcher.state, actionFetcher.data]);
72
+ const actions = useFileActions({
73
+ file: { path: file.path, name: file.name, basePath, rootPath, repo },
74
+ diff: { staged: gitChange?.staged || (gitChange?.status ? gitChange.status[0] !== ' ' && gitChange.status[0] !== '?' : false), status: gitStatusInfo?.charStatus },
75
+ onOpenFile,
76
+ onActionComplete,
77
+ });
78
+ useOnClickOutside(actions.contextMenuRef, actions.closeContextMenu);
96
79
 
97
80
  const handleToggle = (e: globalThis.MouseEvent) => {
98
81
  e.stopPropagation();
@@ -121,72 +104,7 @@ export function FileTreeItem({
121
104
  const handleContextMenu = (e: globalThis.MouseEvent) => {
122
105
  e.preventDefault();
123
106
  e.stopPropagation();
124
- setContextMenu({ x: e.clientX, y: e.clientY });
125
- };
126
-
127
- const submitAction = (formData: FormData) => {
128
- if (repo && repo !== '.') formData.append('repo', repo);
129
- if (rootPath) formData.append('root', rootPath);
130
- actionFetcher.submit(formData, { method: 'post', action: '/api/fs/action' });
131
- };
132
-
133
- const handleAction = (actionType: string) => {
134
- setContextMenu(null);
135
- if (actionType === 'view') {
136
- if (onOpenFile) {
137
- onOpenFile({
138
- ...file,
139
- root: rootPath,
140
- repo: repo || '.',
141
- });
142
- }
143
- } else if (actionType === 'diff') {
144
- window.dispatchEvent(new CustomEvent('omp:open-diff', {
145
- detail: {
146
- file: file.path,
147
- staged: gitChange?.staged || (gitChange?.status && gitChange.status[0] !== ' ' && gitChange.status[0] !== '?'),
148
- status: gitStatusInfo?.charStatus || 'M',
149
- repo: repo || '.',
150
- root: rootPath,
151
- }
152
- }));
153
- } else if (actionType === 'explorer') {
154
- const fd = new FormData();
155
- fd.append('actionType', 'open_explorer');
156
- fd.append('path', file.path);
157
- submitAction(fd);
158
- } else if (actionType === 'copy_path') {
159
- void copyToClipboard(toAbsolutePath(basePath, file.path));
160
- } else if (actionType === 'copy_relative') {
161
- void copyToClipboard(file.path);
162
- } else if (actionType === 'history') {
163
- setShowHistoryModal(true);
164
- const fd = new FormData();
165
- fd.append('actionType', 'git_history');
166
- fd.append('path', file.path);
167
- submitAction(fd);
168
- } else if (actionType === 'rename') {
169
- setRenameValue(file.path);
170
- setShowRenameModal(true);
171
- } else if (actionType === 'delete') {
172
- setShowDeleteModal(true);
173
- }
174
- };
175
-
176
- const submitRename = (e: FormEvent) => {
177
- e.preventDefault();
178
- const fd = new FormData();
179
- fd.append('actionType', 'rename');
180
- fd.append('path', file.path);
181
- fd.append('newPath', renameValue);
182
- submitAction(fd);
183
- };
184
-
185
- const submitDelete = () => {
186
- const fd = new FormData();
187
- fd.append('actionType', 'delete');
188
- fd.append('path', file.path);
189
- submitAction(fd);
107
+ actions.openContextMenu(e.clientX, e.clientY);
190
108
  };
191
109
 
192
110
  return (
@@ -255,44 +173,56 @@ export function FileTreeItem({
255
173
  </div>
256
174
  )}
257
175
 
258
- {mounted && contextMenu && (
176
+ {mounted && actions.contextMenu && (
259
177
  <FileContextMenu
260
- x={contextMenu.x}
261
- y={contextMenu.y}
178
+ x={actions.contextMenu.x}
179
+ y={actions.contextMenu.y}
262
180
  isFolder={isFolder}
263
181
  hasGitStatus={hasGitStatus}
264
- onAction={handleAction}
182
+ onAction={actions.handleAction}
183
+ />
184
+ )}
185
+
186
+ {mounted && actions.showCreateModal && (
187
+ <FileCreateModal
188
+ folderPath={file.path}
189
+ value={actions.createName}
190
+ error={actions.createError}
191
+ isLoading={actions.busy}
192
+ onChange={actions.setCreateName}
193
+ onCancel={actions.closeCreate}
194
+ onSubmit={actions.submitCreate}
265
195
  />
266
196
  )}
267
197
 
268
- {mounted && showDeleteModal && (
198
+ {mounted && actions.showDeleteModal && (
269
199
  <FileDeleteModal
270
200
  fileName={file.name}
271
201
  isFolder={isFolder}
272
- isLoading={actionFetcher.state !== 'idle'}
273
- onCancel={() => setShowDeleteModal(false)}
274
- onConfirm={submitDelete}
202
+ isLoading={actions.busy}
203
+ onCancel={actions.closeDelete}
204
+ onConfirm={actions.submitDelete}
275
205
  />
276
206
  )}
277
207
 
278
- {mounted && showRenameModal && (
208
+ {mounted && actions.showRenameModal && (
279
209
  <FileRenameModal
280
210
  isFolder={isFolder}
281
- renameValue={renameValue}
211
+ renameValue={actions.renameValue}
282
212
  originalPath={file.path}
283
- isLoading={actionFetcher.state !== 'idle'}
284
- onChange={setRenameValue}
285
- onCancel={() => setShowRenameModal(false)}
286
- onSubmit={submitRename}
213
+ isLoading={actions.busy}
214
+ onChange={actions.setRenameValue}
215
+ onCancel={actions.closeRename}
216
+ onSubmit={actions.submitRename}
287
217
  />
288
218
  )}
289
219
 
290
- {mounted && showHistoryModal && (
220
+ {mounted && actions.showHistoryModal && (
291
221
  <FileHistoryModal
292
222
  filePath={file.path}
293
- fetcherState={actionFetcher.state}
294
- fetcherData={actionFetcher.data}
295
- onClose={() => setShowHistoryModal(false)}
223
+ fetcherState={actions.fetcherState}
224
+ fetcherData={actions.fetcherData}
225
+ onClose={actions.closeHistory}
296
226
  />
297
227
  )}
298
228
  </div>
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One phase group of the todo panel: a header with the phase's own `closed/total`
8
+ * badge, then its task rows.
9
+ *
10
+ * The collapse state is the panel's (persisted per session), not local: a list
11
+ * the operator collapsed should stay collapsed across a reload, and the panel
12
+ * owns every phase's flag in one map so "collapse all" is one write.
13
+ */
14
+
15
+ import { ChevronDown, ChevronRight } from 'lucide-preact';
16
+ import type { TodoPhase } from '@/shared/types/todo';
17
+ import { closedTaskCount } from '@/shared/lib/chat/todo/progress';
18
+ import { TodoRow } from '@/client/components/workspace/todo-panel/Row';
19
+
20
+ interface TodoPhaseGroupProps {
21
+ phase: TodoPhase;
22
+ index: number;
23
+ collapsed: boolean;
24
+ /** `phase:task` of the row to highlight as current, or null. */
25
+ currentKey: string | null;
26
+ onToggle: () => void;
27
+ }
28
+
29
+ export function TodoPhaseGroup({ phase, index, collapsed, currentKey, onToggle }: TodoPhaseGroupProps) {
30
+ const closed = closedTaskCount(phase);
31
+ const isComplete = phase.tasks.length > 0 && closed === phase.tasks.length;
32
+
33
+ return (
34
+ <div className="overflow-hidden rounded-lg border border-ink/10 bg-paper">
35
+ <button
36
+ type="button"
37
+ onClick={onToggle}
38
+ aria-expanded={!collapsed}
39
+ className="flex w-full items-center gap-1.5 border-b border-ink/8 bg-canvas/40 px-2.5 py-1.5 text-left hover:bg-ink/5"
40
+ >
41
+ {collapsed ? (
42
+ <ChevronRight size={12} className="shrink-0 text-ink/40" />
43
+ ) : (
44
+ <ChevronDown size={12} className="shrink-0 text-ink/40" />
45
+ )}
46
+ <span
47
+ className={`min-w-0 flex-1 truncate text-[10px] font-semibold uppercase tracking-wider ${
48
+ isComplete ? 'text-ink/35' : 'text-ink/60'
49
+ }`}
50
+ title={phase.name}
51
+ >
52
+ {index + 1}. {phase.name}
53
+ </span>
54
+ <span className="shrink-0 font-mono text-[9.5px] text-ink/40">
55
+ {closed}/{phase.tasks.length}
56
+ </span>
57
+ </button>
58
+
59
+ {!collapsed && (
60
+ <div className="divide-y divide-ink/[0.05] p-0.5">
61
+ {phase.tasks.length === 0 ? (
62
+ <div className="px-2.5 py-1.5 text-[11px] text-ink/40">No tasks in this phase</div>
63
+ ) : (
64
+ phase.tasks.map((task, taskIndex) => (
65
+ <TodoRow
66
+ key={`${taskIndex}-${task.content}`}
67
+ task={task}
68
+ current={currentKey === `${index}:${taskIndex}`}
69
+ />
70
+ ))
71
+ )}
72
+ </div>
73
+ )}
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One task row of the todo panel.
8
+ *
9
+ * The five statuses render through one component because they are one axis of
10
+ * the same row — icon, tone, and whether the text is struck through — and
11
+ * splitting them across branches is how a `blocked` task ends up drawn as an
12
+ * ordinary pending one. A blocked task also carries its own note (`blocker`),
13
+ * which is the only place omp records WHY it is waiting.
14
+ */
15
+
16
+ import { Ban, CheckCircle2, Circle, Loader2, OctagonAlert } from 'lucide-preact';
17
+ import type { TodoItem } from '@/shared/types/todo';
18
+ import { taskNote, todoStatusVisual } from '@/shared/lib/chat/todo/progress';
19
+
20
+ function StatusIcon({ status, className }: { status: TodoItem['status']; className: string }) {
21
+ switch (status) {
22
+ case 'completed':
23
+ return <CheckCircle2 size={13} className={className} />;
24
+ case 'in_progress':
25
+ return <Loader2 size={13} className={`${className} animate-spin`} />;
26
+ case 'abandoned':
27
+ return <Ban size={13} className={className} />;
28
+ case 'blocked':
29
+ return <OctagonAlert size={13} className={className} />;
30
+ default:
31
+ return <Circle size={13} className={className} />;
32
+ }
33
+ }
34
+
35
+ export function TodoRow({ task, current }: { task: TodoItem; current: boolean }) {
36
+ const visual = todoStatusVisual(task.status);
37
+ const note = taskNote(task);
38
+
39
+ return (
40
+ <div
41
+ className={`flex items-start gap-2.5 px-2.5 py-1.5 transition-colors ${
42
+ current ? 'bg-ink/[0.04]' : ''
43
+ }`}
44
+ >
45
+ <StatusIcon status={task.status} className={`mt-[2px] shrink-0 ${visual.tone}`} />
46
+ <div className="min-w-0 flex-1">
47
+ <div
48
+ className={`text-[11.5px] leading-snug ${
49
+ visual.closed
50
+ ? 'text-ink/40 line-through'
51
+ : visual.active
52
+ ? 'font-medium text-ink'
53
+ : 'text-ink/75'
54
+ }`}
55
+ >
56
+ {task.content}
57
+ </div>
58
+ {note && (
59
+ <div className="mt-0.5 text-[10.5px] leading-snug text-warning/90 break-words">{note}</div>
60
+ )}
61
+ </div>
62
+ {visual.active && (
63
+ <span className="shrink-0 rounded bg-ink/8 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-ink/70">
64
+ Now
65
+ </span>
66
+ )}
67
+ </div>
68
+ );
69
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Right-panel Todo view: the session's live todo list, as oh-my-pi itself
8
+ * would resume with it.
9
+ *
10
+ * The list is omp's, read from the session transcript (see
11
+ * `@/server/lib/omp/session/todos`), so this panel adds presentation only —
12
+ * phase groups, a progress bar, and the current task called out. It deliberately
13
+ * does NOT recompute or cache a list of its own: a chamber-side copy would drift
14
+ * from the one the agent rehydrates, and a plan panel that disagrees with the
15
+ * agent is worse than no panel.
16
+ *
17
+ * Empty states are distinguished, because they mean different things: no
18
+ * session selected, a session whose transcript has no todo snapshot at all
19
+ * (the agent never used the tool here), and a snapshot whose every task is
20
+ * closed.
21
+ */
22
+
23
+ import { useMemo } from 'preact/hooks';
24
+ import { AlertCircle, ListTodo, RefreshCw } from 'lucide-preact';
25
+ import { useSearchParams } from '@/client/lib/router/search-params';
26
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
27
+ import { useSessionTodos } from '@/client/hooks/workspace/session-todos';
28
+ import { TodoPhaseGroup } from '@/client/components/workspace/todo-panel/PhaseGroup';
29
+ import { currentTaskLocation, todoProgressLabel, todoProgressPercent } from '@/shared/lib/chat/todo/progress';
30
+
31
+ interface TodoPanelProps {
32
+ className?: string;
33
+ /**
34
+ * False while the view is not the one on screen: pauses the poll and the
35
+ * event-driven re-read.
36
+ *
37
+ * Deliberately NOT the `enabled` gate the workspace-scoped panels take. That
38
+ * flag means "the active session's cwd resolves to a registered workspace
39
+ * folder", which files/search/git/terminal genuinely need. A todo list does
40
+ * not: it lives in the session's own transcript, so a session running outside
41
+ * every registered workspace still has one — gating on the workspace showed
42
+ * "No session selected" for a session that was open and had a full list.
43
+ */
44
+ active?: boolean;
45
+ }
46
+
47
+ export function TodoPanel({ className = '', active = true }: TodoPanelProps) {
48
+ const [searchParams] = useSearchParams();
49
+ const sessionId = searchParams.get('sessionId');
50
+ const [collapsedPhases, setCollapsedPhases] = useSessionState<Record<string, boolean>>(
51
+ 'todo.collapsedPhases',
52
+ {},
53
+ );
54
+ const { data, isLoading, error, reload } = useSessionTodos(sessionId, active);
55
+
56
+ const snapshot = data?.snapshot ?? null;
57
+ const progress = data?.progress ?? null;
58
+
59
+ // `phase:task` of the row to highlight. Derived from the snapshot rather than
60
+ // stored, so it follows the list as it moves.
61
+ const currentKey = useMemo(() => {
62
+ const location = currentTaskLocation(snapshot?.phases ?? []);
63
+ return location ? `${location.phase}:${location.task}` : null;
64
+ }, [snapshot]);
65
+
66
+ if (!sessionId) {
67
+ return (
68
+ <div className={`flex h-full flex-col items-center justify-center bg-paper text-ink/40 ${className}`}>
69
+ <span className="text-xs font-mono">No session selected</span>
70
+ </div>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div className={`flex h-full w-full min-w-0 flex-col overflow-hidden bg-paper text-ink ${className}`}>
76
+ <div className="flex flex-shrink-0 items-center justify-between gap-3 border-b border-ink/10 px-3.5 py-2.5">
77
+ <div className="flex min-w-0 items-center gap-2">
78
+ <h2 className="text-xs font-semibold uppercase tracking-wider text-ink">Todos</h2>
79
+ {snapshot && (
80
+ <span className="truncate font-mono text-[10.5px] text-ink/50" title={todoProgressLabel(progress!)}>
81
+ {todoProgressLabel(progress!)}
82
+ </span>
83
+ )}
84
+ {data?.isMock && (
85
+ <span className="flex-shrink-0 rounded border border-ink/15 bg-ink/5 px-1.5 py-0.5 font-mono text-[10px] uppercase tracking-wider text-ink/60">
86
+ Mock
87
+ </span>
88
+ )}
89
+ </div>
90
+ <button
91
+ type="button"
92
+ onClick={reload}
93
+ disabled={isLoading}
94
+ title="Refresh todos"
95
+ aria-label="Refresh todos"
96
+ className="flex-shrink-0 rounded-md border border-ink/15 bg-paper p-1.5 text-ink/80 transition-colors hover:bg-ink/5 disabled:opacity-50"
97
+ >
98
+ <RefreshCw size={12} className={isLoading ? 'animate-spin text-ink' : ''} />
99
+ </button>
100
+ </div>
101
+
102
+ {progress && progress.total > 0 && (
103
+ <div className="flex-shrink-0 border-b border-ink/10 px-3.5 py-2.5">
104
+ <div className="h-1.5 overflow-hidden rounded-full bg-ink/8">
105
+ <div
106
+ className="h-full rounded-full bg-success transition-all duration-300"
107
+ style={{ width: `${todoProgressPercent(progress)}%` }}
108
+ />
109
+ </div>
110
+ </div>
111
+ )}
112
+
113
+ <div className="min-h-0 flex-1 space-y-2 overflow-y-auto scrollbar-overlay-container scrollbar-overlay-static p-3.5">
114
+ {error && (
115
+ <div className="flex items-start gap-2 rounded-lg border border-error/30 bg-error/5 px-3 py-2 text-[11px] text-error">
116
+ <AlertCircle size={13} className="mt-[1px] shrink-0" />
117
+ <span className="min-w-0 break-words">{error}</span>
118
+ </div>
119
+ )}
120
+
121
+ {!snapshot ? (
122
+ <div className="flex flex-col items-center justify-center gap-2 py-12 text-center text-ink/40">
123
+ <ListTodo size={20} />
124
+ <p className="max-w-[240px] text-[11.5px] leading-relaxed">
125
+ No todo list in this session. The agent writes one with the <span className="font-mono">todo</span> tool
126
+ when a task is worth planning.
127
+ </p>
128
+ </div>
129
+ ) : (
130
+ <>
131
+ {snapshot.phases.map((phase, index) => (
132
+ <TodoPhaseGroup
133
+ key={`${index}-${phase.name}`}
134
+ phase={phase}
135
+ index={index}
136
+ collapsed={Boolean(collapsedPhases[String(index)])}
137
+ currentKey={currentKey}
138
+ onToggle={() =>
139
+ setCollapsedPhases((prev) => ({ ...prev, [String(index)]: !prev[String(index)] }))
140
+ }
141
+ />
142
+ ))}
143
+
144
+ <p className="pt-1 text-[10.5px] leading-relaxed text-ink/35">
145
+ Read from this session&apos;s transcript
146
+ {snapshot.updatedAt ? ` · updated ${formatStamp(snapshot.updatedAt)}` : ''}
147
+ {snapshot.source === 'custom' ? ' · edited by hand' : ''}.
148
+ </p>
149
+ </>
150
+ )}
151
+ </div>
152
+ </div>
153
+ );
154
+ }
155
+
156
+ /** Local time of a snapshot stamp; the raw ISO string when it cannot be read. */
157
+ function formatStamp(value: string): string {
158
+ const date = new Date(value);
159
+ if (Number.isNaN(date.getTime())) return value;
160
+ return date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
161
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The harness `use-editor-find` is exercised through.
8
+ *
9
+ * Split out because the find hook has two test files (opening and stepping /
10
+ * writing and options) and both must render it the same way — the session
11
+ * provider it needs, the surface calls it makes, and a fresh session id per test
12
+ * so the PERSISTED query does not leak between them.
13
+ *
14
+ * The hook's modules are imported dynamically, after the DOM globals exist:
15
+ * Preact binds its environment at evaluation time, and a static import would
16
+ * capture the DOM-less one.
17
+ */
18
+
19
+ import { afterAll, afterEach, beforeAll, beforeEach } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import type { ComponentChildren, h as PreactH, render as PreactRender } from 'preact';
22
+ import type { act as PreactAct } from 'preact/test-utils';
23
+ import type { EditorFindState, useEditorFind as UseEditorFindHook } from '@/client/hooks/editor/use-editor-find';
24
+
25
+ export interface FindHarness {
26
+ /** The latest render's state; re-read after every settle, never held across one. */
27
+ readonly find: EditorFindState | null;
28
+ /** What the hook under test is handed — mutated per test, then re-rendered. */
29
+ readonly input: {
30
+ text: string;
31
+ documentKey: string;
32
+ selection: { start: number; end: number } | null;
33
+ };
34
+ /** Surface calls, so "did it select / reveal / write" is observable. */
35
+ readonly selections: Array<[number, number, boolean]>;
36
+ readonly reveals: number[];
37
+ readonly writes: Array<{ value: string; caretStart: number }>;
38
+ /** Re-render with the current `input` and settle. */
39
+ rerender: () => Promise<void>;
40
+ /** Flush pending effects and renders. */
41
+ flush: () => Promise<void>;
42
+ /**
43
+ * Preact's `act`, exposed because every interaction in these tests is a hook
44
+ * call the DOM never sees: it has to be wrapped to be flushed.
45
+ */
46
+ act: typeof PreactAct;
47
+ /** Register the before/after hooks this harness needs. */
48
+ install: () => void;
49
+ }
50
+
51
+ export async function createFindHarness(): Promise<FindHarness> {
52
+ let useEditorFind: typeof UseEditorFindHook;
53
+ let SessionStateContext: { Provider: (props: { value: { sessionId: string; ready: boolean }; children: ComponentChildren }) => ComponentChildren };
54
+ let h: typeof PreactH;
55
+ let render: typeof PreactRender;
56
+ let act: typeof PreactAct;
57
+
58
+ let find: EditorFindState | null = null;
59
+ let container: HTMLElement;
60
+ let sessionId = 'test-session';
61
+ let sessionCounter = 0;
62
+
63
+ const input: FindHarness['input'] = { text: 'alpha beta alpha', documentKey: 'file-1', selection: { start: 0, end: 5 } };
64
+ const selections: FindHarness['selections'] = [];
65
+ const reveals: number[] = [];
66
+ const writes: FindHarness['writes'] = [];
67
+
68
+ function Inner() {
69
+ find = useEditorFind({
70
+ text: input.text,
71
+ documentKey: input.documentKey,
72
+ readSelection: () => input.selection,
73
+ select: (start, end, focus = true) => selections.push([start, end, focus]),
74
+ reveal: (offset) => reveals.push(offset),
75
+ applyDocument: (value, caretStart) => writes.push({ value, caretStart }),
76
+ });
77
+ return null;
78
+ }
79
+
80
+ function Harness() {
81
+ // The hook persists the query and the toggles per session, so it needs the
82
+ // provider the panel supplies. A test session id is all it reads.
83
+ return h(SessionStateContext.Provider, { value: { sessionId, ready: true }, children: h(Inner, {}) });
84
+ }
85
+
86
+ async function flush() {
87
+ for (let i = 0; i < 3; i++) {
88
+ await act(async () => {
89
+ for (let turn = 0; turn < 4; turn++) await Promise.resolve();
90
+ });
91
+ }
92
+ }
93
+
94
+ async function rerender() {
95
+ await act(async () => {
96
+ render(h(Harness, {}), container);
97
+ });
98
+ await flush();
99
+ }
100
+
101
+ const domGlobals = (win: Window): Record<string, unknown> => ({
102
+ window: win,
103
+ document: win.document,
104
+ navigator: win.navigator,
105
+ Node: win.Node,
106
+ Element: win.Element,
107
+ HTMLElement: win.HTMLElement,
108
+ });
109
+
110
+ const installed: Record<string, unknown> = {};
111
+ const displaced: Record<string, unknown> = {};
112
+
113
+ return {
114
+ get find() {
115
+ return find;
116
+ },
117
+ input,
118
+ selections,
119
+ reveals,
120
+ writes,
121
+ rerender,
122
+ flush,
123
+ get act() {
124
+ return act;
125
+ },
126
+ install() {
127
+ beforeAll(async () => {
128
+ const win = new Window({ url: 'http://localhost' });
129
+ for (const [key, value] of Object.entries(domGlobals(win))) {
130
+ displaced[key] = (globalThis as Record<string, unknown>)[key];
131
+ installed[key] = value;
132
+ (globalThis as Record<string, unknown>)[key] = value;
133
+ }
134
+ ({ useEditorFind } = await import('@/client/hooks/editor/use-editor-find'));
135
+ ({ SessionStateContext } = await import('@/client/hooks/workspace/session-state/context'));
136
+ ({ h, render } = await import('preact'));
137
+ ({ act } = await import('preact/test-utils'));
138
+ });
139
+
140
+ afterAll(() => {
141
+ for (const key of Object.keys(installed)) {
142
+ if (displaced[key] === undefined) delete (globalThis as Record<string, unknown>)[key];
143
+ else (globalThis as Record<string, unknown>)[key] = displaced[key];
144
+ }
145
+ });
146
+
147
+ beforeEach(async () => {
148
+ find = null;
149
+ // The query and the toggles are persisted per session — that is the
150
+ // feature — so each test gets its OWN session id rather than sharing a
151
+ // blob with the test before it.
152
+ sessionId = `test-session-${++sessionCounter}`;
153
+ input.text = 'alpha beta alpha';
154
+ input.documentKey = 'file-1';
155
+ input.selection = { start: 0, end: 5 };
156
+ selections.length = 0;
157
+ reveals.length = 0;
158
+ writes.length = 0;
159
+ container = document.createElement('div');
160
+ document.body.appendChild(container);
161
+ await rerender();
162
+ });
163
+
164
+ afterEach(() => {
165
+ render(null, container);
166
+ container.remove();
167
+ });
168
+ },
169
+ };
170
+ }