@kabmo-labs/kabmo 0.7.0 → 0.8.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 (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. package/dist/client/static/js/index-nzyjf92m.css +0 -1
@@ -1,7 +1,6 @@
1
1
  import { useEffect, useRef, useState } from 'preact/hooks';
2
- import { RefreshCw, Search } from 'lucide-preact';
3
2
  import type { FsNode } from '@/shared/types';
4
- import { GitRepoPicker } from '@/client/components/workspace/repo-picker';
3
+ import { FileExplorerToolbar } from '@/client/components/workspace/file-explorer/Toolbar';
5
4
  import { FileTreeItem } from '@/client/components/workspace/file-explorer/TreeItem';
6
5
  import { RootContextMenu } from '@/client/components/workspace/file-explorer/RootMenu';
7
6
  import { FileCreateModal } from '@/client/components/workspace/file-explorer/Modals';
@@ -11,9 +10,12 @@ import { useGitStatus } from '@/client/hooks/workspace/git-status';
11
10
  import { useFileListing } from '@/client/hooks/workspace/file-listing';
12
11
  import { useFileCreate } from '@/client/hooks/workspace/file-create';
13
12
  import { useFileClipboard, type ClipboardTarget } from '@/client/hooks/workspace/file-clipboard';
13
+ import { rekeyExpansion } from '@/shared/lib/fs/file-tree';
14
+ import { filterFileTree } from '@/client/components/workspace/file-explorer/filter';
14
15
  import { useRepoList, useRepoScope } from '@/client/hooks/workspace/repo-scope';
15
16
  import { useRealtimeTopic } from '@/client/hooks/ui/realtime';
16
17
  import { useToasts } from '@/client/hooks/ui/toasts';
18
+ import { useTouchDevice } from '@/client/hooks/ui/touch-device';
17
19
  import { ToastStack } from '@/client/components/common/ToastStack';
18
20
  import { fsTopic } from '@/shared/lib/realtime/protocol';
19
21
 
@@ -98,6 +100,53 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
98
100
  setActiveRepo(repo);
99
101
  };
100
102
 
103
+ /**
104
+ * Open every folder that leads to `path`.
105
+ *
106
+ * A nested create (`lib/parse.ts`) and a rename into a new folder both make
107
+ * directories the user has never opened, and a row placed inside a collapsed
108
+ * folder is a row nobody can see — the write would look like it failed. EVERY
109
+ * ancestor is opened, not only the direct parent: expanding just the last one
110
+ * still hides the entry behind a collapsed `lib`.
111
+ */
112
+ const revealPath = (path: string) => {
113
+ const segments = path.split('/').slice(0, -1);
114
+ if (segments.length === 0) return;
115
+ const chain = segments.map((_, index) => segments.slice(0, index + 1).join('/'));
116
+ setExpandedPaths(prev => {
117
+ if (chain.every((folder) => prev.has(folder))) return prev;
118
+ const next = new Set(prev);
119
+ for (const folder of chain) next.add(folder);
120
+ setStoredExpandedPaths(Array.from(next));
121
+ return next;
122
+ });
123
+ };
124
+
125
+ /** Place a row a create just produced, without re-reading the tree. */
126
+ const insertCreated = (entry: { path: string; kind: 'file' | 'folder' }) => {
127
+ listing.insertEntry(entry);
128
+ revealPath(entry.path);
129
+ };
130
+
131
+ /**
132
+ * Move a row a rename just relocated, without re-reading.
133
+ *
134
+ * A folder's descendants and every open branch beneath it carry its path as a
135
+ * prefix, so the expansion set is rekeyed in the same step — an open `src`
136
+ * renamed to `lib` must stay open as `lib`, not collapse.
137
+ */
138
+ const moveRenamed = (fromPath: string, toPath: string) => {
139
+ listing.moveEntry(fromPath, toPath);
140
+ setExpandedPaths(prev => {
141
+ const next = rekeyExpansion(prev, fromPath, toPath);
142
+ setStoredExpandedPaths(Array.from(next));
143
+ return next;
144
+ });
145
+ // A rename INTO a new folder (`a.ts` → `nested/a.ts`) has the same problem a
146
+ // nested create does: the destination folder is one nobody has opened.
147
+ revealPath(toPath);
148
+ };
149
+
101
150
  /**
102
151
  * Re-read everything this panel shows. Defined ABOVE the `!enabled` return so
103
152
  * the create flow below can use it: a hook cannot be called after a branch.
@@ -111,6 +160,9 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
111
160
  // One clipboard for the whole tree, owned here and passed down: a
112
161
  // subscription per row would be hundreds of listeners for a value that
113
162
  // changes on a right-click.
163
+ // A row is a drag source; on a finger the native drag is what turned a long
164
+ // press into a text selection of the whole panel (see `useLongPress`).
165
+ const touch = useTouchDevice();
114
166
  const { toasts, pushToast, dismissToast } = useToasts();
115
167
  const clipboard = useFileClipboard({
116
168
  rootPath,
@@ -155,7 +207,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
155
207
  folderPath: '.',
156
208
  rootPath,
157
209
  repo: activeRepo,
158
- onCreated: refresh,
210
+ onCreated: insertCreated,
159
211
  onOpenCreated: (file) => onOpenFile?.({ ...file, root: rootPath, repo: activeRepo }),
160
212
  });
161
213
  /** Portals need a real document; the first client render is the gate. */
@@ -172,34 +224,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
172
224
 
173
225
  const tree = listing.files;
174
226
 
175
- const getFilteredFiles = () => {
176
- if (!searchQuery.trim()) return tree;
177
-
178
- const lowerQuery = searchQuery.toLowerCase();
179
-
180
- const filterNode = (node: any): any => {
181
- const isMatch = node.name.toLowerCase().includes(lowerQuery);
182
-
183
- if (node.type === 'folder') {
184
- const filteredChildren = (node.children || []).map(filterNode).filter(Boolean);
185
-
186
- if (isMatch || filteredChildren.length > 0) {
187
- return {
188
- ...node,
189
- children: isMatch && filteredChildren.length === 0 ? node.children : filteredChildren,
190
- forceExpanded: true
191
- };
192
- }
193
- return null;
194
- }
195
-
196
- return isMatch ? node : null;
197
- };
198
-
199
- return tree.map(filterNode).filter(Boolean);
200
- };
201
-
202
- const files = getFilteredFiles();
227
+ const files = filterFileTree(tree, searchQuery);
203
228
  // The button spins for this panel's own read OR a topic push: the server
204
229
  // republishes `fs:` on every tool call, so the tree moved with nothing on
205
230
  // screen saying so otherwise.
@@ -207,40 +232,23 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
207
232
 
208
233
  return (
209
234
  <div className={`flex flex-col h-full bg-paper ${className}`}>
210
- <div className="p-3 border-b border-ink/10 flex flex-col space-y-2">
211
- <div className="flex items-center justify-between">
212
- <GitRepoPicker
213
- rootPath={rootPath}
214
- activeRepo={activeRepo}
215
- onSelectRepo={handleSelectRepo}
216
- repos={repos}
217
- scanning={reposScanning}
218
- onRefreshRepos={rescanRepos}
219
- />
220
- <button
221
- onClick={refresh}
222
- className="p-1.5 text-ink/40 hover:text-ink hover:bg-ink/5 rounded transition-colors"
223
- title="Refresh"
224
- >
225
- <RefreshCw size={14} className={isRefreshing ? 'animate-spin' : ''} />
226
- </button>
227
- </div>
228
- <div className="relative">
229
- <Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-ink/40" />
230
- <input
231
- type="text"
232
- placeholder="Search files..."
233
- value={searchQuery}
234
- onChange={(e) => setSearchQuery(e.currentTarget.value)}
235
- className="w-full bg-canvas border border-ink/20 rounded pl-8 pr-3 py-1.5 text-xs focus:outline-none focus:border-ink transition-colors text-ink placeholder-ink/40"
236
- />
237
- </div>
238
- </div>
235
+ <FileExplorerToolbar
236
+ rootPath={rootPath}
237
+ activeRepo={activeRepo}
238
+ repos={repos}
239
+ reposScanning={reposScanning}
240
+ onRefreshRepos={rescanRepos}
241
+ onSelectRepo={handleSelectRepo}
242
+ searchQuery={searchQuery}
243
+ onSearchChange={setSearchQuery}
244
+ onRefresh={refresh}
245
+ refreshing={isRefreshing}
246
+ />
239
247
 
240
248
  <div
241
249
  className={`flex-1 scrollbar-overlay-container p-2 font-mono text-[11px] text-ink/80 ${
242
- rootAcceptsDrop ? 'bg-info/[0.06]' : ''
243
- }`}
250
+ touch ? 'file-tree-touch' : ''
251
+ } ${rootAcceptsDrop ? 'bg-info/[0.06]' : ''}`}
244
252
  onContextMenu={(e) => {
245
253
  // The panel's own background. A row stops propagation before this
246
254
  // runs, so this handler only ever sees a right-click that named NO
@@ -273,7 +281,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
273
281
  ) : (
274
282
  files.map(file => (
275
283
  <FileTreeItem
276
- key={file.id}
284
+ key={file.path}
277
285
  file={file}
278
286
  rootPath={rootPath}
279
287
  basePath={listing.root}
@@ -281,6 +289,8 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
281
289
  onLoadChildren={loadChildren}
282
290
  onOpenFile={onOpenFile}
283
291
  onActionComplete={refresh}
292
+ onCreated={insertCreated}
293
+ onRenamed={moveRenamed}
284
294
  expandedPaths={expandedPaths}
285
295
  onToggleFolder={handleToggleFolder}
286
296
  gitFileMap={gitFileMap}
@@ -293,6 +303,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
293
303
  if (!dragging) return;
294
304
  clipboard.move(dragging, target);
295
305
  }}
306
+ touch={touch}
296
307
  />
297
308
  ))
298
309
  )}
@@ -312,7 +323,8 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
312
323
  busy={clipboard.busy}
313
324
  onAction={(action) => {
314
325
  setRootMenu(null);
315
- if (action === 'new_file') rootCreate.openDialog();
326
+ if (action === 'new_file') rootCreate.openDialog('file');
327
+ if (action === 'new_folder') rootCreate.openDialog('folder');
316
328
  if (action === 'paste') clipboard.paste(rootTarget);
317
329
  }}
318
330
  />
@@ -321,6 +333,7 @@ export function FileExplorer({ className = '', enabled = true, rootPath, onOpenF
321
333
  {mounted && rootCreate.open && (
322
334
  <FileCreateModal
323
335
  folderPath="."
336
+ kind={rootCreate.kind}
324
337
  value={rootCreate.name}
325
338
  error={rootCreate.error}
326
339
  isLoading={rootCreate.busy}
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The Files panel's local tree edits: a create and a rename land WITHOUT a
8
+ * reload.
9
+ *
10
+ * Both used to answer a mutation by re-reading the root plus every open folder,
11
+ * which is one request per open folder to learn something the response already
12
+ * names — and it also collapsed nothing but cost a visible spinner. The two
13
+ * specs here assert the two halves that matter: the row appears (or moves) in
14
+ * the rendered tree, and NO `/api/fs/dir` read is issued for the root to make
15
+ * that happen.
16
+ *
17
+ * A nested create is the third case, because it is the one a naive insert gets
18
+ * wrong: the folders leading to the entry do not exist in the tree yet, and a
19
+ * row placed under a folder nobody has opened is a row the user cannot see —
20
+ * so the create also opens the chain it created.
21
+ */
22
+
23
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
24
+ import { createFileExplorerHarness } from '@/test-support/file-explorer.test-util';
25
+
26
+ const panel = createFileExplorerHarness();
27
+
28
+ beforeAll(() => {
29
+ panel.install();
30
+ });
31
+
32
+ afterEach(() => {
33
+ panel.unmount();
34
+ });
35
+
36
+ afterAll(() => {
37
+ panel.restore();
38
+ });
39
+
40
+ /** The root's own reads, which a reload would add one of. */
41
+ function rootReads(): number {
42
+ return panel.dirReads.filter((path) => path === '.').length;
43
+ }
44
+
45
+ describe('Files panel local edits', () => {
46
+ test('a created file appears in the tree without re-reading the root', async () => {
47
+ panel.disk = { '.': { 'existing.ts': 'file' } };
48
+ panel.dirReads = [];
49
+ await panel.mount();
50
+ const before = rootReads();
51
+
52
+ await panel.openRootNewFile();
53
+ await panel.submitNewFile('fresh.ts');
54
+
55
+ expect(panel.buttonLabels()).not.toContain('Create');
56
+ expect(panel.visibleNames()).toContain('fresh.ts');
57
+ // The create answered with the path, so the tree placed it: a reload here
58
+ // would be a root read plus one per open folder.
59
+ expect(rootReads()).toBe(before);
60
+ });
61
+
62
+ test('a created FOLDER appears and is not opened in an editor', async () => {
63
+ panel.disk = { '.': {} };
64
+ panel.dirReads = [];
65
+ await panel.mount();
66
+ const before = rootReads();
67
+
68
+ await panel.openRootNewFolder();
69
+ await panel.submitNewFile('packages');
70
+
71
+ expect(panel.createRequests.at(-1)).toEqual({
72
+ actionType: 'create',
73
+ path: '.',
74
+ name: 'packages',
75
+ kind: 'folder',
76
+ root: '/ws',
77
+ });
78
+ expect(panel.visibleNames()).toContain('packages');
79
+ // A folder has nothing to open — opening one would be an empty editor tab.
80
+ expect(panel.openedFiles).toEqual([]);
81
+ expect(rootReads()).toBe(before);
82
+ });
83
+
84
+ test('a nested create opens the chain it created, so the row is visible', async () => {
85
+ panel.disk = { '.': {} };
86
+ panel.dirReads = [];
87
+ await panel.mount();
88
+
89
+ await panel.openRootNewFile();
90
+ await panel.submitNewFile('lib/deep/parse.ts');
91
+
92
+ // Every folder on the way is expanded and reads its own directory: the
93
+ // entry is not somewhere the user has to go hunting for.
94
+ expect(panel.visibleNames()).toContain('parse.ts');
95
+ expect(panel.visibleNames()).toContain('deep');
96
+ expect(panel.visibleNames()).toContain('lib');
97
+ });
98
+
99
+ test('a rename moves the row in place without re-reading the root', async () => {
100
+ panel.disk = { '.': { 'old.ts': 'file' } };
101
+ panel.dirReads = [];
102
+ await panel.mount();
103
+ const before = rootReads();
104
+
105
+ await panel.rightClickRow('old.ts');
106
+ await panel.clickMenuRow('Rename');
107
+ await panel.submitRename('new.ts');
108
+
109
+ expect(panel.renameRequests.at(-1)).toEqual({
110
+ actionType: 'rename',
111
+ path: 'old.ts',
112
+ newPath: 'new.ts',
113
+ root: '/ws',
114
+ });
115
+ expect(panel.visibleNames()).toContain('new.ts');
116
+ expect(panel.visibleNames()).not.toContain('old.ts');
117
+ expect(rootReads()).toBe(before);
118
+ });
119
+
120
+ test('a refused rename keeps the dialog open with the server’s reason', async () => {
121
+ panel.disk = { '.': { 'old.ts': 'file', 'taken.ts': 'file' } };
122
+ panel.dirReads = [];
123
+ await panel.mount();
124
+
125
+ await panel.rightClickRow('old.ts');
126
+ await panel.clickMenuRow('Rename');
127
+ await panel.submitRename('taken.ts');
128
+
129
+ // The field is still there: "already exists" is a message about what to
130
+ // type next, not a notification about something that happened.
131
+ expect(panel.renameInput()).not.toBeNull();
132
+ expect(panel.documentText()).toContain('taken.ts already exists');
133
+ expect(panel.visibleNames()).toContain('old.ts');
134
+ });
135
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * New worktree — the dialog, modelled on OpenChamber's.
8
+ *
9
+ * Two columns on a wide screen and one on a phone, and the split is the point:
10
+ * the left column is the CHOICE (which branch this checkout is), the right is the
11
+ * NAMES (branch, base, folder). A single column put the choice above a wall of
12
+ * fields and the "Create worktree" button below the fold.
13
+ *
14
+ * Three rules the form carries, each from a measured failure:
15
+ *
16
+ * - **The folder follows the branch until the user edits it.** A dialog that
17
+ * asked for both independently produced a folder named after the previous
18
+ * branch the moment the branch was retyped; the reset button puts it back in
19
+ * sync and is disabled while it already is.
20
+ * - **Validation is the SERVER's.** A branch collision, a branch already checked
21
+ * out, an unborn repository — the client cannot know any of them without git,
22
+ * so the debounced call runs the same plan the create will run.
23
+ * - **The dirty notice states what stays behind.** Uncommitted changes are not
24
+ * carried into a new worktree, and a user told that afterwards has already
25
+ * lost the chance to commit them.
26
+ */
27
+
28
+ import { useEffect, useMemo, useState } from 'preact/hooks';
29
+ import { Modal } from '@/client/components/common/Modal';
30
+ import { createWorktree, validateWorktree } from '@/client/hooks/workspace/worktree/index';
31
+ import { WorktreeRequestError } from '@/client/hooks/workspace/worktree/index';
32
+ import { defaultWorktreeBranch, folderNameForBranch } from '@/shared/lib/workspace/worktree/branch-name';
33
+ import type { WorktreeFailure } from '@/shared/lib/workspace/worktree/types';
34
+
35
+ export interface NewWorktreeModalProps {
36
+ isOpen: boolean;
37
+ onClose: () => void;
38
+ /** The project the worktree belongs to. */
39
+ root: string | null;
40
+ folderId: number | null;
41
+ folderName?: string;
42
+ /** Called with the created path — the caller selects it as the chat's target. */
43
+ onCreated: (worktree: { path: string; branch: string }) => void;
44
+ }
45
+
46
+ type Mode = 'new' | 'existing';
47
+
48
+ /** A thrown request, narrowed to the failure the dialog reports. */
49
+ function asFailure(error: unknown): WorktreeFailure {
50
+ if (error instanceof WorktreeRequestError) return error.failure;
51
+ return { code: 'create_failed', message: error instanceof Error ? error.message : String(error) };
52
+ }
53
+
54
+ const FIELD_CLASS = 'w-full h-8 rounded-md border border-ink/15 bg-paper px-2 text-xs text-ink outline-none focus:border-ink/40';
55
+
56
+ export function NewWorktreeModal(props: NewWorktreeModalProps) {
57
+ // Mounted fresh on every open, never reset by an effect. Preact runs effects
58
+ // AFTER paint, so an effect-seeded form is on screen and focusable while its
59
+ // fields still hold the previous open's values — and anything typed in that
60
+ // window is discarded. Measured in the browser: the branch field was filled
61
+ // with `main`, the effect then restored the generated name, and the dialog
62
+ // created `wt/calm-iguana` instead of reporting that `main` is taken.
63
+ if (!props.isOpen) return null;
64
+ return <WorktreeForm {...props} />;
65
+ }
66
+
67
+ function WorktreeForm({ onClose, root, folderId, folderName, onCreated }: NewWorktreeModalProps) {
68
+ const [mode, setMode] = useState<Mode>('new');
69
+ // Seeded by the initial state, so the very first paint carries a usable form.
70
+ const [branch, setBranch] = useState(defaultWorktreeBranch);
71
+ const [basedOn, setBasedOn] = useState('');
72
+ const [folder, setFolder] = useState(() => folderNameForBranch(branch));
73
+ const [folderFollowsBranch, setFolderFollowsBranch] = useState(true);
74
+ const [failure, setFailure] = useState<WorktreeFailure | null>(null);
75
+ const [creating, setCreating] = useState(false);
76
+
77
+ // The folder mirrors the branch while the user has not renamed it.
78
+ useEffect(() => {
79
+ if (!folderFollowsBranch) return;
80
+ setFolder(folderNameForBranch(branch));
81
+ }, [branch, folderFollowsBranch]);
82
+
83
+ const request = useMemo(
84
+ () => ({
85
+ mode,
86
+ branch: branch.trim(),
87
+ worktreeName: folder.trim(),
88
+ ...(basedOn.trim() ? { startRef: basedOn.trim() } : {}),
89
+ }),
90
+ [mode, branch, folder, basedOn],
91
+ );
92
+
93
+ // Debounced server validation, after the first edit — the answers depend on
94
+ // git state a keystroke cannot know.
95
+ useEffect(() => {
96
+ if (!root) return;
97
+ const timer = setTimeout(() => {
98
+ void validateWorktree({ root, folderId }, request).then((result) => {
99
+ setFailure(result.ok ? null : result.failures[0] ?? null);
100
+ }).catch(() => {
101
+ // A validation request that failed says nothing about the form; the
102
+ // create reports what it finds.
103
+ });
104
+ }, 300);
105
+ return () => clearTimeout(timer);
106
+ }, [root, folderId, request]);
107
+
108
+ const canCreate = Boolean(root) && branch.trim().length > 0 && folder.trim().length > 0 && !creating && !failure;
109
+
110
+ const submit = async () => {
111
+ if (!root || !canCreate) return;
112
+ setCreating(true);
113
+ try {
114
+ const created = await createWorktree({ root, folderId }, request);
115
+ onCreated({ path: created.path, branch: created.branch });
116
+ onClose();
117
+ } catch (error) {
118
+ // `WorktreeRequestError` carries the server's field/code; anything else is
119
+ // reported as a create failure with its own message.
120
+ setFailure(asFailure(error));
121
+ } finally {
122
+ setCreating(false);
123
+ }
124
+ };
125
+
126
+ const choiceColumn = (
127
+ <div className="flex flex-col gap-4">
128
+ <div className="flex flex-col gap-2">
129
+ <span className="text-[11px] font-semibold text-ink">Start from</span>
130
+ {([
131
+ { id: 'new' as const, label: 'New branch', hint: 'A fresh branch, based on the branch you pick.' },
132
+ { id: 'existing' as const, label: 'Existing branch', hint: 'Check out a branch you already have.' },
133
+ ]).map((option) => (
134
+ <button
135
+ key={option.id}
136
+ type="button"
137
+ role="radio"
138
+ aria-checked={mode === option.id}
139
+ onClick={() => setMode(option.id)}
140
+ className={`rounded-md border px-3 py-2 text-left transition-colors ${
141
+ mode === option.id ? 'border-ink/40 bg-ink/5' : 'border-ink/15 hover:bg-ink/5'
142
+ }`}
143
+ >
144
+ <span className="block text-xs font-medium text-ink">{option.label}</span>
145
+ <span className="block text-[10.5px] text-ink/50">{option.hint}</span>
146
+ </button>
147
+ ))}
148
+ </div>
149
+ </div>
150
+ );
151
+
152
+ const fieldColumn = (
153
+ <div className="flex flex-col gap-4">
154
+ <label className="flex flex-col gap-1.5">
155
+ <span className="text-[11px] font-semibold text-ink">{mode === 'existing' ? 'Branch' : 'Branch name'}</span>
156
+ <input
157
+ value={branch}
158
+ onChange={(event) => setBranch(event.currentTarget.value)}
159
+ onKeyDown={(event) => {
160
+ if (event.key === 'Enter') {
161
+ event.preventDefault();
162
+ void submit();
163
+ }
164
+ }}
165
+ placeholder="feature/my-awesome-feature"
166
+ className={FIELD_CLASS}
167
+ aria-invalid={failure?.field === 'branch'}
168
+ />
169
+ </label>
170
+
171
+ <label className="flex flex-col gap-1.5">
172
+ <span className="text-[11px] font-semibold text-ink">Based on</span>
173
+ <input
174
+ value={basedOn}
175
+ onChange={(event) => setBasedOn(event.currentTarget.value)}
176
+ placeholder="origin/main"
177
+ disabled={mode === 'existing'}
178
+ className={`${FIELD_CLASS} disabled:opacity-50`}
179
+ />
180
+ <span className="text-[10px] text-ink/40">
181
+ Defaults to the repository's default branch. Left empty means its current HEAD.
182
+ </span>
183
+ </label>
184
+
185
+ <label className="flex flex-col gap-1.5">
186
+ <span className="text-[11px] font-semibold text-ink">Folder name</span>
187
+ <span className="flex items-center gap-1.5">
188
+ <input
189
+ value={folder}
190
+ onChange={(event) => {
191
+ setFolder(event.currentTarget.value);
192
+ setFolderFollowsBranch(false);
193
+ }}
194
+ placeholder="my-worktree-directory"
195
+ className={`${FIELD_CLASS} min-w-0 flex-1`}
196
+ aria-invalid={failure?.field === 'folder'}
197
+ />
198
+ <button
199
+ type="button"
200
+ title="Reset to match branch name"
201
+ aria-label="Reset to match branch name"
202
+ disabled={folderFollowsBranch}
203
+ onClick={() => {
204
+ setFolderFollowsBranch(true);
205
+ setFolder(folderNameForBranch(branch));
206
+ }}
207
+ className="h-8 shrink-0 rounded-md border border-ink/15 px-2 text-[11px] text-ink/70 hover:bg-ink/5 disabled:opacity-40"
208
+ >
209
+ ⟳
210
+ </button>
211
+ </span>
212
+ </label>
213
+
214
+ <p className="text-[10px] text-ink/45">
215
+ Uncommitted changes stay in {folderName ? `“${folderName}”` : 'the project'}. A new worktree starts from a
216
+ commit, so nothing half-written comes with it.
217
+ </p>
218
+ </div>
219
+ );
220
+
221
+ return (
222
+ <Modal
223
+ onClose={onClose}
224
+ maxWidthClass="max-w-[min(96vw,880px)]"
225
+ header={<h2 className="text-sm font-semibold text-ink">New worktree</h2>}
226
+ footer={
227
+ <div className="flex w-full items-center gap-2 border-t border-ink/10 px-5 py-3">
228
+ {/* One line that never grows the dialog; the full message is its title. */}
229
+ <p
230
+ className="min-w-0 flex-1 truncate text-[11px] text-error"
231
+ title={failure?.message}
232
+ role={failure ? 'alert' : undefined}
233
+ >
234
+ {failure?.message ?? ''}
235
+ </p>
236
+ <button
237
+ type="button"
238
+ onClick={onClose}
239
+ disabled={creating}
240
+ className="h-8 shrink-0 rounded-md border border-ink/15 px-3 text-xs text-ink/80 hover:bg-ink/5"
241
+ >
242
+ Cancel
243
+ </button>
244
+ <button
245
+ type="button"
246
+ onClick={() => void submit()}
247
+ disabled={!canCreate}
248
+ className="h-8 shrink-0 rounded-md bg-ink px-3 text-xs font-medium text-paper hover:bg-ink/90 disabled:opacity-40"
249
+ >
250
+ {creating ? 'Creating…' : 'Create worktree'}
251
+ </button>
252
+ </div>
253
+ }
254
+ >
255
+ <div className="grid grid-cols-1 gap-6 px-5 py-4 md:grid-cols-2">
256
+ {choiceColumn}
257
+ {fieldColumn}
258
+ </div>
259
+ </Modal>
260
+ );
261
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useRef, useState } from 'preact/hooks';
2
2
  import { useTimelineScope } from '@/client/hooks/chat/timeline/scope';
3
+ import { useSpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
3
4
  import type { ChatMessageData } from '@/shared/types';
4
5
  import { useOmpAgent } from '@/client/hooks/chat/omp';
5
6
  import { useChatTimelineQueue } from '@/client/hooks/chat/timeline/queue';
@@ -57,8 +58,7 @@ export function useChatTimeline({
57
58
  const { inputValue, setInputValue, inputAttachments, setInputAttachments } = useComposerDraft();
58
59
  const [localMessages, setLocalMessages] = useState<ChatMessageData[]>([]);
59
60
 
60
- // A pending "new-…" session renders the workspace picker, which owns its own
61
- // timeline and scroll handling.
61
+ // A pending "new-…" session renders the workspace picker with its own timeline.
62
62
  const isPendingSession = Boolean(sessionId?.startsWith('new-'));
63
63
  // Committed history lands asynchronously (session-load.ts), so the container
64
64
  // paints at the top first; jump to the tail once per session when it does.
@@ -211,6 +211,8 @@ export function useChatTimeline({
211
211
  enqueueExtensionDialog,
212
212
  withdrawExtensionDialog,
213
213
  }), activityRef);
214
+ const spawn = useSpawnTarget(sessionId);
215
+
214
216
  const { steerOmpAgent, executeSend } = useChatTimelineSend({
215
217
  folders,
216
218
  selectedFolderId,
@@ -234,15 +236,15 @@ export function useChatTimeline({
234
236
  deferredComposerPickRef,
235
237
  accessModeRef,
236
238
  spawnSelectionRef: modes.spawnSelectionRef,
239
+ spawnWorktreeRef: spawn.worktreePathRef,
237
240
  abortControllerRef,
238
241
  setInputValue,
239
242
  setSearchParams,
240
243
  });
241
244
 
242
245
  // Queue auto-delivery moved server-side: the wrapper's terminal `agent_end`
243
- // schedules the next queued prompt (`lib/queue/delivery.server.ts`), with a
244
- // transactional head claim so no reload/tab race can double-send. The client
245
- // panel is a view; `stopHoldRef` remains only for the mock path below.
246
+ // schedules the next queued prompt, with a transactional head claim so no
247
+ // reload/tab race can double-send. `stopHoldRef` serves only the mock path.
246
248
 
247
249
  const {
248
250
  handleSend,
@@ -339,6 +341,8 @@ export function useChatTimeline({
339
341
  composerModelRef,
340
342
  deferredComposerPickRef,
341
343
  waitingDependency,
344
+ spawnTarget: spawn.target,
345
+ setSpawnTarget: spawn.setTarget,
342
346
  extensionDialogs,
343
347
  resolveExtensionDialog,
344
348
  respondToExtensionUi: ompAgent.respondToExtensionUi,