diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts
index 2f1fd7397f..4adf945b61 100644
--- a/packages/app/src/i18n/resources/ar.ts
+++ b/packages/app/src/i18n/resources/ar.ts
@@ -1334,8 +1334,9 @@ export const ar: TranslationResources = {
},
fields: {
project: "المشروع",
- base: "الأساس",
+ branch: "الفرع",
baseNotApplicable: "غير قابل للتطبيق",
+ host: "المضيف",
},
titlePlaceholder: "العنوان (اختياري)",
errors: {
diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts
index 14cde732e7..9bb0460036 100644
--- a/packages/app/src/i18n/resources/en.ts
+++ b/packages/app/src/i18n/resources/en.ts
@@ -1342,8 +1342,9 @@ export const en = {
},
fields: {
project: "Project",
- base: "Base",
+ branch: "Branch",
baseNotApplicable: "Not applicable",
+ host: "Host",
},
titlePlaceholder: "Title (optional)",
errors: {
diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts
index 18eb17f291..9ed1e85572 100644
--- a/packages/app/src/i18n/resources/es.ts
+++ b/packages/app/src/i18n/resources/es.ts
@@ -1370,8 +1370,9 @@ export const es: TranslationResources = {
},
fields: {
project: "Proyecto",
- base: "Base",
+ branch: "Rama",
baseNotApplicable: "No aplicable",
+ host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts
index 2bae7b8647..6563b1100e 100644
--- a/packages/app/src/i18n/resources/fr.ts
+++ b/packages/app/src/i18n/resources/fr.ts
@@ -1363,8 +1363,9 @@ export const fr: TranslationResources = {
},
fields: {
project: "Projet",
- base: "Base",
+ branch: "Branche",
baseNotApplicable: "Non applicable",
+ host: "Hôte",
},
titlePlaceholder: "Titre (facultatif)",
errors: {
diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts
index 58e2706b95..eb22dd663a 100644
--- a/packages/app/src/i18n/resources/ja.ts
+++ b/packages/app/src/i18n/resources/ja.ts
@@ -1348,8 +1348,9 @@ export const ja: TranslationResources = {
},
fields: {
project: "プロジェクト",
- base: "ベース",
+ branch: "ブランチ",
baseNotApplicable: "該当なし",
+ host: "ホスト",
},
titlePlaceholder: "タイトル(任意)",
errors: {
diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts
index fa962a5562..afdb8974e9 100644
--- a/packages/app/src/i18n/resources/ko.ts
+++ b/packages/app/src/i18n/resources/ko.ts
@@ -1341,8 +1341,9 @@ export const ko: TranslationResources = {
},
fields: {
project: "프로젝트",
- base: "기준",
+ branch: "브랜치",
baseNotApplicable: "해당 없음",
+ host: "호스트",
},
titlePlaceholder: "제목(선택 사항)",
errors: {
diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts
index c5c6607ca6..f89fb97bf0 100644
--- a/packages/app/src/i18n/resources/pt-BR.ts
+++ b/packages/app/src/i18n/resources/pt-BR.ts
@@ -1360,8 +1360,9 @@ export const ptBR: TranslationResources = {
},
fields: {
project: "Projeto",
- base: "Base",
+ branch: "Branch",
baseNotApplicable: "Não aplicável",
+ host: "Host",
},
titlePlaceholder: "Título (opcional)",
errors: {
diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts
index 4df3f721bb..a1a70822b4 100644
--- a/packages/app/src/i18n/resources/ru.ts
+++ b/packages/app/src/i18n/resources/ru.ts
@@ -1352,8 +1352,9 @@ export const ru: TranslationResources = {
},
fields: {
project: "Проект",
- base: "Базовая ветка",
+ branch: "Ветка",
baseNotApplicable: "Неприменимо",
+ host: "Хост",
},
titlePlaceholder: "Заголовок (необязательно)",
errors: {
diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts
index bad9cc4690..ca221d7d3a 100644
--- a/packages/app/src/i18n/resources/zh-CN.ts
+++ b/packages/app/src/i18n/resources/zh-CN.ts
@@ -1323,8 +1323,9 @@ export const zhCN: TranslationResources = {
},
fields: {
project: "项目",
- base: "基线",
+ branch: "分支",
baseNotApplicable: "不适用",
+ host: "主机",
},
titlePlaceholder: "标题(可选)",
errors: {
diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx
index 6d8fd3de5a..3cb01ae9df 100644
--- a/packages/app/src/screens/new-workspace-screen.tsx
+++ b/packages/app/src/screens/new-workspace-screen.tsx
@@ -6,15 +6,13 @@ import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/
import { encodeImages } from "@/utils/encode-images";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
-import type { ReactElement, ReactNode, RefObject } from "react";
+import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
-import type { TFunction } from "i18next";
-import { Pressable, Text, View } from "react-native";
-import type { PressableStateCallbackType } from "react-native";
-import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
+import { router } from "expo-router";
+import { Text, View } from "react-native";
+import { StyleSheet } from "react-native-unistyles";
import { createNameId } from "mnemonic-id";
import { useQuery, useQueryClient } from "@tanstack/react-query";
-import { ChevronDown, Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
import { Composer } from "@/composer";
import { ComposerDock } from "@/composer/dock";
import { useComposerEditingSession, useComposerFullscreen } from "@/composer/editing-session";
@@ -23,14 +21,6 @@ import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
-import { HostStatusDot } from "@/components/host-status-dot";
-import { HostPicker } from "@/components/hosts/host-picker";
-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 { MenuHeader } from "@/components/headers/menu-header";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -42,7 +32,6 @@ import { ensureCheckoutStatus } from "@/git/checkout-status-cache";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
import { resolveTerminalProfiles } from "@getpaseo/protocol/terminal-profiles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
-import { LaunchControl } from "@/new-workspace-launch/launch-control";
import { resolveLaunchTarget, type LaunchTarget } from "@/new-workspace-launch/target";
import { useTerminalComposerState } from "@/new-workspace-launch/composer-state";
import { runCreateTerminalWorkspace } from "./new-workspace-terminal";
@@ -71,12 +60,10 @@ import {
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionId } from "@/keyboard/keyboard-action-dispatcher";
import { useFormPreferences } from "@/hooks/use-form-preferences";
-import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
import { toErrorMessage } from "@/utils/error-messages";
-import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
+import { buildSettingsAddHostRoute } from "@/utils/host-routes";
import {
- getHostProjectSourceDirectory,
getHostProjectId,
getWorktreeSupportForHostProject,
hostProjectFromRoute,
@@ -86,7 +73,6 @@ import {
type HostProjectListItem,
} from "@/projects/host-projects";
import { useProjectIcons } from "@/projects/icons";
-import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { ComposerAttachment } from "@/attachments/types";
import { useDraftWorkspaceAttachmentScopeKey } from "@/attachments/workspace-attachments-store";
import type { MessagePayload } from "@/composer/types";
@@ -122,6 +108,7 @@ import {
} from "./new-workspace-initial-context";
import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
+import { useNewWorkspaceForm } from "./new-workspace/form";
import { ImportSessionButton } from "./new-workspace/import-session-button";
import { useImportSession } from "@/hooks/use-import-session";
import {
@@ -132,12 +119,6 @@ import {
import { captureWorkspaceDraftCleanup } from "./new-workspace/background-handoff";
import { useNewWorkspaceScreenPresence } from "./new-workspace/screen-presence";
-const ThemedFolderPlus = withUnistyles(FolderPlus);
-const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-const addProjectIcon = (
-
-);
-
function useIsNewWorkspaceDraftHandoffActive(input: {
draftId: string | undefined;
selectedServerId: string;
@@ -198,504 +179,8 @@ const NO_TERMINAL_ATTACHMENTS: UserComposerAttachment[] = [];
function noopChangeAttachments() {}
function noopClearDraft() {}
-const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
-const ThemedChevronDown = withUnistyles(ChevronDown);
-const chevronExtraMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
-
-// Every picker chip on this screen shares one chevron so they stay a single
-// visual family. Extra-muted: the chevron is an affordance, not information,
-// and it should sit behind the label it belongs to.
-function MetaChevron(): ReactElement {
- return (
-
-
-
- );
-}
-
-const metaChevron = ;
-
// Stable reference so the keyboard-action handler doesn't re-register each render.
const PROJECT_PICK_ACTIONS: readonly KeyboardActionId[] = ["workspace.project.pick"];
-// Height of a single picker-trigger badge. The Base-row spacer reserves exactly
-// this so toggling Isolation to Local hides the row without shifting the form.
-const BADGE_HEIGHT = 28;
-
-function RefPickerBadgeContent({
- selectedItem,
- triggerLabel,
- iconColor,
- iconSize,
-}: {
- selectedItem: PickerItem | null;
- triggerLabel: string;
- iconColor: string;
- iconSize: number;
-}) {
- return (
- <>
-
- {selectedItem?.kind === "github-pr" ? (
-
- ) : (
-
- )}
-
-
- {triggerLabel}
-
- >
- );
-}
-
-function RefPickerTrigger({
- pickerAnchorRef,
- onPress,
- disabled,
- badgePressableStyle,
- selectedItem,
- triggerLabel,
- accessibilityLabel,
- tooltipLabel,
- iconColor,
- iconSize,
-}: {
- pickerAnchorRef: React.RefObject;
- onPress: () => void;
- disabled: boolean;
- badgePressableStyle: React.ComponentProps["style"];
- selectedItem: PickerItem | null;
- triggerLabel: string;
- accessibilityLabel: string;
- tooltipLabel: string;
- iconColor: string;
- iconSize: number;
-}) {
- return (
-
-
-
-
-
-
-
- {tooltipLabel}
-
-
- );
-}
-
-function ProjectPickerTrigger({
- pickerAnchorRef,
- onPress,
- disabled,
- badgePressableStyle,
- label,
- tooltipLabel,
- projectViewKey,
- iconDataUri,
- iconColor,
- iconSize,
-}: {
- pickerAnchorRef: React.RefObject;
- onPress: () => void;
- disabled: boolean;
- badgePressableStyle: React.ComponentProps["style"];
- label: string;
- tooltipLabel: string;
- projectViewKey: string | null;
- iconDataUri: string | null;
- iconColor: string;
- iconSize: number;
-}) {
- const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label);
- const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?";
- return (
-
-
-
-
- {projectViewKey ? (
-
- ) : (
-
- )}
-
-
- {label}
-
-
-
-
- {tooltipLabel}
-
-
- );
-}
-
-function PickerOptionItem({
- testID,
- label,
- description,
- selected,
- active,
- disabled,
- onPress,
- isBranch,
- trailingLabel,
- accessibilityLabel,
- iconColor,
- iconSize,
-}: {
- testID: string;
- label: string;
- description: string | undefined;
- selected: boolean;
- active: boolean;
- disabled: boolean;
- onPress: () => void;
- isBranch: boolean;
- trailingLabel?: string;
- accessibilityLabel?: string;
- iconColor: string;
- iconSize: number;
-}) {
- const leadingSlot = useMemo(
- () => (
-
- {isBranch ? (
-
- ) : (
-
- )}
-
- ),
- [isBranch, iconSize, iconColor],
- );
- const trailingSlot = useMemo(
- () =>
- trailingLabel ? {trailingLabel} : undefined,
- [trailingLabel],
- );
- return (
-
- );
-}
-
-function IsolationOptionItem({
- optionId,
- label,
- selected,
- active,
- disabled,
- onPress,
- iconColor,
- iconSize,
-}: {
- optionId: string;
- label: string;
- selected: boolean;
- active: boolean;
- disabled: boolean;
- onPress: () => void;
- iconColor: string;
- iconSize: number;
-}) {
- const leadingSlot = useMemo(
- () => (
-
- {optionId === "worktree" ? (
-
- ) : (
-
- )}
-
- ),
- [optionId, iconSize, iconColor],
- );
- return (
-
- );
-}
-
-function ProjectOptionItem({
- testID,
- projectViewKey,
- iconDataUri,
- label,
- description,
- selected,
- active,
- disabled,
- onPress,
-}: {
- testID: string;
- projectViewKey: string;
- iconDataUri: string | null;
- label: string;
- description: string | undefined;
- selected: boolean;
- active: boolean;
- disabled: boolean;
- onPress: () => void;
-}) {
- const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(label);
- const placeholderInitial = placeholderLabel.charAt(0).toUpperCase() || "?";
- const leadingSlot = useMemo(
- () => (
-
-
-
- ),
- [iconDataUri, placeholderInitial, projectViewKey],
- );
-
- return (
-
- );
-}
-
-function NewWorkspacePickerOption({
- option,
- selected,
- active,
- onPress,
- itemById,
- isPending,
-}: {
- option: ComboboxOptionType;
- selected: boolean;
- active: boolean;
- onPress: () => void;
- itemById: Map;
- isPending: boolean;
-}) {
- const { theme } = useUnistyles();
- const { t } = useTranslation();
- const item = itemById.get(option.id);
- if (!item) return ;
-
- const isBranch = item.kind === "branch";
- const testID = isBranch
- ? `new-workspace-ref-picker-branch-${item.name}`
- : `new-workspace-ref-picker-pr-${item.item.number}`;
- const description =
- !isBranch && item.item.baseRefName
- ? t("newWorkspace.refPicker.intoBase", { baseRef: item.item.baseRefName })
- : undefined;
-
- return (
-
- );
-}
-
-function NewWorkspaceProjectPickerOption({
- option,
- selected,
- active,
- onPress,
- projectByOptionId,
- projectIconDataByProjectViewKey,
- selectedServerId,
- isPending,
- supportsWorkspaceMultiplicity,
-}: {
- option: ComboboxOptionType;
- selected: boolean;
- active: boolean;
- onPress: () => void;
- projectByOptionId: Map;
- projectIconDataByProjectViewKey: Map;
- selectedServerId: string;
- isPending: boolean;
- supportsWorkspaceMultiplicity: boolean;
-}) {
- const project = projectByOptionId.get(option.id);
- if (!project) return ;
- const sourceDirectory =
- getHostProjectSourceDirectory(project, selectedServerId) ?? project.iconWorkingDir;
-
- return (
- host.worktreeSupport !== "unsupported"))
- }
- onPress={onPress}
- />
- );
-}
-
-function AddProjectPickerAction({ onPress }: { onPress: () => void }) {
- const { t } = useTranslation();
- const openProjectKeys = useShortcutKeys("new-agent");
- const shortcut = useMemo(
- () => (openProjectKeys ? : null),
- [openProjectKeys],
- );
-
- return (
-
- );
-}
-
-function newWorkspaceHostOptionTestID(serverId: string): string {
- return `new-workspace-host-picker-option-${serverId}`;
-}
-
-function IsolationPickerTrigger({
- pickerAnchorRef,
- onPress,
- disabled,
- badgePressableStyle,
- isolation,
- label,
- tooltipLabel,
- iconColor,
- iconSize,
-}: {
- pickerAnchorRef: React.RefObject;
- onPress: () => void;
- disabled: boolean;
- badgePressableStyle: React.ComponentProps["style"];
- isolation: "local" | "worktree";
- label: string;
- tooltipLabel: string;
- iconColor: string;
- iconSize: number;
-}) {
- return (
-
-
-
-
- {isolation === "worktree" ? (
-
- ) : (
-
- )}
-
-
- {label}
-
-
-
-
- {tooltipLabel}
-
-
- );
-}
-
-// Wraps a single argument control in the mobile vertical stack. On desktop the
-// controls are laid out in one horizontal row, so no per-control wrapper is used.
-// Decorative padding must not block the dock background from iOS hit testing.
-function FormRow({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
interface WorkspaceIsolationState {
isolation: "local" | "worktree";
@@ -738,12 +223,6 @@ function useWorkspaceIsolation(input: {
};
}
-function isolationLabel(t: TFunction, isolation: "local" | "worktree"): string {
- return isolation === "worktree"
- ? t("newWorkspace.isolation.worktree")
- : t("newWorkspace.isolation.local");
-}
-
function normalizeBranchDetails(
data: { branchDetails?: BranchPickerDetail[]; branches?: string[] } | undefined,
): BranchPickerDetail[] {
@@ -1367,264 +846,6 @@ function useNewWorkspaceInitialContext({
};
}
-type RefPickerRenderOption = NonNullable;
-
-interface FormPickerControl {
- anchorRef: RefObject;
- open: () => void;
- openState: boolean;
- onOpenChange: (open: boolean) => void;
-}
-
-interface NewWorkspaceFormStackInput {
- isCompact: boolean;
- isPending: boolean;
- project: FormPickerControl & {
- options: ComboboxOptionType[];
- triggerLabel: string;
- selectedProject: HostProjectListItem | null;
- iconDataByProjectViewKey: Map;
- selectedOptionId: string;
- onSelect: (id: string) => void;
- onAddProject: () => void;
- renderOption: RefPickerRenderOption;
- };
- host: FormPickerControl & {
- allHosts: HostProfile[];
- selectedServerId: string;
- onSelect: (id: string) => void;
- };
- isolation: FormPickerControl & {
- effectiveIsolation: "local" | "worktree";
- options: ComboboxOptionType[];
- onSelect: (id: string) => void;
- renderOption: RefPickerRenderOption;
- canCreateWorktree: boolean;
- };
- base: FormPickerControl & {
- selectedSourceDirectory: string | null;
- selectedItem: PickerItem | null;
- triggerLabel: string;
- options: ComboboxOptionType[];
- selectedOptionId: string;
- onSelect: (id: string) => void;
- setSearchQuery: (query: string) => void;
- emptyText: string;
- renderOption: RefPickerRenderOption;
- showRefPicker: boolean;
- };
- launch: {
- serverId: string;
- target: LaunchTarget;
- onChange: (target: LaunchTarget) => void;
- profiles: readonly TerminalProfile[];
- disabled: boolean;
- };
-}
-
-function useNewWorkspaceFormStack(input: NewWorkspaceFormStackInput): ReactElement {
- const { theme } = useUnistyles();
- const { t } = useTranslation();
- const { isCompact, isPending, project, host, isolation, base, launch } = input;
-
- const selectedHostLabel =
- 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 }) => [
- styles.badge,
- Boolean(hovered) && !isPending && styles.badgeHovered,
- pressed && !isPending && styles.badgePressed,
- isPending && styles.badgeDisabled,
- ],
- [isPending],
- );
-
- const desktopControlStyle = isCompact ? undefined : styles.desktopControl;
-
- const projectControl = (
-
-
-
-
- );
-
- const hostControl = showHostControl ? (
-
-
-
-
-
-
-
-
-
- {selectedHostLabel}
-
- {metaChevron}
-
-
-
- {t("newWorkspace.tooltips.host")}
-
-
-
-
- ) : null;
-
- const isolationControl = isolation.canCreateWorktree ? (
-
-
-
-
- ) : null;
-
- const baseControl = base.showRefPicker ? (
-
-
-
-
- ) : null;
-
- const launchControl = (
-
- );
-
- return isCompact ? (
-
- {projectControl}
- {hostControl ? {hostControl} : null}
- {isolationControl ? {isolationControl} : null}
- {baseControl ? {baseControl} : null}
- {launchControl}
- {/* Keep fixed stack height without separating the visible controls. */}
- {isolationControl ? null : }
- {baseControl ? null : }
-
- ) : (
-
- {projectControl}
- {hostControl}
- {isolationControl}
- {baseControl}
-
- {launchControl}
-
- );
-}
-
export function NewWorkspaceScreen({
serverId,
sourceDirectory: sourceDirectoryProp,
@@ -1633,7 +854,6 @@ export function NewWorkspaceScreen({
draftId,
}: NewWorkspaceScreenProps) {
const queryClient = useQueryClient();
- const { theme } = useUnistyles();
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const toast = useToast();
@@ -1946,6 +1166,10 @@ export function NewWorkspaceScreen({
openAddProjectPicker(selectedServerId);
}, [openAddProjectPicker, selectedServerId]);
+ const handleAddHost = useCallback(() => {
+ router.push(buildSettingsAddHostRoute(Date.now()));
+ }, []);
+
const openPicker = useCallback(() => {
setPickerOpen(true);
}, []);
@@ -1978,50 +1202,6 @@ export function NewWorkspaceScreen({
setIsolationPickerOpen(nextOpen);
}, []);
- // "New worktree" is omitted entirely (not disabled) when the project isn't a
- // git checkout, since worktree isolation is impossible there.
- const isolationOptions = useMemo(() => {
- const localOption = { id: "local", label: isolationLabel(t, "local") };
- if (!canCreateWorktree) return [localOption];
- return [localOption, { id: "worktree", label: isolationLabel(t, "worktree") }];
- }, [canCreateWorktree, t]);
-
- const handleSelectIsolationOption = useCallback(
- (id: string) => {
- setIsolation(id === "worktree" ? "worktree" : "local");
- setIsolationPickerOpen(false);
- },
- [setIsolation],
- );
-
- const renderIsolationOption = useCallback(
- ({
- option,
- selected,
- active,
- onPress,
- }: {
- option: ComboboxOptionType;
- selected: boolean;
- active: boolean;
- onPress: () => void;
- }) => {
- return (
-
- );
- },
- [isPending, theme.colors.foregroundMuted, theme.iconSize.sm],
- );
-
const handleClearDraft = useCallback(() => {
// No-op: screen navigates away on success, text should stay for retry on error
}, []);
@@ -2261,41 +1441,6 @@ export function NewWorkspaceScreen({
withConnectedClient,
]);
- const renderPickerOption = useCallback(
- (props: {
- option: ComboboxOptionType;
- selected: boolean;
- active: boolean;
- onPress: () => void;
- }) => ,
- [isPending, itemById],
- );
-
- const renderProjectOption = useCallback(
- (props: {
- option: ComboboxOptionType;
- selected: boolean;
- active: boolean;
- onPress: () => void;
- }) => (
-
- ),
- [
- isPending,
- projectByOptionId,
- projectIconDataByProjectViewKey,
- selectedServerId,
- supportsWorkspaceMultiplicity,
- ],
- );
-
const agentControlsWithDisabled = useMemo(
() =>
composerState
@@ -2312,57 +1457,58 @@ export function NewWorkspaceScreen({
? t("newWorkspace.refPicker.searching")
: t("newWorkspace.refPicker.noMatchingRefs");
- const formStack = useNewWorkspaceFormStack({
+ const formStack = useNewWorkspaceForm({
isCompact,
isPending,
project: {
anchorRef: projectPickerAnchorRef,
open: openProjectPicker,
- options: projectPickerOptions,
- triggerLabel: projectTriggerLabel,
- selectedProject,
- iconDataByProjectViewKey: projectIconDataByProjectViewKey,
- selectedOptionId: selectedProjectOptionId,
- onSelect: handleSelectProjectOption,
- onAddProject: handleAddProject,
openState: projectPickerOpen,
onOpenChange: handleProjectPickerOpenChange,
- renderOption: renderProjectOption,
+ options: projectPickerOptions,
+ projectByOptionId,
+ iconDataByProjectViewKey: projectIconDataByProjectViewKey,
+ selectedProject,
+ selectedOptionId: selectedProjectOptionId,
+ triggerLabel: projectTriggerLabel,
+ selectedServerId,
+ supportsWorkspaceMultiplicity,
+ onSelect: handleSelectProjectOption,
+ onAddProject: handleAddProject,
},
host: {
+ anchorRef: hostPickerAnchorRef,
+ open: openHostPicker,
+ openState: hostPickerOpen,
+ onOpenChange: handleHostPickerOpenChange,
allHosts,
selectedServerId,
onSelect: handleSelectWorkspaceHost,
- openState: hostPickerOpen,
- onOpenChange: handleHostPickerOpenChange,
- anchorRef: hostPickerAnchorRef,
- open: openHostPicker,
+ onAddHost: handleAddHost,
},
isolation: {
anchorRef: isolationPickerAnchorRef,
open: openIsolationPicker,
- effectiveIsolation,
- options: isolationOptions,
- onSelect: handleSelectIsolationOption,
openState: isolationPickerOpen,
onOpenChange: handleIsolationPickerOpenChange,
- renderOption: renderIsolationOption,
+ effectiveIsolation,
canCreateWorktree,
+ onSelect: setIsolation,
},
base: {
anchorRef: pickerAnchorRef,
open: openPicker,
+ openState: pickerOpen,
+ onOpenChange: handlePickerOpenChange,
selectedSourceDirectory,
selectedItem,
triggerLabel,
options,
+ itemById,
selectedOptionId,
onSelect: handleSelectOption,
- openState: pickerOpen,
- onOpenChange: handlePickerOpenChange,
setSearchQuery: setPickerSearchQuery,
emptyText: pickerEmptyText,
- renderOption: renderPickerOption,
showRefPicker,
},
launch: {
@@ -2491,11 +1637,17 @@ function NewWorkspaceLayout({
onImportSession: () => void;
children: ReactNode;
}) {
- // At the top of the screen on compact layouts, under the composer otherwise.
+ // In the header on compact layouts, under the composer otherwise.
const importSessionButton = ;
- const setupFields = (
+ // Compact has no title: the fields name themselves. The free space above pushes them down onto
+ // the composer, where the thumb is.
+ const setupFields = isCompact ? (
+ <>
+
+ {formStack}
+ >
+ ) : (
<>
- {isCompact ? {importSessionButton} : null}
{title}
@@ -2504,7 +1656,9 @@ function NewWorkspaceLayout({
);
return (
<>
- {fullscreen ? null : }
+ {fullscreen ? null : (
+
+ )}
@@ -2529,11 +1683,8 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
flex: 1,
},
- // Takes the free space above the setup fields, so its button sits at the top of the screen.
- // The inset puts the ghost button's icon on the setup rows' icon rail.
- compactTopActions: {
+ compactSetupSpace: {
flex: 1,
- paddingHorizontal: theme.spacing[3],
},
composerTitleContainer: {
marginBottom: theme.spacing[8],
@@ -2550,105 +1701,4 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.destructive,
lineHeight: 20,
},
- formStack: {
- marginBottom: theme.spacing[8],
- gap: theme.spacing[2],
- },
- formStackDesktop: {
- flexDirection: "row",
- alignItems: "center",
- // Matches the gap between the composer and the Import session pill below it.
- marginBottom: theme.spacing[4],
- // The badge adds its own left padding; offset it so the project icon's left
- // edge lands exactly on the "New workspace" title's left edge. The trailing
- // inset mirrors it so the launch chip stops on the composer's inner content
- // rather than running out to the composer's border.
- paddingLeft: theme.spacing[4],
- paddingRight: theme.spacing[4],
- gap: theme.spacing[2],
- },
- desktopControl: {
- minWidth: 0,
- flexShrink: 1,
- },
- // The row's left inset matches the heading's text x (composerTitleContainer
- // paddingLeft) so the control aligns with the "New workspace" glyph. The badge
- // adds its own left padding, so the row inset is reduced by that amount.
- row: {
- flexDirection: "row",
- alignItems: "center",
- paddingLeft: theme.spacing[4],
- gap: theme.spacing[1],
- },
- baseSpacer: {
- height: BADGE_HEIGHT,
- },
- // Pushes the launch control to the trailing edge of the desktop meta row,
- // next to project/host/branch. The row's own right inset (formStackDesktop)
- // lands it on the composer's inner content, matching the left chips.
- launchSpacer: {
- flex: 1,
- },
- badge: {
- flexDirection: "row",
- alignItems: "center",
- height: BADGE_HEIGHT,
- maxWidth: 240,
- overflow: "hidden",
- paddingHorizontal: theme.spacing[2],
- borderRadius: theme.borderRadius["2xl"],
- gap: theme.spacing[1],
- },
- badgeHovered: {
- backgroundColor: theme.colors.surface2,
- },
- badgePressed: {
- backgroundColor: theme.colors.surface0,
- },
- badgeDisabled: {
- opacity: 0.6,
- },
- badgeText: {
- minWidth: 0,
- fontSize: theme.fontSize.base,
- color: theme.colors.foregroundMuted,
- flexShrink: 1,
- },
- tooltipText: {
- fontSize: theme.fontSize.base,
- color: theme.colors.popoverForeground,
- },
- refDivergenceLabel: {
- fontSize: theme.fontSize.sm,
- color: theme.colors.foregroundMuted,
- fontVariant: ["tabular-nums"],
- },
- chevronContainer: {
- flexShrink: 0,
- transform: [{ translateY: 1 }],
- },
- badgeIconBox: {
- width: theme.iconSize.md,
- height: theme.iconSize.md,
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- },
- projectIconFallbackText: {
- // Single uppercase initial inside an iconSize.md (16px) square — below the
- // smallest font-size token, so it stays a literal sized to the box.
- fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
- fontWeight: "600",
- },
- rowIconBox: {
- width: theme.iconSize.md,
- height: theme.iconSize.md,
- alignItems: "center",
- justifyContent: "center",
- },
- hostStatusDot: {
- width: 8,
- height: 8,
- borderRadius: 4,
- },
}));
diff --git a/packages/app/src/screens/new-workspace/form/field-trigger.tsx b/packages/app/src/screens/new-workspace/form/field-trigger.tsx
new file mode 100644
index 0000000000..c82cadb14a
--- /dev/null
+++ b/packages/app/src/screens/new-workspace/form/field-trigger.tsx
@@ -0,0 +1,217 @@
+import { forwardRef, useCallback, type ReactElement, type ReactNode } from "react";
+import { Text, View, type PressableStateCallbackType } from "react-native";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { ChevronDown } from "lucide-react-native";
+import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
+import { CONTROL_HEIGHTS } from "@/components/ui/control-geometry";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
+import { ICON_SIZE, type Theme } from "@/styles/theme";
+
+/**
+ * `badge` is the desktop meta row: a borderless pill that names its field in a tooltip.
+ * `field` is the compact form: a full-width filled box with the field's label inside, above the
+ * value, so the whole field is one tap target.
+ */
+export type FieldTriggerVariant = "badge" | "field";
+
+const ThemedChevronDown = withUnistyles(ChevronDown);
+const extraMutedColor = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
+
+// Extra-muted: the chevron is an affordance, not information, and sits behind its label.
+function Chevron(): ReactElement {
+ return (
+
+
+
+ );
+}
+
+const chevron = ;
+
+export interface FieldTriggerContentProps {
+ variant: FieldTriggerVariant;
+ /** The field's name, shown inside a `field`. */
+ label: string;
+ icon: ReactNode;
+ value: string;
+ /** Secondary text after the value, such as a terminal profile's name. */
+ detail?: string;
+}
+
+export function FieldTriggerContent({
+ variant,
+ label,
+ icon,
+ value,
+ detail,
+}: FieldTriggerContentProps): ReactElement {
+ const valueLine = (
+ <>
+ {icon}
+
+ {value}
+ {detail ? {detail} : null}
+
+ >
+ );
+ if (variant === "badge") {
+ return (
+ <>
+ {valueLine}
+ {chevron}
+ >
+ );
+ }
+ return (
+ <>
+
+
+ {label}
+
+ {valueLine}
+
+ {chevron}
+ >
+ );
+}
+
+/** Pressable style for a field trigger, for triggers that bring their own Pressable. */
+export function useFieldTriggerStyle(variant: FieldTriggerVariant, disabled: boolean) {
+ return useCallback(
+ (state: PressableStateCallbackType & { hovered?: boolean }) => [
+ variant === "field" ? styles.field : styles.badge,
+ !disabled && Boolean(state.hovered) && styles.highlighted,
+ !disabled &&
+ state.pressed &&
+ (variant === "field" ? styles.highlighted : styles.badgePressed),
+ disabled && styles.disabled,
+ ],
+ [disabled, variant],
+ );
+}
+
+export interface FieldTriggerProps extends FieldTriggerContentProps {
+ /** What a screen reader announces; defaults to `label`. */
+ accessibilityLabel?: string;
+ onPress: () => void;
+ disabled?: boolean;
+ /** Desktop hover hint; badges only, since a field shows its label. */
+ tooltip?: string;
+ testID?: string;
+}
+
+/** A field trigger that anchors a Combobox. */
+export const FieldTrigger = forwardRef(function FieldTrigger(
+ { accessibilityLabel, onPress, disabled = false, tooltip, testID, ...content },
+ ref,
+): ReactElement {
+ const style = useFieldTriggerStyle(content.variant, disabled);
+ const trigger = (
+
+
+
+ );
+ if (content.variant === "field" || !tooltip) return trigger;
+ return (
+
+
+ {trigger}
+
+
+ {tooltip}
+
+
+ );
+});
+
+// Height of a badge. The desktop row sizes from it.
+export const FIELD_BADGE_HEIGHT = CONTROL_HEIGHTS.tight;
+// Height of a compact field: label line plus value line. The compact stack reserves rows from it.
+export const FIELD_HEIGHT = 56;
+
+const styles = StyleSheet.create((theme) => ({
+ badge: {
+ flexDirection: "row",
+ alignItems: "center",
+ height: FIELD_BADGE_HEIGHT,
+ maxWidth: 240,
+ overflow: "hidden",
+ paddingHorizontal: theme.spacing[2],
+ borderRadius: theme.borderRadius["2xl"],
+ gap: theme.spacing[1],
+ },
+ field: {
+ flexDirection: "row",
+ alignItems: "center",
+ height: FIELD_HEIGHT,
+ paddingLeft: theme.spacing[3],
+ paddingRight: theme.spacing[3],
+ borderRadius: theme.borderRadius.xl,
+ backgroundColor: theme.colors.surface1,
+ gap: theme.spacing[2],
+ },
+ highlighted: {
+ backgroundColor: theme.colors.surface2,
+ },
+ badgePressed: {
+ backgroundColor: theme.colors.surface0,
+ },
+ disabled: {
+ opacity: 0.6,
+ },
+ // Optical: the chevron's ink sits high in its box.
+ chevron: {
+ flexShrink: 0,
+ transform: [{ translateY: 1 }],
+ },
+ iconBox: {
+ width: theme.iconSize.md,
+ height: theme.iconSize.md,
+ alignItems: "center",
+ justifyContent: "center",
+ flexShrink: 0,
+ },
+ value: {
+ minWidth: 0,
+ flexShrink: 1,
+ fontSize: theme.fontSize.base,
+ color: theme.colors.foregroundMuted,
+ },
+ fieldBody: {
+ flex: 1,
+ minWidth: 0,
+ gap: theme.spacing[0.5],
+ },
+ fieldLabel: {
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.foregroundMuted,
+ },
+ fieldValueLine: {
+ flexDirection: "row",
+ alignItems: "center",
+ gap: theme.spacing[1.5],
+ },
+ fieldValue: {
+ minWidth: 0,
+ flexShrink: 1,
+ fontSize: theme.fontSize.base,
+ color: theme.colors.foreground,
+ },
+ detail: {
+ color: theme.colors.foregroundExtraMuted,
+ },
+ tooltipText: {
+ fontSize: theme.fontSize.base,
+ color: theme.colors.popoverForeground,
+ },
+}));
diff --git a/packages/app/src/screens/new-workspace/form/icons.tsx b/packages/app/src/screens/new-workspace/form/icons.tsx
new file mode 100644
index 0000000000..9fa277542d
--- /dev/null
+++ b/packages/app/src/screens/new-workspace/form/icons.tsx
@@ -0,0 +1,49 @@
+import type { ReactElement } from "react";
+import { StyleSheet, withUnistyles } from "react-native-unistyles";
+import { Folder, FolderPlus, GitBranch, GitPullRequest } from "lucide-react-native";
+import { ProjectIconView } from "@/components/project-icon-view";
+import { ICON_SIZE, type Theme } from "@/styles/theme";
+import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
+
+const ThemedFolder = withUnistyles(Folder);
+const ThemedFolderPlus = withUnistyles(FolderPlus);
+const ThemedGitBranch = withUnistyles(GitBranch);
+const ThemedGitPullRequest = withUnistyles(GitPullRequest);
+const mutedColor = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
+
+export const folderIcon = ;
+export const addProjectIcon = ;
+export const branchIcon = ;
+export const pullRequestIcon = ;
+
+// Single uppercase initial inside an iconSize.md (16px) square — below the smallest font-size
+// token, so it stays a literal sized to the box.
+const PROJECT_ICON_FALLBACK_FONT_SIZE = 10;
+
+export function ProjectIcon({
+ projectViewKey,
+ projectName,
+ iconDataUri,
+}: {
+ projectViewKey: string;
+ projectName: string;
+ iconDataUri: string | null;
+}): ReactElement {
+ const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(projectName);
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ fallbackText: {
+ fontSize: PROJECT_ICON_FALLBACK_FONT_SIZE,
+ fontWeight: theme.fontWeight.semibold,
+ },
+}));
diff --git a/packages/app/src/screens/new-workspace/form/index.tsx b/packages/app/src/screens/new-workspace/form/index.tsx
new file mode 100644
index 0000000000..d05adb2e61
--- /dev/null
+++ b/packages/app/src/screens/new-workspace/form/index.tsx
@@ -0,0 +1,392 @@
+import { useCallback, useMemo, type ReactElement, type ReactNode, type RefObject } from "react";
+import { View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { StyleSheet } from "react-native-unistyles";
+import type { TerminalProfile } from "@getpaseo/protocol/messages";
+import { Combobox, type ComboboxOption, type ComboboxProps } from "@/components/ui/combobox";
+import { HostStatusDot } from "@/components/host-status-dot";
+import { HostPicker } from "@/components/hosts/host-picker";
+import type { LaunchTarget } from "@/new-workspace-launch/target";
+import type { HostProjectListItem } from "@/projects/host-projects";
+import type { HostProfile } from "@/types/host-connection";
+import type { PickerItem } from "../../new-workspace-picker-item";
+import {
+ FIELD_BADGE_HEIGHT,
+ FIELD_HEIGHT,
+ FieldTrigger,
+ type FieldTriggerVariant,
+} from "./field-trigger";
+import { branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
+import { LaunchControl } from "./launch-control";
+import { AddProjectOption, IsolationOption, ProjectOption, RefOption } from "./options";
+
+type Isolation = "local" | "worktree";
+type RenderOption = NonNullable;
+
+interface PickerControl {
+ anchorRef: RefObject;
+ open: () => void;
+ openState: boolean;
+ onOpenChange: (open: boolean) => void;
+}
+
+export interface NewWorkspaceFormProps {
+ isCompact: boolean;
+ isPending: boolean;
+ project: PickerControl & {
+ options: ComboboxOption[];
+ projectByOptionId: Map;
+ iconDataByProjectViewKey: Map;
+ selectedProject: HostProjectListItem | null;
+ selectedOptionId: string;
+ triggerLabel: string;
+ selectedServerId: string;
+ /** COMPAT(workspaceMultiplicity): without it only worktree-capable projects are selectable. */
+ supportsWorkspaceMultiplicity: boolean;
+ onSelect: (id: string) => void;
+ onAddProject: () => void;
+ };
+ host: PickerControl & {
+ allHosts: HostProfile[];
+ selectedServerId: string;
+ onSelect: (id: string) => void;
+ onAddHost: () => void;
+ };
+ isolation: PickerControl & {
+ effectiveIsolation: Isolation;
+ canCreateWorktree: boolean;
+ onSelect: (isolation: Isolation) => void;
+ };
+ base: PickerControl & {
+ selectedSourceDirectory: string | null;
+ selectedItem: PickerItem | null;
+ triggerLabel: string;
+ options: ComboboxOption[];
+ itemById: Map;
+ selectedOptionId: string;
+ onSelect: (id: string) => void;
+ setSearchQuery: (query: string) => void;
+ emptyText: string;
+ showRefPicker: boolean;
+ };
+ launch: {
+ serverId: string;
+ target: LaunchTarget;
+ onChange: (target: LaunchTarget) => void;
+ profiles: readonly TerminalProfile[];
+ disabled: boolean;
+ };
+}
+
+function hostOptionTestID(serverId: string): string {
+ return `new-workspace-host-picker-option-${serverId}`;
+}
+
+function isProjectSelectable(
+ project: HostProjectListItem,
+ supportsWorkspaceMultiplicity: boolean,
+): boolean {
+ return (
+ supportsWorkspaceMultiplicity ||
+ project.hosts.some((host) => host.worktreeSupport !== "unsupported")
+ );
+}
+
+function useProjectPicker(
+ project: NewWorkspaceFormProps["project"],
+ isPending: boolean,
+): { renderOption: RenderOption; footer: ReactNode } {
+ const { projectByOptionId, iconDataByProjectViewKey, selectedServerId } = project;
+ const { supportsWorkspaceMultiplicity, onAddProject } = project;
+ const renderOption = useCallback(
+ (row) => {
+ const item = projectByOptionId.get(row.option.id);
+ return (
+
+ );
+ },
+ [
+ iconDataByProjectViewKey,
+ isPending,
+ projectByOptionId,
+ selectedServerId,
+ supportsWorkspaceMultiplicity,
+ ],
+ );
+ const footer = useMemo(() => , [onAddProject]);
+ return { renderOption, footer };
+}
+
+function useIsolationPicker(
+ isolation: NewWorkspaceFormProps["isolation"],
+ isPending: boolean,
+): { options: ComboboxOption[]; onSelect: (id: string) => void; renderOption: RenderOption } {
+ const { t } = useTranslation();
+ const { canCreateWorktree, onSelect, onOpenChange } = isolation;
+ // "New worktree" is omitted entirely (not disabled) when the project isn't a
+ // git checkout, since worktree isolation is impossible there.
+ const options = useMemo(() => {
+ const local = { id: "local", label: t("newWorkspace.isolation.local") };
+ if (!canCreateWorktree) return [local];
+ return [local, { id: "worktree", label: t("newWorkspace.isolation.worktree") }];
+ }, [canCreateWorktree, t]);
+ const handleSelect = useCallback(
+ (id: string) => {
+ onSelect(id === "worktree" ? "worktree" : "local");
+ onOpenChange(false);
+ },
+ [onOpenChange, onSelect],
+ );
+ const renderOption = useCallback(
+ (row) => ,
+ [isPending],
+ );
+ return { options, onSelect: handleSelect, renderOption };
+}
+
+function useRefRenderOption(itemById: Map, isPending: boolean): RenderOption {
+ return useCallback(
+ (row) => ,
+ [isPending, itemById],
+ );
+}
+
+/**
+ * The New workspace setup fields, one trigger per field, each opening its picker. Compact: a stack
+ * of full-width fields with the label inside. Desktop: one meta row of badges. Either way, a field
+ * that does not apply to the selected project is left out, and Host only shows with a second host.
+ */
+export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {
+ const { t } = useTranslation();
+ const { isCompact, isPending, project, host, isolation, base, launch } = props;
+ const variant: FieldTriggerVariant = isCompact ? "field" : "badge";
+ const projectPicker = useProjectPicker(project, isPending);
+ const isolationPicker = useIsolationPicker(isolation, isPending);
+ const renderRefOption = useRefRenderOption(base.itemById, isPending);
+
+ const showHost = host.allHosts.length > 1;
+ const selectedHostLabel =
+ host.allHosts.find((h) => h.serverId === host.selectedServerId)?.label ?? "Host";
+ const selectedProject = project.selectedProject;
+ const selectedProjectIconUri = selectedProject
+ ? (project.iconDataByProjectViewKey.get(selectedProject.viewKey) ?? null)
+ : null;
+ const projectIcon = useMemo(
+ () =>
+ selectedProject ? (
+
+ ) : (
+ folderIcon
+ ),
+ [selectedProject, selectedProjectIconUri],
+ );
+ const hostIcon = useMemo(
+ () => ,
+ [host.selectedServerId],
+ );
+ const isWorktree = isolation.effectiveIsolation === "worktree";
+ const isolationValue = isWorktree
+ ? t("newWorkspace.isolation.worktree")
+ : t("newWorkspace.isolation.local");
+ const refIcon = base.selectedItem?.kind === "github-pr" ? pullRequestIcon : branchIcon;
+
+ const projectField = (
+
+ );
+ const hostField = showHost ? (
+
+
+
+ ) : null;
+ const isolationField = isolation.canCreateWorktree ? (
+
+ ) : null;
+ const baseField = base.showRefPicker ? (
+
+ ) : null;
+ const launchField = (
+
+ );
+
+ const pickers = (
+ <>
+
+
+
+ >
+ );
+
+ if (isCompact) {
+ return (
+
+ {projectField}
+ {hostField}
+ {isolationField}
+ {baseField}
+ {launchField}
+ {/* Isolation and Base come and go with the project; their rows stay reserved so the
+ stack keeps one height and nothing above the composer jumps. */}
+ {isolationField ? null : }
+ {baseField ? null : }
+ {pickers}
+
+ );
+ }
+
+ return (
+
+ {projectField}
+ {hostField ? {hostField} : null}
+ {isolationField ? {isolationField} : null}
+ {baseField ? {baseField} : null}
+
+ {launchField}
+ {pickers}
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ compactStack: {
+ marginBottom: theme.spacing[4],
+ paddingHorizontal: theme.spacing[4],
+ gap: theme.spacing[2],
+ },
+ reservedField: {
+ height: FIELD_HEIGHT,
+ },
+ desktopRow: {
+ flexDirection: "row",
+ alignItems: "center",
+ minHeight: FIELD_BADGE_HEIGHT,
+ // Matches the gap between the composer and the Import session pill below it.
+ marginBottom: theme.spacing[4],
+ // The badge adds its own left padding; offset it so the project icon's left
+ // edge lands exactly on the "New workspace" title's left edge. The trailing
+ // inset mirrors it so the launch chip stops on the composer's inner content
+ // rather than running out to the composer's border.
+ paddingHorizontal: theme.spacing[4],
+ gap: theme.spacing[2],
+ },
+ desktopControl: {
+ minWidth: 0,
+ flexShrink: 1,
+ },
+ // Pushes the launch control to the trailing edge of the desktop meta row.
+ launchSpacer: {
+ flex: 1,
+ },
+}));
diff --git a/packages/app/src/new-workspace-launch/launch-control.tsx b/packages/app/src/screens/new-workspace/form/launch-control.tsx
similarity index 79%
rename from packages/app/src/new-workspace-launch/launch-control.tsx
rename to packages/app/src/screens/new-workspace/form/launch-control.tsx
index 785cfb56ac..fb6af66022 100644
--- a/packages/app/src/new-workspace-launch/launch-control.tsx
+++ b/packages/app/src/screens/new-workspace/form/launch-control.tsx
@@ -1,8 +1,8 @@
import { useCallback, useMemo, useState, type ReactElement } from "react";
-import { Pressable, Text, View } from "react-native";
+import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { useRouter } from "expo-router";
-import { ChevronDown, MessageCircle, SquareTerminal } from "lucide-react-native";
+import { MessageCircle, SquareTerminal } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
DropdownMenu,
@@ -30,14 +30,17 @@ import {
resolveLaunchProfile,
terminalLaunchTarget,
type LaunchTarget,
-} from "./target";
+} from "@/new-workspace-launch/target";
+import {
+ FieldTriggerContent,
+ useFieldTriggerStyle,
+ type FieldTriggerVariant,
+} from "./field-trigger";
const ThemedMessageCircle = withUnistyles(MessageCircle);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
-const ThemedChevronDown = withUnistyles(ChevronDown);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-const extraMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundExtraMuted });
const chatIcon = ;
const blankTerminalIcon = ;
@@ -83,12 +86,7 @@ export interface LaunchControlProps {
onChange: (target: LaunchTarget) => void;
profiles: readonly TerminalProfile[];
disabled?: boolean;
- /**
- * The meta row's shared badge style. Passed in rather than redeclared so this
- * trigger stays pixel-identical to the project, host, and branch triggers it
- * sits beside; a local copy drifts the moment one of them changes.
- */
- badgePressableStyle: React.ComponentProps["style"];
+ variant: FieldTriggerVariant;
}
function TriggerIcon({
@@ -108,8 +106,8 @@ function TriggerIcon({
}
/**
- * The meta-row control choosing what New workspace submits to: the chat agent,
- * or a terminal. One trigger shaped like every other chip on that row, opening
+ * The form control choosing what New workspace submits to: the chat agent,
+ * or a terminal. One trigger shaped like every other field trigger, opening
* a menu whose Chat and Terminal groups are what keep a terminal profile named
* "Codex" from reading as a chat Codex agent.
*/
@@ -119,13 +117,18 @@ export function LaunchControl({
onChange,
profiles,
disabled = false,
- badgePressableStyle,
+ variant,
}: LaunchControlProps) {
const { t } = useTranslation();
const router = useRouter();
const [open, setOpen] = useState(false);
const selectedProfile = useMemo(() => resolveLaunchProfile(target, profiles), [target, profiles]);
+ const triggerStyle = useFieldTriggerStyle(variant, disabled);
+ const triggerIcon = useMemo(
+ () => ,
+ [selectedProfile, target],
+ );
// A profile's own name and logo say nothing about where it runs: a row
// reading "(openai logo) Codex" is indistinguishable from a chat Codex agent.
@@ -149,32 +152,26 @@ export function LaunchControl({
return (
- {/* The trigger is the Pressable itself, styled by the meta row's shared
- badge style, so it lands pixel-identical to the project, host, and
- branch chips it sits beside. */}
{/* MenuTrigger is not a forwardRef component and pins its own ref, so the
tooltip anchors to a wrapping View instead. Suppressed while the menu
is open so the two surfaces never overlap. */}
-
+
-
-
-
-
- {triggerLabel}
- {selectedProfile ? (
- {selectedProfile.name}
- ) : null}
-
-
+
@@ -232,27 +229,10 @@ export function LaunchControl({
}
const styles = StyleSheet.create((theme) => ({
- iconBox: {
- width: theme.iconSize.md,
- height: theme.iconSize.md,
- alignItems: "center",
- justifyContent: "center",
- flexShrink: 0,
- },
- label: {
- minWidth: 0,
- fontSize: theme.fontSize.base,
- color: theme.colors.foregroundMuted,
- flexShrink: 1,
- },
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
},
- profileName: {
- fontSize: theme.fontSize.base,
- color: theme.colors.foregroundExtraMuted,
- },
command: {
fontFamily: theme.fontFamily.mono,
fontSize: theme.fontSize.sm,
diff --git a/packages/app/src/screens/new-workspace/form/options.tsx b/packages/app/src/screens/new-workspace/form/options.tsx
new file mode 100644
index 0000000000..88f5c5d4dd
--- /dev/null
+++ b/packages/app/src/screens/new-workspace/form/options.tsx
@@ -0,0 +1,166 @@
+import { useMemo, type ReactElement } from "react";
+import { Text, View } from "react-native";
+import { useTranslation } from "react-i18next";
+import { StyleSheet } from "react-native-unistyles";
+import { ComboboxItem, type ComboboxOption } from "@/components/ui/combobox";
+import { Shortcut } from "@/components/ui/shortcut";
+import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
+import { getHostProjectSourceDirectory, type HostProjectListItem } from "@/projects/host-projects";
+import { pickerItemLabel, type PickerItem } from "../../new-workspace-picker-item";
+import { addProjectIcon, branchIcon, folderIcon, ProjectIcon, pullRequestIcon } from "./icons";
+
+interface OptionRowState {
+ option: ComboboxOption;
+ selected: boolean;
+ active: boolean;
+ onPress: () => void;
+}
+
+function IconBox({ children }: { children: ReactElement }): ReactElement {
+ return {children};
+}
+
+export function ProjectOption({
+ option,
+ selected,
+ active,
+ onPress,
+ project,
+ iconDataUri,
+ selectedServerId,
+ disabled,
+}: OptionRowState & {
+ project: HostProjectListItem | undefined;
+ iconDataUri: string | null;
+ selectedServerId: string;
+ disabled: boolean;
+}): ReactElement {
+ const leadingSlot = useMemo(
+ () =>
+ project ? (
+
+
+
+ ) : undefined,
+ [iconDataUri, project],
+ );
+ if (!project) return ;
+ return (
+
+ );
+}
+
+export function AddProjectOption({ onPress }: { onPress: () => void }): ReactElement {
+ const { t } = useTranslation();
+ const openProjectKeys = useShortcutKeys("new-agent");
+ const shortcut = useMemo(
+ () => (openProjectKeys ? : null),
+ [openProjectKeys],
+ );
+ return (
+
+ );
+}
+
+const worktreeLeading = {branchIcon};
+const localLeading = {folderIcon};
+
+export function IsolationOption({
+ option,
+ selected,
+ active,
+ onPress,
+ disabled,
+}: OptionRowState & { disabled: boolean }): ReactElement {
+ return (
+
+ );
+}
+
+const branchLeading = {branchIcon};
+const pullRequestLeading = {pullRequestIcon};
+
+export function RefOption({
+ option,
+ selected,
+ active,
+ onPress,
+ item,
+ disabled,
+}: OptionRowState & { item: PickerItem | undefined; disabled: boolean }): ReactElement {
+ const { t } = useTranslation();
+ const divergenceLabel = item?.kind === "branch" ? item.divergenceLabel : undefined;
+ const trailingSlot = useMemo(
+ () =>
+ divergenceLabel ? {divergenceLabel} : undefined,
+ [divergenceLabel],
+ );
+ if (!item) return ;
+
+ const isBranch = item.kind === "branch";
+ return (
+
+ );
+}
+
+const styles = StyleSheet.create((theme) => ({
+ iconBox: {
+ width: theme.iconSize.md,
+ height: theme.iconSize.md,
+ alignItems: "center",
+ justifyContent: "center",
+ },
+ divergenceLabel: {
+ fontSize: theme.fontSize.sm,
+ color: theme.colors.foregroundMuted,
+ fontVariant: ["tabular-nums"],
+ },
+}));
diff --git a/packages/app/src/screens/new-workspace/import-session-button.tsx b/packages/app/src/screens/new-workspace/import-session-button.tsx
index 39b55e1b2e..39d4efc1ce 100644
--- a/packages/app/src/screens/new-workspace/import-session-button.tsx
+++ b/packages/app/src/screens/new-workspace/import-session-button.tsx
@@ -5,7 +5,7 @@ import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
/**
- * The new workspace screen's way into Import session: a ghost button at the top on compact
+ * The new workspace screen's way into Import session: a ghost button in the header on compact
* layouts, an outlined pill under the composer otherwise. Both are muted until hovered.
*/
export function ImportSessionButton({
@@ -17,7 +17,7 @@ export function ImportSessionButton({
}) {
const { t } = useTranslation();
return (
-
+