@kabmo-labs/kabmo 0.6.1 → 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-aq0e78gv.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 +74 -1
  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 +126 -50
  101. package/src/server/lib/updates/shell-release.ts +139 -88
  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
@@ -4,6 +4,7 @@ import { useSearchParams } from '@/client/lib/router/search-params';
4
4
  import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
5
5
  import { SubagentList } from '@/client/components/common/subagent-list';
6
6
  import { WorkspaceOptionsMenu } from '@/client/components/common/workspace-options-menu';
7
+ import { useWorktreeDialog } from '@/client/components/mobile/mobile-session-sidebar/WorktreeDialog';
7
8
  import { useShowMore } from '@/client/hooks/ui/show-more';
8
9
  import { useExpandedSessions } from '@/client/hooks/workspace/expanded-sessions';
9
10
  import { useWorkspaceFolderActions } from '@/client/hooks/workspace/workspace-folder-actions';
@@ -25,6 +26,8 @@ interface MobileSessionCategoryProps {
25
26
  activeSessionId: number | string | null;
26
27
  onSelectSession: (id: number | string) => void;
27
28
  onNewSessionForFolder: (folderId: number) => void;
29
+ /** Start a chat in a worktree made from this folder. */
30
+ onNewWorktreeSession?: (folderId: number, worktreePath: string) => void;
28
31
  isExpanded: boolean;
29
32
  onToggleExpand: () => void;
30
33
  showArchived?: boolean;
@@ -43,6 +46,7 @@ export function MobileSessionCategory({
43
46
  activeSessionId,
44
47
  onSelectSession,
45
48
  onNewSessionForFolder,
49
+ onNewWorktreeSession,
46
50
  isExpanded,
47
51
  onToggleExpand,
48
52
  showArchived = false,
@@ -53,6 +57,11 @@ export function MobileSessionCategory({
53
57
  onCancelWait,
54
58
  }: MobileSessionCategoryProps) {
55
59
  const { visibleCount, showMore, showLess, canShowLess } = useShowMore();
60
+ // The phone's folder row opens the same dialog the desktop one does. Its state
61
+ // lives in a hook so this row stays under the size ceiling.
62
+ const worktreeDialog = useWorktreeDialog(folder, (worktreePath) =>
63
+ onNewWorktreeSession?.(folder.id, worktreePath),
64
+ );
56
65
  const { refresh } = useSidebarData();
57
66
  const { expandedSessionIds, toggleSession } = useExpandedSessions();
58
67
  // The viewed transcript, so its session row dims while a roster entry is
@@ -223,6 +232,11 @@ export function MobileSessionCategory({
223
232
  variant="mobile"
224
233
  isPinned={folder.isPinned}
225
234
  onOpenFolder={onOpenFolder}
235
+ onNewWorktree={
236
+ onNewWorktreeSession && folder.project_path
237
+ ? () => { setShowMenu(false); worktreeDialog.open(); }
238
+ : undefined
239
+ }
226
240
  onSelectSessions={
227
241
  onStartSessionSelection
228
242
  ? () => { setShowMenu(false); onStartSessionSelection(folder.id); }
@@ -326,6 +340,8 @@ export function MobileSessionCategory({
326
340
  onClose={sessionDelete.cancelDelete}
327
341
  onConfirm={() => void sessionDelete.confirmDelete()}
328
342
  />
343
+
344
+ {worktreeDialog.dialog}
329
345
  </div>
330
346
  );
331
347
  }
@@ -10,6 +10,8 @@ interface MobileSessionListProps {
10
10
  sessionStatus: Record<string, 'stream' | 'finish' | 'abort'>;
11
11
  onSelectSession: (id: number | string) => void;
12
12
  onNewSessionForFolder: (folderId: number) => void;
13
+ /** Start a chat in a worktree made from this folder. */
14
+ onNewWorktreeSession?: (folderId: number, worktreePath: string) => void;
13
15
  onToggleFolder: (folderId: number) => void;
14
16
  /** Surfaces a refused folder action (the reveal endpoint's own reason). */
15
17
  onToast?: (message: string, type?: 'success' | 'error') => void;
@@ -28,6 +30,7 @@ export function MobileSessionList({
28
30
  sessionStatus,
29
31
  onSelectSession,
30
32
  onNewSessionForFolder,
33
+ onNewWorktreeSession,
31
34
  onToggleFolder,
32
35
  onToast,
33
36
  batch,
@@ -55,6 +58,7 @@ export function MobileSessionList({
55
58
  activeSessionId={activeSessionId}
56
59
  onSelectSession={onSelectSession}
57
60
  onNewSessionForFolder={onNewSessionForFolder}
61
+ onNewWorktreeSession={onNewWorktreeSession}
58
62
  isExpanded={expandedFolders[folder.id] ?? true}
59
63
  onToggleExpand={() => onToggleFolder(folder.id)}
60
64
  showArchived={showArchived}
@@ -4,6 +4,7 @@ import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
  import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
5
5
  import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
6
6
  import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
7
+ import { WorktreeBadge } from '@/client/components/layout/session-sidebar/WorktreeBadge';
7
8
 
8
9
  export interface MobileSessionRowProps {
9
10
  session: SessionItemData;
@@ -175,6 +176,10 @@ export function MobileSessionRow({
175
176
  waiting for {waitingFor?.title ?? 'another session'}
176
177
  </span>
177
178
  )}
179
+ {/* The same chip the desktop row draws, from the same field on the
180
+ row payload — one layout cannot be the only one that says where
181
+ the chat writes. */}
182
+ {session.worktree ? <WorktreeBadge worktree={session.worktree} /> : null}
178
183
  </span>
179
184
  </div>
180
185
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The New Worktree dialog as a phone folder row opens it.
8
+ *
9
+ * Split out because the row was at the file-size ceiling and because the
10
+ * dialog's state (open, and which folder) belongs to the dialog rather than to a
11
+ * row that also draws sessions, a `⋯` menu and a delete confirmation. The row
12
+ * calls the hook, hands `open` to its menu row and renders `dialog` at its tail —
13
+ * the menu row has to stay INSIDE the dropdown, which is why this returns the
14
+ * modal rather than the trigger.
15
+ */
16
+
17
+ import { useState } from 'preact/hooks';
18
+ import { NewWorktreeModal } from '@/client/components/workspace/worktree-modal/index';
19
+ import type { WorkspaceFolderData } from '@/shared/types';
20
+
21
+ export interface WorktreeDialogHandle {
22
+ open: () => void;
23
+ /** Rendered at the row's tail; null until the dialog has been opened once. */
24
+ dialog: preact.JSX.Element | null;
25
+ }
26
+
27
+ export function useWorktreeDialog(
28
+ folder: WorkspaceFolderData,
29
+ onCreated: (worktreePath: string) => void,
30
+ ): WorktreeDialogHandle {
31
+ const [isOpen, setIsOpen] = useState(false);
32
+
33
+ return {
34
+ open: () => setIsOpen(true),
35
+ dialog: isOpen ? (
36
+ <NewWorktreeModal
37
+ isOpen
38
+ onClose={() => setIsOpen(false)}
39
+ root={folder.project_path ?? null}
40
+ folderId={folder.id}
41
+ folderName={folder.name}
42
+ onCreated={(created) => {
43
+ setIsOpen(false);
44
+ onCreated(created.path);
45
+ }}
46
+ />
47
+ ) : null,
48
+ };
49
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Starting a chat inside a worktree, from either layout's sidebar.
8
+ *
9
+ * Desktop and phone do the same three things in the same order, and the order is
10
+ * what makes it work: prewarm the child in the WORKTREE cwd (not the project's),
11
+ * mint the pending session, write its target BEFORE the URL names it, then
12
+ * navigate. Writing the target afterwards would leave the composer drawing the
13
+ * folder while the send path already had its ref armed for something else — the
14
+ * one outcome the pick exists to prevent.
15
+ *
16
+ * Extracted so both sidebars call one function rather than repeating a sequence
17
+ * whose middle step is invisible when it is wrong.
18
+ */
19
+
20
+ import { startPendingSession } from '@/client/hooks/chat/omp/pending-sessions';
21
+ import { triggerSessionPrewarm } from '@/shared/lib/omp/session/prewarm';
22
+ import { setSessionKey } from '@/shared/lib/workspace/session-state/store';
23
+ import { SPAWN_TARGET_KEY } from '@/client/hooks/chat/timeline/spawn-target';
24
+
25
+ export interface WorktreeSessionDeps {
26
+ setSearchParams: (fn: (prev: URLSearchParams) => URLSearchParams, options?: { replace?: boolean }) => void;
27
+ /** Called after the navigation — the phone closes its drawer here. */
28
+ afterNavigate?: () => void;
29
+ }
30
+
31
+ /**
32
+ * Open a pending chat in `worktreePath`, scoped to `folderId`.
33
+ * Returns the pending session's id, so a caller can report or follow it.
34
+ */
35
+ export function startWorktreeSession(
36
+ deps: WorktreeSessionDeps,
37
+ folderId: number,
38
+ worktreePath: string,
39
+ ): string {
40
+ triggerSessionPrewarm(worktreePath);
41
+ const pendingId = startPendingSession(folderId);
42
+ setSessionKey(pendingId, SPAWN_TARGET_KEY, { folderId, worktreePath });
43
+ deps.setSearchParams((prev) => {
44
+ const next = new URLSearchParams(prev);
45
+ next.set('sessionId', pendingId);
46
+ next.set('folderId', folderId.toString());
47
+ return next;
48
+ }, { replace: true });
49
+ deps.afterNavigate?.();
50
+ return pendingId;
51
+ }
@@ -6,9 +6,12 @@ import { LoadingState } from '@/client/components/settings/LoadingState';
6
6
  import { useSettingsMasterDetail } from '@/client/hooks/settings/master-detail';
7
7
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
8
8
  import { SettingsMasterDetail } from '@/client/components/settings/master-detail';
9
+ import { WorktreeSettings } from '@/client/components/settings/categories/project-settings/WorktreeSettings';
10
+ import { useToasts } from '@/client/hooks/ui/toasts';
9
11
 
10
12
  export function ProjectSettings() {
11
13
  const masterDetail = useSettingsMasterDetail();
14
+ const { pushToast } = useToasts();
12
15
  const [projects, setProjects] = useState<ProjectConfigItem[]>([]);
13
16
  const [availableModels, setAvailableModels] = useState<string[]>([]);
14
17
  const [accentColorOptions, setAccentColorOptions] = useState<AccentColorOption[]>([]);
@@ -179,14 +182,25 @@ export function ProjectSettings() {
179
182
  }
180
183
  detail={
181
184
  selectedProject ? (
182
- <ProjectDetailsForm
183
- project={selectedProject}
184
- canDelete={projects.length > 1}
185
- onUpdateField={handleUpdateField}
186
- onDeleteProject={handleDeleteProject}
187
- availableModels={availableModels}
188
- accentColorOptions={accentColorOptions}
189
- />
185
+ <div className="flex flex-col">
186
+ <ProjectDetailsForm
187
+ project={selectedProject}
188
+ canDelete={projects.length > 1}
189
+ onUpdateField={handleUpdateField}
190
+ onDeleteProject={handleDeleteProject}
191
+ availableModels={availableModels}
192
+ accentColorOptions={accentColorOptions}
193
+ />
194
+ {/* Worktrees belong to the PROJECT, not to a session: a create here
195
+ has no chat, and the next chat picks it from the composer. */}
196
+ <div className="border-t border-ink/10 px-4 py-4">
197
+ <WorktreeSettings
198
+ root={selectedProject.path}
199
+ folderId={selectedProject.folderId ?? null}
200
+ onReport={(message, tone) => pushToast(message, tone ?? 'success', { duration: 6000 })}
201
+ />
202
+ </div>
203
+ </div>
190
204
  ) : null
191
205
  }
192
206
  />
@@ -0,0 +1,140 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Settings → Projects → Worktrees: the list, and the two settings that decide
8
+ * where a new one lands.
9
+ *
10
+ * A row carries what the reader actually asks of a list of worktrees: which
11
+ * branch is held there (and therefore cannot be checked out anywhere else), how
12
+ * many chats start in it, and whether it is dirty. `missing` is its own line
13
+ * because a worktree whose folder was deleted outside Kabmo still owns its
14
+ * sessions, and hiding it would hide them.
15
+ *
16
+ * The two fields below the list are the settings a create reads on the server
17
+ * (`worktreeDirectory`, `worktreeCarryIgnored`), so what is typed here is what
18
+ * the next create uses — including one started by a scheduled task.
19
+ */
20
+
21
+ import { useState } from 'preact/hooks';
22
+ import { readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
23
+ import { useWorktrees } from '@/client/hooks/workspace/worktree/index';
24
+ import { removeWorktree } from '@/client/hooks/workspace/worktree/index';
25
+ import { WORKTREE_CARRY_SETTING, WORKTREE_DIRECTORY_SETTING } from '@/shared/lib/fs/worktree/settings-keys';
26
+ import { branchDeletable, removalRefusal, worktreeRowDetail } from '@/client/components/settings/categories/project-settings/worktree-row';
27
+
28
+ export interface WorktreeSettingsProps {
29
+ /** The selected project's `project_path`; the section is inert without one. */
30
+ root: string | null;
31
+ folderId: number | null;
32
+ /** Report a refusal — the section never claims a removal it did not get. */
33
+ onReport: (message: string, tone?: 'success' | 'error') => void;
34
+ }
35
+
36
+ export function WorktreeSettings({ root, folderId, onReport }: WorktreeSettingsProps) {
37
+ const { worktrees, base, loading, error, refresh } = useWorktrees({ root, folderId }, Boolean(root));
38
+ const [directory, setDirectory] = useState(() => {
39
+ const stored = readKabmoSetting<string>(WORKTREE_DIRECTORY_SETTING);
40
+ return typeof stored === 'string' ? stored : '';
41
+ });
42
+ const [carry, setCarry] = useState(() => readKabmoSetting<boolean>(WORKTREE_CARRY_SETTING) !== false);
43
+ // `detached` rows name a commit instead of a branch, and the payload carries it.
44
+
45
+ const [removing, setRemoving] = useState<string | null>(null);
46
+
47
+ if (!root) {
48
+ return <p className="text-xs text-ink/50">Bind this workspace to a repository to manage its worktrees.</p>;
49
+ }
50
+
51
+ const removable = worktrees.filter((worktree) => !worktree.isPrimary);
52
+
53
+ return (
54
+ <div className="flex flex-col gap-4">
55
+ <section className="flex flex-col gap-1">
56
+ <h3 className="text-xs font-semibold text-ink">Worktrees</h3>
57
+ {loading && <p className="text-[11px] text-ink/50">Reading worktrees…</p>}
58
+ {error && (
59
+ // A failed read is REPORTED, never rendered as "none": the two look
60
+ // identical on screen and only one of them is actionable.
61
+ <p className="text-[11px] text-error" title={error}>
62
+ Worktrees could not be read: {error}
63
+ </p>
64
+ )}
65
+ {!loading && !error && removable.length === 0 && (
66
+ <p className="text-[11px] text-ink/50">No worktrees yet.</p>
67
+ )}
68
+ {removable.map((worktree) => (
69
+ <div key={worktree.path} className="group flex items-center gap-2 rounded-md px-1 py-1.5 hover:bg-ink/5">
70
+ <div className="min-w-0 flex-1">
71
+ <p className="truncate text-xs text-ink">{worktree.branch || 'detached'}</p>
72
+ <p className="truncate text-[10.5px] text-ink/50" title={worktree.path}>
73
+ {worktreeRowDetail(worktree)}
74
+ </p>
75
+ </div>
76
+ <button
77
+ type="button"
78
+ disabled={removing === worktree.path || removalRefusal(worktree) !== null}
79
+ aria-label={`Remove worktree ${worktree.branch || worktree.path}`}
80
+ title={removalRefusal(worktree) ?? 'Remove worktree'}
81
+ onClick={() => {
82
+ setRemoving(worktree.path);
83
+ void removeWorktree({ root, folderId }, worktree.path, { deleteBranch: branchDeletable(worktree) })
84
+ .then((result) => {
85
+ onReport(
86
+ result.branchDeleted
87
+ ? `Removed ${worktree.branch} and its branch`
88
+ : `Removed ${worktree.branch}${result.branchKept ? ' (branch kept)' : ''}`,
89
+ 'success',
90
+ );
91
+ return refresh();
92
+ })
93
+ .catch((cause: unknown) => onReport(cause instanceof Error ? cause.message : String(cause), 'error'))
94
+ .finally(() => setRemoving(null));
95
+ }}
96
+ className="shrink-0 rounded px-2 py-1 text-[11px] text-ink/60 hover:bg-error/10 hover:text-error disabled:opacity-40"
97
+ >
98
+ Remove
99
+ </button>
100
+ </div>
101
+ ))}
102
+ </section>
103
+
104
+ <label className="flex flex-col gap-1">
105
+ <span className="text-xs font-semibold text-ink">Worktree location</span>
106
+ <input
107
+ value={directory}
108
+ onChange={(event) => setDirectory(event.currentTarget.value)}
109
+ onBlur={() => writeSetting(WORKTREE_DIRECTORY_SETTING, directory.trim())}
110
+ placeholder={base}
111
+ className="h-8 rounded-md border border-ink/15 bg-paper px-2 text-xs text-ink outline-none focus:border-ink/40"
112
+ />
113
+ <span className="text-[10.5px] text-ink/50">
114
+ Where new worktrees are created. Relative paths are resolved against the project; ~ is your home directory.
115
+ Empty uses {base}.
116
+ </span>
117
+ </label>
118
+
119
+ <label className="flex items-start gap-2">
120
+ <input
121
+ type="checkbox"
122
+ checked={carry}
123
+ onChange={(event) => {
124
+ const next = event.currentTarget.checked;
125
+ setCarry(next);
126
+ writeSetting(WORKTREE_CARRY_SETTING, next);
127
+ }}
128
+ className="mt-0.5"
129
+ />
130
+ <span className="flex flex-col gap-0.5">
131
+ <span className="text-xs font-semibold text-ink">Copy ignored files into new worktrees</span>
132
+ <span className="text-[10.5px] text-ink/50">
133
+ A new worktree gets the project's ignored build folders (node_modules, .venv, target) copied in, so it works
134
+ without reinstalling.
135
+ </span>
136
+ </span>
137
+ </label>
138
+ </div>
139
+ );
140
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * What a worktree row says, and when its Remove control is refused.
8
+ *
9
+ * These are the sentences a reader decides on, so they are functions rather than
10
+ * inline template strings: a markup test would prove the component renders
11
+ * *something*, while the questions here are "which branch is held" (and therefore
12
+ * cannot be checked out anywhere else), "how many chats start here", and "would
13
+ * this removal lose work".
14
+ */
15
+
16
+ import { describe, expect, test } from 'bun:test';
17
+ import {
18
+ branchDeletable,
19
+ removalRefusal,
20
+ worktreeRowDetail,
21
+ } from '@/client/components/settings/categories/project-settings/worktree-row';
22
+ import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
23
+
24
+ const base: WorktreeInfo = {
25
+ path: '/data/worktrees/abc/cosmic-dolphin',
26
+ branch: 'wt/cosmic-dolphin',
27
+ head: 'a1b2c3d4e5f6',
28
+ detached: false,
29
+ missing: false,
30
+ isPrimary: false,
31
+ dirty: 0,
32
+ aheadOfStart: 0,
33
+ startRef: 'HEAD',
34
+ sessions: 2,
35
+ };
36
+
37
+ describe('worktreeRowDetail', () => {
38
+ test('names the branch, the chat count and the tree state', () => {
39
+ expect(worktreeRowDetail(base)).toBe('wt/cosmic-dolphin checked out here · 2 chats · clean');
40
+ });
41
+
42
+ test('a single chat is not "1 chats"', () => {
43
+ expect(worktreeRowDetail({ ...base, sessions: 1 })).toContain('1 chat ·');
44
+ });
45
+
46
+ test('changed files are counted', () => {
47
+ expect(worktreeRowDetail({ ...base, dirty: 3 })).toContain('3 changed');
48
+ });
49
+
50
+ test('a detached row names a commit instead of a branch', () => {
51
+ expect(worktreeRowDetail({ ...base, branch: '', detached: true })).toContain('detached @ a1b2c3d');
52
+ });
53
+
54
+ test('a missing worktree says its chats still open — that is why the row stays', () => {
55
+ expect(worktreeRowDetail({ ...base, missing: true })).toContain('its chats still open');
56
+ });
57
+ });
58
+
59
+ describe('removal rules', () => {
60
+ test('a clean worktree with no commits of its own may lose its branch', () => {
61
+ expect(branchDeletable(base)).toBe(true);
62
+ expect(removalRefusal(base)).toBeNull();
63
+ });
64
+
65
+ test('a branch with commits of its own is kept, and the checkbox is disabled', () => {
66
+ expect(branchDeletable({ ...base, aheadOfStart: 2 })).toBe(false);
67
+ // The worktree itself is still removable: only the branch is protected.
68
+ expect(removalRefusal({ ...base, aheadOfStart: 2 })).toBeNull();
69
+ });
70
+
71
+ test('a dirty worktree is refused with its count', () => {
72
+ expect(removalRefusal({ ...base, dirty: 1 })).toBe('1 uncommitted file here would be lost.');
73
+ expect(removalRefusal({ ...base, dirty: 4 })).toBe('4 uncommitted files here would be lost.');
74
+ });
75
+
76
+ test('the main checkout is never removable', () => {
77
+ expect(removalRefusal({ ...base, isPrimary: true })).toContain('main checkout');
78
+ });
79
+
80
+ test('a detached row has no branch to delete', () => {
81
+ expect(branchDeletable({ ...base, branch: '', detached: true })).toBe(false);
82
+ });
83
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One worktree row's second line, and the words the delete control uses.
8
+ *
9
+ * Split from the component because these are the sentences a reader decides on:
10
+ * "which branch is held here" (and therefore cannot be checked out anywhere
11
+ * else), "how many chats start here", and "is it safe to delete". A markup test
12
+ * would prove the component renders *something*; these functions are what it has
13
+ * to say.
14
+ */
15
+
16
+ import type { WorktreeInfo } from '@/shared/lib/workspace/worktree/types';
17
+
18
+ /** What a row says under its branch. */
19
+ export function worktreeRowDetail(worktree: WorktreeInfo): string {
20
+ if (worktree.missing) return 'folder is missing — its chats still open';
21
+ const branch = worktree.detached
22
+ ? `detached @ ${worktree.head.slice(0, 7) || 'unknown'}`
23
+ : `${worktree.branch} checked out here`;
24
+ const sessions = worktree.sessions === 1 ? '1 chat' : `${worktree.sessions} chats`;
25
+ const changes = worktree.dirty === 0 ? 'clean' : `${worktree.dirty} changed`;
26
+ return `${branch} · ${sessions} · ${changes}`;
27
+ }
28
+
29
+ /**
30
+ * Whether the branch may be deleted along with the worktree.
31
+ *
32
+ * `git branch -d` refuses a branch with commits of its own, so the checkbox is
33
+ * offered only when there is nothing to lose — a control that can only be
34
+ * refused is worse than one that is disabled with the reason beside it.
35
+ */
36
+ export function branchDeletable(worktree: WorktreeInfo): boolean {
37
+ return !worktree.detached && Boolean(worktree.branch) && worktree.aheadOfStart === 0;
38
+ }
39
+
40
+ /** Why the remove control is refused, or null when it may run. */
41
+ export function removalRefusal(worktree: WorktreeInfo): string | null {
42
+ if (worktree.isPrimary) return 'The main checkout cannot be removed.';
43
+ if (worktree.dirty > 0) {
44
+ return `${worktree.dirty} uncommitted file${worktree.dirty === 1 ? '' : 's'} here would be lost.`;
45
+ }
46
+ return null;
47
+ }
@@ -1,9 +1,7 @@
1
1
  import type { SetStateAction } from 'preact/compat';
2
2
  import { Fragment } from 'preact';
3
- import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
4
- import { Check, ChevronDown, Folder } from 'lucide-preact';
5
- import type { Attachment, ChatMessageData } from '@/shared/types';
6
- import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
3
+ import { useEffect, useMemo, useRef } from 'preact/hooks';
4
+ import type { Attachment, ChatMessageData, WorkspaceFolderData } from '@/shared/types';
7
5
  import { ChatInput } from '@/client/components/workspace/chat-timeline/chat-input/index';
8
6
  import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
9
7
  import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter';
@@ -16,6 +14,8 @@ import {
16
14
  type WaitingCandidate,
17
15
  } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
18
16
  import { Hourglass } from 'lucide-preact';
17
+ import { TargetPicker } from '@/client/components/workspace/chat-timeline/target-picker/index';
18
+ import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
19
19
 
20
20
  interface EmptyWorkspacePromptProps {
21
21
  className?: string;
@@ -65,6 +65,15 @@ interface EmptyWorkspacePromptProps {
65
65
  streamStatusMap?: Record<string, string | undefined>;
66
66
  /** A name for a streaming session the sidebar has not scanned yet. */
67
67
  titleForSession?: (id: string) => string | undefined;
68
+ /**
69
+ * Where this chat will run. The picker draws it and the send path reads it
70
+ * through its own ref, so a worktree choice survives the remount a stretch or
71
+ * a reload performs.
72
+ */
73
+ spawnTarget?: SpawnTarget | null;
74
+ onSpawnTargetChange?: (next: SpawnTarget | null) => void;
75
+ /** Reports a worktree the composer created or failed to create. */
76
+ onWorktreeReport?: (message: string) => void;
68
77
  /** The "wait for session" pick, persisted per chat. */
69
78
  waitingFor?: WaitingCandidate | null;
70
79
  onWaitingForChange?: (candidate: WaitingCandidate | null) => void;
@@ -108,20 +117,16 @@ export function EmptyWorkspacePrompt({
108
117
  onNewChat,
109
118
  streamStatusMap = {},
110
119
  titleForSession,
120
+ spawnTarget = null,
121
+ onSpawnTargetChange,
122
+ onWorktreeReport,
111
123
  waitingFor = null,
112
124
  onWaitingForChange,
113
125
  heldWait = null,
114
126
  onCancelWait,
115
127
  }: EmptyWorkspacePromptProps) {
116
- const [showWorkspace, setShowWorkspace] = useState(false);
117
- const workspaceRef = useRef<HTMLDivElement>(null);
118
- useOnClickOutside(workspaceRef, () => setShowWorkspace(false));
119
128
  const timelineRef = useRef<HTMLDivElement>(null);
120
129
 
121
- const selectedFolder = useMemo(() => {
122
- return folders?.find(f => f.id === selectedFolderId);
123
- }, [folders, selectedFolderId]);
124
-
125
130
  // Sessions the wait may name: the ones with a run in flight, from the same
126
131
  // server-tracked map the sidebar's spinner is drawn from.
127
132
  const candidates = useMemo(
@@ -159,51 +164,19 @@ export function EmptyWorkspacePrompt({
159
164
  a target workspace, so the composer is the only focus. */}
160
165
  {messages.length === 0 && (
161
166
  <div className="flex items-center gap-1 flex-wrap">
162
- <div className="relative shrink-0" ref={workspaceRef}>
163
- <button
164
- type="button"
165
- onClick={() => setShowWorkspace(!showWorkspace)}
166
- 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"
167
- >
168
- <Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
169
- <span className="flex items-center space-x-1 max-w-[260px] truncate tracking-tight">
170
- <span>{selectedFolder ? selectedFolder.name : 'Select Workspace Context'}</span>
171
- </span>
172
- <ChevronDown size={13} className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${showWorkspace ? 'rotate-180 text-ink/70' : ''}`} />
173
- </button>
174
-
175
- {showWorkspace && (
176
- <div className="absolute bottom-full left-0 mb-1.5 w-64 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1">
177
- <div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
178
- Target Workspace
179
- </div>
180
- <div className="max-h-56 scrollbar-overlay-container py-1">
181
- {folders.length === 0 ? (
182
- <div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
183
- ) : (
184
- folders.map((f: any) => (
185
- <button
186
- key={f.id}
187
- type="button"
188
- onClick={() => { setSelectedFolderId(f.id); setShowWorkspace(false); }}
189
- className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
190
- selectedFolderId === f.id
191
- ? 'bg-ink/10 text-ink font-semibold'
192
- : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
193
- }`}
194
- >
195
- <div className="flex items-center space-x-2 truncate pr-2">
196
- <Folder size={12} className={selectedFolderId === f.id ? "text-ink flex-shrink-0" : "text-ink/50 flex-shrink-0"} />
197
- <span className="truncate font-medium">{f.name}</span>
198
- </div>
199
- {selectedFolderId === f.id && <Check size={12} className="text-ink flex-shrink-0" />}
200
- </button>
201
- ))
202
- )}
203
- </div>
204
- </div>
205
- )}
206
- </div>
167
+ {/* The target picker asks where the chat runs — the workspace, or one
168
+ of its worktrees — so a worktree chat is created here rather than
169
+ in a dialog the user has to find first. */}
170
+ {onSpawnTargetChange ? (
171
+ <TargetPicker
172
+ folders={folders as WorkspaceFolderData[]}
173
+ selectedFolderId={selectedFolderId}
174
+ setSelectedFolderId={setSelectedFolderId}
175
+ target={spawnTarget}
176
+ setTarget={onSpawnTargetChange}
177
+ onReport={onWorktreeReport}
178
+ />
179
+ ) : null}
207
180
  {/* The wait picker sits BESIDE the workspace picker, because both
208
181
  answer the same question about the chat that has not started:
209
182
  where it runs, and what it waits for. It only appears while there