wip: local changes and task backlog

- Refactor agent-form-dropdowns component
- Simplify app index routing
- Update agent-input-area and message-input
- Improve worktree utility with tests
- Add task backlog for upcoming work
This commit is contained in:
Mohamed Boudra
2026-01-09 08:40:16 +07:00
parent cc74d6d0d2
commit 3ba778eea7
37 changed files with 935 additions and 607 deletions

8
.tasks/0cf32223.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 0cf32223
title: Auto-link URLs in rendered agent markdown
status: open
deps: []
created: 2026-01-08T16:22:32.604Z
---

8
.tasks/10019663.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 10019663
title: Replace all daemon logging with Pino + pino-pretty
status: open
deps: []
created: 2026-01-08T16:19:19.748Z
---

14
.tasks/16ccfef4.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: 16ccfef4
title: Fix bottom tool call sheet - horizontal scroll & remove status badge
status: open
deps: []
created: 2026-01-08T16:29:01.677Z
---
## Notes
**2026-01-08T16:29:16.522Z**
Two issues: 1) Horizontal scroll views (code blocks) not working properly. 2) Remove ugly status badge.

14
.tasks/19f85855.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: 19f85855
title: Fix explorer sidebar not closing on left swipe - git diff block captures gesture
status: done
deps: []
created: 2026-01-08T16:23:49.127Z
---
## Notes
**2026-01-08T16:23:54.581Z**
Git diff block bounces instead of allowing sidebar close. It's capturing the swipe gesture unnecessarily.

8
.tasks/1ec7463f.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 1ec7463f
title: Fix explorer sidebar not closing on right swipe - git diff block captures gesture
status: open
deps: []
created: 2026-01-08T16:24:03.783Z
---

8
.tasks/1f019827.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 1f019827
title: Daemon client supports all daemon actions & frontend uses it
status: open
deps: [933d0b5b, 3913ce12]
created: 2026-01-08T16:15:00.253Z
---

14
.tasks/253b2ff3.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: 253b2ff3
title: Clickable file paths in chat open bottom sheet for fast preview
status: open
deps: []
created: 2026-01-08T16:22:16.892Z
---
## Notes
**2026-01-08T16:22:21.730Z**
Improve file path detection in chat messages. On click, open bottom sheet with file preview instead of navigating away.

8
.tasks/270b2936.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 270b2936
title: Backend: thinking effort selector with TDD
status: open
deps: []
created: 2026-01-08T16:13:41.705Z
---

8
.tasks/2addaa50.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 2addaa50
title: Implement notifications for agent completion and permission requests
status: open
deps: []
created: 2026-01-08T16:30:14.182Z
---

8
.tasks/341ed6c1.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 341ed6c1
title: Bring back realtime voice support
status: open
deps: []
created: 2026-01-08T16:34:14.986Z
---

8
.tasks/3913ce12.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 3913ce12
title: Refactor frontend to use daemon client instead of manual WS code
status: open
deps: [933d0b5b]
created: 2026-01-08T16:15:13.577Z
---

8
.tasks/484edbb7.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 484edbb7
title: Remove integrated sub-agent UI & move to optional MCP
status: open
deps: [ab05b25a, 7ddb69c7, ab0baaa5]
created: 2026-01-08T16:16:58.358Z
---

8
.tasks/5f407d28.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 5f407d28
title: Remove old git diff and file browser screens - keep only file explorer
status: open
deps: []
created: 2026-01-08T16:21:35.362Z
---

8
.tasks/6f89a5fe.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 6f89a5fe
title: Frontend: wire up model switcher UI
status: open
deps: [e0b95c57]
created: 2026-01-08T16:13:38.608Z
---

8
.tasks/7ddb69c7.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 7ddb69c7
title: Remove auto-injected MCPs (Playwright, etc) - make user-configured
status: open
deps: []
created: 2026-01-08T16:17:08.670Z
---

8
.tasks/86e8a5ef.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 86e8a5ef
title: Improve rendering of Claude plans and permission prompts
status: open
deps: []
created: 2026-01-08T16:27:59.892Z
---

8
.tasks/8ee26288.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 8ee26288
title: Frontend: wire up thinking effort selector UI
status: open
deps: [270b2936]
created: 2026-01-08T16:13:43.356Z
---

8
.tasks/8f95bc29.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 8f95bc29
title: Replace bottom sheet tool calls with inline expansion on desktop
status: open
deps: []
created: 2026-01-08T16:25:09.874Z
---

14
.tasks/9328120f.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: 9328120f
title: Fix desktop chat scroll - text captures scroll instead of container
status: open
deps: []
created: 2026-01-08T16:26:52.180Z
---
## Notes
**2026-01-08T16:26:57.949Z**
When hovering over text in agent chat and scrolling, the text element captures the scroll event instead of letting the chat container scroll.

8
.tasks/933d0b5b.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 933d0b5b
title: Extend daemon client to support all daemon actions
status: open
deps: []
created: 2026-01-08T16:15:09.344Z
---

8
.tasks/95cc65b4.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 95cc65b4
title: Clean up settings page - remove unused settings
status: open
deps: []
created: 2026-01-08T16:20:19.483Z
---

8
.tasks/983d44bf.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: 983d44bf
title: Implement thinking effort selector
status: open
deps: [270b2936, 8ee26288]
created: 2026-01-08T16:13:25.916Z
---

14
.tasks/9be15eb4.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: 9be15eb4
title: Replace LLM title generation with set_title MCP tool
status: open
deps: []
created: 2026-01-08T16:18:40.494Z
---
## Notes
**2026-01-08T16:18:46.448Z**
Remove custom LLM call for title generation. Inject a set_title MCP tool that agents can call themselves to update their title based on current work.

8
.tasks/ab05b25a.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: ab05b25a
title: Remove sub-agent UI from frontend
status: open
deps: []
created: 2026-01-08T16:17:06.426Z
---

8
.tasks/ab0baaa5.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: ab0baaa5
title: Implement --experimental-mcp flag or separate CLI for agent control MCP
status: open
deps: []
created: 2026-01-08T16:17:11.571Z
---

14
.tasks/de2e028a.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: de2e028a
title: Fix agent chat scroll behavior during streaming
status: open
deps: []
created: 2026-01-08T16:25:46.095Z
---
## Notes
**2026-01-08T16:25:52.346Z**
Two issues: 1) Can't scroll while agent is streaming. 2) Scroll position isn't stable during streaming - jumps around.

8
.tasks/e0b95c57.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: e0b95c57
title: Backend: model switcher with TDD
status: open
deps: []
created: 2026-01-08T16:13:34.137Z
---

14
.tasks/e8ae632a.md Normal file
View File

@@ -0,0 +1,14 @@
---
id: e8ae632a
title: Allow selecting all text in assistant message across elements
status: open
deps: []
created: 2026-01-08T16:29:44.186Z
---
## Notes
**2026-01-08T16:29:50.182Z**
Currently can only select text within individual elements. Cannot select across paragraphs, lists, or other markdown elements.

8
.tasks/ef288598.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: ef288598
title: Implement model switcher for agent screen
status: open
deps: [e0b95c57, 6f89a5fe]
created: 2026-01-08T16:13:24.234Z
---

8
.tasks/f0fe2219.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: f0fe2219
title: Reorganize agent input area buttons - send, dictate, realtime toggle
status: open
deps: []
created: 2026-01-08T16:34:56.837Z
---

8
.tasks/f20a93b0.md Normal file
View File

@@ -0,0 +1,8 @@
---
id: f20a93b0
title: Add agent detail bottom sheet (agent ID, provider session ID, etc)
status: open
deps: []
created: 2026-01-08T16:21:00.578Z
---

View File

@@ -158,18 +158,12 @@ export default function HomeScreen() {
: undefined;
const [openDropdown, setOpenDropdown] = useState<
"host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent" | null
"host" | "provider" | "mode" | "model" | "agent" | null
>(null);
const [errorMessage, setErrorMessage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [promptText, setPromptText] = useState("");
const [baseBranch, setBaseBranch] = useState("");
const [isolationMode, setIsolationMode] = useState<"none" | "branch" | "worktree">("none");
const [branchName, setBranchName] = useState("");
const [worktreeSlug, setWorktreeSlug] = useState("");
const [branchNameEdited, setBranchNameEdited] = useState(false);
const [worktreeSlugEdited, setWorktreeSlugEdited] = useState(false);
const shouldSyncBaseBranchRef = useRef(true);
const [useWorktree, setUseWorktree] = useState(false);
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
@@ -179,11 +173,8 @@ export default function HomeScreen() {
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
addImagesRef.current = addImages;
}, []);
const createNewBranch = isolationMode === "branch" || isolationMode === "worktree";
const createWorktree = isolationMode === "worktree";
const openDropdownSheet = useCallback(
(key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent") => {
(key: "host" | "provider" | "mode" | "model" | "agent") => {
setOpenDropdown(key);
},
[]
@@ -350,59 +341,26 @@ export default function HomeScreen() {
);
const gitBlockingError = useMemo(() => {
if (isNonGitDirectory) {
if (!useWorktree || isNonGitDirectory) {
return null;
}
const trimmedBase = baseBranch.trim();
const currentBranch = repoInfo?.currentBranch ?? "";
const isCustomBase =
trimmedBase.length > 0 &&
(currentBranch.length === 0 || trimmedBase !== currentBranch);
const requiresBase = createNewBranch || createWorktree || isCustomBase;
if (requiresBase && !trimmedBase) {
return "Select a base branch before launching the agent";
const slug = slugifyWorktreeName(promptText);
if (!slug) {
return null;
}
if (createNewBranch) {
const slug = branchName.trim();
const validation = validateWorktreeName(slug);
if (!slug || !validation.valid) {
return `Invalid branch name: ${
validation.error ?? "Must use lowercase letters, numbers, hyphens, or forward slashes"
}`;
}
const validation = validateWorktreeName(slug);
if (!validation.valid) {
return `Invalid worktree name: ${
validation.error ?? "Must use lowercase letters, numbers, or hyphens"
}`;
}
if (createWorktree) {
const slug = (worktreeSlug || branchName).trim();
const validation = validateWorktreeName(slug);
if (!slug || !validation.valid) {
return `Invalid worktree name: ${
validation.error ?? "Must use lowercase letters, numbers, or hyphens"
}`;
}
}
if (!createWorktree && repoInfo?.isDirty) {
const intendsCheckout =
createNewBranch ||
(trimmedBase.length > 0 && trimmedBase !== repoInfo.currentBranch);
if (intendsCheckout) {
return "Working directory has uncommitted changes. Clean up or create a worktree first.";
}
}
return null;
}, [
baseBranch,
branchName,
createNewBranch,
createWorktree,
useWorktree,
isNonGitDirectory,
repoInfo,
promptText,
slugifyWorktreeName,
validateWorktreeName,
worktreeSlug,
]);
useEffect(() => {
@@ -429,48 +387,10 @@ export default function HomeScreen() {
]);
useEffect(() => {
if (!repoInfo) {
return;
if (isNonGitDirectory && useWorktree) {
setUseWorktree(false);
}
setBaseBranch((prev) => {
if (shouldSyncBaseBranchRef.current || prev.trim().length === 0) {
shouldSyncBaseBranchRef.current = false;
return repoInfo.currentBranch ?? "";
}
return prev;
});
}, [repoInfo]);
useEffect(() => {
if (!isNonGitDirectory) {
return;
}
if (
isolationMode !== "none" ||
baseBranch.trim().length > 0 ||
branchName.trim().length > 0 ||
worktreeSlug.trim().length > 0
) {
setIsolationMode("none");
setBaseBranch("");
setBranchName("");
setWorktreeSlug("");
setBranchNameEdited(false);
setWorktreeSlugEdited(false);
shouldSyncBaseBranchRef.current = true;
}
}, [
baseBranch,
branchName,
isolationMode,
isNonGitDirectory,
worktreeSlug,
]);
const handleBaseBranchChange = useCallback((value: string) => {
setBaseBranch(value);
shouldSyncBaseBranchRef.current = false;
}, []);
}, [isNonGitDirectory, useWorktree]);
const renderConfigRow = useCallback(
({ label, value, meta, onPress, disabled }: ConfigRowProps) => (
@@ -491,29 +411,6 @@ export default function HomeScreen() {
),
[theme.colors.mutedForeground]
);
const renderDropdownTrigger = useCallback(
({
label,
value,
placeholder,
onPress,
disabled,
}: {
label: string;
value: string;
placeholder: string;
onPress: () => void;
disabled?: boolean;
}) =>
renderConfigRow({
label,
value: value || placeholder,
onPress,
disabled,
}),
[renderConfigRow]
);
const pendingRequestIdRef = useRef<string | null>(null);
const sessionMethods = useSessionStore((state) =>
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
@@ -567,26 +464,13 @@ export default function HomeScreen() {
...(modeId ? { modeId } : {}),
...(trimmedModel ? { model: trimmedModel } : {}),
};
const trimmedBaseBranch = baseBranch.trim();
const shouldIncludeBase =
trimmedBaseBranch.length > 0 ||
createNewBranch ||
createWorktree;
const gitOptions =
shouldIncludeBase && !isNonGitDirectory
? {
...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}),
...(createNewBranch
? { createNewBranch: true, newBranchName: branchName.trim() }
: {}),
...(createWorktree
? {
createWorktree: true,
worktreeSlug: (worktreeSlug || branchName).trim(),
}
: {}),
}
: undefined;
const worktreeSlug = slugifyWorktreeName(trimmedPrompt);
const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug
? {
createWorktree: true,
worktreeSlug,
}
: undefined;
void persistFormPreferences();
@@ -602,10 +486,8 @@ export default function HomeScreen() {
});
},
[
baseBranch,
branchName,
createNewBranch,
createWorktree,
useWorktree,
slugifyWorktreeName,
gitBlockingError,
isDirectoryNotExists,
isLoading,
@@ -618,7 +500,6 @@ export default function HomeScreen() {
selectedServerId,
sessionMethods,
workingDir,
worktreeSlug,
]
);
@@ -656,7 +537,6 @@ export default function HomeScreen() {
}
pendingRequestIdRef.current = null;
setIsLoading(false);
setPromptText("");
router.replace({
pathname: "/agent/[serverId]/[agentId]",
params: {
@@ -709,15 +589,9 @@ export default function HomeScreen() {
<WorkingDirectoryDropdown
workingDir={workingDir}
errorMessage=""
isOpen={openDropdown === "workingDir"}
onOpen={() => openDropdownSheet("workingDir")}
onClose={closeDropdown}
disabled={false}
suggestedPaths={agentWorkingDirSuggestions}
onSelectPath={setWorkingDirFromUser}
label="Working Directory"
wrapInContainer={false}
renderTrigger={renderDropdownTrigger}
/>
{isDirectoryNotExists && (
<View style={styles.warningContainer}>
@@ -733,54 +607,13 @@ export default function HomeScreen() {
})}
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
<GitOptionsSection
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
useWorktree={useWorktree}
onUseWorktreeChange={setUseWorktree}
worktreeSlug={slugifyWorktreeName(promptText)}
currentBranch={repoInfo?.currentBranch ?? null}
status={repoInfoStatus}
repoError={repoInfoError}
helperText={gitHelperText}
isGitDisabled={Boolean(gitHelperText)}
warning={
repoInfo?.isDirty
? "Working directory has uncommitted changes"
: null
}
isolationMode={isolationMode}
onIsolationModeChange={(mode) => {
setIsolationMode(mode);
if (mode === "none") {
setBranchName("");
setWorktreeSlug("");
setBranchNameEdited(false);
setWorktreeSlugEdited(false);
} else if (mode === "branch") {
if (!branchNameEdited) {
const slug = slugifyWorktreeName(baseBranch || "");
setBranchName(slug);
}
} else if (mode === "worktree") {
if (!worktreeSlugEdited) {
const slug = slugifyWorktreeName(
branchName || baseBranch || ""
);
setWorktreeSlug(slug);
}
}
}}
branchName={branchName}
onBranchNameChange={(value) => {
setBranchName(slugifyWorktreeName(value));
setBranchNameEdited(true);
}}
worktreeSlug={worktreeSlug}
onWorktreeSlugChange={(value) => {
setWorktreeSlug(slugifyWorktreeName(value));
setWorktreeSlugEdited(true);
}}
gitValidationError={gitBlockingError}
isBaseDropdownOpen={openDropdown === "baseBranch"}
onToggleBaseDropdown={() => openDropdownSheet("baseBranch")}
onCloseDropdown={closeDropdown}
/>
) : null}
</View>

View File

@@ -8,9 +8,10 @@ import {
Modal,
TextInput,
ActivityIndicator,
useWindowDimensions,
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ChevronDown } from "lucide-react-native";
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -97,6 +98,57 @@ export function DropdownField({
);
}
interface SelectFieldProps {
label: string;
value: string;
placeholder?: string;
onPress: () => void;
disabled?: boolean;
errorMessage?: string | null;
warningMessage?: string | null;
helperText?: string | null;
controlRef?: React.RefObject<View | null>;
}
export function SelectField({
label,
value,
placeholder,
onPress,
disabled,
errorMessage,
warningMessage,
helperText,
controlRef,
}: SelectFieldProps): ReactElement {
return (
<View style={styles.selectFieldContainer}>
<Pressable
ref={controlRef}
onPress={onPress}
disabled={disabled}
style={[styles.selectFieldControl, disabled && styles.selectFieldControlDisabled]}
>
<View style={styles.selectFieldContent}>
<Text style={styles.selectFieldLabel}>{label}</Text>
<Text
style={value ? styles.selectFieldValue : styles.selectFieldPlaceholder}
numberOfLines={1}
>
{value || placeholder || "Select..."}
</Text>
</View>
<ChevronRight size={20} color={defaultTheme.colors.mutedForeground} />
</Pressable>
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
{warningMessage ? <Text style={styles.warningText}>{warningMessage}</Text> : null}
{!errorMessage && !warningMessage && helperText ? (
<Text style={styles.helperText}>{helperText}</Text>
) : null}
</View>
);
}
interface DropdownSheetProps {
title: string;
visible: boolean;
@@ -135,49 +187,159 @@ export function DropdownSheet({
);
}
interface AdaptiveSelectProps {
title: string;
visible: boolean;
onClose: () => void;
children: ReactNode;
anchorRef: React.RefObject<View | null>;
}
export function AdaptiveSelect({
title,
visible,
onClose,
children,
anchorRef,
}: AdaptiveSelectProps): ReactElement {
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
useEffect(() => {
if (!visible || isMobile) {
return;
}
const anchor = anchorRef.current;
if (!anchor) {
return;
}
anchor.measureInWindow((x, y, width, height) => {
const verticalOffset = 4;
const horizontalMargin = 16;
const maxDropdownHeight = 400;
let top = y + height + verticalOffset;
const left = Math.max(horizontalMargin, Math.min(x, windowWidth - width - horizontalMargin));
if (top + maxDropdownHeight > windowHeight - horizontalMargin) {
top = y - maxDropdownHeight - verticalOffset;
if (top < horizontalMargin) {
top = y + height + verticalOffset;
}
}
setDropdownPosition({ top, left, width });
});
}, [visible, isMobile, anchorRef, windowWidth, windowHeight]);
if (isMobile) {
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
>
<View style={styles.dropdownSheetOverlay}>
<Pressable style={styles.dropdownSheetBackdrop} onPress={onClose} />
<View style={styles.dropdownSheetContainer}>
<View style={styles.dropdownSheetHandle} />
<Text style={styles.dropdownSheetTitle}>{title}</Text>
<ScrollView
contentContainerStyle={styles.dropdownSheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</View>
</View>
</Modal>
);
}
return (
<Modal
transparent
animationType="fade"
visible={visible}
onRequestClose={onClose}
>
<View style={styles.desktopDropdownOverlay}>
<Pressable style={styles.desktopDropdownBackdrop} onPress={onClose} />
<View
style={[
styles.desktopDropdownContainer,
{
position: "absolute",
top: dropdownPosition.top,
left: dropdownPosition.left,
width: dropdownPosition.width,
minWidth: 200,
},
]}
>
<ScrollView
contentContainerStyle={styles.desktopDropdownScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
style={styles.desktopDropdownScroll}
>
{children}
</ScrollView>
</View>
</View>
</Modal>
);
}
interface AssistantDropdownProps {
providerDefinitions: AgentProviderDefinition[];
selectedProvider: AgentProvider;
disabled: boolean;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onSelect: (provider: AgentProvider) => void;
label?: string;
placeholder?: string;
sheetTitle?: string;
renderTrigger?: DropdownTriggerRenderer;
wrapInContainer?: boolean;
}
export function AssistantDropdown({
providerDefinitions,
selectedProvider,
disabled,
isOpen,
onOpen,
onClose,
onSelect,
label = "Assistant",
placeholder = "Select assistant",
sheetTitle = "Choose Assistant",
renderTrigger,
wrapInContainer = true,
}: AssistantDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedDefinition = providerDefinitions.find(
(definition) => definition.id === selectedProvider
);
const field = (
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
return (
<>
<DropdownField
label={label}
<SelectField
label="AGENT"
value={selectedDefinition?.label ?? ""}
placeholder={placeholder}
onPress={onOpen}
placeholder="Select assistant"
onPress={handleOpen}
disabled={disabled}
renderTrigger={renderTrigger}
controlRef={anchorRef}
/>
<DropdownSheet title={sheetTitle} visible={isOpen} onClose={onClose}>
<AdaptiveSelect
title="Choose Assistant"
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
{providerDefinitions.map((definition) => {
const isSelected = definition.id === selectedProvider;
return (
@@ -189,7 +351,7 @@ export function AssistantDropdown({
]}
onPress={() => {
onSelect(definition.id);
onClose();
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
@@ -201,72 +363,63 @@ export function AssistantDropdown({
</Pressable>
);
})}
</DropdownSheet>
</AdaptiveSelect>
</>
);
if (!wrapInContainer) {
return <>{field}</>;
}
return <View style={styles.selectorColumn}>{field}</View>;
}
interface PermissionsDropdownProps {
modeOptions: AgentMode[];
selectedMode: string;
disabled: boolean;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onSelect: (modeId: string) => void;
label?: string;
placeholder?: string;
sheetTitle?: string;
renderTrigger?: DropdownTriggerRenderer;
wrapInContainer?: boolean;
}
export function PermissionsDropdown({
modeOptions,
selectedMode,
disabled,
isOpen,
onOpen,
onClose,
onSelect,
label = "Permissions",
placeholder,
sheetTitle = "Permissions",
renderTrigger,
wrapInContainer = true,
}: PermissionsDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const hasOptions = modeOptions.length > 0;
const selectedModeLabel = hasOptions
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
modeOptions[0]?.label ??
"Default"
: "Automatic";
const placeholderLabel = hasOptions
? placeholder ?? "Select permissions"
: "Automatic";
const field = (
const handleOpen = useCallback(() => {
if (hasOptions) {
setIsOpen(true);
}
}, [hasOptions]);
const handleClose = useCallback(() => setIsOpen(false), []);
return (
<>
<DropdownField
label={label}
<SelectField
label="PERMISSIONS"
value={selectedModeLabel}
placeholder={placeholderLabel}
onPress={hasOptions ? onOpen : () => {}}
placeholder={hasOptions ? "Select permissions" : "Automatic"}
onPress={handleOpen}
disabled={disabled || !hasOptions}
helperText={
hasOptions
? undefined
: "This assistant does not expose selectable permissions."
}
renderTrigger={renderTrigger}
controlRef={anchorRef}
/>
{hasOptions ? (
<DropdownSheet title={sheetTitle} visible={isOpen} onClose={onClose}>
<AdaptiveSelect
title="Permissions"
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
{modeOptions.map((mode) => {
const isSelected = mode.id === selectedMode;
return (
@@ -278,7 +431,7 @@ export function PermissionsDropdown({
]}
onPress={() => {
onSelect(mode.id);
onClose();
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
@@ -290,16 +443,10 @@ export function PermissionsDropdown({
</Pressable>
);
})}
</DropdownSheet>
</AdaptiveSelect>
) : null}
</>
);
if (!wrapInContainer) {
return <>{field}</>;
}
return <View style={[styles.selectorColumn, styles.selectorColumnFull]}>{field}</View>;
}
interface ModelDropdownProps {
@@ -307,15 +454,9 @@ interface ModelDropdownProps {
selectedModel: string;
isLoading: boolean;
error: string | null;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
onSelect: (modelId: string) => void;
onClear: () => void;
onRefresh: () => void;
label?: string;
renderTrigger?: DropdownTriggerRenderer;
wrapInContainer?: boolean;
}
export function ModelDropdown({
@@ -323,16 +464,13 @@ export function ModelDropdown({
selectedModel,
isLoading,
error,
isOpen,
onOpen,
onClose,
onSelect,
onClear,
onRefresh,
label = "Model",
renderTrigger,
wrapInContainer = true,
}: ModelDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const selectedLabel = selectedModel
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
: "Automatic";
@@ -345,24 +483,27 @@ export function ModelDropdown({
? "This assistant did not expose selectable models."
: undefined;
const field = (
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
return (
<>
<DropdownField
label={label}
<SelectField
label="MODEL"
value={selectedLabel}
placeholder={placeholder}
onPress={onOpen}
onPress={handleOpen}
disabled={false}
errorMessage={error ?? undefined}
helperText={helperText}
renderTrigger={renderTrigger}
controlRef={anchorRef}
/>
<DropdownSheet title="Model" visible={isOpen} onClose={onClose}>
<AdaptiveSelect title="Model" visible={isOpen} onClose={handleClose} anchorRef={anchorRef}>
<Pressable
style={styles.dropdownSheetOption}
onPress={() => {
onClear();
onClose();
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>
@@ -383,7 +524,7 @@ export function ModelDropdown({
]}
onPress={() => {
onSelect(model.id);
onClose();
handleClose();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
@@ -411,47 +552,34 @@ export function ModelDropdown({
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
</View>
) : null}
</DropdownSheet>
</AdaptiveSelect>
</>
);
if (!wrapInContainer) {
return <>{field}</>;
}
return <View style={styles.selectorColumn}>{field}</View>;
}
interface WorkingDirectoryDropdownProps {
workingDir: string;
errorMessage: string;
isOpen: boolean;
onOpen: () => void;
onClose: () => void;
disabled: boolean;
suggestedPaths: string[];
onSelectPath: (value: string) => void;
label?: string;
renderTrigger?: DropdownTriggerRenderer;
wrapInContainer?: boolean;
}
export function WorkingDirectoryDropdown({
workingDir,
errorMessage,
isOpen,
onOpen,
onClose,
disabled,
suggestedPaths,
onSelectPath,
label = "Working Directory",
renderTrigger,
wrapInContainer = true,
}: WorkingDirectoryDropdownProps): ReactElement {
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const inputRef = useRef<TextInput | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const handleOpen = useCallback(() => setIsOpen(true), []);
const handleClose = useCallback(() => setIsOpen(false), []);
useEffect(() => {
if (isOpen) {
setSearchQuery("");
@@ -477,18 +605,18 @@ export function WorkingDirectoryDropdown({
const handleSelect = useCallback(
(path: string) => {
onSelectPath(path);
onClose();
handleClose();
},
[onClose, onSelectPath]
[handleClose, onSelectPath]
);
const field = (
return (
<>
<DropdownField
label={label}
<SelectField
label="WORKING DIRECTORY"
value={workingDir}
placeholder="/path/to/project"
onPress={onOpen}
onPress={handleOpen}
disabled={disabled}
errorMessage={errorMessage || undefined}
helperText={
@@ -496,9 +624,14 @@ export function WorkingDirectoryDropdown({
? "Search directories from existing agents or paste a new path."
: "No agent directories yet - search to add one."
}
renderTrigger={renderTrigger}
controlRef={anchorRef}
/>
<DropdownSheet title="Working Directory" visible={isOpen} onClose={onClose}>
<AdaptiveSelect
title="Working Directory"
visible={isOpen}
onClose={handleClose}
anchorRef={anchorRef}
>
<TextInput
ref={inputRef}
style={styles.dropdownSearchInput}
@@ -555,15 +688,9 @@ export function WorkingDirectoryDropdown({
No agent directories match your search.
</Text>
) : null}
</DropdownSheet>
</AdaptiveSelect>
</>
);
if (!wrapInContainer) {
return <>{field}</>;
}
return <View style={styles.formSection}>{field}</View>;
}
interface ToggleRowProps {
@@ -609,217 +736,53 @@ export function ToggleRow({
);
}
export type IsolationMode = "none" | "branch" | "worktree";
interface IsolationControlProps {
value: IsolationMode;
onChange: (mode: IsolationMode) => void;
disabled?: boolean;
}
export function IsolationControl({
value,
onChange,
disabled,
}: IsolationControlProps): ReactElement {
const options: Array<{ id: IsolationMode; label: string }> = [
{ id: "none", label: "None" },
{ id: "branch", label: "Branch" },
{ id: "worktree", label: "Worktree" },
];
return (
<View style={[styles.segmentedControl, disabled && styles.segmentedControlDisabled]}>
{options.map((option) => {
const isSelected = option.id === value;
return (
<Pressable
key={option.id}
onPress={() => {
if (!disabled) {
onChange(option.id);
}
}}
style={[
styles.segmentedOption,
isSelected && styles.segmentedOptionSelected,
]}
>
<Text
style={[
styles.segmentedOptionText,
isSelected && styles.segmentedOptionTextSelected,
]}
>
{option.label}
</Text>
</Pressable>
);
})}
</View>
);
}
export interface GitOptionsSectionProps {
baseBranch: string;
onBaseBranchChange: (value: string) => void;
branches: Array<{ name: string; isCurrent: boolean }>;
useWorktree: boolean;
onUseWorktreeChange: (value: boolean) => void;
worktreeSlug: string;
currentBranch: string | null;
status: "idle" | "loading" | "ready" | "error";
repoError: string | null;
helperText?: string | null;
warning: string | null;
isolationMode: IsolationMode;
onIsolationModeChange: (mode: IsolationMode) => void;
branchName: string;
onBranchNameChange: (value: string) => void;
worktreeSlug: string;
onWorktreeSlugChange: (value: string) => void;
gitValidationError: string | null;
isGitDisabled?: boolean;
isBaseDropdownOpen: boolean;
onToggleBaseDropdown: () => void;
onCloseDropdown: () => void;
}
export function GitOptionsSection({
baseBranch,
onBaseBranchChange,
branches,
useWorktree,
onUseWorktreeChange,
worktreeSlug,
currentBranch,
status,
repoError,
helperText,
warning,
isolationMode,
onIsolationModeChange,
branchName,
onBranchNameChange,
worktreeSlug,
onWorktreeSlugChange,
gitValidationError,
isGitDisabled,
isBaseDropdownOpen,
onToggleBaseDropdown,
onCloseDropdown,
}: GitOptionsSectionProps): ReactElement {
const [branchSearch, setBranchSearch] = useState("");
const branchFilter = branchSearch.trim().toLowerCase();
const filteredBranches =
branchFilter.length === 0
? branches
: branches.filter((branch) =>
branch.name.toLowerCase().includes(branchFilter)
);
const maxVisible = 30;
const currentBranchLabel =
branches.find((branch) => branch.isCurrent)?.name ?? "";
const baseInputRef = useRef<TextInput | null>(null);
const gitInputsDisabled = Boolean(isGitDisabled) || status === "loading";
// Show warning only for "branch" mode (requires clean state for checkout)
// Worktree mode doesn't require clean state
const showDirtyWarning = isolationMode === "branch" && warning;
useEffect(() => {
if (isBaseDropdownOpen) {
setBranchSearch("");
baseInputRef.current?.focus();
}
}, [isBaseDropdownOpen]);
const isLoading = status === "loading";
return (
<View style={styles.formSection}>
<DropdownField
label="Git"
value={baseBranch}
placeholder={currentBranchLabel || "main"}
onPress={onToggleBaseDropdown}
disabled={gitInputsDisabled}
errorMessage={repoError}
warningMessage={!gitValidationError && !isGitDisabled ? (showDirtyWarning ? warning : null) : null}
helperText={
helperText ??
(status === "loading"
? "Inspecting repository…"
: undefined)
}
/>
<DropdownSheet
title="Base Branch"
visible={isBaseDropdownOpen}
onClose={onCloseDropdown}
<View style={styles.gitOptionsContainer}>
<Pressable
onPress={() => onUseWorktreeChange(!useWorktree)}
disabled={isLoading}
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
>
<TextInput
ref={baseInputRef}
style={styles.dropdownSearchInput}
placeholder={currentBranchLabel || "main"}
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={branchSearch}
onChangeText={setBranchSearch}
autoCapitalize="none"
autoCorrect={false}
/>
{status === "loading" ? (
<View style={styles.dropdownLoading}>
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
<Text style={styles.helperText}>Inspecting repository</Text>
</View>
) : filteredBranches.length === 0 ? (
<Text style={styles.helperText}>
{branchFilter.length === 0
? "No branches detected yet."
: "No branches match your search."}
<View style={[styles.checkbox, useWorktree && styles.checkboxChecked]}>
{useWorktree ? <View style={styles.checkboxDot} /> : null}
</View>
<View style={styles.worktreeToggleContent}>
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
<Text style={styles.worktreeToggleDescription}>
{isLoading
? "Inspecting repository…"
: useWorktree && worktreeSlug
? `Will create: ${worktreeSlug}`
: currentBranch
? `Run isolated from ${currentBranch}`
: "Run in an isolated directory"}
</Text>
) : (
<View style={styles.dropdownSheetList}>
{filteredBranches.slice(0, maxVisible).map((branch) => {
const isActive = branch.name === baseBranch;
return (
<Pressable
key={branch.name}
style={[
styles.dropdownSheetOption,
isActive && styles.dropdownSheetOptionSelected,
]}
onPress={() => {
onBaseBranchChange(branch.name);
onCloseDropdown();
}}
>
<Text style={styles.dropdownSheetOptionLabel}>
{branch.name}
{branch.isCurrent ? " (current)" : ""}
</Text>
</Pressable>
);
})}
</View>
)}
{filteredBranches.length > maxVisible ? (
<Text style={styles.helperText}>
Showing first {maxVisible} matches. Keep typing to narrow it down.
</Text>
) : null}
</DropdownSheet>
</View>
</Pressable>
<View style={styles.formSection}>
<Text style={styles.label}>Isolation</Text>
<IsolationControl
value={isolationMode}
onChange={onIsolationModeChange}
disabled={isGitDisabled}
/>
</View>
{isolationMode !== "none" ? (
<TextInput
style={styles.input}
placeholder={isolationMode === "worktree" ? "worktree-name" : "feature-branch-name"}
placeholderTextColor={defaultTheme.colors.mutedForeground}
value={isolationMode === "worktree" ? worktreeSlug : branchName}
onChangeText={isolationMode === "worktree" ? onWorktreeSlugChange : onBranchNameChange}
autoCapitalize="none"
autoCorrect={false}
/>
{repoError ? (
<Text style={styles.errorText}>{repoError}</Text>
) : null}
{gitValidationError ? (
@@ -1016,32 +979,97 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
segmentedControl: {
flexDirection: "row",
backgroundColor: theme.colors.muted,
borderRadius: theme.borderRadius.lg,
padding: theme.spacing[1],
selectFieldContainer: {
gap: theme.spacing[2],
},
segmentedControlDisabled: {
selectFieldControl: {
flexDirection: "row",
alignItems: "center",
backgroundColor: theme.colors.background,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
selectFieldControlDisabled: {
opacity: theme.opacity[50],
},
segmentedOption: {
selectFieldContent: {
flex: 1,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
gap: theme.spacing[1],
},
segmentedOptionSelected: {
backgroundColor: theme.colors.background,
},
segmentedOptionText: {
selectFieldLabel: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textTransform: "uppercase",
letterSpacing: 0.5,
},
selectFieldValue: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
},
selectFieldPlaceholder: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.base,
},
gitOptionsContainer: {
gap: theme.spacing[3],
},
worktreeToggle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
backgroundColor: theme.colors.background,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
},
worktreeToggleDisabled: {
opacity: theme.opacity[50],
},
worktreeToggleContent: {
flex: 1,
gap: theme.spacing[1],
},
worktreeToggleLabel: {
color: theme.colors.foreground,
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.semibold,
},
segmentedOptionTextSelected: {
color: theme.colors.foreground,
worktreeToggleDescription: {
color: theme.colors.mutedForeground,
fontSize: theme.fontSize.sm,
},
desktopDropdownOverlay: {
flex: 1,
},
desktopDropdownBackdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
desktopDropdownContainer: {
backgroundColor: theme.colors.popover,
borderRadius: theme.borderRadius.lg,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 8,
maxHeight: 400,
},
desktopDropdownScroll: {
maxHeight: 400,
},
desktopDropdownScrollContent: {
padding: theme.spacing[2],
},
}));

View File

@@ -222,17 +222,19 @@ export function AgentInputArea({
}
const isControlled = value !== undefined;
if (!isControlled) {
setUserInput("");
}
setSelectedImages([]);
setIsProcessing(true);
try {
await submitMessage(trimmedMessage, imageAttachments);
// Clear input only after successful submission
if (isControlled) {
onChangeText?.("");
} else {
setUserInput("");
}
} catch (error) {
console.error("[AgentInput] Failed to send message:", error);
setUserInput(trimmedMessage);
if (imageAttachments) {
setSelectedImages(imageAttachments);
}

View File

@@ -156,7 +156,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined;
onSubmit({ text: nextValue, images: imageAttachments });
onChangeText("");
} else {
onChangeText(nextValue);
}
@@ -295,10 +294,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
};
if (isAgentRunning && onQueue) {
onQueue(payload);
onChangeText("");
} else {
onSubmit(payload);
}
onChangeText("");
// Reset input height
inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT);
@@ -420,7 +419,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
forceSend: true,
};
onSubmit(payload);
onChangeText("");
inputHeightRef.current = MIN_INPUT_HEIGHT;
setInputHeight(MIN_INPUT_HEIGHT);
return;

View File

@@ -0,0 +1,156 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { createWorktree } from "./worktree";
import { execSync } from "child_process";
import { mkdtempSync, rmSync, existsSync, realpathSync, writeFileSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
describe("createWorktree", () => {
let tempDir: string;
let repoDir: string;
beforeEach(() => {
// Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS)
tempDir = realpathSync(mkdtempSync(join(tmpdir(), "worktree-test-")));
repoDir = join(tempDir, "test-repo");
// Create a git repo with an initial commit
execSync(`mkdir -p ${repoDir}`);
execSync("git init", { cwd: repoDir });
execSync("git config user.email 'test@test.com'", { cwd: repoDir });
execSync("git config user.name 'Test'", { cwd: repoDir });
execSync("echo 'hello' > file.txt", { cwd: repoDir });
execSync("git add .", { cwd: repoDir });
execSync("git commit -m 'initial'", { cwd: repoDir });
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("creates a worktree for the current branch (main)", async () => {
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "hello-world",
});
expect(result.worktreePath).toBe(join(tempDir, "test-repo-hello-world"));
expect(existsSync(result.worktreePath)).toBe(true);
expect(existsSync(join(result.worktreePath, "file.txt"))).toBe(true);
});
it("creates a worktree with a new branch", async () => {
const result = await createWorktree({
branchName: "feature-branch",
cwd: repoDir,
baseBranch: "main",
worktreeSlug: "my-feature",
});
expect(result.worktreePath).toBe(join(tempDir, "test-repo-my-feature"));
expect(existsSync(result.worktreePath)).toBe(true);
// Verify branch was created
const branches = execSync("git branch", { cwd: repoDir }).toString();
expect(branches).toContain("feature-branch");
});
it("fails with invalid branch name", async () => {
await expect(
createWorktree({
branchName: "INVALID_UPPERCASE",
cwd: repoDir,
worktreeSlug: "test",
})
).rejects.toThrow("Invalid branch name");
});
it("handles branch name collision by adding suffix", async () => {
// Create a branch named "hello" first
execSync("git branch hello", { cwd: repoDir });
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "hello",
});
// Should create branch "hello-1" since "hello" exists
expect(result.worktreePath).toBe(join(tempDir, "test-repo-hello"));
expect(existsSync(result.worktreePath)).toBe(true);
const branches = execSync("git branch", { cwd: repoDir }).toString();
expect(branches).toContain("hello-1");
});
it("handles multiple collisions", async () => {
// Create branches "hello" and "hello-1"
execSync("git branch hello", { cwd: repoDir });
execSync("git branch hello-1", { cwd: repoDir });
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "hello",
});
expect(existsSync(result.worktreePath)).toBe(true);
const branches = execSync("git branch", { cwd: repoDir }).toString();
expect(branches).toContain("hello-2");
});
it("runs setup commands from paseo.json", async () => {
// Create paseo.json with setup commands
const paseoConfig = {
worktree: {
setup: [
'echo "root=$PASEO_ROOT_PATH" > setup.log',
'echo "worktree=$PASEO_WORKTREE_PATH" >> setup.log',
'echo "branch=$PASEO_BRANCH_NAME" >> setup.log',
],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git commit -m 'add paseo.json'", { cwd: repoDir });
const result = await createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "setup-test",
});
expect(existsSync(result.worktreePath)).toBe(true);
// Verify setup ran and env vars were available
const setupLog = readFileSync(join(result.worktreePath, "setup.log"), "utf8");
expect(setupLog).toContain(`root=${repoDir}`);
expect(setupLog).toContain(`worktree=${result.worktreePath}`);
expect(setupLog).toContain("branch=setup-test");
});
it("cleans up worktree if setup command fails", async () => {
// Create paseo.json with failing setup command
const paseoConfig = {
worktree: {
setup: ["exit 1"],
},
};
writeFileSync(join(repoDir, "paseo.json"), JSON.stringify(paseoConfig));
execSync("git add paseo.json && git commit -m 'add paseo.json'", { cwd: repoDir });
const expectedWorktreePath = join(tempDir, "test-repo-fail-test");
await expect(
createWorktree({
branchName: "main",
cwd: repoDir,
worktreeSlug: "fail-test",
})
).rejects.toThrow("Worktree setup command failed");
// Verify worktree was cleaned up
expect(existsSync(expectedWorktreePath)).toBe(false);
});
});

View File

@@ -1,8 +1,14 @@
import { exec } from "child_process";
import { promisify } from "util";
import { existsSync, readFileSync } from "fs";
import { existsSync, readFileSync, rmSync } from "fs";
import { join, basename, dirname } from "path";
interface PaseoConfig {
worktree?: {
setup?: string[];
};
}
const execAsync = promisify(exec);
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
...process.env,
@@ -165,37 +171,6 @@ function sanitizeWorktreeSlug(input: string): string {
return slug.length > 0 ? slug : "worktree";
}
/**
* Check if a worktree already exists for a branch
*/
async function findExistingWorktree(
branchName: string,
repoPath: string
): Promise<string | null> {
try {
const { stdout } = await execAsync("git worktree list --porcelain", {
cwd: repoPath,
});
const lines = stdout.split("\n");
let currentWorktree: string | null = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
currentWorktree = line.substring("worktree ".length);
} else if (
line === `branch refs/heads/${branchName}` &&
currentWorktree
) {
return currentWorktree;
}
}
return null;
} catch (error) {
return null;
}
}
/**
* Create a git worktree with proper naming conventions
@@ -228,52 +203,98 @@ export async function createWorktree({
}
// Check if branch already exists
let branchExists = false;
try {
await execAsync(
`git show-ref --verify --quiet refs/heads/${branchName}`,
{ cwd: repoInfo.path }
);
// Branch exists, check for existing worktree
const existingWorktree = await findExistingWorktree(
branchName,
repoInfo.path
);
if (existingWorktree) {
if (existsSync(existingWorktree)) {
throw new Error(
`Worktree already exists at: ${existingWorktree}. Use 'git worktree remove ${existingWorktree}' to remove it first.`
);
} else {
// Prune stale worktree reference
await execAsync("git worktree prune", { cwd: repoInfo.path });
}
}
// Create worktree using existing branch
await execAsync(`git worktree add "${worktreePath}" "${branchName}"`, {
cwd: repoInfo.path,
});
} catch (error) {
// Branch doesn't exist, create new branch and worktree
const baseArg = baseBranch ? ` "${baseBranch}"` : "";
const command = `git worktree add "${worktreePath}" -b "${branchName}"${baseArg}`;
await execAsync(command, { cwd: repoInfo.path });
branchExists = true;
} catch {
branchExists = false;
}
// Copy .env file if it exists
const envSource =
repoInfo.type === "bare"
? join(repoInfo.path, "main", ".env")
: join(repoInfo.path, ".env");
// Always create a new branch for the worktree
// If branchName already exists, use it as base and create worktree-slug as branch name
// If branchName doesn't exist, create it from baseBranch
const base = branchExists ? branchName : (baseBranch ?? "HEAD");
const candidateBranch = branchExists ? desiredSlug : branchName;
if (existsSync(envSource)) {
await execAsync(`cp "${envSource}" "${worktreePath}/.env"`);
// Find unique branch name if collision
let newBranchName = candidateBranch;
let suffix = 1;
while (true) {
try {
await execAsync(
`git show-ref --verify --quiet refs/heads/${newBranchName}`,
{ cwd: repoInfo.path }
);
// Branch exists, try with suffix
newBranchName = `${candidateBranch}-${suffix}`;
suffix++;
} catch {
break;
}
}
// Also handle worktree path collision
let finalWorktreePath = worktreePath;
let pathSuffix = 1;
while (existsSync(finalWorktreePath)) {
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
pathSuffix++;
}
const command = `git worktree add "${finalWorktreePath}" -b "${newBranchName}" "${base}"`;
await execAsync(command, { cwd: repoInfo.path });
worktreePath = finalWorktreePath;
// Run setup commands from paseo.json if present
const paseoConfigPath = join(repoInfo.path, "paseo.json");
if (existsSync(paseoConfigPath)) {
let config: PaseoConfig;
try {
config = JSON.parse(readFileSync(paseoConfigPath, "utf8"));
} catch {
throw new Error(`Failed to parse paseo.json`);
}
const setupCommands = config.worktree?.setup;
if (setupCommands && setupCommands.length > 0) {
const setupEnv = {
...process.env,
PASEO_ROOT_PATH: repoInfo.path,
PASEO_WORKTREE_PATH: worktreePath,
PASEO_BRANCH_NAME: newBranchName,
};
for (const cmd of setupCommands) {
try {
await execAsync(cmd, {
cwd: worktreePath,
env: setupEnv,
shell: "/bin/bash",
});
} catch (error) {
// Cleanup worktree on setup failure
try {
await execAsync(`git worktree remove "${worktreePath}" --force`, {
cwd: repoInfo.path,
});
} catch {
// If git worktree remove fails, try rmSync
rmSync(worktreePath, { recursive: true, force: true });
}
throw new Error(
`Worktree setup command failed: ${cmd}\n${error instanceof Error ? error.message : String(error)}`
);
}
}
}
}
return {
branchName,
branchName: newBranchName,
worktreePath,
repoType: repoInfo.type,
repoPath: repoInfo.path,