Files
opencode-mobile/app/session/[id].tsx
engineer 42fa36f3cd 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>
2026-06-08 05:34:12 -07:00

825 lines
27 KiB
TypeScript

import { useEffect, useRef, useState, useCallback, useMemo } from "react"
import {
View,
Text,
FlatList,
TextInput,
TouchableOpacity,
StyleSheet,
useColorScheme,
KeyboardAvoidingView,
Platform,
ActivityIndicator,
Alert,
} from "react-native"
import { useLocalSearchParams, Stack, useRouter } from "expo-router"
import { Ionicons } from "@expo/vector-icons"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import * as ImagePicker from "expo-image-picker"
import * as ImageManipulator from "expo-image-manipulator"
import * as Clipboard from "expo-clipboard"
import type BottomSheet from "@gorhom/bottom-sheet"
import {
MessageBubble,
PermissionPrompt,
QuestionPrompt,
StatusIndicator,
SlashPopover,
ModelPicker,
ImageAttachments,
SessionInfo,
type SlashCommand,
type Attachment,
} from "../../src/components/chat"
import { useSessions } from "../../src/stores/sessions"
import { useEvents, refreshPending } from "../../src/stores/events"
import { useConnections } from "../../src/stores/connections"
import { useAuth } from "../../src/stores/auth"
import { useCatalog } from "../../src/stores/catalog"
import { useSpeech } from "../../src/lib/speech"
// --- Builtin slash commands ---
const BUILTIN_COMMANDS: SlashCommand[] = [
{
trigger: "new",
title: "New Session",
description: "Start a new session",
icon: "add-circle-outline",
type: "builtin",
},
{
trigger: "model",
title: "Switch Model",
description: "Choose a different model",
icon: "hardware-chip-outline",
type: "builtin",
},
{
trigger: "agent",
title: "Switch Agent",
description: "Cycle to next agent",
icon: "person-outline",
type: "builtin",
},
]
function getShortDir(dir?: string): string | null {
if (!dir) return null
const parts = dir.split("/").filter(Boolean)
return parts[parts.length - 1] || null
}
export default function SessionScreen() {
const { id, directory } = useLocalSearchParams<{ id: string; directory?: string }>()
const router = useRouter()
const colorScheme = useColorScheme()
const isDark = colorScheme === "dark"
const insets = useSafeAreaInsets()
const flatListRef = useRef<FlatList>(null)
const modelSheetRef = useRef<BottomSheet>(null)
const [input, setInput] = useState("")
const [attachments, setAttachments] = useState<Attachment[]>([])
const [showInfo, setShowInfo] = useState(false)
const {
currentSession,
messages,
parts,
isLoading,
loadingMore,
hasMore,
selectSession,
sendMessage,
abortSession,
loadOlderMessages,
} = useSessions()
// Derive sending state for this specific session
const isSending = useSessions((s) => !!(currentSession && s.sending[currentSession.id]))
const { authenticateForMessage } = useAuth()
const { client, clientForDirectory } = useConnections()
// Use directory-aware client for sessions that belong to a project other than the active one
const sessionClient = useMemo(
() => (currentSession?.directory ? (clientForDirectory(currentSession.directory) ?? client) : client),
[currentSession?.directory, clientForDirectory, client],
)
// Catalog
const catalog = useCatalog()
const agents = Array.isArray(catalog.agents) ? catalog.agents : []
const serverCommands = Array.isArray(catalog.commands) ? catalog.commands : []
const providers = Array.isArray(catalog.providers) ? catalog.providers : []
const agent = catalog.agent || ""
const model = catalog.model
const setModel = catalog.setModel
const cycleAgent = catalog.cycleAgent
// Permission & question state
const sessionID = currentSession?.id
const permissions = useEvents((s) => (sessionID ? s.permissions[sessionID] : undefined)) || []
const questions = useEvents((s) => (sessionID ? s.questions[sessionID] : undefined)) || []
const shortDir = getShortDir(currentSession?.directory)
const [showScrollButton, setShowScrollButton] = useState(false)
// Voice input — transcript appends to the text input on completion
const speech = useSpeech(
useCallback((text: string) => {
setInput((prev) => (prev ? prev + " " + text : text))
}, []),
)
// Slash command state
const slashActive = input.startsWith("/") && !input.includes(" ")
const slashQuery = slashActive ? input.slice(1) : ""
const allCommands = useMemo<SlashCommand[]>(() => {
const custom: SlashCommand[] = serverCommands.map((cmd) => ({
trigger: cmd.name,
title: cmd.name,
description: cmd.description,
icon: "code-slash-outline",
type: "custom",
}))
return [...custom, ...BUILTIN_COMMANDS]
}, [serverCommands])
// Inverted FlatList: data is reversed (newest first) so newest renders at bottom
const messageData = useMemo(
() =>
(messages || [])
.map((msg) => ({
message: msg,
parts: (parts && parts[msg.id]) || [],
}))
.reverse(),
[messages, parts],
)
const scrollToBottom = useCallback((animated = true) => {
flatListRef.current?.scrollToOffset({ offset: 0, animated })
}, [])
useEffect(() => {
if (!id) return
selectSession(id, directory).then(() => {
// Re-fetch pending permissions/questions from the server to recover from
// missed SSE events or failed optimistic removals
const connState = useConnections.getState()
const c = directory ? (connState.clientForDirectory(directory) ?? connState.client) : connState.client
if (c) refreshPending(c, id)
})
}, [id, directory])
// Sync model chip from latest assistant message
useEffect(() => {
if (!messages || messages.length === 0) return
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role === "assistant" && msg.providerID && msg.modelID) {
setModel({ providerID: msg.providerID, modelID: msg.modelID })
return
}
if (msg.role === "user" && msg.model) {
setModel(msg.model)
return
}
}
}, [currentSession?.id, messages?.length])
// Slash command handler
const handleSlashSelect = useCallback(
(cmd: SlashCommand) => {
if (cmd.type === "builtin") {
switch (cmd.trigger) {
case "new":
router.back()
return
case "model":
setInput("")
modelSheetRef.current?.expand()
return
case "agent":
setInput("")
cycleAgent()
return
}
}
setInput(`/${cmd.trigger} `)
},
[router, cycleAgent],
)
// --- Image picking ---
// Convert any image (including HEIC/HEIF from iOS) to guaranteed JPEG bytes
const MAX_DIMENSION = 1568 // Anthropic recommended max
async function toJpeg(uri: string, width: number, height: number): Promise<Attachment> {
const actions: ImageManipulator.Action[] = []
if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
const scale = MAX_DIMENSION / Math.max(width, height)
actions.push({ resize: { width: Math.round(width * scale), height: Math.round(height * scale) } })
}
const result = await ImageManipulator.manipulateAsync(uri, actions, {
format: ImageManipulator.SaveFormat.JPEG,
compress: 0.8,
base64: true,
})
return {
uri: result.uri,
mime: "image/jpeg",
filename: "image.jpg",
width: result.width,
height: result.height,
base64: result.base64 || undefined,
}
}
const pickFromLibrary = useCallback(async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ["images"],
allowsMultipleSelection: true,
quality: 1, // full quality - we compress in manipulator
})
if (result.canceled) return
const items = await Promise.all(result.assets.map((a) => toJpeg(a.uri, a.width, a.height)))
setAttachments((prev) => [...prev, ...items])
}, [])
const pickFromCamera = useCallback(async () => {
const perm = await ImagePicker.requestCameraPermissionsAsync()
if (!perm.granted) {
Alert.alert("Permission needed", "Camera access is required to take photos.")
return
}
const result = await ImagePicker.launchCameraAsync({ quality: 1 })
if (result.canceled) return
const a = result.assets[0]
const item = await toJpeg(a.uri, a.width, a.height)
setAttachments((prev) => [...prev, item])
}, [])
const pasteFromClipboard = useCallback(async () => {
// Try image first
const hasImage = await Clipboard.hasImageAsync()
if (hasImage) {
const img = await Clipboard.getImageAsync({ format: "png" })
if (img?.data) {
const uri = img.data.startsWith("data:") ? img.data : `data:image/png;base64,${img.data}`
setAttachments((prev) => [
...prev,
{
uri,
mime: "image/png",
filename: "clipboard.png",
width: img.size.width,
height: img.size.height,
},
])
return
}
}
// Fall back to text
const hasText = await Clipboard.hasStringAsync()
if (hasText) {
const text = await Clipboard.getStringAsync()
if (text) {
setInput((prev) => prev + text)
return
}
}
Alert.alert("Empty clipboard", "Clipboard does not contain text or an image.")
}, [])
const removeAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index))
}, [])
// --- Send ---
const handleSend = async () => {
if (!input.trim() && attachments.length === 0) return
const authenticated = await authenticateForMessage()
if (!authenticated) {
Alert.alert("Authentication required", "Biometric authentication is required to send. Please try again.")
return
}
const text = input.trim()
const files = [...attachments]
setInput("")
setAttachments([])
// Server slash commands (no attachments for commands)
if (text.startsWith("/") && files.length === 0) {
const [cmdName, ...args] = text.split(" ")
const name = cmdName.slice(1)
const match = serverCommands.find((c) => c.name === name)
if (match && sessionClient && currentSession) {
sessionClient.session
.command(currentSession.id, {
command: name,
arguments: args.join(" "),
agent,
model: model ? `${model.providerID}/${model.modelID}` : undefined,
})
.catch((err) => console.error("Command failed:", err))
return
}
}
// Messages are queued server-side when the session is busy.
// No need to abort - just send and it will be processed after current response.
try {
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.
const handleScroll = useCallback((event: any) => {
const { contentOffset } = event.nativeEvent
setShowScrollButton(contentOffset.y > 200)
}, [])
// Debounce: onEndReached can fire multiple times during a single scroll gesture
const loadingTriggered = useRef(false)
const handleLoadMore = useCallback(() => {
if (hasMore && !loadingMore && !loadingTriggered.current) {
loadingTriggered.current = true
loadOlderMessages()
}
}, [hasMore, loadingMore, loadOlderMessages])
// Reset trigger when loading finishes
useEffect(() => {
if (!loadingMore) loadingTriggered.current = false
}, [loadingMore])
const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => {
if (!sessionClient || !sessionID) return
// Snapshot for rollback
const snapshot = useEvents.getState().permissions[sessionID] || []
// Optimistically remove from UI
useEvents.setState((state) => ({
permissions: {
...state.permissions,
[sessionID]: snapshot.filter((p) => p.id !== requestID),
},
}))
try {
await sessionClient.permission.reply(requestID, reply)
} catch (err) {
console.error("Permission reply failed:", err)
// Restore the prompt so the user can retry
useEvents.setState((state) => ({
permissions: { ...state.permissions, [sessionID]: snapshot },
}))
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
}
}
const handleQuestionReply = async (requestID: string, answers: string[][]) => {
if (!sessionClient || !sessionID) return
const snapshot = useEvents.getState().questions[sessionID] || []
useEvents.setState((state) => ({
questions: {
...state.questions,
[sessionID]: snapshot.filter((q) => q.id !== requestID),
},
}))
try {
await sessionClient.question.reply(requestID, answers)
} catch (err) {
console.error("Question reply failed:", err)
useEvents.setState((state) => ({
questions: { ...state.questions, [sessionID]: snapshot },
}))
Alert.alert("Reply Failed", "Could not send your response. Please try again.")
}
}
const handleQuestionReject = async (requestID: string) => {
if (!sessionClient || !sessionID) return
const snapshot = useEvents.getState().questions[sessionID] || []
useEvents.setState((state) => ({
questions: {
...state.questions,
[sessionID]: snapshot.filter((q) => q.id !== requestID),
},
}))
try {
await sessionClient.question.reject(requestID)
} catch (err) {
console.error("Question reject failed:", err)
useEvents.setState((state) => ({
questions: { ...state.questions, [sessionID]: snapshot },
}))
Alert.alert("Reject Failed", "Could not send your response. Please try again.")
}
}
const handleModelSelect = useCallback(
(providerID: string, modelID: string) => {
setModel({ providerID, modelID })
},
[setModel],
)
// Current agent display
const currentAgent = agents.find((a) => a.name === agent)
const agentColor = currentAgent?.color || "#8b5cf6"
const modelLabel = model?.modelID ? model.modelID.split("/").pop() || model.modelID : "default"
return (
<>
<Stack.Screen
options={{
title: currentSession?.title || "Session",
headerRight: () => (
<View style={s.headerRight}>
{shortDir && (
<View style={[s.dirBadge, isDark && s.dirBadgeDark]}>
<Ionicons name="folder-outline" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[s.dirText, isDark && s.dirTextDark]}>{shortDir}</Text>
</View>
)}
<TouchableOpacity onPress={() => setShowInfo((v) => !v)} hitSlop={8}>
<Ionicons
name={showInfo ? "stats-chart" : "stats-chart-outline"}
size={20}
color={showInfo ? "#3b82f6" : isDark ? "#888888" : "#666666"}
/>
</TouchableOpacity>
</View>
),
}}
/>
<KeyboardAvoidingView
style={[s.container, isDark && s.containerDark]}
behavior={Platform.OS === "ios" ? "padding" : "height"}
keyboardVerticalOffset={Platform.OS === "ios" ? 90 : 0}
>
{/* Session info pulldown */}
<SessionInfo
session={currentSession}
messages={messages || []}
providers={providers}
visible={showInfo}
isDark={isDark}
hasMore={hasMore}
loadingAll={loadingMore}
onLoadAll={() => {
if (hasMore && !loadingMore) loadOlderMessages()
}}
onScrollToTop={() => {
flatListRef.current?.scrollToEnd({ animated: true })
}}
onClose={() => setShowInfo(false)}
/>
{isLoading ? (
<View style={s.loading}>
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
</View>
) : (
<View style={s.listWrap}>
<FlatList
ref={flatListRef}
data={messageData}
inverted
keyExtractor={(item) => item.message.id}
renderItem={({ item }) => <MessageBubble message={item.message} parts={item.parts} isDark={isDark} />}
contentContainerStyle={s.messageList}
onScroll={handleScroll}
scrollEventThrottle={100}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.5}
// Prevent jump when older messages are prepended
maintainVisibleContentPosition={{ minIndexForVisible: 0 }}
ListFooterComponent={
loadingMore ? (
<View style={s.loadingMore}>
<ActivityIndicator size="small" color={isDark ? "#888888" : "#666666"} />
<Text style={[s.loadingMoreText, isDark && s.metaDark]}>Loading older messages...</Text>
</View>
) : null
}
/>
{/* Empty state rendered OUTSIDE the inverted list to avoid the
inverted transform mirroring its text/icon (see #ui-mirror). */}
{messageData.length === 0 && (
<View style={s.emptyOverlay} pointerEvents="none">
<Ionicons name="chatbubble-outline" size={48} color={isDark ? "#444444" : "#cccccc"} />
<Text style={[s.emptyText, isDark && s.metaDark]}>Start a conversation</Text>
<Text style={[s.emptyHint, isDark && s.metaDark]}>Type / for commands</Text>
</View>
)}
{showScrollButton && (
<TouchableOpacity style={[s.scrollBtn, isDark && s.scrollBtnDark]} onPress={() => scrollToBottom(true)}>
<Ionicons name="chevron-down" size={24} color={isDark ? "#ffffff" : "#0a0a0a"} />
</TouchableOpacity>
)}
</View>
)}
{/* Status */}
{currentSession && <StatusIndicator sessionID={currentSession.id} isDark={isDark} />}
{/* Permissions */}
{permissions.map((perm) => (
<PermissionPrompt
key={perm.id}
permission={perm}
isDark={isDark}
onReply={(reply) => handlePermissionReply(perm.id, reply)}
/>
))}
{/* Questions */}
{questions.map((q) => (
<QuestionPrompt
key={q.id}
request={q}
isDark={isDark}
onReply={(answers) => handleQuestionReply(q.id, answers)}
onReject={() => handleQuestionReject(q.id)}
/>
))}
{/* Slash popover */}
{slashActive && (
<SlashPopover query={slashQuery} commands={allCommands} isDark={isDark} onSelect={handleSlashSelect} />
)}
{/* Agent/model toolbar */}
<View style={[s.toolbar, isDark && s.toolbarDark]}>
<TouchableOpacity
style={[s.agentChip, { borderColor: agentColor }]}
onPress={() => cycleAgent()}
onLongPress={() => cycleAgent(-1)}
>
<View style={[s.agentDot, { backgroundColor: agentColor }]} />
<Text style={[s.agentLabel, isDark && s.textWhite]}>{agent || "build"}</Text>
<Ionicons name="swap-horizontal-outline" size={12} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
<TouchableOpacity
style={[s.modelChip, isDark && s.modelChipDark]}
onPress={() => modelSheetRef.current?.expand()}
>
<Ionicons name="hardware-chip-outline" size={14} color={isDark ? "#888888" : "#666666"} />
<Text style={[s.modelLabel, isDark && s.metaDark]} numberOfLines={1}>
{modelLabel}
</Text>
</TouchableOpacity>
</View>
{/* Attachment preview */}
<ImageAttachments attachments={attachments} isDark={isDark} onRemove={removeAttachment} />
{/* Input */}
<View
style={[s.inputContainer, isDark && s.inputContainerDark, { paddingBottom: Math.max(12, insets.bottom) }]}
>
<View style={s.inputRow}>
{/* Attach button */}
<TouchableOpacity style={s.attachBtn} onPress={pickFromLibrary} onLongPress={pickFromCamera}>
<Ionicons name="add-circle-outline" size={26} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
{/* Clipboard paste button */}
<TouchableOpacity style={s.attachBtn} onPress={pasteFromClipboard}>
<Ionicons name="clipboard-outline" size={22} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
<TextInput
style={[s.input, isDark && s.inputDark, speech.listening && s.inputListening]}
placeholder={speech.listening ? "Listening..." : isSending ? "Send a follow-up..." : "Type a message..."}
placeholderTextColor={speech.listening ? "#ef4444" : isDark ? "#666666" : "#999999"}
value={speech.listening ? speech.transcript : input}
onChangeText={speech.listening ? undefined : setInput}
editable={!speech.listening}
multiline
maxLength={10000}
/>
{/* Stop button: only when busy and no input */}
{isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
<TouchableOpacity style={s.stopBtn} onPress={abortSession}>
<Ionicons name="stop" size={20} color="#ffffff" />
</TouchableOpacity>
)}
{/* Mic button: when no input, not sending, and not listening */}
{!isSending && !input.trim() && attachments.length === 0 && !speech.listening && (
<TouchableOpacity style={s.micBtn} onPress={speech.start}>
<Ionicons name="mic" size={22} color={isDark ? "#888888" : "#666666"} />
</TouchableOpacity>
)}
{/* Listening indicator: tap to stop */}
{speech.listening && (
<TouchableOpacity style={s.micBtnActive} onPress={speech.stop}>
<Ionicons name="mic" size={22} color="#ffffff" />
</TouchableOpacity>
)}
{/* Send button: when there's input */}
{!speech.listening && (input.trim() || attachments.length > 0) && (
<TouchableOpacity style={s.sendBtn} onPress={handleSend}>
<Ionicons name="send" size={20} color="#ffffff" />
</TouchableOpacity>
)}
</View>
</View>
</KeyboardAvoidingView>
{/* Model picker bottom sheet */}
<ModelPicker
sheetRef={modelSheetRef}
providers={providers}
selected={model}
isDark={isDark}
onSelect={handleModelSelect}
/>
</>
)
}
const s = StyleSheet.create({
container: { flex: 1, backgroundColor: "#ffffff" },
containerDark: { backgroundColor: "#0a0a0a" },
loading: { flex: 1, justifyContent: "center", alignItems: "center" },
listWrap: { flex: 1, position: "relative" },
// Messages
messageList: { padding: 16, paddingBottom: 8 },
// Scroll button
scrollBtn: {
position: "absolute",
bottom: 16,
right: 16,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: "#ffffff",
justifyContent: "center",
alignItems: "center",
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.2,
shadowRadius: 4,
elevation: 4,
},
scrollBtnDark: { backgroundColor: "#2a2a2a" },
// Loading more (appears at top in inverted list = ListFooterComponent)
loadingMore: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: 8,
paddingVertical: 16,
},
loadingMoreText: { fontSize: 13, color: "#999999" },
// Empty state overlay — sits on top of the (empty) inverted list, untransformed,
// so its text/icon render upright and un-mirrored on Android.
emptyOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
paddingVertical: 64,
},
// Empty
empty: { flex: 1, justifyContent: "center", alignItems: "center", paddingVertical: 64 },
emptyText: { fontSize: 16, color: "#999999", marginTop: 12 },
emptyHint: { fontSize: 13, color: "#bbbbbb", marginTop: 4 },
metaDark: { color: "#666666" },
textWhite: { color: "#ffffff" },
// Toolbar
toolbar: {
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingHorizontal: 12,
paddingVertical: 6,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
backgroundColor: "#ffffff",
},
toolbarDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" },
agentChip: {
flexDirection: "row",
alignItems: "center",
gap: 5,
borderWidth: 1,
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
},
agentDot: { width: 8, height: 8, borderRadius: 4 },
agentLabel: { fontSize: 12, fontWeight: "600", color: "#0a0a0a" },
modelChip: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
borderRadius: 12,
paddingHorizontal: 10,
paddingVertical: 4,
},
modelChipDark: { backgroundColor: "#1a1a1a" },
modelLabel: { fontSize: 12, color: "#666666", maxWidth: 160 },
// Input
inputContainer: {
padding: 12,
borderTopWidth: 1,
borderTopColor: "#e5e5e5",
backgroundColor: "#ffffff",
},
inputContainerDark: { borderTopColor: "#1a1a1a", backgroundColor: "#0a0a0a" },
inputRow: {
flexDirection: "row",
alignItems: "flex-end",
},
attachBtn: {
width: 36,
height: 40,
justifyContent: "center",
alignItems: "center",
},
input: {
flex: 1,
backgroundColor: "#f5f5f5",
borderRadius: 20,
paddingHorizontal: 16,
paddingVertical: 10,
fontSize: 16,
maxHeight: 120,
color: "#0a0a0a",
},
inputDark: { backgroundColor: "#1a1a1a", color: "#ffffff" },
inputListening: { borderWidth: 1, borderColor: "#ef4444" },
sendBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#0a0a0a",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
sendBtnDisabled: { backgroundColor: "#cccccc" },
micBtn: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
micBtnActive: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ef4444",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
stopBtn: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "#ef4444",
justifyContent: "center",
alignItems: "center",
marginLeft: 8,
},
// Header
headerRight: { flexDirection: "row", alignItems: "center", gap: 8 },
dirBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#f5f5f5",
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 6,
},
dirBadgeDark: { backgroundColor: "#1a1a1a" },
dirText: { fontSize: 12, color: "#666666", fontWeight: "500" },
dirTextDark: { color: "#888888" },
})