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
This commit is contained in:
Mohamed Boudra
2026-07-12 19:20:31 +00:00
committed by GitHub
parent 66445adc07
commit ec93ca866e
3 changed files with 115 additions and 4 deletions
+49
View File
@@ -35,6 +35,8 @@ import {
hasGithubAuth, hasGithubAuth,
} from "./helpers/github-fixtures"; } from "./helpers/github-fixtures";
import { getServerId } from "./helpers/server-id"; import { getServerId } from "./helpers/server-id";
import { getE2EDaemonPort } from "./helpers/daemon-port";
import { seedSavedSettingsHosts } from "./helpers/settings";
import { import {
expectSidebarWorkspaceSelected, expectSidebarWorkspaceSelected,
expectWorkspaceHeader, expectWorkspaceHeader,
@@ -209,6 +211,53 @@ test.describe("New workspace flow", () => {
await client?.close().catch(() => undefined); 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 }) => { test("sidebar workspace navigation updates URL and header", async ({ page }) => {
const serverId = getServerId(); const serverId = getServerId();
@@ -116,6 +116,8 @@ export interface ComboboxProps {
desktopFixedHeight?: number; desktopFixedHeight?: number;
/** Content rendered above the scroll area on desktop (sticky header). */ /** Content rendered above the scroll area on desktop (sticky header). */
stickyHeader?: ReactNode; stickyHeader?: ReactNode;
/** Content rendered below the scroll area. */
footer?: ReactNode;
/** When true, selecting an option does not close the picker (multi-select mode). */ /** When true, selecting an option does not close the picker (multi-select mode). */
keepOpenOnSelect?: boolean; keepOpenOnSelect?: boolean;
anchorRef: React.RefObject<View | null>; anchorRef: React.RefObject<View | null>;
@@ -928,6 +930,7 @@ interface MobileBodyProps {
header: SheetHeader | undefined; header: SheetHeader | undefined;
onClose: () => void; onClose: () => void;
stickyHeader: ReactNode; stickyHeader: ReactNode;
footer: ReactNode;
searchable: boolean; searchable: boolean;
hasChildren: boolean; hasChildren: boolean;
mobileChildrenScrollEnabled: boolean; mobileChildrenScrollEnabled: boolean;
@@ -1026,6 +1029,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
{body} {body}
</BottomSheetScrollView> </BottomSheetScrollView>
)} )}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</IsolatedBottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -1039,6 +1043,7 @@ interface DesktopBodyProps {
handleDesktopContentLayout: (event: LayoutChangeEvent) => void; handleDesktopContentLayout: (event: LayoutChangeEvent) => void;
header: SheetHeader | undefined; header: SheetHeader | undefined;
stickyHeader: ReactNode; stickyHeader: ReactNode;
footer: ReactNode;
searchable: boolean; searchable: boolean;
searchPlaceholder: string; searchPlaceholder: string;
searchQuery: string; searchQuery: string;
@@ -1192,6 +1197,7 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement {
renderOption={props.renderOption} renderOption={props.renderOption}
/> />
)} )}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</FloatingSurface> </FloatingSurface>
</View> </View>
</Modal> </Modal>
@@ -1224,6 +1230,7 @@ export function Combobox({
desktopMinWidth, desktopMinWidth,
desktopFixedHeight, desktopFixedHeight,
stickyHeader, stickyHeader,
footer,
keepOpenOnSelect = false, keepOpenOnSelect = false,
anchorRef, anchorRef,
children, children,
@@ -1504,6 +1511,7 @@ export function Combobox({
header={header} header={header}
onClose={handleClose} onClose={handleClose}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
footer={footer}
searchable={searchable} searchable={searchable}
hasChildren={hasChildren} hasChildren={hasChildren}
mobileChildrenScrollEnabled={mobileChildrenScrollEnabled} mobileChildrenScrollEnabled={mobileChildrenScrollEnabled}
@@ -1537,6 +1545,7 @@ export function Combobox({
handleDesktopContentLayout={handleDesktopContentLayout} handleDesktopContentLayout={handleDesktopContentLayout}
header={header} header={header}
stickyHeader={stickyHeader} stickyHeader={stickyHeader}
footer={footer}
searchable={searchable} searchable={searchable}
searchPlaceholder={effectiveSearchPlaceholder} searchPlaceholder={effectiveSearchPlaceholder}
searchQuery={searchQuery} searchQuery={searchQuery}
@@ -1650,6 +1659,10 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
footer: {
borderTopWidth: 1,
borderTopColor: theme.colors.border,
},
bottomSheetHeader: { bottomSheetHeader: {
paddingHorizontal: theme.spacing[6], paddingHorizontal: theme.spacing[6],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
@@ -5,11 +5,19 @@ import type { TFunction } from "i18next";
import { Pressable, Text, View } from "react-native"; import { Pressable, Text, View } from "react-native";
import type { PressableStateCallbackType } from "react-native"; import type { PressableStateCallbackType } from "react-native";
import ReanimatedAnimated from "react-native-reanimated"; 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 { useSafeAreaInsets } from "react-native-safe-area-context";
import { createNameId } from "mnemonic-id"; import { createNameId } from "mnemonic-id";
import { useQuery } from "@tanstack/react-query"; 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 { Composer } from "@/composer";
import { FileDropZone } from "@/components/file-drop/file-drop-zone"; import { FileDropZone } from "@/components/file-drop/file-drop-zone";
import { DraftAgentModeControl } from "@/composer/agent-controls/mode-control"; 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 { Combobox, ComboboxItem } from "@/components/ui/combobox";
import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox"; import type { ComboboxOption as ComboboxOptionType, ComboboxProps } from "@/components/ui/combobox";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; 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 { useWorkspace } from "@/stores/session-store-hooks";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store"; import { useDraftStore } from "@/stores/draft-store";
import { useProjectPickerStore } from "@/stores/project-picker-store";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store"; import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import { import {
useWorkspaceDraftSubmissionStore, useWorkspaceDraftSubmissionStore,
@@ -50,6 +60,7 @@ import {
} from "@/stores/workspace-draft-submission-store"; } from "@/stores/workspace-draft-submission-store";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style"; import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { useFormPreferences } from "@/hooks/use-form-preferences"; import { useFormPreferences } from "@/hooks/use-form-preferences";
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { generateMessageId } from "@/types/stream"; import { generateMessageId } from "@/types/stream";
import { toErrorMessage } from "@/utils/error-messages"; import { toErrorMessage } from "@/utils/error-messages";
@@ -63,6 +74,7 @@ import {
type HostProjectListItem, type HostProjectListItem,
} from "@/projects/host-projects"; } from "@/projects/host-projects";
import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types"; import type { ComposerAttachment, UserComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store"; import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types"; import type { MessagePayload } from "@/composer/types";
@@ -87,6 +99,12 @@ import {
} from "./new-workspace-initial-context"; } from "./new-workspace-initial-context";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker"; import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
const ThemedFolderPlus = withUnistyles(FolderPlus);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const addProjectIcon = (
<ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
);
function resolveCheckoutRequest( function resolveCheckoutRequest(
selectedItem: PickerItem | null, selectedItem: PickerItem | null,
currentBranch: string | 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 ? <Shortcut chord={openProjectKeys} /> : null),
[openProjectKeys],
);
return (
<ComboboxItem
testID="new-workspace-project-picker-add-project"
label={t("sidebar.actions.addProject")}
onPress={onPress}
leadingSlot={addProjectIcon}
trailingSlot={shortcut}
/>
);
}
function formatPrLabel(item: { number: number; title: string }): string { function formatPrLabel(item: { number: number; title: string }): string {
return `#${item.number} ${item.title}`; return `#${item.number} ${item.title}`;
} }
@@ -1357,6 +1394,7 @@ interface NewWorkspaceFormStackInput {
iconDataByProjectKey: Map<string, string | null>; iconDataByProjectKey: Map<string, string | null>;
selectedOptionId: string; selectedOptionId: string;
onSelect: (id: string) => void; onSelect: (id: string) => void;
onAddProject: () => void;
renderOption: RefPickerRenderOption; renderOption: RefPickerRenderOption;
}; };
host: FormPickerControl & { host: FormPickerControl & {
@@ -1394,6 +1432,10 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host"; host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
const showHostControl = host.allHosts.length > 1; const showHostControl = host.allHosts.length > 1;
const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation); const isolationTriggerLabel = isolationLabel(t, isolation.effectiveIsolation);
const addProjectAction = useMemo(
() => <AddProjectPickerAction onPress={project.onAddProject} />,
[project.onAddProject],
);
const badgePressableStyle = useCallback( const badgePressableStyle = useCallback(
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
@@ -1410,7 +1452,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
<ProjectPickerTrigger <ProjectPickerTrigger
pickerAnchorRef={project.anchorRef} pickerAnchorRef={project.anchorRef}
onPress={project.open} onPress={project.open}
disabled={isPending || project.options.length === 0} disabled={isPending}
badgePressableStyle={badgePressableStyle} badgePressableStyle={badgePressableStyle}
label={project.triggerLabel} label={project.triggerLabel}
projectKey={project.selectedProject?.projectKey ?? null} projectKey={project.selectedProject?.projectKey ?? null}
@@ -1435,6 +1477,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
anchorRef={project.anchorRef} anchorRef={project.anchorRef}
emptyText="No projects available." emptyText="No projects available."
renderOption={project.renderOption} renderOption={project.renderOption}
footer={addProjectAction}
/> />
</View> </View>
); );
@@ -1589,6 +1632,7 @@ export function NewWorkspaceScreen({
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null); const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [projectPickerOpen, setProjectPickerOpen] = useState(false); const [projectPickerOpen, setProjectPickerOpen] = useState(false);
const openAddProjectPicker = useProjectPickerStore((state) => state.open);
const [isolationPickerOpen, setIsolationPickerOpen] = useState(false); const [isolationPickerOpen, setIsolationPickerOpen] = useState(false);
const [pickerSearchQuery, setPickerSearchQuery] = useState(""); const [pickerSearchQuery, setPickerSearchQuery] = useState("");
const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState(""); const [debouncedPickerSearchQuery, setDebouncedPickerSearchQuery] = useState("");
@@ -1623,7 +1667,6 @@ export function NewWorkspaceScreen({
lastActiveProject, lastActiveProject,
allowAllProjects: supportsWorkspaceMultiplicity, allowAllProjects: supportsWorkspaceMultiplicity,
}); });
const projectIconTargets = useMemo( const projectIconTargets = useMemo(
() => () =>
projects.flatMap((project) => { projects.flatMap((project) => {
@@ -1797,6 +1840,11 @@ export function NewWorkspaceScreen({
[selectProjectOption], [selectProjectOption],
); );
const handleAddProject = useCallback(() => {
setProjectPickerOpen(false);
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
const checkoutHintPrAttachment = useMemo( const checkoutHintPrAttachment = useMemo(
() => () =>
findCheckoutHintPrAttachment({ findCheckoutHintPrAttachment({
@@ -2099,6 +2147,7 @@ export function NewWorkspaceScreen({
iconDataByProjectKey: projectIconDataByProjectKey, iconDataByProjectKey: projectIconDataByProjectKey,
selectedOptionId: selectedProjectOptionId, selectedOptionId: selectedProjectOptionId,
onSelect: handleSelectProjectOption, onSelect: handleSelectProjectOption,
onAddProject: handleAddProject,
openState: projectPickerOpen, openState: projectPickerOpen,
onOpenChange: handleProjectPickerOpenChange, onOpenChange: handleProjectPickerOpenChange,
renderOption: renderProjectOption, renderOption: renderProjectOption,