diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 27861dc..c82349d 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -166,6 +166,10 @@ export default function SessionsScreen() { const [renaming, setRenaming] = useState(null) const [renameText, setRenameText] = useState("") const renamingInFlight = useRef(false) + // Synchronous re-entrancy guard: `isCreating` state lags by a render, so a + // fast double-tap on the FAB / "Use this folder" would fire two session + // creates before the disabled state lands. This blocks the second call. + const creatingInFlight = useRef(false) const [serverProjects, setServerProjects] = useState([]) const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions() @@ -316,57 +320,67 @@ export default function SessionsScreen() { ) const onCreateSession = async () => { - const session = await createSession() - if (session) { - router.push({ - pathname: `/session/[id]`, - params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, - }) + if (creatingInFlight.current) return + creatingInFlight.current = true + try { + const session = await createSession() + if (session) { + router.push({ + pathname: `/session/[id]`, + params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, + }) + } else { + Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) + } + } finally { + creatingInFlight.current = false } } const onCreateInDirectory = async (dir?: string) => { if (!activeConnection) return - + if (creatingInFlight.current) return + creatingInFlight.current = true setIsCreating(true) - // If a custom directory is specified, use a one-off client for that directory - // so we don't mutate the connection's default project - if (dir && dir.trim()) { - const dirClient = clientForDirectory(dir.trim()) - if (!dirClient) { - setIsCreating(false) + try { + // If a custom directory is specified, use a one-off client for that directory + // so we don't mutate the connection's default project + if (dir && dir.trim()) { + const dirClient = clientForDirectory(dir.trim()) + if (!dirClient) return + try { + const session = await dirClient.session.create({}) + addRecentDirectory(dir.trim()) + setShowNewSession(false) + setCustomDir("") + if (session) { + router.push({ + pathname: `/session/[id]`, + params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, + }) + } + } catch (error) { + console.error("Failed to create session in directory:", error) + Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) + } return } - try { - const session = await dirClient.session.create({}) - addRecentDirectory(dir.trim()) - setIsCreating(false) - setShowNewSession(false) - setCustomDir("") - if (session) { - router.push({ - pathname: `/session/[id]`, - params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, - }) - } - } catch (error) { - console.error("Failed to create session in directory:", error) - Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) - setIsCreating(false) - } - return - } - const session = await createSession() - setIsCreating(false) - setShowNewSession(false) - setCustomDir("") - if (session) { - router.push({ - pathname: `/session/[id]`, - params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, - }) + const session = await createSession() + setShowNewSession(false) + setCustomDir("") + if (session) { + router.push({ + pathname: `/session/[id]`, + params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) }, + }) + } else { + Alert.alert(t("common.error"), t("sessionsList.alerts.createFailedMessage")) + } + } finally { + creatingInFlight.current = false + setIsCreating(false) } } diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 51777af..eeb7ab6 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -258,7 +258,7 @@ "deleteMessage": "Delete \"{{title}}\"?", "deleteFailedTitle": "Delete failed", "deleteFailedMessage": "Could not delete the session. Please try again.", - "createFailedMessage": "Failed to create session in that directory." + "createFailedMessage": "Failed to create session. Check your connection and try again." }, "empty": { "noConnectionTitle": "No Connection", diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 66d1adc..fbc605e 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -258,7 +258,7 @@ "deleteMessage": "删除 \"{{title}}\"?", "deleteFailedTitle": "删除失败", "deleteFailedMessage": "无法删除该会话。请重试。", - "createFailedMessage": "无法在该目录中创建会话。" + "createFailedMessage": "创建会话失败。请检查网络连接后重试。" }, "empty": { "noConnectionTitle": "无连接", diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 5156b1b..ab944fa 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -108,7 +108,11 @@ export const useAuth = create((set, get) => ({ const { settings, hasBiometrics, isAuthenticated } = get() if (!isAuthenticated) return false - if (!settings.requireBiometricForMessages || !hasBiometrics) return true + // The per-message lock is a sub-feature of the app-lock: only enforce it + // when the parent "Require biometric to open" is also on. Otherwise, after + // turning the parent off, the (now-disabled) messages toggle could stay + // stuck ON and keep prompting on every send with no way to clear it. + if (!settings.requireBiometric || !settings.requireBiometricForMessages || !hasBiometrics) return true try { const result = await LocalAuthentication.authenticateAsync({ diff --git a/src/stores/connections.ts b/src/stores/connections.ts index b0a563b..2fb33de 100644 --- a/src/stores/connections.ts +++ b/src/stores/connections.ts @@ -6,6 +6,7 @@ import { createClient, type Client, type Project } from "../lib/sdk" import { addBreadcrumb } from "../lib/sentry" import { AnalyticsEvent, classifyConnectionError, track, type ConnectionTestSource } from "../lib/analytics" import { buildAuth } from "../lib/auth" +import { stripTrailingSlash } from "../lib/path-utils" const CONNECTIONS_KEY = "opencode_connections" const PASSWORDS_PREFIX = "opencode_password_" @@ -345,8 +346,11 @@ export const useConnections = create((set, get) => ({ switchDirectory: async (directory) => { const active = get().activeConnection if (!active) return - // Update connection directory and recreate client - const dir = directory?.trim() || undefined + // Update connection directory and recreate client. Normalize trailing + // slashes so "/home/user" and "/home/user/" don't diverge (recent-dir + // duplicates + a mismatched "current directory" highlight). + const trimmed = directory?.trim() + const dir = trimmed ? stripTrailingSlash(trimmed) : undefined await get().updateConnection(active.id, { directory: dir }) // Record in recents if it's a real directory if (dir) await get().addRecentDirectory(dir) @@ -354,6 +358,9 @@ export const useConnections = create((set, get) => ({ addRecentDirectory: async (directory) => { const current = get().recentDirectories + // Normalize trailing slashes so the same dir entered as ".../x" and + // ".../x/" dedups to one recent-list entry instead of two. + directory = stripTrailingSlash(directory.trim()) // Move to front, dedup, cap at MAX const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS) set({ recentDirectories: updated })