mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user