fix: session-create + settings edge cases (double-tap, biometric stuck, recent-dir dupes) (#133)
Four bugs from a review of session creation, the sessions list, and settings
(lower-severity than the core-path hunts — the core is now well-hardened):
1. Double-tap on the new-session FAB / 'Use this folder' created duplicate
sessions (isCreating state lags a render). Added a synchronous re-entrancy
ref guard.
2. 'Require biometric for messages' got stuck ON and enforced with no UI escape
after turning off the parent 'Require biometric to open' toggle (the child
switch is then disabled). authenticateForMessage now also gates on the parent.
3. Session-create failure on the default path silently closed the modal with no
feedback (only the dir path alerted). Both paths now alert; message made generic.
4. Recent-directories got duplicate entries ('/x' vs '/x/') and a mismatched
'current directory' highlight. switchDirectory/addRecentDirectory now
stripTrailingSlash.
typecheck clean, 199 tests, i18n parity.
Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6
Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -166,6 +166,10 @@ export default function SessionsScreen() {
|
|||||||
const [renaming, setRenaming] = useState<Session | null>(null)
|
const [renaming, setRenaming] = useState<Session | null>(null)
|
||||||
const [renameText, setRenameText] = useState("")
|
const [renameText, setRenameText] = useState("")
|
||||||
const renamingInFlight = useRef(false)
|
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<Project[]>([])
|
const [serverProjects, setServerProjects] = useState<Project[]>([])
|
||||||
|
|
||||||
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
|
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
|
||||||
@@ -316,57 +320,67 @@ export default function SessionsScreen() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const onCreateSession = async () => {
|
const onCreateSession = async () => {
|
||||||
const session = await createSession()
|
if (creatingInFlight.current) return
|
||||||
if (session) {
|
creatingInFlight.current = true
|
||||||
router.push({
|
try {
|
||||||
pathname: `/session/[id]`,
|
const session = await createSession()
|
||||||
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
|
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) => {
|
const onCreateInDirectory = async (dir?: string) => {
|
||||||
if (!activeConnection) return
|
if (!activeConnection) return
|
||||||
|
if (creatingInFlight.current) return
|
||||||
|
creatingInFlight.current = true
|
||||||
setIsCreating(true)
|
setIsCreating(true)
|
||||||
|
|
||||||
// If a custom directory is specified, use a one-off client for that directory
|
try {
|
||||||
// so we don't mutate the connection's default project
|
// If a custom directory is specified, use a one-off client for that directory
|
||||||
if (dir && dir.trim()) {
|
// so we don't mutate the connection's default project
|
||||||
const dirClient = clientForDirectory(dir.trim())
|
if (dir && dir.trim()) {
|
||||||
if (!dirClient) {
|
const dirClient = clientForDirectory(dir.trim())
|
||||||
setIsCreating(false)
|
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
|
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()
|
const session = await createSession()
|
||||||
setIsCreating(false)
|
setShowNewSession(false)
|
||||||
setShowNewSession(false)
|
setCustomDir("")
|
||||||
setCustomDir("")
|
if (session) {
|
||||||
if (session) {
|
router.push({
|
||||||
router.push({
|
pathname: `/session/[id]`,
|
||||||
pathname: `/session/[id]`,
|
params: { id: session.id, ...(session.directory ? { directory: session.directory } : {}) },
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -258,7 +258,7 @@
|
|||||||
"deleteMessage": "Delete \"{{title}}\"?",
|
"deleteMessage": "Delete \"{{title}}\"?",
|
||||||
"deleteFailedTitle": "Delete failed",
|
"deleteFailedTitle": "Delete failed",
|
||||||
"deleteFailedMessage": "Could not delete the session. Please try again.",
|
"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": {
|
"empty": {
|
||||||
"noConnectionTitle": "No Connection",
|
"noConnectionTitle": "No Connection",
|
||||||
|
|||||||
@@ -258,7 +258,7 @@
|
|||||||
"deleteMessage": "删除 \"{{title}}\"?",
|
"deleteMessage": "删除 \"{{title}}\"?",
|
||||||
"deleteFailedTitle": "删除失败",
|
"deleteFailedTitle": "删除失败",
|
||||||
"deleteFailedMessage": "无法删除该会话。请重试。",
|
"deleteFailedMessage": "无法删除该会话。请重试。",
|
||||||
"createFailedMessage": "无法在该目录中创建会话。"
|
"createFailedMessage": "创建会话失败。请检查网络连接后重试。"
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noConnectionTitle": "无连接",
|
"noConnectionTitle": "无连接",
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ export const useAuth = create<AuthState>((set, get) => ({
|
|||||||
const { settings, hasBiometrics, isAuthenticated } = get()
|
const { settings, hasBiometrics, isAuthenticated } = get()
|
||||||
|
|
||||||
if (!isAuthenticated) return false
|
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 {
|
try {
|
||||||
const result = await LocalAuthentication.authenticateAsync({
|
const result = await LocalAuthentication.authenticateAsync({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createClient, type Client, type Project } from "../lib/sdk"
|
|||||||
import { addBreadcrumb } from "../lib/sentry"
|
import { addBreadcrumb } from "../lib/sentry"
|
||||||
import { AnalyticsEvent, classifyConnectionError, track, type ConnectionTestSource } from "../lib/analytics"
|
import { AnalyticsEvent, classifyConnectionError, track, type ConnectionTestSource } from "../lib/analytics"
|
||||||
import { buildAuth } from "../lib/auth"
|
import { buildAuth } from "../lib/auth"
|
||||||
|
import { stripTrailingSlash } from "../lib/path-utils"
|
||||||
|
|
||||||
const CONNECTIONS_KEY = "opencode_connections"
|
const CONNECTIONS_KEY = "opencode_connections"
|
||||||
const PASSWORDS_PREFIX = "opencode_password_"
|
const PASSWORDS_PREFIX = "opencode_password_"
|
||||||
@@ -345,8 +346,11 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
switchDirectory: async (directory) => {
|
switchDirectory: async (directory) => {
|
||||||
const active = get().activeConnection
|
const active = get().activeConnection
|
||||||
if (!active) return
|
if (!active) return
|
||||||
// Update connection directory and recreate client
|
// Update connection directory and recreate client. Normalize trailing
|
||||||
const dir = directory?.trim() || undefined
|
// 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 })
|
await get().updateConnection(active.id, { directory: dir })
|
||||||
// Record in recents if it's a real directory
|
// Record in recents if it's a real directory
|
||||||
if (dir) await get().addRecentDirectory(dir)
|
if (dir) await get().addRecentDirectory(dir)
|
||||||
@@ -354,6 +358,9 @@ export const useConnections = create<ConnectionsState>((set, get) => ({
|
|||||||
|
|
||||||
addRecentDirectory: async (directory) => {
|
addRecentDirectory: async (directory) => {
|
||||||
const current = get().recentDirectories
|
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
|
// Move to front, dedup, cap at MAX
|
||||||
const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS)
|
const updated = [directory, ...current.filter((d) => d !== directory)].slice(0, MAX_RECENT_DIRS)
|
||||||
set({ recentDirectories: updated })
|
set({ recentDirectories: updated })
|
||||||
|
|||||||
Reference in New Issue
Block a user