fix(web): warn before creating missing session directories (#317)

This commit is contained in:
ROOOO
2026-03-19 15:11:54 +08:00
committed by GitHub
parent ecc7236692
commit eb18530fed
7 changed files with 198 additions and 37 deletions
@@ -6,6 +6,7 @@ export function ActionButtons(props: {
isPending: boolean
canCreate: boolean
isDisabled: boolean
createLabel?: string
onCancel: () => void
onCreate: () => void
}) {
@@ -32,7 +33,7 @@ export function ActionButtons(props: {
{t('newSession.creating')}
</>
) : (
t('newSession.create')
(props.createLabel ?? t('newSession.create'))
)}
</Button>
</div>
@@ -10,6 +10,8 @@ export function DirectorySection(props: {
selectedIndex: number
isDisabled: boolean
recentPaths: string[]
statusMessage?: string | null
statusTone?: 'warning' | 'error' | null
onDirectoryChange: (value: string) => void
onDirectoryFocus: () => void
onDirectoryBlur: () => void
@@ -68,6 +70,18 @@ export function DirectorySection(props: {
</div>
</div>
)}
{props.statusMessage ? (
<div
className={`mt-1 rounded-md px-2 py-1 text-xs ${
props.statusTone === 'error'
? 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400'
: 'bg-amber-500/10 text-[var(--app-hint)]'
}`}
>
{props.statusMessage}
</div>
) : null}
</div>
)
}
+55 -30
View File
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import type { ApiClient } from '@/api/client'
import type { Machine } from '@/types/api'
import { usePlatform } from '@/hooks/usePlatform'
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
import { useSessions } from '@/hooks/queries/useSessions'
import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions'
import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions'
import { useRecentPaths } from '@/hooks/useRecentPaths'
import { useTranslation } from '@/lib/use-translation'
import type { AgentType, CodexReasoningEffort, SessionType } from './types'
import { ActionButtons } from './ActionButtons'
import { AgentSelector } from './AgentSelector'
@@ -32,6 +34,7 @@ export function NewSession(props: {
onCancel: () => void
}) {
const { haptic } = usePlatform()
const { t } = useTranslation()
const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api)
const { sessions } = useSessions(props.api)
const isFormDisabled = Boolean(isPending || props.isLoading)
@@ -41,13 +44,13 @@ export function NewSession(props: {
const [directory, setDirectory] = useState('')
const [suppressSuggestions, setSuppressSuggestions] = useState(false)
const [isDirectoryFocused, setIsDirectoryFocused] = useState(false)
const [pathExistence, setPathExistence] = useState<Record<string, boolean>>({})
const [agent, setAgent] = useState<AgentType>(loadPreferredAgent)
const [model, setModel] = useState('auto')
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode)
const [sessionType, setSessionType] = useState<SessionType>('simple')
const [worktreeName, setWorktreeName] = useState('')
const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false)
const [error, setError] = useState<string | null>(null)
const worktreeInputRef = useRef<HTMLInputElement>(null)
@@ -99,41 +102,46 @@ export function NewSession(props: {
[getRecentPaths, machineId]
)
const trimmedDirectory = directory.trim()
const deferredDirectory = useDeferredValue(trimmedDirectory)
const allPaths = useDirectorySuggestions(machineId, sessions, recentPaths)
const pathsToCheck = useMemo(
() => Array.from(new Set(allPaths)).slice(0, 1000),
[allPaths]
() => Array.from(new Set([
...(deferredDirectory ? [deferredDirectory] : []),
...allPaths
])).slice(0, 1000),
[allPaths, deferredDirectory]
)
useEffect(() => {
let cancelled = false
if (!machineId || pathsToCheck.length === 0) {
setPathExistence({})
return () => { cancelled = true }
}
void props.api.checkMachinePathsExists(machineId, pathsToCheck)
.then((result) => {
if (cancelled) return
setPathExistence(result.exists ?? {})
})
.catch(() => {
if (cancelled) return
setPathExistence({})
})
return () => {
cancelled = true
}
}, [machineId, pathsToCheck, props.api])
const { pathExistence, checkPathsExists } = useMachinePathsExists(props.api, machineId, pathsToCheck)
const verifiedPaths = useMemo(
() => allPaths.filter((path) => pathExistence[path]),
[allPaths, pathExistence]
)
const currentDirectoryExists = trimmedDirectory ? pathExistence[trimmedDirectory] : undefined
const needsDirectoryCreationWarning = sessionType === 'simple' && trimmedDirectory !== '' && currentDirectoryExists === false
const missingWorktreeDirectory = sessionType === 'worktree' && trimmedDirectory !== '' && currentDirectoryExists === false
const directoryStatusMessage = missingWorktreeDirectory
? t('session.directoryMissingWorktree')
: needsDirectoryCreationWarning
? (
directoryCreationConfirmed
? t('session.directoryMissingSimpleConfirm')
: t('session.directoryMissingSimple')
)
: null
const directoryStatusTone = missingWorktreeDirectory ? 'error' : needsDirectoryCreationWarning ? 'warning' : null
const createLabel = needsDirectoryCreationWarning && directoryCreationConfirmed
? t('session.createAndCreateDirectory')
: undefined
useEffect(() => {
setDirectoryCreationConfirmed(false)
}, [machineId, sessionType, trimmedDirectory])
const getSuggestions = useCallback(async (query: string): Promise<Suggestion[]> => {
const lowered = query.toLowerCase()
return verifiedPaths
@@ -217,17 +225,31 @@ export function NewSession(props: {
}, [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect])
async function handleCreate() {
if (!machineId || !directory.trim()) return
if (!machineId || !trimmedDirectory) return
setError(null)
try {
const existsResult = await checkPathsExists([trimmedDirectory])
const directoryExists = existsResult[trimmedDirectory]
if (sessionType === 'worktree' && directoryExists === false) {
haptic.notification('error')
setError(t('session.directoryMissingWorktree'))
return
}
if (sessionType === 'simple' && directoryExists === false && !directoryCreationConfirmed) {
setDirectoryCreationConfirmed(true)
return
}
const resolvedModel = model !== 'auto' && agent !== 'opencode' ? model : undefined
const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default'
? modelReasoningEffort
: undefined
const result = await spawnSession({
machineId,
directory: directory.trim(),
directory: trimmedDirectory,
agent,
model: resolvedModel,
modelReasoningEffort: resolvedModelReasoningEffort,
@@ -239,7 +261,7 @@ export function NewSession(props: {
if (result.type === 'success') {
haptic.notification('success')
setLastUsedMachineId(machineId)
addRecentPath(machineId, directory.trim())
addRecentPath(machineId, trimmedDirectory)
props.onSuccess(result.sessionId)
return
}
@@ -252,7 +274,7 @@ export function NewSession(props: {
}
}
const canCreate = Boolean(machineId && directory.trim() && !isFormDisabled)
const canCreate = Boolean(machineId && trimmedDirectory && !isFormDisabled && !missingWorktreeDirectory)
return (
<div className="flex flex-col divide-y divide-[var(--app-divider)]">
@@ -274,6 +296,8 @@ export function NewSession(props: {
selectedIndex={selectedIndex}
isDisabled={isFormDisabled}
recentPaths={recentPaths}
statusMessage={directoryStatusMessage}
statusTone={directoryStatusTone}
onDirectoryChange={handleDirectoryChange}
onDirectoryFocus={handleDirectoryFocus}
onDirectoryBlur={handleDirectoryBlur}
@@ -322,6 +346,7 @@ export function NewSession(props: {
isPending={isPending}
canCreate={canCreate}
isDisabled={isFormDisabled}
createLabel={createLabel}
onCancel={props.onCancel}
onCreate={handleCreate}
/>
+61 -6
View File
@@ -1,8 +1,9 @@
import { useMemo, useState } from 'react'
import { useDeferredValue, useEffect, useMemo, useState } from 'react'
import type { ApiClient } from '@/api/client'
import type { Machine } from '@/types/api'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
import { usePlatform } from '@/hooks/usePlatform'
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
import { formatRunnerSpawnError } from '@/utils/formatRunnerSpawnError'
@@ -29,6 +30,7 @@ export function SpawnSession(props: {
const [directory, setDirectory] = useState('')
const [sessionType, setSessionType] = useState<SessionType>('simple')
const [worktreeName, setWorktreeName] = useState('')
const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false)
const [error, setError] = useState<string | null>(null)
const { spawnSession, isPending, error: spawnError } = useSpawnSession(props.api)
@@ -37,16 +39,59 @@ export function SpawnSession(props: {
() => formatRunnerSpawnError(props.machine),
[props.machine?.runnerState?.lastSpawnError]
)
const trimmedDirectory = directory.trim()
const deferredDirectory = useDeferredValue(trimmedDirectory)
const pathsToCheck = useMemo(
() => deferredDirectory ? [deferredDirectory] : [],
[deferredDirectory]
)
const { pathExistence, checkPathsExists } = useMachinePathsExists(
props.api,
props.machineId,
pathsToCheck
)
const currentDirectoryExists = trimmedDirectory ? pathExistence[trimmedDirectory] : undefined
const needsDirectoryCreationWarning = sessionType === 'simple' && trimmedDirectory !== '' && currentDirectoryExists === false
const missingWorktreeDirectory = sessionType === 'worktree' && trimmedDirectory !== '' && currentDirectoryExists === false
const directoryStatusMessage = missingWorktreeDirectory
? t('session.directoryMissingWorktree')
: needsDirectoryCreationWarning
? (
directoryCreationConfirmed
? t('session.directoryMissingSimpleConfirm')
: t('session.directoryMissingSimple')
)
: null
const createLabel = needsDirectoryCreationWarning && directoryCreationConfirmed
? t('session.createAndCreateDirectory')
: t('spawn.create')
useEffect(() => {
setDirectoryCreationConfirmed(false)
}, [props.machineId, sessionType, trimmedDirectory])
async function spawn() {
const trimmed = directory.trim()
if (!trimmed) return
if (!trimmedDirectory) return
setError(null)
try {
const existsResult = await checkPathsExists([trimmedDirectory])
const directoryExists = existsResult[trimmedDirectory]
if (sessionType === 'worktree' && directoryExists === false) {
haptic.notification('error')
setError(t('session.directoryMissingWorktree'))
return
}
if (sessionType === 'simple' && directoryExists === false && !directoryCreationConfirmed) {
setDirectoryCreationConfirmed(true)
return
}
const result = await spawnSession({
machineId: props.machineId,
directory: trimmed,
directory: trimmedDirectory,
sessionType,
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
})
@@ -82,6 +127,16 @@ export function SpawnSession(props: {
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]"
/>
{directoryStatusMessage ? (
<div className={`rounded-md px-2 py-1 text-xs ${
missingWorktreeDirectory
? 'bg-red-50 text-red-600 dark:bg-red-900/20 dark:text-red-400'
: 'bg-amber-500/10 text-[var(--app-hint)]'
}`}>
{directoryStatusMessage}
</div>
) : null}
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-[var(--app-hint)]">
Session type
@@ -171,9 +226,9 @@ export function SpawnSession(props: {
</Button>
<Button
onClick={spawn}
disabled={isPending || !directory.trim()}
disabled={isPending || !trimmedDirectory || missingWorktreeDirectory}
>
{isPending ? t('spawn.creating') : t('spawn.create')}
{isPending ? t('spawn.creating') : createLabel}
</Button>
</div>
</div>
+58
View File
@@ -0,0 +1,58 @@
import { useCallback, useEffect, useState } from 'react'
import type { ApiClient } from '@/api/client'
export function useMachinePathsExists(
api: ApiClient,
machineId: string | null,
paths: string[]
): {
pathExistence: Record<string, boolean>
checkPathsExists: (pathsToCheck: string[]) => Promise<Record<string, boolean>>
} {
const [pathExistence, setPathExistence] = useState<Record<string, boolean>>({})
useEffect(() => {
setPathExistence({})
}, [machineId])
useEffect(() => {
let cancelled = false
if (!machineId || paths.length === 0) {
setPathExistence({})
return () => {
cancelled = true
}
}
void api.checkMachinePathsExists(machineId, paths)
.then((result) => {
if (cancelled) return
setPathExistence(result.exists ?? {})
})
.catch(() => {
if (cancelled) return
setPathExistence({})
})
return () => {
cancelled = true
}
}, [api, machineId, paths])
const checkPathsExists = useCallback(async (pathsToCheck: string[]) => {
if (!machineId || pathsToCheck.length === 0) {
return {}
}
const result = await api.checkMachinePathsExists(machineId, pathsToCheck)
const exists = result.exists ?? {}
setPathExistence((current) => ({ ...current, ...exists }))
return exists
}, [api, machineId])
return {
pathExistence,
checkPathsExists,
}
}
+4
View File
@@ -120,6 +120,10 @@ export default {
'spawn.cancel': 'Cancel',
'spawn.create': 'Create Session',
'spawn.creating': 'Creating…',
'session.directoryMissingSimple': 'Directory does not exist. Creating the session will create it automatically.',
'session.directoryMissingSimpleConfirm': 'Directory does not exist. Click again to create it automatically.',
'session.directoryMissingWorktree': 'Worktree sessions require an existing repository directory.',
'session.createAndCreateDirectory': 'Create and make directory',
// Machine
'machine.unknown': 'Unknown platform',
+4
View File
@@ -122,6 +122,10 @@ export default {
'spawn.cancel': '取消',
'spawn.create': '创建会话',
'spawn.creating': '创建中…',
'session.directoryMissingSimple': '目录不存在,创建会话时将自动创建。',
'session.directoryMissingSimpleConfirm': '目录不存在。再次点击按钮将自动新建该目录。',
'session.directoryMissingWorktree': 'worktree 需要已存在的仓库目录。',
'session.createAndCreateDirectory': '创建并新建目录',
// Machine
'machine.unknown': '未知平台',