mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
* fix(mobile): keep the create form on screen through drawer swaps and survive reconnects The create-worktree flow could reach a state where the shared modal host was mounted with no sheet in it: a full-screen transparent window that swallows every tap with no way out. Frame analysis of the reported recording and a live simulator repro both land on the same state - the form sheet laid out at the right frame with progress=1, backdrop painting, sheet not painted. - Keep the form sheet mounted through every drawer transition, so the host Modal is never on screen without a sheet, and drop the render-read pin ref. - Re-assert a pinned sheet's enter transform when it takes the window back from a fill picker; nothing re-applied it before. - Key the form session on hostId, not on the RpcClient object: useHostClient swaps that object on every reconnect, which silently remounted the form and threw away the picked source. - Run the pasted-item lookup concurrently with the provider fan-out instead of after it (measured 2631ms -> 1480ms for a typed PR number). * fix(mobile): remount the sheet view on window hand-back so a rebuilt native view repaints On-device confirmation showed the committed hand-back re-assert never reaches the native view: progress already sits at 1 and translateY at 0, so withTiming produces no style delta, and the dead screen stayed reachable (1/25 on the committed build; 1/9 with a sub-pixel value nudge, which lands on the stale native binding when the view was rebuilt with a new tag). Remounting the sheet's Animated.View on an epoch keyed to the hand-back mounts a fresh native view with the style computed from the current shared values - progress is already 1, so it paints in place with no visible animation. 0 dead in 50 attempts on the remount build under the same churn condition that reproduced the dead screen on base. LANE-REPORT.md carries the full confirmation evidence and limits. * chore: drop the stray lane report from the repo root It is a working artifact, not source, and the root directory guard blocks any new top-level entry. * test(mobile): assert the sheet subtree rebuild directly, not through a test-only prop The hand-back test proved the remount by reading an epoch-keyed nativeID that existed only for it — production markup shaped by a test, and an assertion a future refactor could satisfy without rebuilding anything. Count mounts of the sheet's content instead, which is the property the fix actually depends on, and drop the nativeID. Also stop typing test renderers as 'ReactTestRenderer | null'. The static analysis job installs no mobile/node_modules, so that type is unresolvable there and the union trips no-redundant-type-constituents on every added line. The hand-back re-assert is not dead code as the old comment implied: the drawer swap hands back at 166ms, before the 180ms enter animation ends. * fix(mobile): keep the create form when a render is thrown away The session key was built from counters mutated during render. A blurred screen suspends this subtree (react-native-screens freezes via react-freeze), so React runs the component and then discards that render — but the counter bumps survive it. The next committed render then produced a new key and remounted the form, throwing away the picked source for a host switch or a close that never committed. Hold the open epoch in state, which React discards with the render that set it, and put the host in the key directly instead of counting host changes.
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { Keyboard } from 'react-native'
|
|
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
|
|
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
|
|
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
|
|
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
|
|
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
|
|
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
|
|
import { useNewWorktreeRuntimeCapabilities } from '../tasks/worktree-create-capability'
|
|
import {
|
|
buildRetiredWorktreeNamesRefreshKey,
|
|
useRetiredWorktreeNames
|
|
} from '../worktree/use-retired-worktree-names'
|
|
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
|
|
import {
|
|
getMobileWorkspaceRepoBadgeColor,
|
|
type MobileWorkspaceRepo,
|
|
type NewWorktreeModalProps
|
|
} from './new-worktree-modal-types'
|
|
import {
|
|
buildNewWorkspaceProjectOptions,
|
|
buildNewWorkspaceRunTargetOptions,
|
|
getNewWorkspaceRunTarget
|
|
} from './new-workspace-project-targets'
|
|
import { NewWorktreeFormSheet } from './NewWorktreeFormSheet'
|
|
import { NewWorktreeModalDrawers } from './NewWorktreeModalDrawers'
|
|
import { useNewWorkspaceAgentSelection } from './use-new-workspace-agent-selection'
|
|
import { useNewWorkspaceCreateSubmit } from './use-new-workspace-create-submit'
|
|
import { useNewWorkspaceExecutionTarget } from './use-new-workspace-execution-target'
|
|
import { useNewWorkspaceRepositories } from './use-new-workspace-repositories'
|
|
import { useNewWorkspaceRuntimeContext } from './use-new-workspace-runtime-context'
|
|
import { useNewWorkspaceSetupScript } from './use-new-workspace-setup-script'
|
|
import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
|
|
|
|
export function NewWorktreeModal(props: NewWorktreeModalProps) {
|
|
// Why: each drawer opening is a fresh form session; remounting resets local
|
|
// form state before paint instead of clearing it in a visible-prop Effect.
|
|
// State, not a ref: react-native-screens freezes a blurred screen by suspending
|
|
// this subtree, and a counter bumped during a render React then throws away
|
|
// would restart the session for an opening that never committed.
|
|
const [session, setSession] = useState({ openEpoch: 0, visible: props.visible })
|
|
if (session.visible !== props.visible) {
|
|
setSession({
|
|
openEpoch: props.visible ? session.openEpoch + 1 : session.openEpoch,
|
|
visible: props.visible
|
|
})
|
|
}
|
|
|
|
// Why: key the session on the HOST, never on the RpcClient object. A reconnect,
|
|
// forceReconnect, or foreground revival swaps that object for the same host
|
|
// (see useHostClient), and keying on it silently remounted this form mid-edit
|
|
// and threw away the picked source. Every client-scoped hook below already
|
|
// drops responses from a superseded client, so no remount is needed for that.
|
|
return <NewWorktreeModalContent key={`${session.openEpoch}:${props.hostId}`} {...props} />
|
|
}
|
|
|
|
function NewWorktreeModalContent(props: NewWorktreeModalProps) {
|
|
const { visible, client, hostId, existingWorktreePaths, existingWorktrees, onCreated, onClose } =
|
|
props
|
|
const { repos, selectedRepo, setSelectedRepo, loading } = useNewWorkspaceRepositories({
|
|
client,
|
|
hostId,
|
|
visible
|
|
})
|
|
const navigation = useNewWorktreeDrawerNavigation(visible)
|
|
const [note, setNote] = useState('')
|
|
const [error, setError] = useState('')
|
|
const runtime = useNewWorkspaceRuntimeContext(client, visible, hostId)
|
|
const { tasksSupported, hostPlatform, getWorktreeCreateCutoverSupport } =
|
|
useNewWorktreeRuntimeCapabilities(client, visible)
|
|
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
|
|
const executionTarget = useNewWorkspaceExecutionTarget({
|
|
client,
|
|
connectionId: selectedRepoConnectionId,
|
|
visible
|
|
})
|
|
const setupScript = useNewWorkspaceSetupScript({ client, selectedRepo })
|
|
const selectedRepoWorktreeBranches = useMemo(
|
|
() => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null),
|
|
[existingWorktrees, selectedRepo]
|
|
)
|
|
const composer = useMobileComposerSource({
|
|
client,
|
|
selectedRepoId: selectedRepo?.id ?? null,
|
|
worktreeBranches: selectedRepoWorktreeBranches,
|
|
onError: setError
|
|
})
|
|
const agentSelection = useNewWorkspaceAgentSelection({
|
|
visible,
|
|
runtimeSettings: runtime.runtimeSettings,
|
|
detectedAgentIds: executionTarget.detectedAgentIds
|
|
})
|
|
const retiredNamesRefreshKey = useMemo(
|
|
() => buildRetiredWorktreeNamesRefreshKey(existingWorktreePaths),
|
|
[existingWorktreePaths]
|
|
)
|
|
const retiredWorktreeNames = useRetiredWorktreeNames(
|
|
client,
|
|
selectedRepo?.id,
|
|
retiredNamesRefreshKey
|
|
)
|
|
const createSubmit = useNewWorkspaceCreateSubmit({
|
|
client,
|
|
selectedRepo,
|
|
selectedAgent: agentSelection.selectedAgent,
|
|
setSelectedAgent: agentSelection.setSelectedAgent,
|
|
setAgentOverridden: agentSelection.setAgentOverridden,
|
|
runtimeSettings: runtime.runtimeSettings,
|
|
setRuntimeSettings: runtime.setRuntimeSettings,
|
|
detectedAgentIds: executionTarget.detectedAgentIds,
|
|
sshGate: executionTarget.sshGate,
|
|
composer,
|
|
note,
|
|
existingWorktreePaths,
|
|
retiredWorktreeNames,
|
|
setupCommand: setupScript.setupCommand,
|
|
setupTrust: setupScript.setupTrust,
|
|
setupRunPolicy: setupScript.setupRunPolicy,
|
|
setupDecisionChoice: setupScript.setupDecisionChoice,
|
|
runSetup: setupScript.runSetup,
|
|
trustedOrcaHooks: runtime.trustedOrcaHooks,
|
|
setTrustedOrcaHooks: runtime.setTrustedOrcaHooks,
|
|
getWorktreeCreateCutoverSupport,
|
|
transitionDrawer: navigation.transitionDrawer,
|
|
setError,
|
|
onCreated,
|
|
onClose
|
|
})
|
|
|
|
const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true
|
|
const sourceAvailability: SmartModeAvailabilityInput = {
|
|
textOnly: selectedRepo != null && !selectedRepoIsGit,
|
|
tasksSupported,
|
|
hasRepo: selectedRepo != null,
|
|
githubAvailable: runtime.availableProviders.includes('github'),
|
|
gitlabAvailable: runtime.availableProviders.includes('gitlab'),
|
|
linearAvailable: runtime.availableProviders.includes('linear')
|
|
}
|
|
const pasteRepos = useMemo<PasteRepoCandidate[]>(
|
|
() =>
|
|
repos.map((repo) => ({
|
|
id: repo.id,
|
|
displayName: repo.displayName,
|
|
slug: deriveRepoSlug(repo)
|
|
})),
|
|
[repos]
|
|
)
|
|
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
|
|
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
|
|
const selectedProject =
|
|
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
|
|
const runTargetPickerItems = useMemo(
|
|
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform),
|
|
[hostPlatform, repos, selectedProjectId]
|
|
)
|
|
const selectedRunTarget = selectedRepo
|
|
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform)
|
|
: null
|
|
const needsSetupChoice = Boolean(setupScript.setupCommand) && setupScript.setupRunPolicy === 'ask'
|
|
const canCreate =
|
|
selectedRepo != null &&
|
|
!createSubmit.creating &&
|
|
!executionTarget.sshGate.requiresConnection &&
|
|
(!needsSetupChoice || setupScript.setupDecisionChoice != null)
|
|
|
|
function openPicker(view: 'project' | 'runTarget' | 'agent'): void {
|
|
Keyboard.dismiss()
|
|
navigation.transitionDrawer(view)
|
|
}
|
|
|
|
function selectRepo(repo: MobileWorkspaceRepo, clearRepoScopedSource: boolean): void {
|
|
const repoChanged = repo.id !== selectedRepo?.id
|
|
setSelectedRepo(repo)
|
|
if (
|
|
clearRepoScopedSource &&
|
|
repoChanged &&
|
|
!shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)
|
|
) {
|
|
composer.handleClearSmartNameSelection()
|
|
}
|
|
}
|
|
|
|
function requestClose(): void {
|
|
if (navigation.drawerView === 'form') {
|
|
onClose()
|
|
} else if (navigation.drawerView === 'trust') {
|
|
createSubmit.closeSetupTrust()
|
|
} else {
|
|
navigation.transitionDrawer('form')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<BottomDrawerModalHost visible={visible} onRequestClose={requestClose}>
|
|
<NewWorktreeFormSheet
|
|
visible={navigation.formSheetVisible}
|
|
interactive={navigation.formSheetInteractive}
|
|
loading={loading}
|
|
hasRepos={repos.length > 0}
|
|
project={selectedProject}
|
|
runTarget={selectedRunTarget}
|
|
projectBadgeColor={selectedRepo ? getMobileWorkspaceRepoBadgeColor(selectedRepo) : null}
|
|
selectedRepoIsGit={selectedRepoIsGit}
|
|
selectedRepoConnectionId={selectedRepoConnectionId}
|
|
selectedRepoName={selectedRepo?.displayName ?? 'Remote repository'}
|
|
sshGate={executionTarget.sshGate}
|
|
composer={composer}
|
|
selectedAgent={agentSelection.selectedAgent}
|
|
showAdvanced={setupScript.showAdvanced}
|
|
note={note}
|
|
setupCommand={setupScript.setupCommand}
|
|
setupSource={setupScript.setupSource}
|
|
setupRunPolicy={setupScript.setupRunPolicy}
|
|
setupDecisionChoice={setupScript.setupDecisionChoice}
|
|
runSetup={setupScript.runSetup}
|
|
error={error}
|
|
creating={createSubmit.creating}
|
|
canCreate={canCreate}
|
|
onClose={onClose}
|
|
onOpenProject={() => openPicker('project')}
|
|
onOpenRunTarget={() => openPicker('runTarget')}
|
|
onOpenSource={navigation.openSourceDrawer}
|
|
onClearError={() => setError('')}
|
|
onConnect={() => void executionTarget.connect()}
|
|
onOpenAgent={() => openPicker('agent')}
|
|
onShowAdvancedChange={setupScript.setShowAdvanced}
|
|
onNoteChange={setNote}
|
|
onSetupDecisionChange={setupScript.setSetupDecisionChoice}
|
|
onRunSetupChange={setupScript.setRunSetup}
|
|
onCreate={() => void createSubmit.create()}
|
|
/>
|
|
|
|
<NewWorktreeModalDrawers
|
|
visible={visible}
|
|
drawerView={navigation.drawerView}
|
|
client={client}
|
|
composer={composer}
|
|
sourceAvailability={sourceAvailability}
|
|
selectedRepo={selectedRepo}
|
|
repos={repos}
|
|
pasteRepos={pasteRepos}
|
|
sshReady={!executionTarget.sshGate.requiresConnection}
|
|
projectPickerItems={projectPickerItems}
|
|
selectedProjectId={selectedProjectId}
|
|
runTargetPickerItems={runTargetPickerItems}
|
|
pickerAgentOptions={agentSelection.pickerAgentOptions}
|
|
selectedAgent={agentSelection.selectedAgent}
|
|
setupTrustPrompt={createSubmit.setupTrustPrompt}
|
|
creating={createSubmit.creating}
|
|
onSourceRepoChange={(repo) => selectRepo(repo, false)}
|
|
onRepoChange={(repo) => selectRepo(repo, true)}
|
|
onAgentChange={(agent) => {
|
|
agentSelection.setAgentOverridden(true)
|
|
agentSelection.setSelectedAgent(agent)
|
|
}}
|
|
onTransitionToForm={() => navigation.transitionDrawer('form')}
|
|
onApproveSetupTrust={(alwaysTrust) => void createSubmit.approveSetupTrust(alwaysTrust)}
|
|
onSkipSetupTrust={createSubmit.skipSetupTrust}
|
|
onCloseSetupTrust={createSubmit.closeSetupTrust}
|
|
/>
|
|
</BottomDrawerModalHost>
|
|
)
|
|
}
|