From ecd42015b98f27f2ddf4708f7266a5267ac638d4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 17 Oct 2026 22:27:55 +0700 Subject: [PATCH] Redesign the compact New workspace form as full-width field triggers (#7239) * Redesign the compact New workspace form as full-width field triggers Each compact field is one full-width trigger with its label inside, above the value, so the whole field is the tap target and presses highlight. The stack stays anchored on the composer with Isolation and Branch rows reserved, so nothing shifts as they come and go. Import session moves into the compact header, and the title is gone; the fields name themselves. The form's presentational code moves out of new-workspace-screen.tsx into screens/new-workspace/form, where desktop badges and compact fields share one FieldTrigger. * Restore the desktop badge pressed colour Main pressed badges to surface0; the field-trigger extraction folded it into the surface2 hover state. * Use base text size for compact field values docs/design.md sets interface text at fontSize.base; hierarchy comes from the muted label above. --- packages/app/src/i18n/resources/ar.ts | 3 +- packages/app/src/i18n/resources/en.ts | 3 +- packages/app/src/i18n/resources/es.ts | 3 +- packages/app/src/i18n/resources/fr.ts | 3 +- packages/app/src/i18n/resources/ja.ts | 3 +- packages/app/src/i18n/resources/ko.ts | 3 +- packages/app/src/i18n/resources/pt-BR.ts | 3 +- packages/app/src/i18n/resources/ru.ts | 3 +- packages/app/src/i18n/resources/zh-CN.ts | 3 +- .../app/src/screens/new-workspace-screen.tsx | 1038 +---------------- .../new-workspace/form/field-trigger.tsx | 217 ++++ .../src/screens/new-workspace/form/icons.tsx | 49 + .../src/screens/new-workspace/form/index.tsx | 392 +++++++ .../new-workspace/form}/launch-control.tsx | 72 +- .../screens/new-workspace/form/options.tsx | 166 +++ .../new-workspace/import-session-button.tsx | 7 +- 16 files changed, 914 insertions(+), 1054 deletions(-) create mode 100644 packages/app/src/screens/new-workspace/form/field-trigger.tsx create mode 100644 packages/app/src/screens/new-workspace/form/icons.tsx create mode 100644 packages/app/src/screens/new-workspace/form/index.tsx rename packages/app/src/{new-workspace-launch => screens/new-workspace/form}/launch-control.tsx (79%) create mode 100644 packages/app/src/screens/new-workspace/form/options.tsx 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 ( - +