From ec93ca866e0f7d0d1110f7ca5d58a9b7da4e5c45 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 12 Jul 2026 21:20:31 +0200 Subject: [PATCH] Make adding projects discoverable in new workspaces (#2026) * fix(app): make project setup discoverable in new workspaces * fix(app): keep project picker actions outside options --- packages/app/e2e/new-workspace.spec.ts | 49 ++++++++++++++++ packages/app/src/components/ui/combobox.tsx | 13 +++++ .../app/src/screens/new-workspace-screen.tsx | 57 +++++++++++++++++-- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index b189bf6e8..2b5faa246 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -35,6 +35,8 @@ import { hasGithubAuth, } from "./helpers/github-fixtures"; import { getServerId } from "./helpers/server-id"; +import { getE2EDaemonPort } from "./helpers/daemon-port"; +import { seedSavedSettingsHosts } from "./helpers/settings"; import { expectSidebarWorkspaceSelected, expectWorkspaceHeader, @@ -209,6 +211,53 @@ test.describe("New workspace flow", () => { await client?.close().catch(() => undefined); }); + test("adds a project from the selected empty host", async ({ page }) => { + const repo = await createTempGitRepo("new-workspace-project-picker-"); + const primaryServerId = getServerId(); + const emptyServerId = "empty-new-workspace-host"; + + try { + const openedProject = await openProjectViaDaemon(client, repo.path); + localWorkspaceIds.add(openedProject.workspaceId); + await seedSavedSettingsHosts(page, [ + { + serverId: primaryServerId, + label: "Primary host", + endpoint: `127.0.0.1:${getE2EDaemonPort()}`, + }, + { + serverId: emptyServerId, + label: "Empty host", + endpoint: "127.0.0.1:9", + }, + ]); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openGlobalNewWorkspaceComposer(page); + + const projectTrigger = page.getByTestId("new-workspace-project-picker-trigger"); + await projectTrigger.click(); + await page.getByPlaceholder("Search projects").fill("no matching project"); + await expect(page.getByTestId("new-workspace-project-picker-add-project")).toBeVisible(); + await page.keyboard.press("Escape"); + + await page.getByTestId("host-picker-trigger").click(); + await page.getByTestId(`new-workspace-host-picker-option-${emptyServerId}`).click(); + await expect(projectTrigger).toContainText("Choose project"); + await projectTrigger.click(); + + const addProject = page.getByTestId("new-workspace-project-picker-add-project"); + await expect(addProject).toContainText("Add project"); + await expect(addProject).toContainText(/(?:⌘|Ctrl\+)O/); + await addProject.click(); + + await expect(page.getByTestId("project-picker-input")).toBeVisible(); + } finally { + await repo.cleanup(); + } + }); + test("sidebar workspace navigation updates URL and header", async ({ page }) => { const serverId = getServerId(); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 867685faf..279954404 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -116,6 +116,8 @@ export interface ComboboxProps { desktopFixedHeight?: number; /** Content rendered above the scroll area on desktop (sticky header). */ stickyHeader?: ReactNode; + /** Content rendered below the scroll area. */ + footer?: ReactNode; /** When true, selecting an option does not close the picker (multi-select mode). */ keepOpenOnSelect?: boolean; anchorRef: React.RefObject; @@ -928,6 +930,7 @@ interface MobileBodyProps { header: SheetHeader | undefined; onClose: () => void; stickyHeader: ReactNode; + footer: ReactNode; searchable: boolean; hasChildren: boolean; mobileChildrenScrollEnabled: boolean; @@ -1026,6 +1029,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { {body} )} + {props.footer ? {props.footer} : null} ); } @@ -1039,6 +1043,7 @@ interface DesktopBodyProps { handleDesktopContentLayout: (event: LayoutChangeEvent) => void; header: SheetHeader | undefined; stickyHeader: ReactNode; + footer: ReactNode; searchable: boolean; searchPlaceholder: string; searchQuery: string; @@ -1192,6 +1197,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement { renderOption={props.renderOption} /> )} + {props.footer ? {props.footer} : null} @@ -1224,6 +1230,7 @@ export function Combobox({ desktopMinWidth, desktopFixedHeight, stickyHeader, + footer, keepOpenOnSelect = false, anchorRef, children, @@ -1504,6 +1511,7 @@ export function Combobox({ header={header} onClose={handleClose} stickyHeader={stickyHeader} + footer={footer} searchable={searchable} hasChildren={hasChildren} mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} @@ -1537,6 +1545,7 @@ export function Combobox({ handleDesktopContentLayout={handleDesktopContentLayout} header={header} stickyHeader={stickyHeader} + footer={footer} searchable={searchable} searchPlaceholder={effectiveSearchPlaceholder} searchQuery={searchQuery} @@ -1650,6 +1659,10 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + footer: { + borderTopWidth: 1, + borderTopColor: theme.colors.border, + }, bottomSheetHeader: { paddingHorizontal: theme.spacing[6], paddingBottom: theme.spacing[2], diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 5e4d1faa0..a5a3e3279 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -5,11 +5,19 @@ import type { TFunction } from "i18next"; import { Pressable, Text, View } from "react-native"; import type { PressableStateCallbackType } from "react-native"; import ReanimatedAnimated from "react-native-reanimated"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { createNameId } from "mnemonic-id"; import { useQuery } from "@tanstack/react-query"; -import { Check, ChevronDown, Folder, GitBranch, GitPullRequest, X } from "lucide-react-native"; +import { + Check, + ChevronDown, + Folder, + FolderPlus, + GitBranch, + GitPullRequest, + X, +} from "lucide-react-native"; import { Composer } from "@/composer"; import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; @@ -20,6 +28,7 @@ import { ProjectIconView } from "@/components/project-icon-view"; import { Combobox, ComboboxItem } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; +import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { SidebarMenuToggle } from "@/components/headers/menu-header"; @@ -43,6 +52,7 @@ import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session- import { useWorkspace } from "@/stores/session-store-hooks"; import { generateDraftId } from "@/stores/draft-keys"; import { useDraftStore } from "@/stores/draft-store"; +import { useProjectPickerStore } from "@/stores/project-picker-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { useWorkspaceDraftSubmissionStore, @@ -50,6 +60,7 @@ import { } from "@/stores/workspace-draft-submission-store"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useFormPreferences } from "@/hooks/use-form-preferences"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { generateMessageId } from "@/types/stream"; import { toErrorMessage } from "@/utils/error-messages"; @@ -63,6 +74,7 @@ import { type HostProjectListItem, } from "@/projects/host-projects"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import type { MessagePayload } from "@/composer/types"; @@ -87,6 +99,12 @@ import { } from "./new-workspace-initial-context"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; +const ThemedFolderPlus = withUnistyles(FolderPlus); +const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const addProjectIcon = ( + +); + function resolveCheckoutRequest( selectedItem: PickerItem | null, currentBranch: string | null, @@ -596,6 +614,25 @@ function NewWorkspaceProjectPickerOption({ ); } +function AddProjectPickerAction({ onPress }: { onPress: () => void }) { + const { t } = useTranslation(); + const openProjectKeys = useShortcutKeys("new-agent"); + const shortcut = useMemo( + () => (openProjectKeys ? : null), + [openProjectKeys], + ); + + return ( + + ); +} + function formatPrLabel(item: { number: number; title: string }): string { return `#${item.number} ${item.title}`; } @@ -1357,6 +1394,7 @@ interface NewWorkspaceFormStackInput { iconDataByProjectKey: Map; selectedOptionId: string; onSelect: (id: string) => void; + onAddProject: () => void; renderOption: RefPickerRenderOption; }; host: FormPickerControl & { @@ -1394,6 +1432,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; const showHostControl = host.allHosts.length > 1; const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); + const addProjectAction = useMemo( + () => , + [project.onAddProject], + ); const badgePressableStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -1410,7 +1452,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme ); @@ -1589,6 +1632,7 @@ export function NewWorkspaceScreen({ const [manualPickerSelection, setManualPickerSelection] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false); + const openAddProjectPicker = useProjectPickerStore((state) => state.open); const [isolationPickerOpen, setIsolationPickerOpen] = useState(false); const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); @@ -1623,7 +1667,6 @@ export function NewWorkspaceScreen({ lastActiveProject, allowAllProjects: supportsWorkspaceMultiplicity, }); - const projectIconTargets = useMemo( () => projects.flatMap((project) => { @@ -1797,6 +1840,11 @@ export function NewWorkspaceScreen({ [selectProjectOption], ); + const handleAddProject = useCallback(() => { + setProjectPickerOpen(false); + openAddProjectPicker(selectedServerId); + }, [openAddProjectPicker, selectedServerId]); + const checkoutHintPrAttachment = useMemo( () => findCheckoutHintPrAttachment({ @@ -2099,6 +2147,7 @@ export function NewWorkspaceScreen({ iconDataByProjectKey: projectIconDataByProjectKey, selectedOptionId: selectedProjectOptionId, onSelect: handleSelectProjectOption, + onAddProject: handleAddProject, openState: projectPickerOpen, onOpenChange: handleProjectPickerOpenChange, renderOption: renderProjectOption,