mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): warn before creating missing session directories (#317)
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -122,6 +122,10 @@ export default {
|
||||
'spawn.cancel': '取消',
|
||||
'spawn.create': '创建会话',
|
||||
'spawn.creating': '创建中…',
|
||||
'session.directoryMissingSimple': '目录不存在,创建会话时将自动创建。',
|
||||
'session.directoryMissingSimpleConfirm': '目录不存在。再次点击按钮将自动新建该目录。',
|
||||
'session.directoryMissingWorktree': 'worktree 需要已存在的仓库目录。',
|
||||
'session.createAndCreateDirectory': '创建并新建目录',
|
||||
|
||||
// Machine
|
||||
'machine.unknown': '未知平台',
|
||||
|
||||
Reference in New Issue
Block a user