fix(ux): 10 UI/UX bugs from pre-release audit (toward no-bug launch gate)
Found via parallel screen audit; each confirmed in code: - AuthGate: auto-prompt biometrics on lock (useEffect was imported but unused) - CodeBlock: horizontal scroll for long code lines (were wrapped/mangled) - DiffView: horizontal scroll instead of numberOfLines=1 truncation - chat: biometric-cancel on send shows feedback instead of silently dropping msg - chat: send failure restores input + attachments and alerts - chat: removed dead /compact + /clear builtin commands (advertised, no-op) - sessions: delete + rename failures alert instead of silent; rename guarded against double-submit - sessions: onRefresh spinner no longer hangs forever if a refresh rejects - add/edit connection: validate URL has http(s):// scheme before save/test typecheck clean, 65/65 unit tests pass. Runtime UI behavior still needs on-device verification per the pre-posting test gate (HANDOFF §0b). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,7 @@ export default function SessionsScreen() {
|
|||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
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 { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
|
const { sessions, isLoading, error, loadSessions, createSession, deleteSession } = useSessions()
|
||||||
const {
|
const {
|
||||||
@@ -149,9 +150,14 @@ export default function SessionsScreen() {
|
|||||||
|
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
|
try {
|
||||||
await Promise.all([loadSessions(), refreshProject()])
|
await Promise.all([loadSessions(), refreshProject()])
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Refresh failed:", err)
|
||||||
|
} finally {
|
||||||
setRefreshing(false)
|
setRefreshing(false)
|
||||||
}, [])
|
}
|
||||||
|
}, [loadSessions, refreshProject])
|
||||||
|
|
||||||
const handleRename = useCallback((session: Session) => {
|
const handleRename = useCallback((session: Session) => {
|
||||||
setRenameText(session.title || "")
|
setRenameText(session.title || "")
|
||||||
@@ -160,13 +166,21 @@ export default function SessionsScreen() {
|
|||||||
|
|
||||||
const submitRename = useCallback(async () => {
|
const submitRename = useCallback(async () => {
|
||||||
const title = renameText.trim()
|
const title = renameText.trim()
|
||||||
if (!title || !renaming) return
|
if (!title || !renaming || renamingInFlight.current) return
|
||||||
const renameClient = renaming.directory ? (clientForDirectory(renaming.directory) ?? client) : client
|
const renameClient = renaming.directory ? (clientForDirectory(renaming.directory) ?? client) : client
|
||||||
if (!renameClient) return
|
if (!renameClient) return
|
||||||
|
renamingInFlight.current = true
|
||||||
|
try {
|
||||||
await renameClient.session.update(renaming.id, { title })
|
await renameClient.session.update(renaming.id, { title })
|
||||||
setRenaming(null)
|
setRenaming(null)
|
||||||
setRenameText("")
|
setRenameText("")
|
||||||
loadSessions()
|
loadSessions()
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Rename failed:", err)
|
||||||
|
Alert.alert("Rename failed", "Could not rename the session. Please try again.")
|
||||||
|
} finally {
|
||||||
|
renamingInFlight.current = false
|
||||||
|
}
|
||||||
}, [renaming, renameText, client, clientForDirectory, loadSessions])
|
}, [renaming, renameText, client, clientForDirectory, loadSessions])
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -177,7 +191,12 @@ export default function SessionsScreen() {
|
|||||||
text: "Delete",
|
text: "Delete",
|
||||||
style: "destructive",
|
style: "destructive",
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
|
try {
|
||||||
await deleteSession(session.id)
|
await deleteSession(session.id)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete failed:", err)
|
||||||
|
Alert.alert("Delete failed", "Could not delete the session. Please try again.")
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useConnections } from "../../src/stores/connections"
|
|||||||
import type { ConnectionType } from "../../src/lib/types"
|
import type { ConnectionType } from "../../src/lib/types"
|
||||||
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||||
import { captureDiagnostic } from "../../src/lib/sentry"
|
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||||
|
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||||
|
|
||||||
const CONNECTION_TYPES: Array<{
|
const CONNECTION_TYPES: Array<{
|
||||||
type: ConnectionType
|
type: ConnectionType
|
||||||
@@ -68,6 +69,10 @@ export default function EditConnectionScreen() {
|
|||||||
Alert.alert("Error", "Please enter a server URL")
|
Alert.alert("Error", "Please enter a server URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!parseUrl(url).valid) {
|
||||||
|
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsTesting(true)
|
setIsTesting(true)
|
||||||
const result = await testConnection(
|
const result = await testConnection(
|
||||||
@@ -111,6 +116,10 @@ export default function EditConnectionScreen() {
|
|||||||
Alert.alert("Error", "Please enter a server URL")
|
Alert.alert("Error", "Please enter a server URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!parseUrl(url).valid) {
|
||||||
|
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
await updateConnection(connection.id, {
|
await updateConnection(connection.id, {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { useConnections } from "../../src/stores/connections"
|
|||||||
import type { ConnectionType } from "../../src/lib/types"
|
import type { ConnectionType } from "../../src/lib/types"
|
||||||
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
import { probeConnection, shareReport } from "../../src/lib/diagnostics"
|
||||||
import { captureDiagnostic } from "../../src/lib/sentry"
|
import { captureDiagnostic } from "../../src/lib/sentry"
|
||||||
|
import { parseUrl } from "../../src/lib/diagnostics-classify"
|
||||||
|
|
||||||
export default function AddConnectionScreen() {
|
export default function AddConnectionScreen() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
@@ -121,6 +122,10 @@ export default function AddConnectionScreen() {
|
|||||||
Alert.alert("Error", "Please enter a server URL")
|
Alert.alert("Error", "Please enter a server URL")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (!parseUrl(url).valid) {
|
||||||
|
Alert.alert("Invalid URL", "Enter a full URL including http:// or https://, e.g. http://192.168.1.100:4096")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsConnecting(true)
|
setIsConnecting(true)
|
||||||
await addConnection(
|
await addConnection(
|
||||||
|
|||||||
@@ -61,14 +61,6 @@ const BUILTIN_COMMANDS: SlashCommand[] = [
|
|||||||
icon: "person-outline",
|
icon: "person-outline",
|
||||||
type: "builtin",
|
type: "builtin",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
trigger: "compact",
|
|
||||||
title: "Compact",
|
|
||||||
description: "Summarize conversation",
|
|
||||||
icon: "contract-outline",
|
|
||||||
type: "builtin",
|
|
||||||
},
|
|
||||||
{ trigger: "clear", title: "Clear", description: "Clear the session", icon: "trash-outline", type: "builtin" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
function getShortDir(dir?: string): string | null {
|
function getShortDir(dir?: string): string | null {
|
||||||
@@ -214,12 +206,6 @@ export default function SessionScreen() {
|
|||||||
setInput("")
|
setInput("")
|
||||||
cycleAgent()
|
cycleAgent()
|
||||||
return
|
return
|
||||||
case "compact":
|
|
||||||
setInput("")
|
|
||||||
return
|
|
||||||
case "clear":
|
|
||||||
setInput("")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setInput(`/${cmd.trigger} `)
|
setInput(`/${cmd.trigger} `)
|
||||||
@@ -316,7 +302,10 @@ export default function SessionScreen() {
|
|||||||
const handleSend = async () => {
|
const handleSend = async () => {
|
||||||
if (!input.trim() && attachments.length === 0) return
|
if (!input.trim() && attachments.length === 0) return
|
||||||
const authenticated = await authenticateForMessage()
|
const authenticated = await authenticateForMessage()
|
||||||
if (!authenticated) return
|
if (!authenticated) {
|
||||||
|
Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
const files = [...attachments]
|
const files = [...attachments]
|
||||||
@@ -343,7 +332,15 @@ export default function SessionScreen() {
|
|||||||
|
|
||||||
// Messages are queued server-side when the session is busy.
|
// Messages are queued server-side when the session is busy.
|
||||||
// No need to abort - just send and it will be processed after current response.
|
// No need to abort - just send and it will be processed after current response.
|
||||||
|
try {
|
||||||
await sendMessage(text, model || undefined, agent || undefined, files)
|
await sendMessage(text, model || undefined, agent || undefined, files)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Send failed:", err)
|
||||||
|
// Restore the user's text and attachments so their input isn't lost.
|
||||||
|
setInput((prev) => (prev ? prev : text))
|
||||||
|
setAttachments((prev) => (prev.length ? prev : files))
|
||||||
|
Alert.alert("Message not sent", "Could not send your message. Check your connection and try again.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom.
|
// In inverted mode, offset 0 = bottom. Show scroll button when scrolled away from bottom.
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export function AuthGate({ children }: Props) {
|
|||||||
|
|
||||||
const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth()
|
const { isAuthenticated, settings, hasBiometrics, biometricType, authenticate, error } = useAuth()
|
||||||
|
|
||||||
|
// Auto-prompt the OS biometric dialog once when the lock screen appears, so users
|
||||||
|
// aren't forced to tap "Unlock" on every cold start. If they cancel/fail, the
|
||||||
|
// manual "Unlock" button below is the fallback.
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.requireBiometric && hasBiometrics && !isAuthenticated) {
|
||||||
|
authenticate()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [])
|
||||||
|
|
||||||
// If biometric not required, or no biometrics available, show children
|
// If biometric not required, or no biometrics available, show children
|
||||||
if (!settings.requireBiometric || !hasBiometrics) {
|
if (!settings.requireBiometric || !hasBiometrics) {
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, Text, StyleSheet, Platform } from "react-native"
|
import { View, Text, StyleSheet, Platform, ScrollView } from "react-native"
|
||||||
|
|
||||||
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
|
const mono = Platform.OS === "ios" ? "Menlo" : "monospace"
|
||||||
|
|
||||||
@@ -76,6 +76,8 @@ export function DiffView({ before, after, isDark }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[s.container, isDark && s.containerDark]}>
|
<View style={[s.container, isDark && s.containerDark]}>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator>
|
||||||
|
<View>
|
||||||
{lines.map((line, idx) => (
|
{lines.map((line, idx) => (
|
||||||
<View
|
<View
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -96,13 +98,14 @@ export function DiffView({ before, after, isDark }: Props) {
|
|||||||
line.type === "remove" && s.removeText,
|
line.type === "remove" && s.removeText,
|
||||||
]}
|
]}
|
||||||
selectable
|
selectable
|
||||||
numberOfLines={1}
|
|
||||||
>
|
>
|
||||||
{line.text}
|
{line.text}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +138,6 @@ const s = StyleSheet.create({
|
|||||||
prefixDark: { color: "#666666" },
|
prefixDark: { color: "#666666" },
|
||||||
|
|
||||||
text: {
|
text: {
|
||||||
flex: 1,
|
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontFamily: mono,
|
fontFamily: mono,
|
||||||
color: "#0a0a0a",
|
color: "#0a0a0a",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform } from "react-native"
|
import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform, ScrollView } from "react-native"
|
||||||
import * as Clipboard from "expo-clipboard"
|
import * as Clipboard from "expo-clipboard"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -27,9 +27,11 @@ export function CodeBlock({ code, language }: Props) {
|
|||||||
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? "Copied!" : "Copy"}</Text>
|
<Text style={[styles.copyBtn, isDark && styles.copyBtnDark]}>{copied ? "Copied!" : "Copy"}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
<ScrollView horizontal showsHorizontalScrollIndicator contentContainerStyle={styles.codeScroll}>
|
||||||
<Text style={[styles.code, isDark && styles.codeDark]} selectable>
|
<Text style={[styles.code, isDark && styles.codeDark]} selectable>
|
||||||
{code}
|
{code}
|
||||||
</Text>
|
</Text>
|
||||||
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -72,12 +74,14 @@ const styles = StyleSheet.create({
|
|||||||
copyBtnDark: {
|
copyBtnDark: {
|
||||||
color: "#a78bfa",
|
color: "#a78bfa",
|
||||||
},
|
},
|
||||||
|
codeScroll: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
code: {
|
code: {
|
||||||
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
color: "#1a1a1a",
|
color: "#1a1a1a",
|
||||||
padding: 12,
|
|
||||||
},
|
},
|
||||||
codeDark: {
|
codeDark: {
|
||||||
color: "#e5e5e5",
|
color: "#e5e5e5",
|
||||||
|
|||||||
Reference in New Issue
Block a user