diff --git a/packages/app/e2e/draft-explorer-sidebar.spec.ts b/packages/app/e2e/draft-explorer-sidebar.spec.ts new file mode 100644 index 000000000..040043be3 --- /dev/null +++ b/packages/app/e2e/draft-explorer-sidebar.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "./fixtures"; +import { ensureHostSelected, gotoHome, setWorkingDirectory } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; + +test("draft enables explorer after selecting a working directory", async ({ page }) => { + const repo = await createTempGitRepo("paseo-e2e-draft-explorer-"); + + try { + await gotoHome(page); + await ensureHostSelected(page); + + const newAgentButton = page.getByTestId("sidebar-new-agent").first(); + await expect(newAgentButton).toBeVisible({ timeout: 30000 }); + await newAgentButton.click(); + await expect(page).toHaveURL(/\/agent\/?$/, { timeout: 30000 }); + + await setWorkingDirectory(page, repo.path); + + const toggle = page + .getByRole("button", { + name: /open explorer|close explorer|toggle explorer/i, + }) + .first(); + await expect(toggle).toBeVisible({ timeout: 30000 }); + + await toggle.click(); + await expect( + page.locator('[data-testid="explorer-header"]:visible').first() + ).toBeVisible({ timeout: 30000 }); + + const terminalsTab = page + .locator('[data-testid="explorer-tab-terminals"]:visible') + .first(); + await expect(terminalsTab).toBeVisible({ timeout: 30000 }); + await terminalsTab.click(); + await expect( + page.locator('[data-testid="terminal-surface"]:visible').first() + ).toBeVisible({ timeout: 30000 }); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/src/screens/agent/draft-agent-screen.tsx b/packages/app/src/screens/agent/draft-agent-screen.tsx index feb26a8c9..60fd70fe2 100644 --- a/packages/app/src/screens/agent/draft-agent-screen.tsx +++ b/packages/app/src/screens/agent/draft-agent-screen.tsx @@ -13,12 +13,15 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; -import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"; -import { Folder, GitBranch } from "lucide-react-native"; +import { Gesture, GestureDetector } from "react-native-gesture-handler"; +import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; +import { Folder, GitBranch, PanelRight } from "lucide-react-native"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { AgentInputArea } from "@/components/agent-input-area"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns"; +import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { Combobox } from "@/components/ui/combobox"; import { FileDropZone } from "@/components/file-drop-zone"; import { useQuery } from "@tanstack/react-query"; @@ -37,6 +40,10 @@ import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-d import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions"; import { useSessionStore } from "@/stores/session-store"; import { useCreateFlowStore } from "@/stores/create-flow-store"; +import { + ExplorerSidebarAnimationProvider, +} from "@/contexts/explorer-sidebar-animation-context"; +import { usePanelStore, type ExplorerCheckoutContext } from "@/stores/panel-store"; import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { WelcomeScreen } from "@/components/welcome-screen"; import type { Agent } from "@/contexts/session-context"; @@ -125,6 +132,22 @@ export function DraftAgentScreen({ isVisible = true, onCreateFlowActiveChange, forcedServerId, +}: DraftAgentScreenProps = {}) { + return ( + + + + ); +} + +function DraftAgentScreenContent({ + isVisible = true, + onCreateFlowActiveChange, + forcedServerId, }: DraftAgentScreenProps = {}) { const { theme } = useUnistyles(); const router = useRouter(); @@ -229,6 +252,16 @@ export function DraftAgentScreen({ : undefined; const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; + const mobileView = usePanelStore((state) => state.mobileView); + const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen); + const toggleFileExplorer = usePanelStore((state) => state.toggleFileExplorer); + const openFileExplorer = usePanelStore((state) => state.openFileExplorer); + const closeFileExplorer = usePanelStore((state) => state.closeFileExplorer); + const setActiveExplorerCheckout = usePanelStore((state) => state.setActiveExplorerCheckout); + const activateExplorerTabForCheckout = usePanelStore( + (state) => state.activateExplorerTabForCheckout + ); + const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen; const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none"); const [baseBranch, setBaseBranch] = useState(""); @@ -690,6 +723,72 @@ export function DraftAgentScreen({ const selectedWorktreeLabel = worktreeOptions.find((option) => option.path === selectedWorktreePath)?.label ?? ""; + const explorerCwd = useMemo( + () => + ( + isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir + ).trim(), + [isAttachWorktree, selectedWorktreePath, workingDir] + ); + const draftExplorerCheckout = useMemo(() => { + if (!selectedServerId || !explorerCwd) { + return null; + } + return { + serverId: selectedServerId, + cwd: explorerCwd, + isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true, + }; + }, [ + selectedServerId, + explorerCwd, + isAttachWorktree, + selectedWorktreePath, + checkout?.isGit, + ]); + const canOpenExplorer = draftExplorerCheckout !== null; + const openExplorerForDraftCheckout = useCallback(() => { + if (!draftExplorerCheckout) { + return; + } + activateExplorerTabForCheckout(draftExplorerCheckout); + openFileExplorer(); + }, [activateExplorerTabForCheckout, draftExplorerCheckout, openFileExplorer]); + const handleToggleExplorer = useCallback(() => { + if (!canOpenExplorer) { + return; + } + if (isExplorerOpen) { + toggleFileExplorer(); + return; + } + openExplorerForDraftCheckout(); + }, [ + canOpenExplorer, + isExplorerOpen, + openExplorerForDraftCheckout, + toggleFileExplorer, + ]); + const handleOpenExplorerFromGesture = useCallback(() => { + if (!canOpenExplorer) { + return; + } + openExplorerForDraftCheckout(); + }, [canOpenExplorer, openExplorerForDraftCheckout]); + const explorerOpenGesture = useMemo( + () => + Gesture.Pan() + .enabled(isMobile && !isExplorerOpen && canOpenExplorer) + .activeOffsetX(-15) + .failOffsetY([-10, 10]) + .onEnd((event) => { + const shouldOpen = event.translationX < -80 || event.velocityX < -500; + if (shouldOpen) { + runOnJS(handleOpenExplorerFromGesture)(); + } + }), + [canOpenExplorer, handleOpenExplorerFromGesture, isExplorerOpen, isMobile] + ); const hasWorkingDirectorySearch = debouncedWorkingDirSearchQuery.length > 0; const workingDirSearchError = directorySuggestionsQuery.error instanceof Error @@ -893,6 +992,26 @@ export function DraftAgentScreen({ selectedWorktreePath, workingDir, ]); + useEffect(() => { + setActiveExplorerCheckout(draftExplorerCheckout); + }, [draftExplorerCheckout, setActiveExplorerCheckout]); + useEffect(() => { + if (!draftExplorerCheckout) { + return; + } + activateExplorerTabForCheckout(draftExplorerCheckout); + }, [activateExplorerTabForCheckout, draftExplorerCheckout]); + useEffect(() => { + if (canOpenExplorer || !isExplorerOpen) { + return; + } + closeFileExplorer(); + }, [canOpenExplorer, closeFileExplorer, isExplorerOpen]); + useEffect(() => { + return () => { + setActiveExplorerCheckout(null); + }; + }, [setActiveExplorerCheckout]); const handleCreateFromInput = useCallback( async ({ @@ -1082,9 +1201,11 @@ export function DraftAgentScreen({ ); } - return ( - - + const explorerServerId = draftExplorerCheckout?.serverId ?? null; + const explorerIsGit = draftExplorerCheckout?.isGit ?? false; + const mainContent = ( + + - - - - - {machine.tag === "creating" && draftAgent && selectedServerId ? ( - - - - ) : ( - - - - setIsWorkingDirOpen(true)} - icon={} - showLabel={false} - valueEllipsizeMode="middle" - testID="working-directory-select" - /> - - {isDirectoryNotExists && ( - - - Directory does not exist on the selected host - - - )} - {isMobile ? : null} - - {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( - - ) : null} - {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( - - setIsWorktreePickerOpen(true)} - icon={} - showLabel={false} - valueEllipsizeMode="middle" - /> - {worktreeMode === "create" ? ( - setIsBranchOpen(true)} - disabled={repoInfoStatus === "loading"} - icon={} - showLabel={false} + + + {!isMobile && canOpenExplorer ? ( + + - ) : null} - - ) : null} - {baseBranchError ? {baseBranchError} : null} - {repoInfoError ? {repoInfoError} : null} - {gitBlockingError ? {gitBlockingError} : null} - {attachWorktreeError ? {attachWorktreeError} : null} - {worktreeOptionsError ? {worktreeOptionsError} : null} - - { - if (id === "__create_new__") { - setWorktreeMode("create"); - if (!worktreeSlug) { - setWorktreeSlug(createNameId()); - } - setSelectedWorktreePath(""); - return; - } - if (id === "__none__") { - setWorktreeMode("none"); - setSelectedWorktreePath(""); - return; - } - handleSelectWorktreePath(id); - setWorktreeMode("attach"); - }} - title="Select worktree" - searchPlaceholder="Search worktrees..." - open={isWorktreePickerOpen} - onOpenChange={setIsWorktreePickerOpen} - emptyText="No worktrees found" - anchorRef={worktreeAnchorRef} - /> - - - - { - setIsBranchOpen(nextOpen); - if (!nextOpen) { - setBranchSearchQuery(""); - } - }} - anchorRef={branchAnchorRef} - /> - - {formErrorMessage ? ( - - {formErrorMessage} + + ) : null} - ) : null} - - )} - - - dispatch({ type: "DRAFT_SET_PROMPT", text: next })} - autoFocus={machine.tag === "draft"} - onAddImages={handleAddImagesCallback} - commandDraftConfig={draftCommandConfig} - /> + + + + {machine.tag === "creating" && draftAgent && selectedServerId ? ( + + + + ) : ( + + + + setIsWorkingDirOpen(true)} + icon={} + showLabel={false} + valueEllipsizeMode="middle" + testID="working-directory-select" + /> + + {isDirectoryNotExists && ( + + + Directory does not exist on the selected host + + + )} + {isMobile ? : null} + + {isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( + + ) : null} + {trimmedWorkingDir.length > 0 && !isNonGitDirectory ? ( + + setIsWorktreePickerOpen(true)} + icon={} + showLabel={false} + valueEllipsizeMode="middle" + /> + {worktreeMode === "create" ? ( + setIsBranchOpen(true)} + disabled={repoInfoStatus === "loading"} + icon={} + showLabel={false} + /> + ) : null} + + ) : null} + {baseBranchError ? {baseBranchError} : null} + {repoInfoError ? {repoInfoError} : null} + {gitBlockingError ? {gitBlockingError} : null} + {attachWorktreeError ? {attachWorktreeError} : null} + {worktreeOptionsError ? {worktreeOptionsError} : null} + + { + if (id === "__create_new__") { + setWorktreeMode("create"); + if (!worktreeSlug) { + setWorktreeSlug(createNameId()); + } + setSelectedWorktreePath(""); + return; + } + if (id === "__none__") { + setWorktreeMode("none"); + setSelectedWorktreePath(""); + return; + } + handleSelectWorktreePath(id); + setWorktreeMode("attach"); + }} + title="Select worktree" + searchPlaceholder="Search worktrees..." + open={isWorktreePickerOpen} + onOpenChange={setIsWorktreePickerOpen} + emptyText="No worktrees found" + anchorRef={worktreeAnchorRef} + /> + + + + { + setIsBranchOpen(nextOpen); + if (!nextOpen) { + setBranchSearchQuery(""); + } + }} + anchorRef={branchAnchorRef} + /> + + {formErrorMessage ? ( + + {formErrorMessage} + + ) : null} + + )} + + + dispatch({ type: "DRAFT_SET_PROMPT", text: next })} + autoFocus={machine.tag === "draft"} + onAddImages={handleAddImagesCallback} + commandDraftConfig={draftCommandConfig} + /> + + + {!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? ( + + ) : null} - + ); + + return ( + + <> + {isMobile ? ( + + {mainContent} + + ) : ( + mainContent + )} + + {isMobile && explorerServerId && draftExplorerCheckout ? ( + + ) : null} + + ); } @@ -1293,13 +1473,25 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, backgroundColor: theme.colors.surface0, }, + outerContainer: { + flex: 1, + flexDirection: "row", + }, agentPanel: { flex: 1, }, menuToggleContainer: { paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], - alignItems: "flex-start", + }, + menuToggleRow: { + width: "100%", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }, + menuButton: { + marginLeft: theme.spacing[2], }, contentContainer: { flex: 1, diff --git a/packages/app/src/stores/panel-store.ts b/packages/app/src/stores/panel-store.ts index f94d59a8e..d258df8d5 100644 --- a/packages/app/src/stores/panel-store.ts +++ b/packages/app/src/stores/panel-store.ts @@ -69,6 +69,7 @@ interface PanelState { // Actions openAgentList: () => void; openFileExplorer: () => void; + closeFileExplorer: () => void; closeToAgent: () => void; toggleAgentList: () => void; toggleFileExplorer: () => void; @@ -147,6 +148,14 @@ export const usePanelStore = create()( ...(resolvedTab ? { explorerTab: resolvedTab } : {}), }; }), + closeFileExplorer: () => + set((state) => ({ + mobileView: state.mobileView === "file-explorer" ? "agent" : state.mobileView, + desktop: { + ...state.desktop, + fileExplorerOpen: false, + }, + })), closeToAgent: () => set((state) => ({