From c8c620cd21caa0db770fdec04efc854fd4960a31 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:52:38 +0000 Subject: [PATCH] feat(mobile): rename a workspace from the workspace menu (#38728) * feat(mobile): rename a workspace from the workspace menu The phone's workspace menu gets a Rename row that swaps the drawer to the text sheet, prefilled and selected. Saving writes worktree.set displayName at the row's owner, so server rows work too; an empty name returns the label to its automatic branch or folder name, as desktop's Workspace details does. A failed save keeps the sheet and text, shows the host's error and offers Retry. * refactor: share the automatic workspace name rule with the phone's rename sheet --- mobile/src/components/TextInputModal.tsx | 82 ++++++++++++++----- .../src/host-screen/host-screen-operations.ts | 11 +++ .../src/host-screen/host-screen-overlays.tsx | 33 ++++++-- .../host-screen/use-host-worktree-actions.ts | 26 +++++- .../host-screen/workspace-rename-sheet.tsx | 82 +++++++++++++++++++ mobile/src/i18n/locales/en.json | 7 ++ src/main/ipc/worktree-metadata-merge.ts | 8 +- .../worktree/display-name-provenance.ts | 15 ++++ 8 files changed, 230 insertions(+), 34 deletions(-) create mode 100644 mobile/src/host-screen/workspace-rename-sheet.tsx diff --git a/mobile/src/components/TextInputModal.tsx b/mobile/src/components/TextInputModal.tsx index 00a268ea52f..43cbe91cb18 100644 --- a/mobile/src/components/TextInputModal.tsx +++ b/mobile/src/components/TextInputModal.tsx @@ -7,38 +7,38 @@ import { useMobileTranslation } from '../i18n/use-mobile-translation' import { BottomDrawer } from './bottom-drawer/BottomDrawer' import { TextField } from './composer-input/TextField' -type Props = { - visible: boolean +type SheetProps = { title: string message?: string - defaultValue?: string + value: string + onChangeText: (value: string) => void placeholder?: string submitLabel?: string selectTextOnFocus?: boolean allowEmpty?: boolean keyboardType?: KeyboardTypeOptions + /** Shown under the field; the text stays so the user can retry. */ + error?: string | null + /** A save in flight: Save is disabled so it can't be sent twice. */ + busy?: boolean onSubmit: (value: string) => void onCancel: () => void +} + +type Props = Omit & { + visible: boolean + defaultValue?: string /** Called once the drawer has gone, which is when the field stops holding the focus. */ onAfterClose?: () => void } export function TextInputModal({ visible, - title, - message, defaultValue = '', - placeholder, - submitLabel, - selectTextOnFocus = false, - allowEmpty = false, - keyboardType, - onSubmit, onCancel, - onAfterClose + onAfterClose, + ...sheetProps }: Props) { - useMobileColorScheme() - const t = useMobileTranslation() const [value, setValue] = useState(defaultValue) const [previousVisible, setPreviousVisible] = useState(visible) const [previousDefaultValue, setPreviousDefaultValue] = useState(defaultValue) @@ -53,17 +53,47 @@ export function TextInputModal({ } } + return ( + + + + ) +} + +/** The text sheet's body, for a drawer that is already open (e.g. a menu that swaps to it). */ +export function TextInputSheetContent({ + title, + message, + value, + onChangeText, + placeholder, + submitLabel, + selectTextOnFocus = false, + allowEmpty = false, + keyboardType, + error, + busy = false, + onSubmit, + onCancel +}: SheetProps) { + useMobileColorScheme() + const t = useMobileTranslation() + + const canSubmit = !busy && (allowEmpty || value.trim().length > 0) + function handleSubmit() { - const trimmed = value.trim() - if (trimmed || allowEmpty) { - onSubmit(trimmed) + if (canSubmit) { + onSubmit(value.trim()) } } - const canSubmit = allowEmpty || value.trim().length > 0 - return ( - + <> {title} {message ? {message} : null} @@ -71,7 +101,7 @@ export function TextInputModal({ + {error ? {error} : null} + [styles.cancelButton, pressed && styles.buttonPressed]} @@ -101,7 +133,7 @@ export function TextInputModal({ {submitLabel ?? t('common.save', 'Save')} - + ) } @@ -120,6 +152,12 @@ const styles = themedStyleSheet(() => ({ color: colors.textMuted, marginTop: 2 }, + error: { + color: colors.statusRed, + fontSize: typography.metaSize, + marginTop: spacing.sm, + paddingHorizontal: spacing.xs + }, actions: { flexDirection: 'row', justifyContent: 'flex-end', diff --git a/mobile/src/host-screen/host-screen-operations.ts b/mobile/src/host-screen/host-screen-operations.ts index 4ad7c4fc0e8..70ae837d709 100644 --- a/mobile/src/host-screen/host-screen-operations.ts +++ b/mobile/src/host-screen/host-screen-operations.ts @@ -86,6 +86,17 @@ export const worktreePinWrite = bindDeferredRpcOperation( }) ) +// Rename reports refusal so the sheet can keep the text and offer Retry. +export const worktreeDisplayNameWrite = bindDeferredRpcOperation( + defineRpcOperation({ + name: 'worktree.set-display-name', + method: 'worktree.set', + acceptance: 'require-result-or-throw-message', + barrier: 'after-caller-barrier', + read: rpcResultVariant('display-name-written', hostScreenUnreadReplySchema) + }) +) + export const worktreeSleep = bindDeferredRpcOperation( defineRpcOperation({ name: 'worktree.sleep-or-skip', diff --git a/mobile/src/host-screen/host-screen-overlays.tsx b/mobile/src/host-screen/host-screen-overlays.tsx index aec94b95b2d..b5b67c1a866 100644 --- a/mobile/src/host-screen/host-screen-overlays.tsx +++ b/mobile/src/host-screen/host-screen-overlays.tsx @@ -1,6 +1,7 @@ +import { useState } from 'react' import { openExternalLink } from '../platform/external-link' import { Pressable, Text, View } from 'react-native' -import { Moon, Pin, PinOff } from 'lucide-react-native' +import { Edit3, Moon, Pin, PinOff } from 'lucide-react-native' import { buildWorktreeNavigationActions } from '../agent-history/worktree-navigation-actions' import { workspaceRouteHref } from '../navigation/workspace-execution-host' import { useWorkspaceServer } from '../transport/workspace-server' @@ -11,11 +12,12 @@ import { ConfirmModal } from '../components/ConfirmModal' import { OrcaHookTrustDrawer } from '../components/OrcaHookTrustDrawer' import { NewWorktreeModalController } from '../components/NewWorktreeModalController' import { hostNewWorktreeSessionRoute } from '../host-new-worktree-session-route' -import { isWorktreePinned } from '../worktree/workspace-list-sections' +import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' import { useMobileColorScheme } from '../theme/use-mobile-color-scheme' import { hostScreenStyles as styles } from './host-screen-styles' import { WorkspaceOptionsSheet } from './workspace-options-sheet' import { WorkspaceDetailsSheet } from './workspace-details-sheet' +import { WorkspaceRenameSheet } from './workspace-rename-sheet' import type { HostScreenController } from './use-host-screen-controller' import { useMobileTranslation } from '../i18n/use-mobile-translation' @@ -33,6 +35,13 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr state } = controller const actionTarget = state.actionTarget + // Tied to the menu's row, so a menu closed elsewhere never reopens on the rename sheet. + const [renameTarget, setRenameTarget] = useState(null) + const closeActionSheet = () => { + setRenameTarget(null) + state.setConfirmDelete(null) + state.setActionTarget(null) + } // A server row's screens are offered by what that server supports, not the desktop. const actionServer = useWorkspaceServer({ client, @@ -47,13 +56,7 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr {/* iOS cannot present over a closing sibling Modal; delete's confirm, trust prompt and failure share one. */} - { - state.setConfirmDelete(null) - state.setActionTarget(null) - }} - > + {state.confirmDelete ? ( @@ -102,6 +105,12 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr + ) : actionTarget && renameTarget === actionTarget ? ( + ) : ( state.setActionTarget(null) }), + { + id: 'rename', + label: t('hostScreen.hostScreenOverlays.rename', 'Rename'), + icon: Edit3, + onPress: () => setRenameTarget(actionTarget) + }, // Server sleep is not offered yet; the desktop would sleep its own id. ...(actionServer.server ? [] diff --git a/mobile/src/host-screen/use-host-worktree-actions.ts b/mobile/src/host-screen/use-host-worktree-actions.ts index 729270f7f34..1d51e79ba9e 100644 --- a/mobile/src/host-screen/use-host-worktree-actions.ts +++ b/mobile/src/host-screen/use-host-worktree-actions.ts @@ -18,7 +18,12 @@ import { leaveHostRoute } from '../host-route-exit' import { getWorktreeRowIdentity, isSameWorktreeRow } from '../worktree/worktree-host-row-identity' import { noteLastVisitedWorktree } from '../worktree/last-visited-worktree-by-host' import { isWorktreePinned, type Worktree } from '../worktree/workspace-list-sections' -import { worktreeActivate, worktreePinWrite, worktreeSleep } from './host-screen-operations' +import { + worktreeActivate, + worktreeDisplayNameWrite, + worktreePinWrite, + worktreeSleep +} from './host-screen-operations' import type { HostScreenState } from './use-host-screen-state' import { useHostWorktreeDelete } from './use-host-worktree-delete' @@ -165,6 +170,24 @@ export function useHostWorktreeActions(args: { [client, setSleptIds] ) + // Written at the row's owner, as desktop does; empty text returns the label to automatic. + const renameWorktree = useCallback( + async (item: Worktree, displayName: string) => { + const rowClient = clientForRow(item) + if (!rowClient) { + throw new Error('Not connected: worktree.set') + } + worktreeDisplayNameWrite.interpret( + await worktreeDisplayNameWrite.request(rowClient, { + worktree: `id:${item.worktreeId}`, + displayName + }) + ) + void fetchWorktrees() + }, + [clientForRow, fetchWorktrees] + ) + const worktreeDelete = useHostWorktreeDelete({ client, clientForRow, fetchWorktrees, state }) const handleRemoveHost = useCallback(async () => { @@ -273,6 +296,7 @@ export function useHostWorktreeActions(args: { closeNewWorktreeModal, openWorktreeSession, putWorktreeToSleep, + renameWorktree, togglePin } } diff --git a/mobile/src/host-screen/workspace-rename-sheet.tsx b/mobile/src/host-screen/workspace-rename-sheet.tsx new file mode 100644 index 00000000000..2719942650d --- /dev/null +++ b/mobile/src/host-screen/workspace-rename-sheet.tsx @@ -0,0 +1,82 @@ +import { useEffect, useRef, useState } from 'react' +import { automaticWorktreeDisplayName } from '../../../src/shared/worktree/display-name-provenance' +import { TextInputSheetContent } from '../components/TextInputModal' +import { useMobileTranslation } from '../i18n/use-mobile-translation' +import type { Worktree } from '../worktree/workspace-list-types' + +// Swapped into the workspace menu's open drawer. +export function WorkspaceRenameSheet({ + row, + rename, + onClose +}: { + row: Worktree + rename: (row: Worktree, displayName: string) => Promise + onClose: () => void +}) { + const t = useMobileTranslation() + const [value, setValue] = useState(row.displayName) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const automaticName = automaticWorktreeDisplayName({ + branch: row.branch, + projectName: row.repo, + path: row.path + }) + // A detached HEAD falls back to the project's name, which neither hint describes. + const hint = row.branch + ? t('hostScreen.workspaceRenameSheet.emptyUsesBranch', 'Leave empty to use the branch name.') + : row.detachedHead + ? undefined + : t('hostScreen.workspaceRenameSheet.emptyUsesFolder', 'Leave empty to use the folder name.') + + async function submit(displayName: string) { + // Like desktop's Workspace details, an unchanged name is not written (it would pin the label). + if (displayName === row.displayName) { + onClose() + return + } + setBusy(true) + setError(null) + try { + await rename(row, displayName) + // A late success must not close a menu reopened since. + if (mountedRef.current) { + onClose() + } + } catch (failure) { + const reason = failure instanceof Error ? failure.message : '' + setError( + t('hostScreen.workspaceRenameSheet.failed', "Couldn't rename the workspace. {{reason}}", { + reason + }).trim() + ) + setBusy(false) + } + } + + return ( + void submit(displayName)} + onCancel={onClose} + /> + ) +} diff --git a/mobile/src/i18n/locales/en.json b/mobile/src/i18n/locales/en.json index e8de2cd69bc..0326524a590 100644 --- a/mobile/src/i18n/locales/en.json +++ b/mobile/src/i18n/locales/en.json @@ -635,12 +635,19 @@ "sleep": "Sleep", "unpin": "Unpin", "pin": "Pin", + "rename": "Rename", "primaryWorktreeDeleteHint": "Primary worktree — can't be deleted. Remove the project instead.", "removeHost": "Remove Host", "removeHostMessage": "Remove \"{{name}}\"? You can re-pair later.", "deleteFailed": "Failed to delete \"{{name}}\"", "deleteAnyway": "Delete Anyway" }, + "workspaceRenameSheet": { + "title": "Rename Workspace", + "emptyUsesBranch": "Leave empty to use the branch name.", + "emptyUsesFolder": "Leave empty to use the folder name.", + "failed": "Couldn't rename the workspace. {{reason}}" + }, "workspaceDetailsSheet": { "merged": "Merged", "closed": "Closed", diff --git a/src/main/ipc/worktree-metadata-merge.ts b/src/main/ipc/worktree-metadata-merge.ts index 77cd70efc94..a2c7f353f30 100644 --- a/src/main/ipc/worktree-metadata-merge.ts +++ b/src/main/ipc/worktree-metadata-merge.ts @@ -1,10 +1,10 @@ -import { basename } from 'node:path' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GitWorktreeInfo, Worktree } from '../../shared/worktree/types' import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses' import { getLinkedWorkItemMetadata } from './worktree-linked-work-item-metadata' import { normalizeWorkspaceCreatorProvenance } from '../../shared/workspace-creator-provenance' import { createWorktreeIdentity } from '../../shared/worktree/identity' +import { automaticWorktreeDisplayName } from '../../shared/worktree/display-name-provenance' export function mergeWorktree( repoId: string, @@ -15,7 +15,11 @@ export function mergeWorktree( const branchShort = git.branch.replace(/^refs\/heads\//, '') const creatorProvenance = normalizeWorkspaceCreatorProvenance(meta?.creatorProvenance) const worktreeId = `${repoId}::${git.path}` - const automaticDisplayName = branchShort || defaultDisplayName || basename(git.path) + const automaticDisplayName = automaticWorktreeDisplayName({ + branch: git.branch, + projectName: defaultDisplayName, + path: git.path + }) // CLI-created labels predate displayNameIsPinned but are still explicit names. const legacyCliDisplayNameIsPinned = meta?.displayNameIsPinned === undefined && meta?.cliProvenance?.kind === 'created-by-cli' diff --git a/src/shared/worktree/display-name-provenance.ts b/src/shared/worktree/display-name-provenance.ts index a352ab466ba..12bc74217de 100644 --- a/src/shared/worktree/display-name-provenance.ts +++ b/src/shared/worktree/display-name-provenance.ts @@ -1,4 +1,19 @@ +import { getRuntimePathBasename } from '../cross-platform-path' + /** A rename with text pins the label; empty text returns it to automatic (branch-derived). */ export function displayNameUpdatePinsLabel(displayName: string | undefined): boolean { return Boolean(displayName?.trim()) } + +/** The label an unpinned workspace shows: its branch, else its project's name, else its folder. */ +export function automaticWorktreeDisplayName(args: { + branch: string + projectName: string | undefined + path: string +}): string { + return ( + args.branch.replace(/^refs\/heads\//, '') || + args.projectName || + getRuntimePathBasename(args.path) + ) +}