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 ( - +