@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-aq0e78gv.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +74 -1
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +126 -50
- package/src/server/lib/updates/shell-release.ts +139 -88
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- 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
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
4
|
-
import {
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|