@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
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The tree edits that let a create and a rename skip a reload.
8
+ *
9
+ * Each rule here is a way the rendered tree would disagree with the disk:
10
+ *
11
+ * 1. **A nested entry brings the chain that leads to it.** `lib/parse.ts` names
12
+ * a folder the tree does not carry yet; without the chain the row is placed
13
+ * under a key no branch reaches and simply never renders.
14
+ * 2. **A collapsed folder is not guessed at.** Its children are not this
15
+ * tree's knowledge, so an entry created inside one is deliberately NOT
16
+ * inserted — expanding it reads the real directory, which by then holds the
17
+ * new entry. Inserting into a guess would show a listing the server never
18
+ * produced.
19
+ * 3. **A rename re-points every descendant.** A folder's children carry its
20
+ * path as a prefix; moving the folder without rewriting them leaves rows
21
+ * whose `path` no longer resolves and an expansion set keyed by names that
22
+ * are gone.
23
+ * 4. **A rename never loses the moved entry's own loaded children.** Its cache
24
+ * entry is rekeyed with it, so a folder renamed while open stays open with
25
+ * its contents.
26
+ */
27
+
28
+ import { describe, expect, test } from 'bun:test';
29
+ import type { FsNode } from '@/shared/types';
30
+ import {
31
+ baseNameOf,
32
+ parentPathOf,
33
+ placeEntry,
34
+ rekeyCache,
35
+ rekeyExpansion,
36
+ renameEntryAt,
37
+ sortEntries,
38
+ takeEntry,
39
+ } from '@/shared/lib/fs/file-tree';
40
+
41
+ const file = (path: string): FsNode => ({ name: baseNameOf(path), path, type: 'file' });
42
+ const folder = (path: string, children?: FsNode[]): FsNode =>
43
+ children ? { name: baseNameOf(path), path, type: 'folder', children } : { name: baseNameOf(path), path, type: 'folder' };
44
+
45
+ describe('placeEntry', () => {
46
+ test('a top-level entry lands in the root, folders first', () => {
47
+ const placed = placeEntry([folder('src'), file('z.ts')], {}, file('a.ts'));
48
+
49
+ expect(placed.nodes.map((node) => node.path)).toEqual(['src', 'a.ts', 'z.ts']);
50
+ expect(placed.cache).toEqual({});
51
+ });
52
+
53
+ test('a nested entry creates the chain that leads to it', () => {
54
+ const placed = placeEntry([], {}, file('lib/deep/parse.ts'));
55
+
56
+ expect(placed.nodes.map((node) => node.path)).toEqual(['lib']);
57
+ expect(placed.cache.lib?.map((node) => node.path)).toEqual(['lib/deep']);
58
+ expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/parse.ts']);
59
+ });
60
+
61
+ test('an existing chain is extended, not duplicated', () => {
62
+ const placed = placeEntry([folder('lib')], { lib: [folder('lib/deep')], 'lib/deep': [] }, file('lib/deep/x.ts'));
63
+
64
+ expect(placed.nodes).toHaveLength(1);
65
+ expect(placed.cache['lib/deep']?.map((node) => node.path)).toEqual(['lib/deep/x.ts']);
66
+ });
67
+
68
+ test('an entry inside a COLLAPSED folder is not inserted', () => {
69
+ // `src` is listed with `children: null` — the tree has never read it, so
70
+ // there is nothing to place the entry into and no guess to make.
71
+ const tree = [folder('src')];
72
+ const placed = placeEntry(tree, {}, file('src/hidden.ts'));
73
+
74
+ expect(placed.nodes).toBe(tree);
75
+ expect(placed.cache).toEqual({});
76
+ });
77
+
78
+ test('an empty folder is LOADED, and can receive an entry', () => {
79
+ const placed = placeEntry([folder('src')], { src: [] }, file('src/new.ts'));
80
+
81
+ expect(placed.cache.src?.map((node) => node.path)).toEqual(['src/new.ts']);
82
+ });
83
+
84
+ test('a created folder is a folder in the cache too, so it can be expanded', () => {
85
+ const placed = placeEntry([], {}, folder('packages'));
86
+
87
+ expect(placed.nodes[0].type).toBe('folder');
88
+ expect(placed.nodes[0].children).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe('takeEntry', () => {
93
+ test('removes a root entry and hands it back', () => {
94
+ const taken = takeEntry([file('a.ts'), file('b.ts')], {}, 'a.ts');
95
+
96
+ expect(taken.entry?.path).toBe('a.ts');
97
+ expect(taken.nodes.map((node) => node.path)).toEqual(['b.ts']);
98
+ });
99
+
100
+ test('removes a nested entry from its parent cache', () => {
101
+ const taken = takeEntry([folder('src')], { src: [file('src/a.ts'), file('src/b.ts')] }, 'src/a.ts');
102
+
103
+ expect(taken.entry?.path).toBe('src/a.ts');
104
+ expect(taken.cache.src?.map((node) => node.path)).toEqual(['src/b.ts']);
105
+ });
106
+
107
+ test('a path the tree does not carry reports no entry and changes nothing', () => {
108
+ const cache = { src: [file('src/a.ts')] };
109
+ const taken = takeEntry([folder('src')], cache, 'src/hidden.ts');
110
+
111
+ expect(taken.entry).toBeNull();
112
+ expect(taken.cache).toBe(cache);
113
+ });
114
+ });
115
+
116
+ describe('renameEntryAt', () => {
117
+ test('re-points the node and every descendant', () => {
118
+ const tree = [folder('src', [folder('src/lib', [file('src/lib/a.ts')])])];
119
+ const moved = renameEntryAt(tree, 'src', 'lib');
120
+
121
+ expect(moved[0].path).toBe('lib');
122
+ expect(moved[0].name).toBe('lib');
123
+ expect(moved[0].children?.[0].path).toBe('lib/lib');
124
+ expect(moved[0].children?.[0].children?.[0].path).toBe('lib/lib/a.ts');
125
+ });
126
+
127
+ test('a sibling whose path merely shares a prefix is left alone', () => {
128
+ const tree = [file('src.ts'), file('src/a.ts')];
129
+ const moved = renameEntryAt(tree, 'src', 'lib');
130
+
131
+ expect(moved[0]).toBe(tree[0]);
132
+ expect(moved[1].path).toBe('lib/a.ts');
133
+ });
134
+
135
+ test('a file rename updates its own name', () => {
136
+ const moved = renameEntryAt([file('a.ts')], 'a.ts', 'nested/b.ts');
137
+
138
+ expect(moved[0].path).toBe('nested/b.ts');
139
+ expect(moved[0].name).toBe('b.ts');
140
+ });
141
+ });
142
+
143
+ describe('rekeyCache', () => {
144
+ test('moves the folder key and every key beneath it', () => {
145
+ const cache = { src: [folder('src/lib')], 'src/lib': [file('src/lib/a.ts')], other: [file('other/x.ts')] };
146
+ const moved = rekeyCache(cache, 'src', 'app');
147
+
148
+ expect(Object.keys(moved).sort()).toEqual(['app', 'app/lib', 'other']);
149
+ expect(moved['app/lib']?.[0].path).toBe('app/lib/a.ts');
150
+ expect(moved.other).toBe(cache.other);
151
+ });
152
+ });
153
+
154
+ describe('rekeyExpansion', () => {
155
+ test('an open folder and its open children stay open under their new paths', () => {
156
+ const next = rekeyExpansion(new Set(['src', 'src/lib', 'docs']), 'src', 'app');
157
+
158
+ expect([...next].sort()).toEqual(['app', 'app/lib', 'docs']);
159
+ });
160
+
161
+ test('a path that only shares a prefix is untouched', () => {
162
+ const next = rekeyExpansion(new Set(['src.ts']), 'src', 'app');
163
+
164
+ expect([...next]).toEqual(['src.ts']);
165
+ });
166
+ });
167
+
168
+ describe('sortEntries', () => {
169
+ test('folders first, then by name — the server’s own order', () => {
170
+ const sorted = sortEntries([file('b.ts'), folder('z'), file('a.ts'), folder('a')]);
171
+
172
+ expect(sorted.map((node) => node.path)).toEqual(['a', 'z', 'a.ts', 'b.ts']);
173
+ });
174
+ });
175
+
176
+ describe('path helpers', () => {
177
+ test('parentPathOf and baseNameOf split a nested path', () => {
178
+ expect(parentPathOf('lib/deep/a.ts')).toBe('lib/deep');
179
+ expect(parentPathOf('a.ts')).toBe('.');
180
+ expect(baseNameOf('lib/deep/a.ts')).toBe('a.ts');
181
+ expect(baseNameOf('a.ts')).toBe('a.ts');
182
+ });
183
+ });
@@ -29,3 +29,162 @@ export function rehydrateTree(nodes: FsNode[], cache: Record<string, FsNode[]>):
29
29
  return { ...node, children: rehydrateTree(cached, cache) };
30
30
  });
31
31
  }
32
+
33
+ /** The folder a path lives in: `.` for a top-level entry. */
34
+ export function parentPathOf(path: string): string {
35
+ const cut = path.lastIndexOf('/');
36
+ return cut === -1 ? '.' : path.slice(0, cut);
37
+ }
38
+
39
+ /** The last segment of a path. */
40
+ export function baseNameOf(path: string): string {
41
+ const cut = path.lastIndexOf('/');
42
+ return cut === -1 ? path : path.slice(cut + 1);
43
+ }
44
+
45
+ /**
46
+ * The server's own ordering (`lib/fs/listing.ts`): folders first, then by name.
47
+ *
48
+ * A locally inserted row has to land where the server would have put it, or the
49
+ * next real read would visibly reorder the tree under the user.
50
+ */
51
+ export function sortEntries(nodes: FsNode[]): FsNode[] {
52
+ return [...nodes].sort((a, b) => {
53
+ if (a.type !== b.type) return a.type === 'folder' ? -1 : 1;
54
+ return a.name.localeCompare(b.name);
55
+ });
56
+ }
57
+
58
+ /** The listing after a local edit: the root list plus the children cache. */
59
+ export interface TreeEdit {
60
+ nodes: FsNode[];
61
+ cache: Record<string, FsNode[]>;
62
+ }
63
+
64
+ /**
65
+ * Insert `entry` where its own path says it belongs, creating the folder chain
66
+ * that leads to it when the tree does not carry it yet.
67
+ *
68
+ * This is what lets a create and a rename skip a reload: the panel already
69
+ * knows the whole answer, and re-reading the root plus every open folder to
70
+ * learn it back would collapse nothing but cost a request per open folder.
71
+ *
72
+ * A chain is only extended through folders whose children are KNOWN — the root
73
+ * list plus the cache. A collapsed folder's contents are not this tree's to
74
+ * guess at, so an entry created inside one is simply not inserted: expanding it
75
+ * reads the real directory, which by then holds the new entry. The chain nodes
76
+ * that ARE inserted carry no `children` array, so the row's own expand effect
77
+ * reads them from disk like any other unread folder.
78
+ */
79
+ export function placeEntry(nodes: FsNode[], cache: Record<string, FsNode[]>, entry: FsNode): TreeEdit {
80
+ const segments = entry.path.split('/');
81
+ const nextNodes = [...nodes];
82
+ const nextCache = { ...cache };
83
+ let container = nextNodes;
84
+ let containerKey: string | null = null;
85
+
86
+ for (let i = 0; i < segments.length - 1; i += 1) {
87
+ const folderPath = segments.slice(0, i + 1).join('/');
88
+ const existing = container.find((node) => node.path === folderPath);
89
+ const known = existing ? (nextCache[folderPath] ?? existing.children) : null;
90
+ if (existing && !Array.isArray(known)) return { nodes, cache };
91
+ const children = Array.isArray(known) ? [...known] : [];
92
+ if (!existing) container.push({ name: segments[i], path: folderPath, type: 'folder', ignored: false });
93
+ nextCache[folderPath] = children;
94
+ container = children;
95
+ containerKey = folderPath;
96
+ }
97
+
98
+ container.push(entry);
99
+ if (containerKey !== null) nextCache[containerKey] = sortEntries(container);
100
+ return { nodes: sortEntries(nextNodes), cache: nextCache };
101
+ }
102
+
103
+ /**
104
+ * Remove the entry at `path` from wherever it lives, leaving the rest of the
105
+ * tree untouched. Returns `entry: null` when the path is not in this tree —
106
+ * a collapsed folder's contents are not, and that is not an error.
107
+ */
108
+ export function takeEntry(
109
+ nodes: FsNode[],
110
+ cache: Record<string, FsNode[]>,
111
+ path: string,
112
+ ): TreeEdit & { entry: FsNode | null } {
113
+ const parent = parentPathOf(path);
114
+ if (parent === '.') {
115
+ const entry = nodes.find((node) => node.path === path) ?? null;
116
+ if (!entry) return { entry: null, nodes, cache };
117
+ return { entry, nodes: nodes.filter((node) => node.path !== path), cache };
118
+ }
119
+ const siblings = cache[parent];
120
+ const entry = siblings?.find((node) => node.path === path) ?? null;
121
+ if (!entry || !siblings) return { entry: null, nodes, cache };
122
+ return {
123
+ entry,
124
+ nodes,
125
+ cache: { ...cache, [parent]: siblings.filter((node) => node.path !== path) },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Re-point one entry and every path beneath it at a new location.
131
+ *
132
+ * A folder's descendants carry their parent's path as a prefix, so moving the
133
+ * folder without rewriting them would leave every child pointing at the old
134
+ * tree — a row whose `path` no longer resolves, and an expansion set keyed by
135
+ * names that are gone.
136
+ *
137
+ * A branch that does not hold the moved path is returned AS IS: the arrays are
138
+ * what React diffs, and a rename that rebuilt every unrelated subtree would
139
+ * re-render the whole tree for a change it did not touch.
140
+ */
141
+ export function renameEntryAt(nodes: FsNode[], fromPath: string, toPath: string): FsNode[] {
142
+ let changed = false;
143
+ const next = nodes.map((node) => {
144
+ const isMoved = node.path === fromPath || node.path.startsWith(`${fromPath}/`);
145
+ const nested = Array.isArray(node.children) ? renameEntryAt(node.children, fromPath, toPath) : undefined;
146
+ // `nested === node.children` holds for a file (`undefined`) and for a folder
147
+ // whose subtree did not move, so an untouched row keeps its identity.
148
+ if (!isMoved && nested === node.children) return node;
149
+ changed = true;
150
+ const path = isMoved ? (node.path === fromPath ? toPath : `${toPath}${node.path.slice(fromPath.length)}`) : node.path;
151
+ const renamed: FsNode = isMoved ? { ...node, path, name: baseNameOf(path) } : { ...node };
152
+ if (nested !== undefined) renamed.children = nested;
153
+ return renamed;
154
+ });
155
+ return changed ? next : nodes;
156
+ }
157
+
158
+ /**
159
+ * Move every cache entry under `fromPath` to `toPath`, paths included.
160
+ *
161
+ * An entry outside the move keeps its own array identity, for the reason
162
+ * `renameEntryAt` does: those arrays are what the tree diffs.
163
+ */
164
+ export function rekeyCache(
165
+ cache: Record<string, FsNode[]>,
166
+ fromPath: string,
167
+ toPath: string,
168
+ ): Record<string, FsNode[]> {
169
+ const next: Record<string, FsNode[]> = {};
170
+ for (const [key, children] of Object.entries(cache)) {
171
+ const moved = key === fromPath
172
+ ? toPath
173
+ : key.startsWith(`${fromPath}/`)
174
+ ? `${toPath}${key.slice(fromPath.length)}`
175
+ : key;
176
+ next[moved] = moved === key ? children : renameEntryAt(children, fromPath, toPath);
177
+ }
178
+ return next;
179
+ }
180
+
181
+ /** Rewrite the expansion set for a renamed folder, so open branches stay open. */
182
+ export function rekeyExpansion(expanded: Set<string>, fromPath: string, toPath: string): Set<string> {
183
+ const next = new Set<string>();
184
+ for (const path of expanded) {
185
+ if (path === fromPath) next.add(toPath);
186
+ else if (path.startsWith(`${fromPath}/`)) next.add(`${toPath}${path.slice(fromPath.length)}`);
187
+ else next.add(path);
188
+ }
189
+ return next;
190
+ }
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { describe, expect, test } from 'bun:test';
7
- import { isVirtualPath, stripReadSelector } from '@/shared/lib/fs/paths';
7
+ import { isVirtualPath, lastPathSegment, stripReadSelector } from '@/shared/lib/fs/paths';
8
8
 
9
9
  describe('stripReadSelector', () => {
10
10
  test('peels every selector shape omp accepts', () => {
@@ -88,3 +88,26 @@ describe('isVirtualPath', () => {
88
88
  expect(isVirtualPath('')).toBe(false);
89
89
  });
90
90
  });
91
+
92
+ describe('lastPathSegment', () => {
93
+ test('names the last segment on either separator', () => {
94
+ // The Windows case is the one that shipped broken: the composer's target
95
+ // trigger read `kabmo · C:\Users\…\cosmic-dolphin`, because `split('/')`
96
+ // has nothing to cut in a `\`-separated absolute path.
97
+ expect(lastPathSegment('/data/worktrees/abc/cosmic-dolphin')).toBe('cosmic-dolphin');
98
+ expect(lastPathSegment('C:\\Users\\u\\AppData\\Local\\worktrees\\abc\\cosmic-dolphin')).toBe('cosmic-dolphin');
99
+ expect(lastPathSegment('wt/nested/branch')).toBe('branch');
100
+ });
101
+
102
+ test('a trailing separator names the same thing', () => {
103
+ expect(lastPathSegment('/data/worktrees/abc/cosmic-dolphin/')).toBe('cosmic-dolphin');
104
+ expect(lastPathSegment('C:\\worktrees\\abc\\cosmic-dolphin\\')).toBe('cosmic-dolphin');
105
+ });
106
+
107
+ test('nothing to name is empty, not a stray separator', () => {
108
+ expect(lastPathSegment(undefined)).toBe('');
109
+ expect(lastPathSegment(null)).toBe('');
110
+ expect(lastPathSegment('')).toBe('');
111
+ expect(lastPathSegment('/')).toBe('');
112
+ });
113
+ });
@@ -107,3 +107,20 @@ export function toAbsolutePath(baseDir: string | null | undefined, relPath: stri
107
107
  // Join with the base's own separator: `C:\proj` + `src` → `C:\proj\src`.
108
108
  return `${base}${base.includes('\\') && !base.includes('/') ? '\\' : '/'}${rel}`;
109
109
  }
110
+
111
+ /**
112
+ * The last segment of a path on ANY platform.
113
+ *
114
+ * `split('/').pop()` is the shape this replaces, and it is wrong for every
115
+ * ABSOLUTE path on Windows: a worktree the server created under
116
+ * `C:\…\worktrees\<id>\cosmic-dolphin` came back whole, so the composer's target
117
+ * trigger read `kabmo · C:\Users\…\cosmic-dolphin` instead of
118
+ * `kabmo · cosmic-dolphin` (measured on the Windows E2E leg, which is the only
119
+ * place it shows — every POSIX path splits the same either way). A trailing
120
+ * separator is dropped first so `…/cosmic-dolphin/` names the same worktree.
121
+ */
122
+ export function lastPathSegment(target: string | null | undefined): string {
123
+ if (!target) return '';
124
+ const parts = target.replace(/[/\\]+$/, '').split(/[/\\]/);
125
+ return parts[parts.length - 1] ?? '';
126
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The two worktree setting keys, in a module with NO server imports.
8
+ *
9
+ * The server reads them from `kabmo_settings` (`lib/fs/worktree/settings.server.ts`)
10
+ * and the settings panel writes them through `writeSetting`; both sides need the
11
+ * same strings, and the client bundle must not pull in `@/db.server` to get them.
12
+ */
13
+
14
+ /** Parent directory new worktrees are created in; empty means the default. */
15
+ export const WORKTREE_DIRECTORY_SETTING = 'worktreeDirectory';
16
+
17
+ /** Whether a create copies the project's ignored build folders. Default: on. */
18
+ export const WORKTREE_CARRY_SETTING = 'worktreeCarryIgnored';
@@ -32,7 +32,14 @@ export function spawnCwdForNewSession(
32
32
  folders: PrewarmFolderShape[],
33
33
  activeSessionId: string | number | null,
34
34
  folderId?: number | string,
35
+ /**
36
+ * A worktree the draft chose. It wins over the folder's project path, because
37
+ * that is the whole point of picking one: the chat is born in the worktree and
38
+ * nothing is ever relocated afterwards.
39
+ */
40
+ worktreePath?: string | null,
35
41
  ): string | null {
42
+ if (worktreePath) return worktreePath;
36
43
  const target = folderId !== undefined
37
44
  ? folders.find((f) => String(f.id) === String(folderId))
38
45
  : folders.find((f) => f.sessions?.some((s) => String(s.id) === String(activeSessionId)))
@@ -19,14 +19,34 @@
19
19
  */
20
20
 
21
21
  /** The native shells that report themselves on the UA. */
22
- export type ShellKind = 'android' | 'ios';
22
+ export type MobileShellKind = 'android' | 'ios';
23
23
 
24
- /** The token's name, before the slash, per shell. */
25
- export const SHELL_UA_PREFIXES: Record<ShellKind, string> = {
24
+ /**
25
+ * The desktop shells. They carry NO User-Agent token — a desktop app is not
26
+ * downloaded from the console, so the server has nothing to parse per request.
27
+ * They report their version over the shell bridge instead (`__kabmoDesktop`),
28
+ * which is why they are a separate family: the version exists, the UA channel
29
+ * does not.
30
+ */
31
+ export type DesktopShellKind = 'macos' | 'windows' | 'linux';
32
+
33
+ /** Every shell Kabmo publishes an artifact for. */
34
+ export type ShellKind = MobileShellKind | DesktopShellKind;
35
+
36
+ /** The token's name, before the slash, per MOBILE shell. */
37
+ export const SHELL_UA_PREFIXES: Record<MobileShellKind, string> = {
26
38
  android: 'KabmoAndroid/',
27
39
  ios: 'KabmoIOS/',
28
40
  };
29
41
 
42
+ /** The desktop family, in the order their release tracks were added. */
43
+ export const DESKTOP_SHELL_KINDS: readonly DesktopShellKind[] = ['macos', 'windows', 'linux'];
44
+
45
+ /** Whether a shell reports itself over the bridge rather than the User-Agent. */
46
+ export function isDesktopShellKind(kind: string): kind is DesktopShellKind {
47
+ return (DESKTOP_SHELL_KINDS as readonly string[]).includes(kind);
48
+ }
49
+
30
50
  /**
31
51
  * Whether this request came from an Android device at all — the app or a
32
52
  * browser on it.
@@ -60,7 +80,7 @@ export function isAndroidDevice(userAgent: string | null | undefined): boolean {
60
80
  */
61
81
  export function shellVersion(
62
82
  userAgent: string | null | undefined,
63
- kind: ShellKind,
83
+ kind: MobileShellKind,
64
84
  ): string | null {
65
85
  if (!userAgent) return null;
66
86
 
@@ -23,6 +23,29 @@ export function activeProjectForSession(
23
23
  return { folder: null, projectPath: null };
24
24
  }
25
25
 
26
+ /**
27
+ * The root the right-panel tools should operate on for this session: its own
28
+ * worktree when the session's cwd is a linked checkout, else the folder's
29
+ * registered project path.
30
+ *
31
+ * A worktree session runs OUTSIDE its folder's project directory, so pointing
32
+ * the panels at `project_path` showed the checkout the agent had left — measured:
33
+ * every panel kept reading the main tree while the run wrote to the worktree.
34
+ * The server's `resolveRoot` accepts a worktree of a registered project for the
35
+ * same reason, so the two agree about what is readable.
36
+ */
37
+ export function effectiveRootFor(
38
+ folders: WorkspaceFolderData[],
39
+ sessionId: string | number | null | undefined,
40
+ ): string | null {
41
+ const { folder } = activeProjectForSession(folders, sessionId);
42
+ if (!folder) return null;
43
+ const row = (folder.sessions || []).find((session) => String(session.id) === String(sessionId));
44
+ const worktree = row?.worktree;
45
+ if (worktree && worktree.status === 'ready') return worktree.path;
46
+ return folder.project_path ?? null;
47
+ }
48
+
26
49
  /**
27
50
  * Resolve the workspace root the composer should scope `@` file mentions to:
28
51
  * the active session's bound project first, then the explicitly selected
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { afterAll, afterEach, describe, expect, test } from 'bun:test';
22
22
 
23
- import { activeProjectForSession, composerRootFor } from '@/shared/lib/workspace/active-project';
23
+ import { activeProjectForSession, composerRootFor, effectiveRootFor } from '@/shared/lib/workspace/active-project';
24
24
  import { diffTabId, diffTabName, fileTabId } from '@/shared/lib/workspace/file-tab-id';
25
25
  import {
26
26
  DEFAULT_RIGHT_PANEL_FRACTION,
@@ -173,6 +173,54 @@ describe('active-project', () => {
173
173
  expect(composerRootFor(folders, 30, 1)).toBe('/work/gamma');
174
174
  });
175
175
 
176
+ /**
177
+ * The root the panels follow. A worktree session runs OUTSIDE its folder's
178
+ * project path, so pointing the panels at `project_path` showed the checkout
179
+ * the agent had left — measured: every panel kept reading the main tree while
180
+ * the run wrote to the worktree.
181
+ */
182
+ describe('effectiveRootFor', () => {
183
+ const withWorktree = (status: 'ready' | 'missing'): WorkspaceFolderData[] => [
184
+ {
185
+ id: 1,
186
+ name: 'alpha',
187
+ project_path: '/work/alpha',
188
+ isExpanded: true,
189
+ hasMore: false,
190
+ totalSessions: 2,
191
+ sessions: [
192
+ {
193
+ id: 'wt-session',
194
+ folder_id: 1,
195
+ title: 'in a worktree',
196
+ worktree: {
197
+ branch: 'wt/cosmic-dolphin',
198
+ path: '/data/worktrees/abc/cosmic-dolphin',
199
+ primaryRoot: '/work/alpha',
200
+ status,
201
+ detached: false,
202
+ },
203
+ },
204
+ { id: 'plain', folder_id: 1, title: 'in the checkout' },
205
+ ],
206
+ },
207
+ ];
208
+
209
+ test('a ready worktree wins over the folder project path', () => {
210
+ expect(effectiveRootFor(withWorktree('ready'), 'wt-session')).toBe('/data/worktrees/abc/cosmic-dolphin');
211
+ });
212
+
213
+ test('a MISSING worktree falls back to the project path — a directory that is gone is not a root', () => {
214
+ expect(effectiveRootFor(withWorktree('missing'), 'wt-session')).toBe('/work/alpha');
215
+ });
216
+
217
+ test('an ordinary session keeps the project path', () => {
218
+ expect(effectiveRootFor(withWorktree('ready'), 'plain')).toBe('/work/alpha');
219
+ expect(effectiveRootFor(folders, 30)).toBe('/work/gamma');
220
+ expect(effectiveRootFor(folders, 999)).toBeNull();
221
+ });
222
+ });
223
+
176
224
  test('composerRootFor falls back to the selected folder without a session match', () => {
177
225
  expect(composerRootFor(folders, null, 3)).toBe('/work/gamma');
178
226
  expect(composerRootFor(folders, 999, 3)).toBe('/work/gamma');
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Branch naming and slugging.
8
+ *
9
+ * Two rules here are load-bearing and each was a defect first:
10
+ *
11
+ * - `slug('..')` is `..`, and `path.join(root, '..')` escapes the root — so a
12
+ * dot-only result is REJECTED, not passed through.
13
+ * - our own `wt/` namespace is stripped from the folder (the folder does not
14
+ * repeat it) while a repository's own namespace stays visible (`feat/x` →
15
+ * `feat-x`), so a folder never reads as a second branch name.
16
+ */
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import {
20
+ defaultWorktreeBranch,
21
+ folderNameForBranch,
22
+ generateBranchSlug,
23
+ isPlausibleBranchName,
24
+ normalizeBranchName,
25
+ slugWorktreeName,
26
+ WORKTREE_BRANCH_PREFIX,
27
+ } from '@/shared/lib/workspace/worktree/branch-name';
28
+
29
+ describe('worktree branch names', () => {
30
+ test('the generated branch carries our namespace', () => {
31
+ const branch = defaultWorktreeBranch();
32
+ expect(branch.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)).toBe(true);
33
+ expect(branch.slice(WORKTREE_BRANCH_PREFIX.length + 1)).toMatch(/^[a-z]+-[a-z]+$/);
34
+ });
35
+
36
+ test('the ref namespace is stripped and only that', () => {
37
+ expect(normalizeBranchName('refs/heads/wt/x')).toBe('wt/x');
38
+ expect(normalizeBranchName('heads/wt/x')).toBe('wt/x');
39
+ expect(normalizeBranchName(' wt/x ')).toBe('wt/x');
40
+ });
41
+ });
42
+
43
+ describe('folder naming', () => {
44
+ test('our namespace is stripped, a repository namespace is kept', () => {
45
+ expect(folderNameForBranch('wt/cosmic-dolphin')).toBe('cosmic-dolphin');
46
+ expect(folderNameForBranch('feat/payments')).toBe('feat-payments');
47
+ expect(folderNameForBranch('refs/heads/wt/yak')).toBe('yak');
48
+ expect(folderNameForBranch('plain')).toBe('plain');
49
+ });
50
+
51
+ test('a folder is one path segment and cannot escape its root', () => {
52
+ expect(slugWorktreeName('../../etc/passwd')).toBe('etc-passwd');
53
+ expect(slugWorktreeName('a b/c')).toBe('a-b-c');
54
+ expect(slugWorktreeName('-weird-')).toBe('weird');
55
+ // The trap: these two ARE valid path segments and would leave the root.
56
+ expect(slugWorktreeName('..')).toBe('');
57
+ expect(slugWorktreeName('.')).toBe('');
58
+ expect(slugWorktreeName('...')).toBe('');
59
+ });
60
+
61
+ test('a folder name is bounded', () => {
62
+ expect(slugWorktreeName('x'.repeat(200)).length).toBeLessThanOrEqual(80);
63
+ });
64
+
65
+ test('a non-Latin branch leaves no slug, so the caller generates a name', () => {
66
+ expect(folderNameForBranch('测试分支')).toBe('');
67
+ });
68
+ });
69
+
70
+ describe('generated slugs', () => {
71
+ test('stay inside the word lists and vary', () => {
72
+ const seen = new Set<string>();
73
+ for (let index = 0; index < 60; index += 1) seen.add(generateBranchSlug());
74
+ expect(seen.size).toBeGreaterThan(10);
75
+ for (const slug of seen) expect(slug).toMatch(/^[a-z]+-[a-z]+$/);
76
+ });
77
+ });
78
+
79
+ describe('branch name plausibility', () => {
80
+ test('accepts what git accepts', () => {
81
+ expect(isPlausibleBranchName('wt/ok')).toBe(true);
82
+ expect(isPlausibleBranchName('feat/payments-v2')).toBe(true);
83
+ });
84
+
85
+ test('refuses what git refuses, before git has to say so', () => {
86
+ expect(isPlausibleBranchName('')).toBe(false);
87
+ expect(isPlausibleBranchName('has space')).toBe(false);
88
+ expect(isPlausibleBranchName('a..b')).toBe(false);
89
+ expect(isPlausibleBranchName('-leading')).toBe(false);
90
+ expect(isPlausibleBranchName('trailing/')).toBe(false);
91
+ expect(isPlausibleBranchName('tilde~1')).toBe(false);
92
+ expect(isPlausibleBranchName('at@{1}')).toBe(false);
93
+ expect(isPlausibleBranchName('a//b')).toBe(false);
94
+ });
95
+ });