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:
engineer
2026-06-08 05:34:12 -07:00
parent 609bd45889
commit 42fa36f3cd
7 changed files with 103 additions and 57 deletions

View File

@@ -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.")
}
}, },
}, },
]) ])

View File

@@ -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, {

View File

@@ -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(

View File

@@ -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.

View File

@@ -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}</>

View File

@@ -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",

View File

@@ -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",