diff --git a/web/src/components/NewSession/ActionButtons.tsx b/web/src/components/NewSession/ActionButtons.tsx index e35daede..37f6ef07 100644 --- a/web/src/components/NewSession/ActionButtons.tsx +++ b/web/src/components/NewSession/ActionButtons.tsx @@ -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')) )} diff --git a/web/src/components/NewSession/DirectorySection.tsx b/web/src/components/NewSession/DirectorySection.tsx index a322d13a..6b2d302b 100644 --- a/web/src/components/NewSession/DirectorySection.tsx +++ b/web/src/components/NewSession/DirectorySection.tsx @@ -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: { )} + + {props.statusMessage ? ( +
+ {props.statusMessage} +
+ ) : null} ) } diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index 407999cf..2323878d 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -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>({}) const [agent, setAgent] = useState(loadPreferredAgent) const [model, setModel] = useState('auto') const [modelReasoningEffort, setModelReasoningEffort] = useState('default') const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode) const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') + const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false) const [error, setError] = useState(null) const worktreeInputRef = useRef(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 => { 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 (
@@ -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} /> diff --git a/web/src/components/SpawnSession.tsx b/web/src/components/SpawnSession.tsx index 2b215a3d..407ed62d 100644 --- a/web/src/components/SpawnSession.tsx +++ b/web/src/components/SpawnSession.tsx @@ -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('simple') const [worktreeName, setWorktreeName] = useState('') + const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false) const [error, setError] = useState(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 ? ( +
+ {directoryStatusMessage} +
+ ) : null} +
diff --git a/web/src/hooks/useMachinePathsExists.ts b/web/src/hooks/useMachinePathsExists.ts new file mode 100644 index 00000000..2ee4781a --- /dev/null +++ b/web/src/hooks/useMachinePathsExists.ts @@ -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 + checkPathsExists: (pathsToCheck: string[]) => Promise> +} { + const [pathExistence, setPathExistence] = useState>({}) + + 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, + } +} diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 5f2b91aa..d957a70f 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index bf511b38..32b203e0 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -122,6 +122,10 @@ export default { 'spawn.cancel': '取消', 'spawn.create': '创建会话', 'spawn.creating': '创建中…', + 'session.directoryMissingSimple': '目录不存在,创建会话时将自动创建。', + 'session.directoryMissingSimpleConfirm': '目录不存在。再次点击按钮将自动新建该目录。', + 'session.directoryMissingWorktree': 'worktree 需要已存在的仓库目录。', + 'session.createAndCreateDirectory': '创建并新建目录', // Machine 'machine.unknown': '未知平台',