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
This commit is contained in:
Kelvin Amoaba
2026-11-06 01:52:38 +00:00
committed by GitHub
parent 554a16f328
commit c8c620cd21
8 changed files with 230 additions and 34 deletions
+60 -22
View File
@@ -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<SheetProps, 'value' | 'onChangeText' | 'error' | 'busy'> & {
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 (
<BottomDrawer visible={visible} onClose={onCancel} onAfterClose={onAfterClose}>
<TextInputSheetContent
{...sheetProps}
value={value}
onChangeText={setValue}
onCancel={onCancel}
/>
</BottomDrawer>
)
}
/** 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 (
<BottomDrawer visible={visible} onClose={onCancel} onAfterClose={onAfterClose}>
<>
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{message ? <Text style={styles.message}>{message}</Text> : null}
@@ -71,7 +101,7 @@ export function TextInputModal({
<TextField
value={value}
onChangeText={setValue}
onChangeText={onChangeText}
placeholder={placeholder}
autoFocus
autoCapitalize="none"
@@ -82,6 +112,8 @@ export function TextInputModal({
onSubmitEditing={handleSubmit}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<View style={styles.actions}>
<Pressable
style={({ pressed }) => [styles.cancelButton, pressed && styles.buttonPressed]}
@@ -101,7 +133,7 @@ export function TextInputModal({
<Text style={styles.submitText}>{submitLabel ?? t('common.save', 'Save')}</Text>
</Pressable>
</View>
</BottomDrawer>
</>
)
}
@@ -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',
@@ -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',
@@ -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<Worktree | null>(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. */}
<BottomDrawerModalHost>
<BottomDrawer
visible={actionTarget != null}
onClose={() => {
state.setConfirmDelete(null)
state.setActionTarget(null)
}}
>
<BottomDrawer visible={actionTarget != null} onClose={closeActionSheet}>
{state.confirmDelete ? (
<View>
<View style={styles.confirmContent}>
@@ -102,6 +105,12 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
</Pressable>
</View>
</View>
) : actionTarget && renameTarget === actionTarget ? (
<WorkspaceRenameSheet
row={actionTarget}
rename={actions.renameWorktree}
onClose={closeActionSheet}
/>
) : (
<ActionSheetContent
actions={
@@ -117,6 +126,12 @@ export function HostScreenOverlays({ controller }: { controller: HostScreenContr
navigate: actions.navigateFromHostList,
onDone: () => 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
? []
@@ -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
}
}
@@ -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<void>
onClose: () => void
}) {
const t = useMobileTranslation()
const [value, setValue] = useState(row.displayName)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(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 (
<TextInputSheetContent
title={t('hostScreen.workspaceRenameSheet.title', 'Rename Workspace')}
message={hint}
value={value}
onChangeText={setValue}
placeholder={automaticName}
submitLabel={error ? t('common.retry', 'Retry') : undefined}
selectTextOnFocus
allowEmpty
error={error}
busy={busy}
onSubmit={(displayName) => void submit(displayName)}
onCancel={onClose}
/>
)
}
+7
View File
@@ -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",
+6 -2
View File
@@ -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'
@@ -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)
)
}