@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
@@ -20,6 +20,7 @@ import type { ComposerModes } from '@/client/components/workspace/chat-timeline/
20
20
  import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
21
21
  import type { DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
22
22
  import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
23
+ import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
23
24
 
24
25
  export interface PendingChatViewProps {
25
26
  className: string;
@@ -59,6 +60,10 @@ export interface PendingChatViewProps {
59
60
  onWaitingForChange: (candidate: WaitingCandidate | null) => void;
60
61
  heldWait: { title: string | null } | null;
61
62
  onCancelWait: () => void;
63
+ /** Where the chat will run: the folder, or one of its worktrees. */
64
+ spawnTarget?: SpawnTarget | null;
65
+ onSpawnTargetChange?: (next: SpawnTarget | null) => void;
66
+ onWorktreeReport?: (message: string) => void;
62
67
  }
63
68
 
64
69
  export function PendingChatView(props: PendingChatViewProps) {
@@ -101,6 +106,9 @@ export function PendingChatView(props: PendingChatViewProps) {
101
106
  onWaitingForChange={props.onWaitingForChange}
102
107
  heldWait={props.heldWait}
103
108
  onCancelWait={props.onCancelWait}
109
+ spawnTarget={props.spawnTarget}
110
+ onSpawnTargetChange={props.onSpawnTargetChange}
111
+ onWorktreeReport={props.onWorktreeReport}
104
112
  />
105
113
  );
106
114
  }
@@ -88,6 +88,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
88
88
  extensionDialogs,
89
89
  resolveExtensionDialog,
90
90
  respondToExtensionUi,
91
+ spawnTarget,
92
+ setSpawnTarget,
91
93
  } = useChatTimeline({ folders, streamStatusMap, appSettings, reportActionError: pushToast });
92
94
 
93
95
  // Ask dialogs render on their own tool card; anything else omp is blocked on
@@ -215,6 +217,9 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
215
217
  onWaitingForChange={waitingDependency.set}
216
218
  heldWait={heldWait ? { title: heldWait.dependsOnTitle } : null}
217
219
  onCancelWait={cancelHeldWait}
220
+ spawnTarget={spawnTarget}
221
+ onSpawnTargetChange={setSpawnTarget}
222
+ onWorktreeReport={(message) => pushToast(message, 'success', { duration: 6000 })}
218
223
  />
219
224
  );
220
225
  }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer's target picker and the New Worktree dialog, rendered.
8
+ *
9
+ * Unit-testing markup is usually a test of the test, so these cases are the ones
10
+ * a reader would otherwise discover in a browser and nowhere else:
11
+ *
12
+ * - the trigger names the WORKTREE when one is picked, because "kabmo" alone
13
+ * would be equally true of the checkout it is not running in;
14
+ * - the menu's groups are the three the plan names, and creating is offered
15
+ * ABOVE the list so a long list cannot hide it;
16
+ * - the dialog opens on a generated branch with the folder following it, and
17
+ * the folder keeps following until it is edited (the defect this rule
18
+ * prevents: a folder named after the previous branch).
19
+ */
20
+
21
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
22
+ import { Window } from 'happy-dom';
23
+ import { h, render } from 'preact';
24
+ import { act } from 'preact/test-utils';
25
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
26
+ import { TargetPicker, targetLabel } from '@/client/components/workspace/chat-timeline/target-picker/index';
27
+ import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
28
+ import type { WorkspaceFolderData } from '@/shared/types';
29
+
30
+ const folder: WorkspaceFolderData = {
31
+ id: 1,
32
+ name: 'kabmo',
33
+ project_path: '/repo',
34
+ isExpanded: true,
35
+ hasMore: false,
36
+ totalSessions: 0,
37
+ sessions: [],
38
+ };
39
+
40
+ const nativeFetch = Bun.fetch;
41
+ const mounted: HTMLElement[] = [];
42
+
43
+ beforeAll(() => {
44
+ installDomGlobals(new Window({ url: 'http://localhost' }));
45
+ });
46
+
47
+ afterEach(() => {
48
+ for (const host of mounted.splice(0)) {
49
+ render(null, host);
50
+ host.remove();
51
+ }
52
+ (globalThis as unknown as Record<string, unknown>).fetch = nativeFetch;
53
+ });
54
+
55
+ afterAll(() => {
56
+ restoreDomGlobals();
57
+ });
58
+
59
+ /**
60
+ * Mount, with the first render FLUSHED. Preact batches a state update into a
61
+ * microtask, so a `useEffect` that seeds a field (the dialog's generated branch)
62
+ * has not run when `render` returns — reading the inputs then reads empty ones,
63
+ * which is a property of the test harness and not of the dialog.
64
+ */
65
+ async function mount(node: h.JSX.Element): Promise<HTMLElement> {
66
+ const host = document.createElement('div');
67
+ document.body.appendChild(host);
68
+ await act(async () => {
69
+ render(node, host);
70
+ });
71
+ mounted.push(host);
72
+ return host;
73
+ }
74
+
75
+ /** The picker reads the worktree list on open; answer it without a server. */
76
+ /** Calls the picker made, so a test can tell "not read" from "read empty". */
77
+ let fetchCalls: string[] = [];
78
+
79
+ function stubWorktreeList(worktrees: unknown[] = []): void {
80
+ fetchCalls = [];
81
+ (globalThis as unknown as Record<string, unknown>).fetch = async (input: RequestInfo | URL) => {
82
+ fetchCalls.push(String(input));
83
+ // A plain envelope, not a real `Response`: `Response.json()` resolves on a
84
+ // stream tick that this harness has no reason to wait for, and the picker
85
+ // only reads `ok` and `json()`.
86
+ const payload = { worktrees, base: '/data/worktrees/x', isRepo: true };
87
+ return { ok: true, json: async () => payload } as unknown as Response;
88
+ };
89
+ }
90
+
91
+ /** Collapsed whitespace: the markup carries newlines the reader never sees. */
92
+ function textOf(host: HTMLElement): string {
93
+ return (host.textContent ?? '').replace(/\s+/g, ' ').trim();
94
+ }
95
+
96
+ describe('targetLabel', () => {
97
+ test('the folder alone when the chat runs in the checkout', () => {
98
+ expect(targetLabel([folder], 1, null)).toBe('kabmo');
99
+ });
100
+
101
+ test('folder · worktree when a worktree is picked', () => {
102
+ expect(targetLabel([folder], 1, { folderId: 1, worktreePath: '/data/worktrees/abc/cosmic-dolphin' })).toBe(
103
+ 'kabmo · cosmic-dolphin',
104
+ );
105
+ });
106
+
107
+ test('a Windows worktree path names the worktree, not the whole path', () => {
108
+ // The label was `split('/').pop()`, which has nothing to cut in a
109
+ // `\`-separated path: the trigger read `kabmo · C:\Users\…\cosmic-dolphin`
110
+ // on a Windows runner while every POSIX leg passed.
111
+ expect(
112
+ targetLabel([folder], 1, {
113
+ folderId: 1,
114
+ worktreePath: 'C:\\Users\\u\\AppData\\Local\\Temp\\kabmo\\worktrees\\abc\\cosmic-dolphin',
115
+ }),
116
+ ).toBe('kabmo · cosmic-dolphin');
117
+ });
118
+
119
+ test('an unresolved folder still says what it is waiting for', () => {
120
+ expect(targetLabel([], null, null)).toBe('Select Workspace Context');
121
+ });
122
+ });
123
+
124
+ describe('TargetPicker', () => {
125
+ test('the menu offers the project root, both create paths, and the worktree list', async () => {
126
+ stubWorktreeList([
127
+ { path: '/data/worktrees/x/old-one', branch: 'wt/old-one', detached: false, missing: false, isPrimary: false, dirty: 0, aheadOfStart: 0, startRef: 'HEAD', sessions: 0 },
128
+ ]);
129
+ const host = await mount(
130
+ h(TargetPicker, {
131
+ folders: [folder],
132
+ selectedFolderId: 1,
133
+ setSelectedFolderId: () => {},
134
+ target: null,
135
+ setTarget: () => {},
136
+ }),
137
+ );
138
+
139
+ const trigger = host.querySelector('button');
140
+ expect(trigger?.textContent).toContain('kabmo');
141
+ await act(async () => {
142
+ (trigger as HTMLButtonElement).click();
143
+ });
144
+ // The list is read when the menu opens; each empty `act` drains one round of
145
+ // the fetch's promise chain, the same way the stash section's suite does.
146
+ for (let round = 0; round < 10; round += 1) await act(async () => {});
147
+
148
+ const text = textOf(host);
149
+ expect(text).toContain('Project root');
150
+ expect(text).toContain('Quick worktree');
151
+ expect(text).toContain('New worktree…');
152
+ expect(text).toContain('Worktrees');
153
+ // Creating sits above the LIST: a workspace with many worktrees must not
154
+ // push the action that makes one out of sight. Compared against a listed
155
+ // branch, because the group heading is also spelled "Worktrees".
156
+ expect(fetchCalls.length).toBeGreaterThan(0);
157
+ expect(text).toContain('wt/old-one');
158
+ expect(text.indexOf('Quick worktree')).toBeLessThan(text.indexOf('wt/old-one'));
159
+ });
160
+
161
+ test('the trigger shows the picked worktree, not just the folder', async () => {
162
+ const host = await mount(
163
+ h(TargetPicker, {
164
+ folders: [folder],
165
+ selectedFolderId: 1,
166
+ setSelectedFolderId: () => {},
167
+ target: { folderId: 1, worktreePath: '/data/worktrees/abc/cosmic-dolphin' },
168
+ setTarget: () => {},
169
+ }),
170
+ );
171
+ expect(host.querySelector('button')?.textContent).toContain('kabmo · cosmic-dolphin');
172
+ });
173
+ });
174
+
175
+ describe('NewWorktreeModal', () => {
176
+ test('opens on a generated branch, with the folder following it', async () => {
177
+ const host = await mount(
178
+ h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
179
+ );
180
+
181
+ const inputs = [...host.querySelectorAll('input')] as HTMLInputElement[];
182
+ const branch = inputs[0]?.value ?? '';
183
+ const folderName = inputs[2]?.value ?? '';
184
+ expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
185
+ // The folder is the branch without our namespace, so it is never `wt-x`.
186
+ expect(folderName).toBe(branch.replace(/^wt\//, ''));
187
+ expect(folderName.startsWith('wt-')).toBe(false);
188
+ });
189
+
190
+ test('the generated branch is in the FIRST paint, before any effect flushes', () => {
191
+ const host = document.createElement('div');
192
+ document.body.appendChild(host);
193
+ // Deliberately NOT `mount`: no `act`, so no effect has run. A field seeded
194
+ // by an effect reads empty here, and the browser's own window between paint
195
+ // and the effect flush is exactly when a user types into it — measured: the
196
+ // branch filled as `main` was replaced by the generated name, and the dialog
197
+ // created `wt/calm-iguana` instead of reporting that `main` is taken.
198
+ render(
199
+ h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
200
+ host,
201
+ );
202
+ mounted.push(host);
203
+
204
+ const inputs = [...host.querySelectorAll('input')] as HTMLInputElement[];
205
+ const branch = inputs[0]?.value ?? '';
206
+ expect(branch).toMatch(/^wt\/[a-z]+-[a-z]+$/);
207
+ // One generated name for both fields: deriving the folder from a SECOND
208
+ // call would show a folder the branch does not have.
209
+ expect(inputs[2]?.value ?? '').toBe(branch.replace(/^wt\//, ''));
210
+ });
211
+
212
+ test('states what happens to uncommitted changes, and on which project', async () => {
213
+ const host = await mount(
214
+ h(NewWorktreeModal, { isOpen: true, onClose: () => {}, root: '/repo', folderId: 1, folderName: 'kabmo', onCreated: () => {} }),
215
+ );
216
+ const text = textOf(host);
217
+ expect(text).toContain('Uncommitted changes stay in');
218
+ expect(text).toContain('kabmo');
219
+ });
220
+
221
+ test('renders nothing while closed', async () => {
222
+ const host = await mount(
223
+ h(NewWorktreeModal, { isOpen: false, onClose: () => {}, root: '/repo', folderId: 1, onCreated: () => {} }),
224
+ );
225
+ expect(host.textContent).toBe('');
226
+ });
227
+ });
@@ -0,0 +1,260 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Where a chat that has not started will run: the workspace, or one of the
8
+ * workspace's worktrees.
9
+ *
10
+ * One control rather than two, because both answers are the same question. The
11
+ * menu is grouped exactly as OpenChamber's draft target is — the project root
12
+ * first, then the two ways to make a worktree, then the worktrees that already
13
+ * exist — and creating one here means the user does not have to leave the draft
14
+ * to get it.
15
+ *
16
+ * The pick is `{folderId, worktreePath}`: choosing a folder CLEARS the worktree
17
+ * (the groups are mutually exclusive), while choosing a worktree keeps its
18
+ * folder, so the row the composer draws can always name both.
19
+ */
20
+
21
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
22
+ import { Check, ChevronDown, Folder, GitBranch } from 'lucide-preact';
23
+ import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
24
+ import { useWorktrees } from '@/client/hooks/workspace/worktree/index';
25
+ import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
26
+ import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
27
+ import { createWorktree } from '@/client/hooks/workspace/worktree/index';
28
+ import { lastPathSegment } from '@/shared/lib/fs/paths';
29
+ import type { WorkspaceFolderData } from '@/shared/types';
30
+
31
+ export interface TargetPickerProps {
32
+ folders: WorkspaceFolderData[];
33
+ selectedFolderId: number | null;
34
+ setSelectedFolderId: (id: number | null) => void;
35
+ target: SpawnTarget | null;
36
+ setTarget: (next: SpawnTarget | null) => void;
37
+ /**
38
+ * Reports a completed create/removal to the caller (a toast). Optional: the
39
+ * picker owns the flows, so a caller that only wants the pick needs nothing.
40
+ */
41
+ onReport?: (message: string) => void;
42
+ }
43
+
44
+ /** The trigger's words: the folder, plus the worktree when one is picked. */
45
+ export function targetLabel(
46
+ folders: WorkspaceFolderData[],
47
+ selectedFolderId: number | null,
48
+ target: SpawnTarget | null,
49
+ ): string {
50
+ const folder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId));
51
+ const name = folder?.name ?? 'Select Workspace Context';
52
+ const worktree = lastPathSegment(target?.worktreePath);
53
+ return worktree ? `${name} · ${worktree}` : name;
54
+ }
55
+
56
+ export function TargetPicker({
57
+ folders,
58
+ selectedFolderId,
59
+ setSelectedFolderId,
60
+ target,
61
+ setTarget,
62
+ onReport,
63
+ }: TargetPickerProps) {
64
+ const [open, setOpen] = useState(false);
65
+ const [creating, setCreating] = useState(false);
66
+ const [dialogFolder, setDialogFolder] = useState<WorkspaceFolderData | null>(null);
67
+ const containerRef = useRef<HTMLDivElement>(null);
68
+ useOnClickOutside(containerRef, () => setOpen(false));
69
+
70
+ const selectedFolder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId)) ?? null;
71
+ const scopeRoot = selectedFolder?.project_path ?? null;
72
+
73
+ // The list is read only while the menu is open: it costs a `git status` per
74
+ // worktree, and a closed picker must never pay for it.
75
+ const { worktrees, error, refresh } = useWorktrees(
76
+ { root: scopeRoot, folderId: selectedFolder?.id ?? null },
77
+ open && Boolean(scopeRoot),
78
+ );
79
+ useEffect(() => {
80
+ if (open) void refresh();
81
+ }, [open, refresh]);
82
+
83
+ const list = useMemo(() => worktrees.filter((worktree) => !worktree.isPrimary), [worktrees]);
84
+
85
+ const pickFolder = (folder: WorkspaceFolderData) => {
86
+ setSelectedFolderId(folder.id);
87
+ // A folder and a worktree are alternative answers, not a pair.
88
+ setTarget(null);
89
+ setOpen(false);
90
+ };
91
+
92
+ const pickWorktree = (folder: WorkspaceFolderData, worktreePath: string) => {
93
+ setSelectedFolderId(folder.id);
94
+ setTarget({ folderId: Number(folder.id), worktreePath });
95
+ setOpen(false);
96
+ };
97
+
98
+ /**
99
+ * Quick worktree: a generated branch and folder, created and selected without
100
+ * leaving the draft. Reported rather than silent — the create takes a moment
101
+ * and a pick that changed with no word reads as a mis-click.
102
+ */
103
+ const quickWorktree = async (folder: WorkspaceFolderData) => {
104
+ if (!folder.project_path) return;
105
+ setCreating(true);
106
+ try {
107
+ const created = await createWorktree(
108
+ { root: folder.project_path, folderId: folder.id },
109
+ { mode: 'new' },
110
+ );
111
+ pickWorktree(folder, created.path);
112
+ onReport?.(`Worktree ${created.branch || lastPathSegment(created.path)} created`);
113
+ } catch (error) {
114
+ onReport?.(error instanceof Error ? error.message : String(error));
115
+ } finally {
116
+ setCreating(false);
117
+ }
118
+ };
119
+
120
+ return (
121
+ <div className="relative shrink-0" ref={containerRef}>
122
+ <button
123
+ type="button"
124
+ onClick={() => setOpen((value) => !value)}
125
+ aria-haspopup="menu"
126
+ aria-expanded={open}
127
+ className="group flex items-center space-x-1.5 px-1.5 py-1 rounded-md text-xs font-medium text-ink/75 hover:text-ink hover:bg-ink/5 focus-visible:bg-ink/5 transition-all outline-none cursor-pointer select-none"
128
+ >
129
+ {target?.worktreePath ? (
130
+ <GitBranch size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
131
+ ) : (
132
+ <Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
133
+ )}
134
+ <span className="flex items-center space-x-1 max-w-[260px] truncate tracking-tight">
135
+ <span>{targetLabel(folders, selectedFolderId, target)}</span>
136
+ </span>
137
+ <ChevronDown
138
+ size={13}
139
+ className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${open ? 'rotate-180 text-ink/70' : ''}`}
140
+ />
141
+ </button>
142
+
143
+ {open && (
144
+ <div
145
+ role="menu"
146
+ className="absolute bottom-full left-0 mb-1.5 w-72 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1"
147
+ >
148
+ <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
149
+ Project root
150
+ </div>
151
+ <div className="max-h-56 scrollbar-overlay-container py-1">
152
+ {folders.length === 0 ? (
153
+ <div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
154
+ ) : (
155
+ folders.map((folder) => (
156
+ <button
157
+ key={folder.id}
158
+ type="button"
159
+ role="menuitemradio"
160
+ aria-checked={selectedFolderId === folder.id && !target?.worktreePath}
161
+ onClick={() => pickFolder(folder)}
162
+ className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
163
+ selectedFolderId === folder.id && !target?.worktreePath
164
+ ? 'bg-ink/10 text-ink font-semibold'
165
+ : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
166
+ }`}
167
+ >
168
+ <span className="flex items-center space-x-2 truncate pr-2">
169
+ <Folder size={12} className={selectedFolderId === folder.id ? 'text-ink flex-shrink-0' : 'text-ink/50 flex-shrink-0'} />
170
+ <span className="truncate font-medium">{folder.name}</span>
171
+ </span>
172
+ {selectedFolderId === folder.id && !target?.worktreePath && <Check size={12} className="text-ink flex-shrink-0" />}
173
+ </button>
174
+ ))
175
+ )}
176
+ </div>
177
+
178
+ {/* Making one comes before the list, so a long list never hides it. */}
179
+ {selectedFolder && scopeRoot && (
180
+ <>
181
+ <div className="border-t border-ink/5 px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold">
182
+ Worktrees
183
+ </div>
184
+ <button
185
+ type="button"
186
+ role="menuitem"
187
+ disabled={creating}
188
+ onClick={() => void quickWorktree(selectedFolder)}
189
+ className="w-full text-left px-3 py-1.5 flex items-center gap-2 text-ink/80 hover:bg-ink/5 hover:text-ink disabled:opacity-50"
190
+ >
191
+ <span aria-hidden="true">⚡</span>
192
+ {creating ? 'Creating…' : 'Quick worktree'}
193
+ </button>
194
+ <button
195
+ type="button"
196
+ role="menuitem"
197
+ onClick={() => {
198
+ setOpen(false);
199
+ setDialogFolder(selectedFolder);
200
+ }}
201
+ className="w-full text-left px-3 py-1.5 flex items-center gap-2 text-ink/80 hover:bg-ink/5 hover:text-ink"
202
+ >
203
+ <GitBranch size={12} className="text-ink/50" />
204
+ New worktree…
205
+ </button>
206
+ <div className="max-h-56 scrollbar-overlay-container py-1">
207
+ {list.length === 0 ? (
208
+ <div className="px-3 py-2 text-ink/40 italic">
209
+ {error ? 'Worktrees could not be read' : 'No worktrees yet'}
210
+ </div>
211
+ ) : (
212
+ list.map((worktree) => (
213
+ <button
214
+ key={worktree.path}
215
+ type="button"
216
+ role="menuitemradio"
217
+ aria-checked={target?.worktreePath === worktree.path}
218
+ onClick={() => pickWorktree(selectedFolder, worktree.path)}
219
+ title={worktree.path}
220
+ className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
221
+ target?.worktreePath === worktree.path
222
+ ? 'bg-ink/10 text-ink font-semibold'
223
+ : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
224
+ }`}
225
+ >
226
+ <span className="flex items-center gap-2 truncate pr-2">
227
+ <GitBranch size={12} className="text-ink/50 flex-shrink-0" />
228
+ <span className="truncate font-medium">{worktree.branch || 'detached'}</span>
229
+ {worktree.missing && <span className="text-warning shrink-0">missing</span>}
230
+ {worktree.dirty > 0 && (
231
+ <span className="text-ink/40 shrink-0" title={`${worktree.dirty} uncommitted files`}>
232
+ ●{worktree.dirty}
233
+ </span>
234
+ )}
235
+ </span>
236
+ {target?.worktreePath === worktree.path && <Check size={12} className="text-ink flex-shrink-0" />}
237
+ </button>
238
+ ))
239
+ )}
240
+ </div>
241
+ </>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ <NewWorktreeModal
247
+ isOpen={dialogFolder !== null}
248
+ onClose={() => setDialogFolder(null)}
249
+ root={dialogFolder?.project_path ?? null}
250
+ folderId={dialogFolder ? Number(dialogFolder.id) : null}
251
+ folderName={dialogFolder?.name}
252
+ onCreated={(created) => {
253
+ if (!dialogFolder) return;
254
+ pickWorktree(dialogFolder, created.path);
255
+ onReport?.(`Worktree ${created.branch} created`);
256
+ }}
257
+ />
258
+ </div>
259
+ );
260
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The `eval` card body.
8
+ *
9
+ * The alignment defect is pinned here, and it was reported from a live
10
+ * transcript rather than found by reading the code: **the gutter numbers
11
+ * drifted off the code.** The gutter row was `text-[10px]` while the code was
12
+ * `text-[11px]`, and both inherited `leading-relaxed` — a ratio, so the row box
13
+ * was 16.25px against the code's 17.875px and every number sat higher than its
14
+ * line by a growing amount (measured after a reload: line 12's number was a
15
+ * full line above its code). The two sides now share one explicit line box,
16
+ * which is what the alignment case asserts — by comparing the two elements' own
17
+ * leading class, so a future edit that changes one side is caught instead of
18
+ * copied.
19
+ *
20
+ * The result cases cover the OTHER half of the same report ("output does not
21
+ * render while streaming"): the panel does read `tool.output` as it arrives, so
22
+ * what made a live eval look empty was the card being folded — pinned in
23
+ * `hooks/chat/timeline/tool-open.test.ts`, where a running eval opens itself.
24
+ *
25
+ * Rendered with `h()` against happy-dom, following `panels/Read.test.ts`.
26
+ */
27
+
28
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
29
+ import { Window } from 'happy-dom';
30
+ import { h, render } from 'preact';
31
+ import { act } from 'preact/test-utils';
32
+ import { Eval } from '@/client/components/workspace/chat-timeline/tool-renderers/panels/Eval';
33
+ import type { ToolCallData } from '@/shared/types';
34
+
35
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
36
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
37
+
38
+ let container: HTMLElement;
39
+
40
+ beforeAll(() => {
41
+ const win = new Window({ url: 'http://localhost' });
42
+ const target = globalThis as unknown as Record<string, unknown>;
43
+ for (const key of DOM_GLOBALS) {
44
+ nativeGlobals[key] = target[key];
45
+ target[key] = (win as unknown as Record<string, unknown>)[key];
46
+ }
47
+ });
48
+
49
+ afterAll(() => {
50
+ const target = globalThis as unknown as Record<string, unknown>;
51
+ for (const key of DOM_GLOBALS) {
52
+ if (nativeGlobals[key] === undefined) delete target[key];
53
+ else target[key] = nativeGlobals[key];
54
+ }
55
+ });
56
+
57
+ beforeEach(() => {
58
+ container = document.createElement('div');
59
+ document.body.appendChild(container);
60
+ });
61
+
62
+ function mount(tool: ToolCallData): HTMLElement {
63
+ act(() => {
64
+ render(h(Eval, { tool }), container);
65
+ });
66
+ return container;
67
+ }
68
+
69
+ const CODE = ['const a = 1;', 'const b = 2;', 'console.log(a + b);'].join('\n');
70
+
71
+ function evalTool(overrides: Partial<ToolCallData> = {}): ToolCallData {
72
+ return {
73
+ id: 'call_1',
74
+ type: 'eval',
75
+ name: 'eval',
76
+ title: 'Sum two numbers',
77
+ input: { language: 'js', code: CODE },
78
+ status: 'running',
79
+ ...overrides,
80
+ } as ToolCallData;
81
+ }
82
+
83
+ /** The `leading-[…]` token a node carries, or null. */
84
+ function leadingOf(el: Element): string | null {
85
+ return /(?:^|\s)(leading-\[\d+px\])/.exec(el.className)?.[1] ?? null;
86
+ }
87
+
88
+ /** The code card: the gutter/pre pair whose pre holds the SCRIPT. */
89
+ function codeCard(root: HTMLElement): { gutter: Element; pre: Element } {
90
+ const gutter = root.querySelectorAll('.sticky')[0];
91
+ const pre = gutter?.parentElement?.querySelector('pre');
92
+ if (!gutter || !pre) throw new Error('code card not rendered');
93
+ return { gutter, pre };
94
+ }
95
+
96
+ describe('Eval — result rendering', () => {
97
+ test('a running call renders the output it has so far', () => {
98
+ // omp's `tool_execution_update` carries the cell's output SO FAR, and the
99
+ // panel reads it from `tool.output` — which is what the fold writes from
100
+ // the frame's `content`. Verified against a real omp run: `content` and
101
+ // `details.cells[].output` are byte-identical on every update frame.
102
+ const root = mount(evalTool({ output: 'live 1\nlive 2' }));
103
+ const text = root.textContent ?? '';
104
+ expect(text).toContain('live 1');
105
+ expect(text).toContain('live 2');
106
+ });
107
+
108
+ test('renders only the script while a running call has produced nothing yet', () => {
109
+ const root = mount(evalTool({ output: '' }));
110
+ expect(root.querySelectorAll('pre')).toHaveLength(1);
111
+ expect(root.textContent).toContain('Script Evaluation');
112
+ });
113
+ });
114
+
115
+ describe('Eval — gutter aligns with the code', () => {
116
+ test('the gutter and the code share one line box', () => {
117
+ const { gutter, pre } = codeCard(mount(evalTool()));
118
+ const gutterLeading = leadingOf(gutter);
119
+ expect(gutterLeading).not.toBeNull();
120
+ // The bug: an inherited RATIO (`leading-relaxed`) resolved against two
121
+ // different font sizes, so the boxes differed by 1.625px per line.
122
+ expect(leadingOf(pre)).toBe(gutterLeading);
123
+ });
124
+
125
+ test('one gutter row per code line', () => {
126
+ const { gutter } = codeCard(mount(evalTool()));
127
+ expect(gutter.children).toHaveLength(CODE.split('\n').length);
128
+ });
129
+
130
+ test('the JSON result block aligns the same way', () => {
131
+ // The pretty-printed result draws its own gutter, and it had the same
132
+ // mismatch (plus a `py-2` against the code's `p-2.5`).
133
+ const root = mount(evalTool({ output: '{"a": 1, "b": [2, 3]}', status: 'success' }));
134
+ const gutters = root.querySelectorAll('.sticky');
135
+ const jsonGutter = gutters[1];
136
+ expect(jsonGutter).toBeDefined();
137
+ const pre = jsonGutter.parentElement?.querySelector('pre');
138
+ expect(pre).not.toBeNull();
139
+ // Explicit on BOTH, not merely equal: `null === null` would pass while both
140
+ // sides inherit a ratio against different font sizes, which is the bug.
141
+ expect(leadingOf(jsonGutter)).not.toBeNull();
142
+ expect(leadingOf(pre as Element)).toBe(leadingOf(jsonGutter));
143
+ // `{"a": 1, "b": [2, 3]}` pretty-prints to 7 lines.
144
+ expect(jsonGutter.children).toHaveLength(7);
145
+ });
146
+ });