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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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<View | null>;
|
||||
@@ -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}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</IsolatedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
@@ -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 ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</FloatingSurface>
|
||||
</View>
|
||||
</Modal>
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = (
|
||||
<ThemedFolderPlus size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
|
||||
);
|
||||
|
||||
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 ? <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 {
|
||||
return `#${item.number} ${item.title}`;
|
||||
}
|
||||
@@ -1357,6 +1394,7 @@ interface NewWorkspaceFormStackInput {
|
||||
iconDataByProjectKey: Map<string, string | null>;
|
||||
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(
|
||||
() => <AddProjectPickerAction onPress={project.onAddProject} />,
|
||||
[project.onAddProject],
|
||||
);
|
||||
|
||||
const badgePressableStyle = useCallback(
|
||||
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
@@ -1410,7 +1452,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
<ProjectPickerTrigger
|
||||
pickerAnchorRef={project.anchorRef}
|
||||
onPress={project.open}
|
||||
disabled={isPending || project.options.length === 0}
|
||||
disabled={isPending}
|
||||
badgePressableStyle={badgePressableStyle}
|
||||
label={project.triggerLabel}
|
||||
projectKey={project.selectedProject?.projectKey ?? null}
|
||||
@@ -1435,6 +1477,7 @@ function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactEleme
|
||||
anchorRef={project.anchorRef}
|
||||
emptyText="No projects available."
|
||||
renderOption={project.renderOption}
|
||||
footer={addProjectAction}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -1589,6 +1632,7 @@ export function NewWorkspaceScreen({
|
||||
const [manualPickerSelection, setManualPickerSelection] = useState<PickerSelection | null>(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,
|
||||
|
||||
Reference in New Issue
Block a user