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,