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:
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: 0cf32223
|
||||||
|
title: Auto-link URLs in rendered agent markdown
|
||||||
|
status: open
|
||||||
|
deps: []
|
||||||
|
created: 2026-01-08T16:22:32.604Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: 270b2936
|
||||||
|
title: Backend: thinking effort selector with TDD
|
||||||
|
status: open
|
||||||
|
deps: []
|
||||||
|
created: 2026-01-08T16:13:41.705Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: 341ed6c1
|
||||||
|
title: Bring back realtime voice support
|
||||||
|
status: open
|
||||||
|
deps: []
|
||||||
|
created: 2026-01-08T16:34:14.986Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: 6f89a5fe
|
||||||
|
title: Frontend: wire up model switcher UI
|
||||||
|
status: open
|
||||||
|
deps: [e0b95c57]
|
||||||
|
created: 2026-01-08T16:13:38.608Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: 95cc65b4
|
||||||
|
title: Clean up settings page - remove unused settings
|
||||||
|
status: open
|
||||||
|
deps: []
|
||||||
|
created: 2026-01-08T16:20:19.483Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: 983d44bf
|
||||||
|
title: Implement thinking effort selector
|
||||||
|
status: open
|
||||||
|
deps: [270b2936, 8ee26288]
|
||||||
|
created: 2026-01-08T16:13:25.916Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: ab05b25a
|
||||||
|
title: Remove sub-agent UI from frontend
|
||||||
|
status: open
|
||||||
|
deps: []
|
||||||
|
created: 2026-01-08T16:17:06.426Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
id: e0b95c57
|
||||||
|
title: Backend: model switcher with TDD
|
||||||
|
status: open
|
||||||
|
deps: []
|
||||||
|
created: 2026-01-08T16:13:34.137Z
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
@@ -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
|
||||||
|
---
|
||||||
|
|
||||||
+31
-198
@@ -158,18 +158,12 @@ export default function HomeScreen() {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<
|
const [openDropdown, setOpenDropdown] = useState<
|
||||||
"host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent" | null
|
"host" | "provider" | "mode" | "model" | "agent" | null
|
||||||
>(null);
|
>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [promptText, setPromptText] = useState("");
|
const [promptText, setPromptText] = useState("");
|
||||||
const [baseBranch, setBaseBranch] = useState("");
|
const [useWorktree, setUseWorktree] = useState(false);
|
||||||
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 addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
|
|
||||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||||
@@ -179,11 +173,8 @@ export default function HomeScreen() {
|
|||||||
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
const handleAddImagesCallback = useCallback((addImages: (images: ImageAttachment[]) => void) => {
|
||||||
addImagesRef.current = addImages;
|
addImagesRef.current = addImages;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createNewBranch = isolationMode === "branch" || isolationMode === "worktree";
|
|
||||||
const createWorktree = isolationMode === "worktree";
|
|
||||||
const openDropdownSheet = useCallback(
|
const openDropdownSheet = useCallback(
|
||||||
(key: "host" | "provider" | "mode" | "model" | "workingDir" | "baseBranch" | "agent") => {
|
(key: "host" | "provider" | "mode" | "model" | "agent") => {
|
||||||
setOpenDropdown(key);
|
setOpenDropdown(key);
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
@@ -350,59 +341,26 @@ export default function HomeScreen() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const gitBlockingError = useMemo(() => {
|
const gitBlockingError = useMemo(() => {
|
||||||
if (isNonGitDirectory) {
|
if (!useWorktree || isNonGitDirectory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const trimmedBase = baseBranch.trim();
|
const slug = slugifyWorktreeName(promptText);
|
||||||
const currentBranch = repoInfo?.currentBranch ?? "";
|
if (!slug) {
|
||||||
const isCustomBase =
|
return null;
|
||||||
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 validation = validateWorktreeName(slug);
|
||||||
if (createNewBranch) {
|
if (!validation.valid) {
|
||||||
const slug = branchName.trim();
|
return `Invalid worktree name: ${
|
||||||
const validation = validateWorktreeName(slug);
|
validation.error ?? "Must use lowercase letters, numbers, or hyphens"
|
||||||
if (!slug || !validation.valid) {
|
}`;
|
||||||
return `Invalid branch name: ${
|
|
||||||
validation.error ?? "Must use lowercase letters, numbers, hyphens, or forward slashes"
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return null;
|
||||||
}, [
|
}, [
|
||||||
baseBranch,
|
useWorktree,
|
||||||
branchName,
|
|
||||||
createNewBranch,
|
|
||||||
createWorktree,
|
|
||||||
isNonGitDirectory,
|
isNonGitDirectory,
|
||||||
repoInfo,
|
promptText,
|
||||||
|
slugifyWorktreeName,
|
||||||
validateWorktreeName,
|
validateWorktreeName,
|
||||||
worktreeSlug,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -429,48 +387,10 @@ export default function HomeScreen() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!repoInfo) {
|
if (isNonGitDirectory && useWorktree) {
|
||||||
return;
|
setUseWorktree(false);
|
||||||
}
|
}
|
||||||
setBaseBranch((prev) => {
|
}, [isNonGitDirectory, useWorktree]);
|
||||||
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;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const renderConfigRow = useCallback(
|
const renderConfigRow = useCallback(
|
||||||
({ label, value, meta, onPress, disabled }: ConfigRowProps) => (
|
({ label, value, meta, onPress, disabled }: ConfigRowProps) => (
|
||||||
@@ -491,29 +411,6 @@ export default function HomeScreen() {
|
|||||||
),
|
),
|
||||||
[theme.colors.mutedForeground]
|
[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 pendingRequestIdRef = useRef<string | null>(null);
|
||||||
const sessionMethods = useSessionStore((state) =>
|
const sessionMethods = useSessionStore((state) =>
|
||||||
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
selectedServerId ? state.sessions[selectedServerId]?.methods : undefined
|
||||||
@@ -567,26 +464,13 @@ export default function HomeScreen() {
|
|||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||||
};
|
};
|
||||||
const trimmedBaseBranch = baseBranch.trim();
|
const worktreeSlug = slugifyWorktreeName(trimmedPrompt);
|
||||||
const shouldIncludeBase =
|
const gitOptions = useWorktree && !isNonGitDirectory && worktreeSlug
|
||||||
trimmedBaseBranch.length > 0 ||
|
? {
|
||||||
createNewBranch ||
|
createWorktree: true,
|
||||||
createWorktree;
|
worktreeSlug,
|
||||||
const gitOptions =
|
}
|
||||||
shouldIncludeBase && !isNonGitDirectory
|
: undefined;
|
||||||
? {
|
|
||||||
...(trimmedBaseBranch ? { baseBranch: trimmedBaseBranch } : {}),
|
|
||||||
...(createNewBranch
|
|
||||||
? { createNewBranch: true, newBranchName: branchName.trim() }
|
|
||||||
: {}),
|
|
||||||
...(createWorktree
|
|
||||||
? {
|
|
||||||
createWorktree: true,
|
|
||||||
worktreeSlug: (worktreeSlug || branchName).trim(),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
void persistFormPreferences();
|
void persistFormPreferences();
|
||||||
|
|
||||||
@@ -602,10 +486,8 @@ export default function HomeScreen() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
baseBranch,
|
useWorktree,
|
||||||
branchName,
|
slugifyWorktreeName,
|
||||||
createNewBranch,
|
|
||||||
createWorktree,
|
|
||||||
gitBlockingError,
|
gitBlockingError,
|
||||||
isDirectoryNotExists,
|
isDirectoryNotExists,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -618,7 +500,6 @@ export default function HomeScreen() {
|
|||||||
selectedServerId,
|
selectedServerId,
|
||||||
sessionMethods,
|
sessionMethods,
|
||||||
workingDir,
|
workingDir,
|
||||||
worktreeSlug,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -656,7 +537,6 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
pendingRequestIdRef.current = null;
|
pendingRequestIdRef.current = null;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setPromptText("");
|
|
||||||
router.replace({
|
router.replace({
|
||||||
pathname: "/agent/[serverId]/[agentId]",
|
pathname: "/agent/[serverId]/[agentId]",
|
||||||
params: {
|
params: {
|
||||||
@@ -709,15 +589,9 @@ export default function HomeScreen() {
|
|||||||
<WorkingDirectoryDropdown
|
<WorkingDirectoryDropdown
|
||||||
workingDir={workingDir}
|
workingDir={workingDir}
|
||||||
errorMessage=""
|
errorMessage=""
|
||||||
isOpen={openDropdown === "workingDir"}
|
|
||||||
onOpen={() => openDropdownSheet("workingDir")}
|
|
||||||
onClose={closeDropdown}
|
|
||||||
disabled={false}
|
disabled={false}
|
||||||
suggestedPaths={agentWorkingDirSuggestions}
|
suggestedPaths={agentWorkingDirSuggestions}
|
||||||
onSelectPath={setWorkingDirFromUser}
|
onSelectPath={setWorkingDirFromUser}
|
||||||
label="Working Directory"
|
|
||||||
wrapInContainer={false}
|
|
||||||
renderTrigger={renderDropdownTrigger}
|
|
||||||
/>
|
/>
|
||||||
{isDirectoryNotExists && (
|
{isDirectoryNotExists && (
|
||||||
<View style={styles.warningContainer}>
|
<View style={styles.warningContainer}>
|
||||||
@@ -733,54 +607,13 @@ export default function HomeScreen() {
|
|||||||
})}
|
})}
|
||||||
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
{trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
||||||
<GitOptionsSection
|
<GitOptionsSection
|
||||||
baseBranch={baseBranch}
|
useWorktree={useWorktree}
|
||||||
onBaseBranchChange={handleBaseBranchChange}
|
onUseWorktreeChange={setUseWorktree}
|
||||||
branches={repoInfo?.branches ?? []}
|
worktreeSlug={slugifyWorktreeName(promptText)}
|
||||||
|
currentBranch={repoInfo?.currentBranch ?? null}
|
||||||
status={repoInfoStatus}
|
status={repoInfoStatus}
|
||||||
repoError={repoInfoError}
|
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}
|
gitValidationError={gitBlockingError}
|
||||||
isBaseDropdownOpen={openDropdown === "baseBranch"}
|
|
||||||
onToggleBaseDropdown={() => openDropdownSheet("baseBranch")}
|
|
||||||
onCloseDropdown={closeDropdown}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
TextInput,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
useWindowDimensions,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet, UnistylesRuntime } from "react-native-unistyles";
|
||||||
import { ChevronDown } from "lucide-react-native";
|
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||||
import { theme as defaultTheme } from "@/styles/theme";
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
import type {
|
import type {
|
||||||
AgentMode,
|
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 {
|
interface DropdownSheetProps {
|
||||||
title: string;
|
title: string;
|
||||||
visible: boolean;
|
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 {
|
interface AssistantDropdownProps {
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
selectedProvider: AgentProvider;
|
selectedProvider: AgentProvider;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (provider: AgentProvider) => void;
|
onSelect: (provider: AgentProvider) => void;
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
sheetTitle?: string;
|
|
||||||
renderTrigger?: DropdownTriggerRenderer;
|
|
||||||
wrapInContainer?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantDropdown({
|
export function AssistantDropdown({
|
||||||
providerDefinitions,
|
providerDefinitions,
|
||||||
selectedProvider,
|
selectedProvider,
|
||||||
disabled,
|
disabled,
|
||||||
isOpen,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
label = "Assistant",
|
|
||||||
placeholder = "Select assistant",
|
|
||||||
sheetTitle = "Choose Assistant",
|
|
||||||
renderTrigger,
|
|
||||||
wrapInContainer = true,
|
|
||||||
}: AssistantDropdownProps): ReactElement {
|
}: AssistantDropdownProps): ReactElement {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedDefinition = providerDefinitions.find(
|
const selectedDefinition = providerDefinitions.find(
|
||||||
(definition) => definition.id === selectedProvider
|
(definition) => definition.id === selectedProvider
|
||||||
);
|
);
|
||||||
const field = (
|
|
||||||
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
|
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownField
|
<SelectField
|
||||||
label={label}
|
label="AGENT"
|
||||||
value={selectedDefinition?.label ?? ""}
|
value={selectedDefinition?.label ?? ""}
|
||||||
placeholder={placeholder}
|
placeholder="Select assistant"
|
||||||
onPress={onOpen}
|
onPress={handleOpen}
|
||||||
disabled={disabled}
|
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) => {
|
{providerDefinitions.map((definition) => {
|
||||||
const isSelected = definition.id === selectedProvider;
|
const isSelected = definition.id === selectedProvider;
|
||||||
return (
|
return (
|
||||||
@@ -189,7 +351,7 @@ export function AssistantDropdown({
|
|||||||
]}
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSelect(definition.id);
|
onSelect(definition.id);
|
||||||
onClose();
|
handleClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
|
<Text style={styles.dropdownSheetOptionLabel}>{definition.label}</Text>
|
||||||
@@ -201,72 +363,63 @@ export function AssistantDropdown({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</DropdownSheet>
|
</AdaptiveSelect>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!wrapInContainer) {
|
|
||||||
return <>{field}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View style={styles.selectorColumn}>{field}</View>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PermissionsDropdownProps {
|
interface PermissionsDropdownProps {
|
||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
selectedMode: string;
|
selectedMode: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (modeId: string) => void;
|
onSelect: (modeId: string) => void;
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
sheetTitle?: string;
|
|
||||||
renderTrigger?: DropdownTriggerRenderer;
|
|
||||||
wrapInContainer?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PermissionsDropdown({
|
export function PermissionsDropdown({
|
||||||
modeOptions,
|
modeOptions,
|
||||||
selectedMode,
|
selectedMode,
|
||||||
disabled,
|
disabled,
|
||||||
isOpen,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
label = "Permissions",
|
|
||||||
placeholder,
|
|
||||||
sheetTitle = "Permissions",
|
|
||||||
renderTrigger,
|
|
||||||
wrapInContainer = true,
|
|
||||||
}: PermissionsDropdownProps): ReactElement {
|
}: PermissionsDropdownProps): ReactElement {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const hasOptions = modeOptions.length > 0;
|
const hasOptions = modeOptions.length > 0;
|
||||||
const selectedModeLabel = hasOptions
|
const selectedModeLabel = hasOptions
|
||||||
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
? modeOptions.find((mode) => mode.id === selectedMode)?.label ??
|
||||||
modeOptions[0]?.label ??
|
modeOptions[0]?.label ??
|
||||||
"Default"
|
"Default"
|
||||||
: "Automatic";
|
: "Automatic";
|
||||||
const placeholderLabel = hasOptions
|
|
||||||
? placeholder ?? "Select permissions"
|
const handleOpen = useCallback(() => {
|
||||||
: "Automatic";
|
if (hasOptions) {
|
||||||
const field = (
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
}, [hasOptions]);
|
||||||
|
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownField
|
<SelectField
|
||||||
label={label}
|
label="PERMISSIONS"
|
||||||
value={selectedModeLabel}
|
value={selectedModeLabel}
|
||||||
placeholder={placeholderLabel}
|
placeholder={hasOptions ? "Select permissions" : "Automatic"}
|
||||||
onPress={hasOptions ? onOpen : () => {}}
|
onPress={handleOpen}
|
||||||
disabled={disabled || !hasOptions}
|
disabled={disabled || !hasOptions}
|
||||||
helperText={
|
helperText={
|
||||||
hasOptions
|
hasOptions
|
||||||
? undefined
|
? undefined
|
||||||
: "This assistant does not expose selectable permissions."
|
: "This assistant does not expose selectable permissions."
|
||||||
}
|
}
|
||||||
renderTrigger={renderTrigger}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
{hasOptions ? (
|
{hasOptions ? (
|
||||||
<DropdownSheet title={sheetTitle} visible={isOpen} onClose={onClose}>
|
<AdaptiveSelect
|
||||||
|
title="Permissions"
|
||||||
|
visible={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
anchorRef={anchorRef}
|
||||||
|
>
|
||||||
{modeOptions.map((mode) => {
|
{modeOptions.map((mode) => {
|
||||||
const isSelected = mode.id === selectedMode;
|
const isSelected = mode.id === selectedMode;
|
||||||
return (
|
return (
|
||||||
@@ -278,7 +431,7 @@ export function PermissionsDropdown({
|
|||||||
]}
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSelect(mode.id);
|
onSelect(mode.id);
|
||||||
onClose();
|
handleClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
|
<Text style={styles.dropdownSheetOptionLabel}>{mode.label}</Text>
|
||||||
@@ -290,16 +443,10 @@ export function PermissionsDropdown({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</DropdownSheet>
|
</AdaptiveSelect>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!wrapInContainer) {
|
|
||||||
return <>{field}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View style={[styles.selectorColumn, styles.selectorColumnFull]}>{field}</View>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelDropdownProps {
|
interface ModelDropdownProps {
|
||||||
@@ -307,15 +454,9 @@ interface ModelDropdownProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
isOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (modelId: string) => void;
|
onSelect: (modelId: string) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
label?: string;
|
|
||||||
renderTrigger?: DropdownTriggerRenderer;
|
|
||||||
wrapInContainer?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelDropdown({
|
export function ModelDropdown({
|
||||||
@@ -323,16 +464,13 @@ export function ModelDropdown({
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isOpen,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
onSelect,
|
onSelect,
|
||||||
onClear,
|
onClear,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
label = "Model",
|
|
||||||
renderTrigger,
|
|
||||||
wrapInContainer = true,
|
|
||||||
}: ModelDropdownProps): ReactElement {
|
}: ModelDropdownProps): ReactElement {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const selectedLabel = selectedModel
|
const selectedLabel = selectedModel
|
||||||
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
|
? models.find((model) => model.id === selectedModel)?.label ?? selectedModel
|
||||||
: "Automatic";
|
: "Automatic";
|
||||||
@@ -345,24 +483,27 @@ export function ModelDropdown({
|
|||||||
? "This assistant did not expose selectable models."
|
? "This assistant did not expose selectable models."
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const field = (
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
|
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||||
|
|
||||||
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownField
|
<SelectField
|
||||||
label={label}
|
label="MODEL"
|
||||||
value={selectedLabel}
|
value={selectedLabel}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onPress={onOpen}
|
onPress={handleOpen}
|
||||||
disabled={false}
|
disabled={false}
|
||||||
errorMessage={error ?? undefined}
|
errorMessage={error ?? undefined}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
renderTrigger={renderTrigger}
|
controlRef={anchorRef}
|
||||||
/>
|
/>
|
||||||
<DropdownSheet title="Model" visible={isOpen} onClose={onClose}>
|
<AdaptiveSelect title="Model" visible={isOpen} onClose={handleClose} anchorRef={anchorRef}>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.dropdownSheetOption}
|
style={styles.dropdownSheetOption}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onClear();
|
onClear();
|
||||||
onClose();
|
handleClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.dropdownSheetOptionLabel}>
|
<Text style={styles.dropdownSheetOptionLabel}>
|
||||||
@@ -383,7 +524,7 @@ export function ModelDropdown({
|
|||||||
]}
|
]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
onSelect(model.id);
|
onSelect(model.id);
|
||||||
onClose();
|
handleClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
|
<Text style={styles.dropdownSheetOptionLabel}>{model.label}</Text>
|
||||||
@@ -411,47 +552,34 @@ export function ModelDropdown({
|
|||||||
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
|
<ActivityIndicator size="small" color={defaultTheme.colors.foreground} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</DropdownSheet>
|
</AdaptiveSelect>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!wrapInContainer) {
|
|
||||||
return <>{field}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View style={styles.selectorColumn}>{field}</View>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkingDirectoryDropdownProps {
|
interface WorkingDirectoryDropdownProps {
|
||||||
workingDir: string;
|
workingDir: string;
|
||||||
errorMessage: string;
|
errorMessage: string;
|
||||||
isOpen: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
suggestedPaths: string[];
|
suggestedPaths: string[];
|
||||||
onSelectPath: (value: string) => void;
|
onSelectPath: (value: string) => void;
|
||||||
label?: string;
|
|
||||||
renderTrigger?: DropdownTriggerRenderer;
|
|
||||||
wrapInContainer?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkingDirectoryDropdown({
|
export function WorkingDirectoryDropdown({
|
||||||
workingDir,
|
workingDir,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isOpen,
|
|
||||||
onOpen,
|
|
||||||
onClose,
|
|
||||||
disabled,
|
disabled,
|
||||||
suggestedPaths,
|
suggestedPaths,
|
||||||
onSelectPath,
|
onSelectPath,
|
||||||
label = "Working Directory",
|
|
||||||
renderTrigger,
|
|
||||||
wrapInContainer = true,
|
|
||||||
}: WorkingDirectoryDropdownProps): ReactElement {
|
}: WorkingDirectoryDropdownProps): ReactElement {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const anchorRef = useRef<View>(null);
|
||||||
const inputRef = useRef<TextInput | null>(null);
|
const inputRef = useRef<TextInput | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => setIsOpen(true), []);
|
||||||
|
const handleClose = useCallback(() => setIsOpen(false), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
@@ -477,18 +605,18 @@ export function WorkingDirectoryDropdown({
|
|||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
onSelectPath(path);
|
onSelectPath(path);
|
||||||
onClose();
|
handleClose();
|
||||||
},
|
},
|
||||||
[onClose, onSelectPath]
|
[handleClose, onSelectPath]
|
||||||
);
|
);
|
||||||
|
|
||||||
const field = (
|
return (
|
||||||
<>
|
<>
|
||||||
<DropdownField
|
<SelectField
|
||||||
label={label}
|
label="WORKING DIRECTORY"
|
||||||
value={workingDir}
|
value={workingDir}
|
||||||
placeholder="/path/to/project"
|
placeholder="/path/to/project"
|
||||||
onPress={onOpen}
|
onPress={handleOpen}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
errorMessage={errorMessage || undefined}
|
errorMessage={errorMessage || undefined}
|
||||||
helperText={
|
helperText={
|
||||||
@@ -496,9 +624,14 @@ export function WorkingDirectoryDropdown({
|
|||||||
? "Search directories from existing agents or paste a new path."
|
? "Search directories from existing agents or paste a new path."
|
||||||
: "No agent directories yet - search to add one."
|
: "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
|
<TextInput
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
style={styles.dropdownSearchInput}
|
style={styles.dropdownSearchInput}
|
||||||
@@ -555,15 +688,9 @@ export function WorkingDirectoryDropdown({
|
|||||||
No agent directories match your search.
|
No agent directories match your search.
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</DropdownSheet>
|
</AdaptiveSelect>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!wrapInContainer) {
|
|
||||||
return <>{field}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View style={styles.formSection}>{field}</View>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ToggleRowProps {
|
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 {
|
export interface GitOptionsSectionProps {
|
||||||
baseBranch: string;
|
useWorktree: boolean;
|
||||||
onBaseBranchChange: (value: string) => void;
|
onUseWorktreeChange: (value: boolean) => void;
|
||||||
branches: Array<{ name: string; isCurrent: boolean }>;
|
worktreeSlug: string;
|
||||||
|
currentBranch: string | null;
|
||||||
status: "idle" | "loading" | "ready" | "error";
|
status: "idle" | "loading" | "ready" | "error";
|
||||||
repoError: string | null;
|
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;
|
gitValidationError: string | null;
|
||||||
isGitDisabled?: boolean;
|
|
||||||
isBaseDropdownOpen: boolean;
|
|
||||||
onToggleBaseDropdown: () => void;
|
|
||||||
onCloseDropdown: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GitOptionsSection({
|
export function GitOptionsSection({
|
||||||
baseBranch,
|
useWorktree,
|
||||||
onBaseBranchChange,
|
onUseWorktreeChange,
|
||||||
branches,
|
worktreeSlug,
|
||||||
|
currentBranch,
|
||||||
status,
|
status,
|
||||||
repoError,
|
repoError,
|
||||||
helperText,
|
|
||||||
warning,
|
|
||||||
isolationMode,
|
|
||||||
onIsolationModeChange,
|
|
||||||
branchName,
|
|
||||||
onBranchNameChange,
|
|
||||||
worktreeSlug,
|
|
||||||
onWorktreeSlugChange,
|
|
||||||
gitValidationError,
|
gitValidationError,
|
||||||
isGitDisabled,
|
|
||||||
isBaseDropdownOpen,
|
|
||||||
onToggleBaseDropdown,
|
|
||||||
onCloseDropdown,
|
|
||||||
}: GitOptionsSectionProps): ReactElement {
|
}: GitOptionsSectionProps): ReactElement {
|
||||||
const [branchSearch, setBranchSearch] = useState("");
|
const isLoading = status === "loading";
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.formSection}>
|
<View style={styles.gitOptionsContainer}>
|
||||||
<DropdownField
|
<Pressable
|
||||||
label="Git"
|
onPress={() => onUseWorktreeChange(!useWorktree)}
|
||||||
value={baseBranch}
|
disabled={isLoading}
|
||||||
placeholder={currentBranchLabel || "main"}
|
style={[styles.worktreeToggle, isLoading && styles.worktreeToggleDisabled]}
|
||||||
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}
|
|
||||||
>
|
>
|
||||||
<TextInput
|
<View style={[styles.checkbox, useWorktree && styles.checkboxChecked]}>
|
||||||
ref={baseInputRef}
|
{useWorktree ? <View style={styles.checkboxDot} /> : null}
|
||||||
style={styles.dropdownSearchInput}
|
</View>
|
||||||
placeholder={currentBranchLabel || "main"}
|
<View style={styles.worktreeToggleContent}>
|
||||||
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
<Text style={styles.worktreeToggleLabel}>Create worktree</Text>
|
||||||
value={branchSearch}
|
<Text style={styles.worktreeToggleDescription}>
|
||||||
onChangeText={setBranchSearch}
|
{isLoading
|
||||||
autoCapitalize="none"
|
? "Inspecting repository…"
|
||||||
autoCorrect={false}
|
: useWorktree && worktreeSlug
|
||||||
/>
|
? `Will create: ${worktreeSlug}`
|
||||||
{status === "loading" ? (
|
: currentBranch
|
||||||
<View style={styles.dropdownLoading}>
|
? `Run isolated from ${currentBranch}`
|
||||||
<ActivityIndicator color={defaultTheme.colors.mutedForeground} />
|
: "Run in an isolated directory"}
|
||||||
<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."}
|
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
</View>
|
||||||
<View style={styles.dropdownSheetList}>
|
</Pressable>
|
||||||
{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 style={styles.formSection}>
|
{repoError ? (
|
||||||
<Text style={styles.label}>Isolation</Text>
|
<Text style={styles.errorText}>{repoError}</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}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{gitValidationError ? (
|
{gitValidationError ? (
|
||||||
@@ -1016,32 +979,97 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
segmentedControl: {
|
selectFieldContainer: {
|
||||||
flexDirection: "row",
|
gap: theme.spacing[2],
|
||||||
backgroundColor: theme.colors.muted,
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
|
||||||
padding: theme.spacing[1],
|
|
||||||
},
|
},
|
||||||
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],
|
opacity: theme.opacity[50],
|
||||||
},
|
},
|
||||||
segmentedOption: {
|
selectFieldContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
paddingVertical: theme.spacing[2],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
borderRadius: theme.borderRadius.md,
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
},
|
||||||
segmentedOptionSelected: {
|
selectFieldLabel: {
|
||||||
backgroundColor: theme.colors.background,
|
|
||||||
},
|
|
||||||
segmentedOptionText: {
|
|
||||||
color: theme.colors.mutedForeground,
|
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,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
segmentedOptionTextSelected: {
|
worktreeToggleDescription: {
|
||||||
color: theme.colors.foreground,
|
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],
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -222,17 +222,19 @@ export function AgentInputArea({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isControlled = value !== undefined;
|
const isControlled = value !== undefined;
|
||||||
if (!isControlled) {
|
|
||||||
setUserInput("");
|
|
||||||
}
|
|
||||||
setSelectedImages([]);
|
setSelectedImages([]);
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await submitMessage(trimmedMessage, imageAttachments);
|
await submitMessage(trimmedMessage, imageAttachments);
|
||||||
|
// Clear input only after successful submission
|
||||||
|
if (isControlled) {
|
||||||
|
onChangeText?.("");
|
||||||
|
} else {
|
||||||
|
setUserInput("");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[AgentInput] Failed to send message:", error);
|
console.error("[AgentInput] Failed to send message:", error);
|
||||||
setUserInput(trimmedMessage);
|
|
||||||
if (imageAttachments) {
|
if (imageAttachments) {
|
||||||
setSelectedImages(imageAttachments);
|
setSelectedImages(imageAttachments);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,7 +156,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
if (shouldAutoSend) {
|
if (shouldAutoSend) {
|
||||||
const imageAttachments = images.length > 0 ? images : undefined;
|
const imageAttachments = images.length > 0 ? images : undefined;
|
||||||
onSubmit({ text: nextValue, images: imageAttachments });
|
onSubmit({ text: nextValue, images: imageAttachments });
|
||||||
onChangeText("");
|
|
||||||
} else {
|
} else {
|
||||||
onChangeText(nextValue);
|
onChangeText(nextValue);
|
||||||
}
|
}
|
||||||
@@ -295,10 +294,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
};
|
};
|
||||||
if (isAgentRunning && onQueue) {
|
if (isAgentRunning && onQueue) {
|
||||||
onQueue(payload);
|
onQueue(payload);
|
||||||
|
onChangeText("");
|
||||||
} else {
|
} else {
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
}
|
}
|
||||||
onChangeText("");
|
|
||||||
// Reset input height
|
// Reset input height
|
||||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||||
setInputHeight(MIN_INPUT_HEIGHT);
|
setInputHeight(MIN_INPUT_HEIGHT);
|
||||||
@@ -420,7 +419,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
forceSend: true,
|
forceSend: true,
|
||||||
};
|
};
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
onChangeText("");
|
|
||||||
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
inputHeightRef.current = MIN_INPUT_HEIGHT;
|
||||||
setInputHeight(MIN_INPUT_HEIGHT);
|
setInputHeight(MIN_INPUT_HEIGHT);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promisify } from "util";
|
import { promisify } from "util";
|
||||||
import { existsSync, readFileSync } from "fs";
|
import { existsSync, readFileSync, rmSync } from "fs";
|
||||||
import { join, basename, dirname } from "path";
|
import { join, basename, dirname } from "path";
|
||||||
|
|
||||||
|
interface PaseoConfig {
|
||||||
|
worktree?: {
|
||||||
|
setup?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
@@ -165,37 +171,6 @@ function sanitizeWorktreeSlug(input: string): string {
|
|||||||
return slug.length > 0 ? slug : "worktree";
|
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
|
* Create a git worktree with proper naming conventions
|
||||||
@@ -228,52 +203,98 @@ export async function createWorktree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if branch already exists
|
// Check if branch already exists
|
||||||
|
let branchExists = false;
|
||||||
try {
|
try {
|
||||||
await execAsync(
|
await execAsync(
|
||||||
`git show-ref --verify --quiet refs/heads/${branchName}`,
|
`git show-ref --verify --quiet refs/heads/${branchName}`,
|
||||||
{ cwd: repoInfo.path }
|
{ cwd: repoInfo.path }
|
||||||
);
|
);
|
||||||
|
branchExists = true;
|
||||||
// Branch exists, check for existing worktree
|
} catch {
|
||||||
const existingWorktree = await findExistingWorktree(
|
branchExists = false;
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy .env file if it exists
|
// Always create a new branch for the worktree
|
||||||
const envSource =
|
// If branchName already exists, use it as base and create worktree-slug as branch name
|
||||||
repoInfo.type === "bare"
|
// If branchName doesn't exist, create it from baseBranch
|
||||||
? join(repoInfo.path, "main", ".env")
|
const base = branchExists ? branchName : (baseBranch ?? "HEAD");
|
||||||
: join(repoInfo.path, ".env");
|
const candidateBranch = branchExists ? desiredSlug : branchName;
|
||||||
|
|
||||||
if (existsSync(envSource)) {
|
// Find unique branch name if collision
|
||||||
await execAsync(`cp "${envSource}" "${worktreePath}/.env"`);
|
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 {
|
return {
|
||||||
branchName,
|
branchName: newBranchName,
|
||||||
worktreePath,
|
worktreePath,
|
||||||
repoType: repoInfo.type,
|
repoType: repoInfo.type,
|
||||||
repoPath: repoInfo.path,
|
repoPath: repoInfo.path,
|
||||||
|
|||||||
Reference in New Issue
Block a user