mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
1278 lines
41 KiB
TypeScript
1278 lines
41 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import {
|
|
View,
|
|
Text,
|
|
TextInput,
|
|
Pressable,
|
|
Switch,
|
|
StyleSheet,
|
|
Platform,
|
|
ActivityIndicator
|
|
} from 'react-native'
|
|
import { ChevronDown, ChevronUp, Check } from 'lucide-react-native'
|
|
import type { RpcClient } from '../transport/rpc-client'
|
|
import type { RpcSuccess } from '../transport/types'
|
|
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
|
|
import { BottomDrawer } from './BottomDrawer'
|
|
import { MobileAgentIcon } from './MobileAgentIcon'
|
|
import { getSuggestedCreatureName } from './worktree-name-suggestion'
|
|
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
|
|
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
|
|
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
|
|
import {
|
|
isSetupHookTrusted,
|
|
normalizeSetupHookTrust,
|
|
trustedOrcaHooksWithSetupApproval,
|
|
wasSetupHookPreviouslyApproved,
|
|
type SetupHookTrust
|
|
} from '../tasks/setup-hook-trust'
|
|
import { isMobileTuiAgent, MOBILE_TUI_AGENT_LAUNCH_COMMANDS } from '../tasks/mobile-tui-agents'
|
|
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
|
|
import type { SshConnectionState } from '../../../src/shared/ssh-types'
|
|
|
|
type Repo = {
|
|
id: string
|
|
displayName: string
|
|
path: string
|
|
badgeColor?: string
|
|
connectionId?: string | null
|
|
}
|
|
|
|
type SetupDecision = 'inherit' | 'run' | 'skip'
|
|
type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
|
|
type RuntimeSettings = {
|
|
defaultTuiAgent?: string | 'blank' | null
|
|
agentCmdOverrides?: Record<string, string>
|
|
}
|
|
|
|
type RepoHooksResponse = {
|
|
hooks: { scripts?: { setup?: string } } | null
|
|
source: string | null
|
|
setupRunPolicy?: SetupRunPolicy
|
|
setupTrust?: SetupHookTrust
|
|
}
|
|
|
|
type CreateOptions = {
|
|
setupOverride?: Exclude<SetupDecision, 'inherit'>
|
|
approvedSetupContentHash?: string
|
|
}
|
|
|
|
type SetupTrustPrompt = {
|
|
repoId: string
|
|
repoName: string
|
|
scriptContent: string
|
|
contentHash: string
|
|
previouslyApproved: boolean
|
|
}
|
|
|
|
type AgentOption = {
|
|
id: string
|
|
label: string
|
|
faviconDomain?: string
|
|
}
|
|
|
|
const AGENT_OPTIONS: AgentOption[] = MOBILE_AGENT_CATALOG
|
|
|
|
const BLANK_TERMINAL: AgentOption = { id: '__blank__', label: 'Blank Terminal' }
|
|
|
|
function agentOptionFor(id: string | null | undefined): AgentOption | null {
|
|
if (!id) return null
|
|
if (id === 'blank' || id === '__blank__') return BLANK_TERMINAL
|
|
return AGENT_OPTIONS.find((agent) => agent.id === id) ?? null
|
|
}
|
|
|
|
function pickPreferredAgent(
|
|
settings: RuntimeSettings | null,
|
|
detectedAgentIds: Set<string> | null
|
|
): AgentOption {
|
|
const preferred = agentOptionFor(settings?.defaultTuiAgent)
|
|
if (preferred?.id === '__blank__') {
|
|
return preferred
|
|
}
|
|
if (preferred && (detectedAgentIds === null || detectedAgentIds.has(preferred.id)))
|
|
return preferred
|
|
const detectedOption = AGENT_OPTIONS.find(
|
|
(agent) => detectedAgentIds === null || detectedAgentIds.has(agent.id)
|
|
)
|
|
return detectedOption ?? BLANK_TERMINAL
|
|
}
|
|
|
|
function repoColor(name: string): string {
|
|
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
|
|
let hash = 0
|
|
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) | 0
|
|
return palette[Math.abs(hash) % palette.length]!
|
|
}
|
|
|
|
function repoBadgeColor(repo: Repo | null): string {
|
|
return repo?.badgeColor || repoColor(repo?.displayName ?? 'repository')
|
|
}
|
|
|
|
// ── Picker sub-modal ────────────────────────────────────────────────
|
|
// Why: inline dropdowns with position:absolute + ScrollView have persistent
|
|
// touch-conflict issues in React Native. A separate modal for the picker
|
|
// list is the standard mobile pattern — it scrolls reliably and feels native.
|
|
|
|
function PickerListModal<T extends { id: string; label: string }>({
|
|
visible,
|
|
title,
|
|
items,
|
|
selectedId,
|
|
onSelect,
|
|
onClose,
|
|
renderIcon
|
|
}: {
|
|
visible: boolean
|
|
title: string
|
|
items: T[]
|
|
selectedId: string
|
|
onSelect: (item: T) => void
|
|
onClose: () => void
|
|
renderIcon?: (item: T) => React.ReactNode
|
|
}) {
|
|
return (
|
|
<BottomDrawer visible={visible} onClose={onClose}>
|
|
<View style={styles.pickerHeader}>
|
|
<Text style={styles.pickerTitle}>{title}</Text>
|
|
</View>
|
|
<View style={styles.pickerGroup}>
|
|
{items.map((item, index) => {
|
|
const selected = item.id === selectedId
|
|
return (
|
|
<View key={item.id}>
|
|
{index > 0 && <View style={styles.pickerSeparator} />}
|
|
<Pressable
|
|
style={({ pressed }) => [styles.pickerItem, pressed && styles.pickerItemPressed]}
|
|
onPress={() => {
|
|
onSelect(item)
|
|
onClose()
|
|
}}
|
|
>
|
|
{renderIcon?.(item)}
|
|
<Text
|
|
style={[styles.pickerItemText, selected && styles.pickerItemTextSelected]}
|
|
numberOfLines={1}
|
|
>
|
|
{item.label}
|
|
</Text>
|
|
{selected && <Check size={14} color={colors.textPrimary} />}
|
|
</Pressable>
|
|
</View>
|
|
)
|
|
})}
|
|
</View>
|
|
</BottomDrawer>
|
|
)
|
|
}
|
|
|
|
// ── Main modal ──────────────────────────────────────────────────────
|
|
|
|
type Props = {
|
|
visible: boolean
|
|
client: RpcClient | null
|
|
// Why: existing worktree paths from the host so we can pick a unique
|
|
// marine-creature default when the user leaves the name blank, matching
|
|
// the desktop UI's behavior. The "already exists locally" collision is
|
|
// on the on-disk directory basename, so paths (not displayNames) are
|
|
// what the suggestion logic must dedupe against.
|
|
existingWorktreePaths?: readonly string[]
|
|
onCreated: (worktreeId: string, name: string) => void
|
|
onClose: () => void
|
|
}
|
|
|
|
export function NewWorktreeModal({
|
|
visible,
|
|
client,
|
|
existingWorktreePaths,
|
|
onCreated,
|
|
onClose
|
|
}: Props) {
|
|
const [repos, setRepos] = useState<Repo[]>([])
|
|
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
|
|
const [showRepoPicker, setShowRepoPicker] = useState(false)
|
|
const [selectedAgent, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
|
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
|
|
const [detectedAgentIds, setDetectedAgentIds] = useState<Set<string> | null>(null)
|
|
const [agentOverridden, setAgentOverridden] = useState(false)
|
|
const [showAgentPicker, setShowAgentPicker] = useState(false)
|
|
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
|
|
const [sshConnecting, setSshConnecting] = useState(false)
|
|
const [name, setName] = useState('')
|
|
const [note, setNote] = useState('')
|
|
const [showAdvanced, setShowAdvanced] = useState(false)
|
|
const [setupCommand, setSetupCommand] = useState<string | null>(null)
|
|
const [setupSource, setSetupSource] = useState<string | null>(null)
|
|
const [setupTrust, setSetupTrust] = useState<SetupHookTrust | null>(null)
|
|
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
|
|
const [setupTrustPrompt, setSetupTrustPrompt] = useState<SetupTrustPrompt | null>(null)
|
|
const [setupRunPolicy, setSetupRunPolicy] = useState<SetupRunPolicy>('run-by-default')
|
|
const [setupDecisionChoice, setSetupDecisionChoice] = useState<Exclude<
|
|
SetupDecision,
|
|
'inherit'
|
|
> | null>(null)
|
|
const [runSetup, setRunSetup] = useState(true)
|
|
const [creating, setCreating] = useState(false)
|
|
const [error, setError] = useState('')
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
// Why: matches the desktop UI — the input shows a generic "Workspace name"
|
|
// placeholder, not the suggested creature. The creature name is only used
|
|
// as a server-bound fallback when the user submits with a blank field, so
|
|
// it's recomputed lazily inside handleCreate() to stay fresh against
|
|
// existingWorktreePaths at submission time.
|
|
|
|
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
|
|
const sshGate = deriveWorkspaceSshGate({
|
|
connectionId: selectedRepoConnectionId,
|
|
state: sshState,
|
|
connecting: sshConnecting
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setShowRepoPicker(false)
|
|
setShowAgentPicker(false)
|
|
return
|
|
}
|
|
if (!client) return
|
|
let stale = false
|
|
setName('')
|
|
setNote('')
|
|
setShowAdvanced(false)
|
|
setSetupCommand(null)
|
|
setSetupSource(null)
|
|
setSetupTrust(null)
|
|
setTrustedOrcaHooks({})
|
|
setSetupTrustPrompt(null)
|
|
setSetupRunPolicy('run-by-default')
|
|
setSetupDecisionChoice(null)
|
|
setRunSetup(true)
|
|
setError('')
|
|
setCreating(false)
|
|
setShowRepoPicker(false)
|
|
setShowAgentPicker(false)
|
|
setRuntimeSettings(null)
|
|
setDetectedAgentIds(null)
|
|
setAgentOverridden(false)
|
|
setSshState(null)
|
|
setSshConnecting(false)
|
|
setSelectedAgent(AGENT_OPTIONS[0]!)
|
|
setLoading(true)
|
|
|
|
void (async () => {
|
|
try {
|
|
const [repoResponse, settingsResponse, uiResponse] = await Promise.all([
|
|
client.sendRequest('repo.list'),
|
|
client.sendRequest('settings.get'),
|
|
client.sendRequest('ui.get')
|
|
])
|
|
if (stale) return
|
|
if (settingsResponse.ok) {
|
|
const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings }
|
|
setRuntimeSettings(result.settings)
|
|
}
|
|
if (uiResponse.ok) {
|
|
const result = (uiResponse as RpcSuccess).result as {
|
|
ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks }
|
|
}
|
|
setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {})
|
|
}
|
|
if (repoResponse.ok) {
|
|
const result = (repoResponse as RpcSuccess).result as { repos: Repo[] }
|
|
setRepos(result.repos)
|
|
if (result.repos.length === 1) {
|
|
setSelectedRepo(result.repos[0]!)
|
|
} else {
|
|
setSelectedRepo(null)
|
|
}
|
|
}
|
|
} catch {
|
|
if (!stale) setRepos([])
|
|
} finally {
|
|
if (!stale) setLoading(false)
|
|
}
|
|
})()
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [visible, client])
|
|
|
|
useEffect(() => {
|
|
if (!visible || !client || !selectedRepoConnectionId) {
|
|
setSshState(null)
|
|
setSshConnecting(false)
|
|
return
|
|
}
|
|
let stale = false
|
|
void client
|
|
.sendRequest('ssh.getState', { targetId: selectedRepoConnectionId })
|
|
.then((response) => {
|
|
if (stale) return
|
|
if (!response.ok) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const state = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
|
setSshState(
|
|
state.state ?? {
|
|
targetId: selectedRepoConnectionId,
|
|
status: 'disconnected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
)
|
|
})
|
|
.catch((err) => {
|
|
if (!stale) {
|
|
setSshState({
|
|
targetId: selectedRepoConnectionId,
|
|
status: 'error',
|
|
error: err instanceof Error ? err.message : 'Failed to read SSH connection state.',
|
|
reconnectAttempt: 0
|
|
})
|
|
}
|
|
})
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, selectedRepoConnectionId, visible])
|
|
|
|
useEffect(() => {
|
|
if (!visible || !client) return
|
|
if (selectedRepoConnectionId && sshGate.status !== 'connected') {
|
|
setDetectedAgentIds(null)
|
|
return
|
|
}
|
|
let stale = false
|
|
setDetectedAgentIds(null)
|
|
void (async () => {
|
|
try {
|
|
const response = selectedRepoConnectionId
|
|
? await client.sendRequest('preflight.detectRemoteAgents', {
|
|
connectionId: selectedRepoConnectionId
|
|
})
|
|
: await client.sendRequest('preflight.detectAgents')
|
|
if (stale) return
|
|
setDetectedAgentIds(
|
|
response.ok ? new Set((response as RpcSuccess).result as string[]) : new Set()
|
|
)
|
|
} catch {
|
|
if (!stale) setDetectedAgentIds(new Set())
|
|
}
|
|
})()
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, selectedRepoConnectionId, sshGate.status, visible])
|
|
|
|
useEffect(() => {
|
|
if (!visible || agentOverridden) return
|
|
setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds))
|
|
}, [agentOverridden, detectedAgentIds, runtimeSettings, visible])
|
|
|
|
useEffect(() => {
|
|
if (!visible || detectedAgentIds === null || selectedAgent.id === '__blank__') return
|
|
if (detectedAgentIds.has(selectedAgent.id)) return
|
|
setSelectedAgent(pickPreferredAgent(runtimeSettings, detectedAgentIds))
|
|
setAgentOverridden(false)
|
|
}, [detectedAgentIds, runtimeSettings, selectedAgent.id, visible])
|
|
|
|
useEffect(() => {
|
|
if (!client || !selectedRepo) {
|
|
setSetupCommand(null)
|
|
setSetupSource(null)
|
|
setSetupTrust(null)
|
|
return
|
|
}
|
|
let stale = false
|
|
void (async () => {
|
|
try {
|
|
const response = await client.sendRequest('repo.hooks', {
|
|
repo: `id:${selectedRepo.id}`
|
|
})
|
|
if (stale) return
|
|
if (response.ok) {
|
|
const result = (response as RpcSuccess).result as RepoHooksResponse
|
|
const cmd = result.hooks?.scripts?.setup?.trim() || null
|
|
const policy = result.setupRunPolicy ?? 'run-by-default'
|
|
setSetupCommand(cmd)
|
|
setSetupSource(result.source)
|
|
setSetupTrust(normalizeSetupHookTrust(result.setupTrust))
|
|
setSetupRunPolicy(policy)
|
|
setSetupDecisionChoice(null)
|
|
setRunSetup(policy !== 'skip-by-default')
|
|
if (cmd && policy === 'ask') {
|
|
setShowAdvanced(true)
|
|
}
|
|
}
|
|
} catch {
|
|
if (!stale) {
|
|
setSetupCommand(null)
|
|
setSetupSource(null)
|
|
setSetupTrust(null)
|
|
setSetupRunPolicy('run-by-default')
|
|
setSetupDecisionChoice(null)
|
|
}
|
|
}
|
|
})()
|
|
return () => {
|
|
stale = true
|
|
}
|
|
}, [client, selectedRepo])
|
|
|
|
async function connectSelectedSshRepo(): Promise<void> {
|
|
if (!client || !selectedRepoConnectionId) return
|
|
setSshConnecting(true)
|
|
setSshState({
|
|
targetId: selectedRepoConnectionId,
|
|
status: 'connecting',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
})
|
|
try {
|
|
const response = await client.sendRequest(
|
|
'ssh.connect',
|
|
{ targetId: selectedRepoConnectionId },
|
|
{ timeoutMs: 120_000 }
|
|
)
|
|
if (!response.ok) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
const result = (response as RpcSuccess).result as { state?: SshConnectionState | null }
|
|
setSshState(
|
|
result.state ?? {
|
|
targetId: selectedRepoConnectionId,
|
|
status: 'connected',
|
|
error: null,
|
|
reconnectAttempt: 0
|
|
}
|
|
)
|
|
} catch (err) {
|
|
setSshState({
|
|
targetId: selectedRepoConnectionId,
|
|
status: 'error',
|
|
error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.',
|
|
reconnectAttempt: 0
|
|
})
|
|
} finally {
|
|
setSshConnecting(false)
|
|
}
|
|
}
|
|
|
|
async function persistSetupHookTrust(
|
|
repoId: string,
|
|
contentHash: string,
|
|
alwaysTrust: boolean
|
|
): Promise<void> {
|
|
if (!client) return
|
|
const next = trustedOrcaHooksWithSetupApproval({
|
|
trust: trustedOrcaHooks,
|
|
repoId,
|
|
contentHash,
|
|
alwaysTrust
|
|
})
|
|
const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next })
|
|
if (!response.ok) {
|
|
throw new Error(response.error.message)
|
|
}
|
|
setTrustedOrcaHooks(next)
|
|
}
|
|
|
|
async function handleCreate(options: CreateOptions = {}) {
|
|
if (!client || !selectedRepo) return
|
|
setCreating(true)
|
|
setError('')
|
|
|
|
try {
|
|
if (sshGate.requiresConnection) {
|
|
setError(`Connect ${selectedRepo.displayName} before creating a workspace.`)
|
|
return
|
|
}
|
|
const command =
|
|
selectedAgent.id !== '__blank__'
|
|
? (runtimeSettings?.agentCmdOverrides?.[selectedAgent.id] ??
|
|
(isMobileTuiAgent(selectedAgent.id)
|
|
? MOBILE_TUI_AGENT_LAUNCH_COMMANDS[selectedAgent.id]
|
|
: undefined))
|
|
: undefined
|
|
|
|
// Why: blank name field — match desktop behavior by computing the
|
|
// next available marine-creature name at submit time and passing it
|
|
// to the server. The server's worktree.create rejects empty/invalid
|
|
// names, so we must generate one client-side rather than letting the
|
|
// server invent one. The pre-flight basename dedupe is only a hint;
|
|
// the authoritative collision is checked server-side against git
|
|
// branches/remotes/PRs, so we also retry-with-suffix on conflict.
|
|
const trimmedName = name.trim()
|
|
const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? [])
|
|
|
|
// Why: mirrors src/renderer/src/store/slices/worktrees.ts
|
|
// (createWorktree retry loop). Server-side checks (Branch X already
|
|
// exists locally / on a remote / already has PR #N) can fire even
|
|
// after the pre-flight basename dedupe — branches outlive worktrees
|
|
// in git, and remote branches/PRs aren't visible from worktree.ps.
|
|
// Retry up to 25 times by appending -2, -3, ... before surfacing
|
|
// the error. The desktop applies this to user-typed names too, so
|
|
// mobile follows suit for parity.
|
|
const retryablePatterns = [
|
|
/already exists locally/i,
|
|
/already exists on a remote/i,
|
|
/already has pr #\d+/i
|
|
]
|
|
const candidateFor = (attempt: number): string =>
|
|
attempt === 0 ? baseName : `${baseName}-${attempt + 1}`
|
|
let setupDecision: SetupDecision = 'inherit'
|
|
if (setupCommand) {
|
|
if (options.setupOverride) {
|
|
setupDecision = options.setupOverride
|
|
} else if (setupRunPolicy === 'ask') {
|
|
if (!setupDecisionChoice) {
|
|
setError('Choose whether to run the setup script.')
|
|
return
|
|
}
|
|
setupDecision = setupDecisionChoice
|
|
} else {
|
|
setupDecision = runSetup ? 'run' : 'skip'
|
|
}
|
|
}
|
|
if (
|
|
setupDecision === 'run' &&
|
|
setupTrust &&
|
|
setupTrust.contentHash !== options.approvedSetupContentHash &&
|
|
!isSetupHookTrusted(trustedOrcaHooks, selectedRepo.id, setupTrust.contentHash)
|
|
) {
|
|
// Why: desktop prompts before running repo-owned orca.yaml setup hooks.
|
|
// Mobile stores the same trust hash so approvals carry across surfaces.
|
|
setSetupTrustPrompt({
|
|
repoId: selectedRepo.id,
|
|
repoName: selectedRepo.displayName,
|
|
scriptContent: setupTrust.scriptContent,
|
|
contentHash: setupTrust.contentHash,
|
|
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
|
|
})
|
|
return
|
|
}
|
|
|
|
let lastError: string | null = null
|
|
for (let attempt = 0; attempt < 25; attempt += 1) {
|
|
const candidateName = candidateFor(attempt)
|
|
const params: Record<string, unknown> = {
|
|
repo: `id:${selectedRepo.id}`,
|
|
startupCommand: command,
|
|
setupDecision,
|
|
name: candidateName
|
|
}
|
|
if (selectedAgent.id !== '__blank__') params.createdWithAgent = selectedAgent.id
|
|
if (note.trim()) params.comment = note.trim()
|
|
|
|
const response = await client.sendRequest('worktree.create', params, {
|
|
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
|
|
})
|
|
if (response.ok) {
|
|
const result = (response as RpcSuccess).result as { worktree: { id: string } }
|
|
onClose()
|
|
onCreated(result.worktree.id, candidateName)
|
|
return
|
|
}
|
|
|
|
lastError = response.error.message
|
|
if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) {
|
|
break
|
|
}
|
|
}
|
|
setError(lastError ?? 'Failed to create workspace')
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Failed to create workspace')
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
const needsSetupChoice = Boolean(setupCommand) && setupRunPolicy === 'ask'
|
|
const agentDetectionPending =
|
|
selectedRepo != null && !sshGate.requiresConnection && detectedAgentIds === null
|
|
const canCreate =
|
|
selectedRepo != null &&
|
|
!creating &&
|
|
!sshGate.requiresConnection &&
|
|
!agentDetectionPending &&
|
|
(!needsSetupChoice || setupDecisionChoice != null)
|
|
const visibleAgentOptions =
|
|
detectedAgentIds === null
|
|
? AGENT_OPTIONS
|
|
: AGENT_OPTIONS.filter((agent) => detectedAgentIds.has(agent.id))
|
|
const pickerAgentOptions = [...visibleAgentOptions, BLANK_TERMINAL]
|
|
|
|
return (
|
|
<>
|
|
<BottomDrawer visible={visible} onClose={onClose}>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>Create Workspace</Text>
|
|
<Text style={styles.subtitle}>
|
|
Pick a repository and agent to spin up a new workspace.
|
|
</Text>
|
|
</View>
|
|
|
|
{loading ? (
|
|
<View style={styles.loadingContainer}>
|
|
<ActivityIndicator size="small" color={colors.textSecondary} />
|
|
</View>
|
|
) : repos.length === 0 ? (
|
|
<View style={styles.loadingContainer}>
|
|
<Text style={styles.emptyText}>No repositories found</Text>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Repository</Text>
|
|
<Pressable style={styles.fieldButton} onPress={() => setShowRepoPicker(true)}>
|
|
{selectedRepo ? (
|
|
<View
|
|
style={[styles.repoDot, { backgroundColor: repoBadgeColor(selectedRepo) }]}
|
|
/>
|
|
) : null}
|
|
<Text
|
|
style={[styles.fieldButtonText, !selectedRepo && styles.fieldButtonPlaceholder]}
|
|
numberOfLines={1}
|
|
>
|
|
{selectedRepo?.displayName ?? 'Select a repository'}
|
|
</Text>
|
|
<ChevronDown size={14} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
{selectedRepoConnectionId ? (
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>SSH Connection</Text>
|
|
<View style={styles.sshBox}>
|
|
<View style={styles.sshRow}>
|
|
<View
|
|
style={[
|
|
styles.sshDot,
|
|
sshGate.status === 'connected'
|
|
? styles.sshDotConnected
|
|
: sshGate.connectInProgress
|
|
? styles.sshDotProgress
|
|
: styles.sshDotDisconnected
|
|
]}
|
|
/>
|
|
<View style={styles.sshCopy}>
|
|
<Text style={styles.sshTitle} numberOfLines={1}>
|
|
{selectedRepo?.displayName ?? 'Remote repository'}
|
|
</Text>
|
|
<Text style={styles.sshSubtitle}>
|
|
{workspaceSshStatusLabel(sshGate.status)}
|
|
</Text>
|
|
</View>
|
|
{sshGate.status === 'connected' ? null : (
|
|
<Pressable
|
|
style={[
|
|
styles.sshConnectButton,
|
|
sshGate.connectInProgress && styles.disabled
|
|
]}
|
|
disabled={sshGate.connectInProgress}
|
|
onPress={() => void connectSelectedSshRepo()}
|
|
>
|
|
<Text style={styles.sshConnectText}>
|
|
{sshGate.connectInProgress ? 'Connecting...' : 'Connect'}
|
|
</Text>
|
|
</Pressable>
|
|
)}
|
|
</View>
|
|
{sshGate.error ? <Text style={styles.errorInline}>{sshGate.error}</Text> : null}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>
|
|
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
|
|
</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={name}
|
|
onChangeText={(t) => {
|
|
setName(t)
|
|
setError('')
|
|
}}
|
|
placeholder="Workspace name"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
autoFocus={repos.length <= 1}
|
|
returnKeyType="done"
|
|
onSubmitEditing={() => {
|
|
if (canCreate) void handleCreate()
|
|
}}
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Agent</Text>
|
|
<Pressable
|
|
style={[styles.fieldButton, sshGate.requiresConnection && styles.disabled]}
|
|
disabled={sshGate.requiresConnection}
|
|
onPress={() => setShowAgentPicker(true)}
|
|
>
|
|
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
|
|
<Text style={styles.fieldButtonText} numberOfLines={1}>
|
|
{sshGate.requiresConnection ? 'Connect repository first' : selectedAgent.label}
|
|
</Text>
|
|
<ChevronDown size={14} color={colors.textMuted} />
|
|
</Pressable>
|
|
</View>
|
|
|
|
<Pressable style={styles.advancedToggle} onPress={() => setShowAdvanced(!showAdvanced)}>
|
|
<Text style={styles.advancedText}>Advanced</Text>
|
|
{showAdvanced ? (
|
|
<ChevronUp size={14} color={colors.textSecondary} />
|
|
) : (
|
|
<ChevronDown size={14} color={colors.textSecondary} />
|
|
)}
|
|
</Pressable>
|
|
|
|
{showAdvanced && (
|
|
<>
|
|
<View style={styles.field}>
|
|
<Text style={styles.label}>Note</Text>
|
|
<TextInput
|
|
style={styles.input}
|
|
value={note}
|
|
onChangeText={setNote}
|
|
placeholder="Write a note"
|
|
placeholderTextColor={colors.textMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
/>
|
|
</View>
|
|
|
|
{setupCommand ? (
|
|
<View style={styles.field}>
|
|
<View style={styles.setupHeader}>
|
|
<Text style={styles.label}>Setup script</Text>
|
|
{setupSource && (
|
|
<View style={styles.sourceBadge}>
|
|
<Text style={styles.sourceBadgeText}>
|
|
{setupSource === 'orca.yaml' ? 'ORCA.YAML' : 'HOOKS'}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
<View style={styles.setupBox}>
|
|
{setupRunPolicy === 'ask' ? (
|
|
<View style={styles.setupChoiceRow}>
|
|
<Pressable
|
|
style={[
|
|
styles.setupChoiceButton,
|
|
setupDecisionChoice === 'run' && styles.setupChoiceButtonSelected
|
|
]}
|
|
onPress={() => setSetupDecisionChoice('run')}
|
|
>
|
|
<Text style={styles.setupChoiceText}>Run</Text>
|
|
</Pressable>
|
|
<Pressable
|
|
style={[
|
|
styles.setupChoiceButton,
|
|
setupDecisionChoice === 'skip' && styles.setupChoiceButtonSelected
|
|
]}
|
|
onPress={() => setSetupDecisionChoice('skip')}
|
|
>
|
|
<Text style={styles.setupChoiceText}>Skip</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<View style={styles.setupToggleRow}>
|
|
<Text style={styles.setupToggleLabel}>Run setup command</Text>
|
|
<Switch
|
|
value={runSetup}
|
|
onValueChange={setRunSetup}
|
|
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
|
|
thumbColor={colors.textPrimary}
|
|
style={styles.setupSwitch}
|
|
/>
|
|
</View>
|
|
)}
|
|
<View style={styles.setupCommandBlock}>
|
|
<Text style={styles.setupCommand}>{setupCommand}</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
)}
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
|
|
<View style={styles.actions}>
|
|
<Pressable
|
|
style={[styles.createButton, !canCreate && styles.createButtonDisabled]}
|
|
disabled={!canCreate}
|
|
onPress={() => void handleCreate()}
|
|
>
|
|
{creating ? (
|
|
<ActivityIndicator size="small" color={colors.bgBase} />
|
|
) : (
|
|
<Text style={styles.createText}>
|
|
{sshGate.requiresConnection ? 'Connect Repository' : 'Create Workspace'}
|
|
</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</>
|
|
)}
|
|
</BottomDrawer>
|
|
|
|
{/* Sub-modals for pickers — rendered outside the main modal so they
|
|
layer on top and scroll without touch conflicts. */}
|
|
<PickerListModal
|
|
visible={visible && showRepoPicker}
|
|
title="Repository"
|
|
items={repos.map((r) => ({ id: r.id, label: r.displayName, _repo: r }))}
|
|
selectedId={selectedRepo?.id ?? ''}
|
|
onSelect={(item) => setSelectedRepo((item as { _repo: Repo })._repo)}
|
|
onClose={() => setShowRepoPicker(false)}
|
|
renderIcon={(item) => {
|
|
const repo = (item as { _repo: Repo })._repo
|
|
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(repo) }]} />
|
|
}}
|
|
/>
|
|
|
|
<PickerListModal
|
|
visible={visible && showAgentPicker}
|
|
title="Agent"
|
|
items={pickerAgentOptions}
|
|
selectedId={selectedAgent.id}
|
|
onSelect={(agent) => {
|
|
setAgentOverridden(true)
|
|
setSelectedAgent(agent)
|
|
}}
|
|
onClose={() => setShowAgentPicker(false)}
|
|
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
|
|
/>
|
|
|
|
<BottomDrawer
|
|
visible={visible && setupTrustPrompt != null}
|
|
onClose={() => setSetupTrustPrompt(null)}
|
|
>
|
|
{setupTrustPrompt ? (
|
|
<View>
|
|
<View style={styles.trustHeader}>
|
|
<Text style={styles.title}>
|
|
{setupTrustPrompt.previouslyApproved
|
|
? `${setupTrustPrompt.repoName}'s setup script changed`
|
|
: `Run setup from ${setupTrustPrompt.repoName}?`}
|
|
</Text>
|
|
<Text style={styles.subtitle}>
|
|
This repository's orca.yaml runs before the workspace starts. Only run it if you
|
|
trust this repository.
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={styles.trustScriptBox}>
|
|
<Text style={styles.trustScriptLabel}>
|
|
{setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'}
|
|
</Text>
|
|
<Text style={styles.trustScriptText}>{setupTrustPrompt.scriptContent}</Text>
|
|
</View>
|
|
|
|
<View style={styles.trustActionGroup}>
|
|
<Pressable
|
|
style={styles.trustActionRow}
|
|
disabled={creating}
|
|
onPress={() =>
|
|
void (async () => {
|
|
try {
|
|
await persistSetupHookTrust(
|
|
setupTrustPrompt.repoId,
|
|
setupTrustPrompt.contentHash,
|
|
false
|
|
)
|
|
const approvedHash = setupTrustPrompt.contentHash
|
|
setSetupTrustPrompt(null)
|
|
await handleCreate({
|
|
setupOverride: 'run',
|
|
approvedSetupContentHash: approvedHash
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
|
}
|
|
})()
|
|
}
|
|
>
|
|
<Check size={16} color={colors.textPrimary} />
|
|
<Text style={styles.trustActionText}>Run hooks</Text>
|
|
</Pressable>
|
|
<View style={styles.trustActionSeparator} />
|
|
<Pressable
|
|
style={styles.trustActionRow}
|
|
disabled={creating}
|
|
onPress={() =>
|
|
void (async () => {
|
|
try {
|
|
await persistSetupHookTrust(
|
|
setupTrustPrompt.repoId,
|
|
setupTrustPrompt.contentHash,
|
|
true
|
|
)
|
|
const approvedHash = setupTrustPrompt.contentHash
|
|
setSetupTrustPrompt(null)
|
|
await handleCreate({
|
|
setupOverride: 'run',
|
|
approvedSetupContentHash: approvedHash
|
|
})
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
|
|
}
|
|
})()
|
|
}
|
|
>
|
|
<Check size={16} color={colors.textPrimary} />
|
|
<Text style={styles.trustActionText}>Always trust and run</Text>
|
|
</Pressable>
|
|
<View style={styles.trustActionSeparator} />
|
|
<Pressable
|
|
style={styles.trustActionRow}
|
|
disabled={creating}
|
|
onPress={() => {
|
|
setSetupTrustPrompt(null)
|
|
void handleCreate({ setupOverride: 'skip' })
|
|
}}
|
|
>
|
|
<Text style={styles.trustActionText}>Don't run</Text>
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</BottomDrawer>
|
|
</>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
header: {
|
|
paddingHorizontal: spacing.xs,
|
|
marginBottom: spacing.md
|
|
},
|
|
title: {
|
|
fontSize: 15,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary
|
|
},
|
|
subtitle: {
|
|
fontSize: 13,
|
|
color: colors.textMuted,
|
|
marginTop: 2
|
|
},
|
|
loadingContainer: {
|
|
paddingVertical: spacing.xl,
|
|
alignItems: 'center'
|
|
},
|
|
emptyText: {
|
|
color: colors.textSecondary,
|
|
fontSize: typography.bodySize
|
|
},
|
|
field: {
|
|
marginBottom: spacing.md
|
|
},
|
|
label: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
color: colors.textSecondary,
|
|
marginBottom: spacing.xs
|
|
},
|
|
labelHint: {
|
|
fontWeight: '400',
|
|
color: colors.textMuted
|
|
},
|
|
fieldButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle
|
|
},
|
|
fieldButtonText: {
|
|
flex: 1,
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
fieldButtonPlaceholder: {
|
|
color: colors.textMuted
|
|
},
|
|
repoDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 999
|
|
},
|
|
disabled: {
|
|
opacity: 0.55
|
|
},
|
|
sshBox: {
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: spacing.sm,
|
|
gap: spacing.xs
|
|
},
|
|
sshRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm
|
|
},
|
|
sshDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 999
|
|
},
|
|
sshDotConnected: {
|
|
backgroundColor: colors.statusGreen
|
|
},
|
|
sshDotProgress: {
|
|
backgroundColor: colors.statusAmber
|
|
},
|
|
sshDotDisconnected: {
|
|
backgroundColor: colors.statusRed
|
|
},
|
|
sshCopy: {
|
|
flex: 1,
|
|
minWidth: 0
|
|
},
|
|
sshTitle: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary,
|
|
fontWeight: '600'
|
|
},
|
|
sshSubtitle: {
|
|
fontSize: 12,
|
|
color: colors.textSecondary,
|
|
marginTop: 1
|
|
},
|
|
sshConnectButton: {
|
|
borderRadius: radii.button,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
paddingHorizontal: spacing.sm,
|
|
paddingVertical: spacing.xs
|
|
},
|
|
sshConnectText: {
|
|
color: colors.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: '600'
|
|
},
|
|
errorInline: {
|
|
color: colors.statusRed,
|
|
fontSize: 12
|
|
},
|
|
input: {
|
|
backgroundColor: colors.bgRaised,
|
|
color: colors.textPrimary,
|
|
borderRadius: radii.input,
|
|
paddingHorizontal: spacing.md,
|
|
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
|
|
fontSize: typography.bodySize,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle
|
|
},
|
|
error: {
|
|
color: colors.statusRed,
|
|
fontSize: 13,
|
|
marginBottom: spacing.md
|
|
},
|
|
advancedToggle: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.xs,
|
|
paddingVertical: spacing.sm,
|
|
marginBottom: spacing.xs
|
|
},
|
|
advancedText: {
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '500',
|
|
color: colors.textSecondary
|
|
},
|
|
setupHeader: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: spacing.xs
|
|
},
|
|
sourceBadge: {
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: 4,
|
|
paddingHorizontal: spacing.xs + 2,
|
|
paddingVertical: 2
|
|
},
|
|
sourceBadgeText: {
|
|
fontSize: 10,
|
|
fontWeight: '600',
|
|
color: colors.textMuted,
|
|
letterSpacing: 0.5
|
|
},
|
|
setupBox: {
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
padding: spacing.md
|
|
},
|
|
setupToggleRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: spacing.sm
|
|
},
|
|
setupToggleLabel: {
|
|
fontSize: 13,
|
|
color: colors.textSecondary
|
|
},
|
|
setupChoiceRow: {
|
|
flexDirection: 'row',
|
|
gap: spacing.sm,
|
|
marginBottom: spacing.sm
|
|
},
|
|
setupChoiceButton: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
borderRadius: radii.button,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
setupChoiceButtonSelected: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderColor: colors.textSecondary
|
|
},
|
|
setupChoiceText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
color: colors.textPrimary
|
|
},
|
|
setupSwitch: {
|
|
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
|
|
},
|
|
setupCommandBlock: {
|
|
backgroundColor: colors.bgBase,
|
|
borderRadius: 6,
|
|
paddingHorizontal: spacing.sm + 2,
|
|
paddingVertical: spacing.sm
|
|
},
|
|
setupCommand: {
|
|
fontSize: 13,
|
|
fontFamily: typography.monoFamily,
|
|
color: colors.textPrimary
|
|
},
|
|
trustHeader: {
|
|
paddingHorizontal: spacing.xs,
|
|
marginBottom: spacing.md
|
|
},
|
|
trustScriptBox: {
|
|
backgroundColor: colors.bgRaised,
|
|
borderRadius: radii.input,
|
|
borderWidth: 1,
|
|
borderColor: colors.borderSubtle,
|
|
padding: spacing.md,
|
|
marginBottom: spacing.md
|
|
},
|
|
trustScriptLabel: {
|
|
fontSize: 12,
|
|
fontWeight: '600',
|
|
color: colors.textSecondary,
|
|
marginBottom: spacing.sm
|
|
},
|
|
trustScriptText: {
|
|
fontSize: 13,
|
|
fontFamily: typography.monoFamily,
|
|
color: colors.textPrimary
|
|
},
|
|
trustActionGroup: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: radii.input,
|
|
overflow: 'hidden'
|
|
},
|
|
trustActionRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingVertical: spacing.md,
|
|
paddingHorizontal: spacing.md
|
|
},
|
|
trustActionText: {
|
|
flex: 1,
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary,
|
|
fontWeight: '500'
|
|
},
|
|
trustActionSeparator: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginHorizontal: spacing.md
|
|
},
|
|
actions: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'flex-end',
|
|
marginTop: spacing.sm
|
|
},
|
|
createButton: {
|
|
backgroundColor: colors.textPrimary,
|
|
paddingHorizontal: spacing.lg,
|
|
paddingVertical: spacing.sm,
|
|
borderRadius: radii.button,
|
|
minWidth: 160,
|
|
alignItems: 'center'
|
|
},
|
|
createButtonDisabled: {
|
|
opacity: 0.4
|
|
},
|
|
createText: {
|
|
color: colors.bgBase,
|
|
fontSize: typography.bodySize,
|
|
fontWeight: '600'
|
|
},
|
|
// Picker sub-modal styles
|
|
pickerHeader: {
|
|
paddingHorizontal: spacing.xs,
|
|
paddingBottom: spacing.sm
|
|
},
|
|
pickerTitle: {
|
|
fontSize: 13,
|
|
fontWeight: '500',
|
|
color: colors.textMuted
|
|
},
|
|
pickerGroup: {
|
|
backgroundColor: colors.bgPanel,
|
|
borderRadius: 12,
|
|
overflow: 'hidden'
|
|
},
|
|
pickerSeparator: {
|
|
height: StyleSheet.hairlineWidth,
|
|
backgroundColor: colors.borderSubtle,
|
|
marginHorizontal: spacing.md
|
|
},
|
|
pickerList: {
|
|
flexGrow: 0
|
|
},
|
|
pickerItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: spacing.sm,
|
|
paddingVertical: spacing.md,
|
|
paddingHorizontal: spacing.md + 2
|
|
},
|
|
pickerItemPressed: {
|
|
backgroundColor: colors.bgRaised
|
|
},
|
|
pickerItemText: {
|
|
flex: 1,
|
|
fontSize: typography.bodySize,
|
|
color: colors.textPrimary
|
|
},
|
|
pickerItemTextSelected: {
|
|
fontWeight: '600'
|
|
}
|
|
})
|