Files
opencode-mobile/app/session/[id].tsx
Dennis V 5427abdb5e feat(ux): SSE disconnect/reconnect banner in session view (#42)
Shows amber 'Reconnecting… (attempt N)' banner when SSE is down.
Shows brief green 'Connected ✓' flash on reconnect (useRef transition
to avoid atomic state reset bug where lastDisconnectAt resets with
reconnectAttempts in the same set() call).

Banner disappears automatically when SSE is stable.

Updates CUA scenario to check for both ASCII and Unicode ellipsis.

Closes #42

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 16:31:58 +00:00

865 lines
29 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)
// SSE reconnect banner
const reconnectAttempts = useEvents((s) => s.reconnectAttempts)
const [showConnectedFlash, setShowConnectedFlash] = useState(false)
const prevReconnecting = useRef(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])
// Detect reconnecting → stable transition for the "Connected ✓" flash.
// reconnectAttempts and lastDisconnectAt reset in the same set() call, so we
// can't use lastDisconnectAt alone; a useRef tracks the prior reconnecting state.
useEffect(() => {
const isReconnecting = reconnectAttempts > 0
if (prevReconnecting.current && !isReconnecting) {
setShowConnectedFlash(true)
const t = setTimeout(() => setShowConnectedFlash(false), 2000)
return () => clearTimeout(t)
}
prevReconnecting.current = isReconnecting
}, [reconnectAttempts])
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)}
/>
{/* SSE reconnect/connected banner */}
{reconnectAttempts > 0 && (
<View style={[s.banner, s.bannerReconnecting]}>
<Text style={s.bannerText}>Reconnecting\u2026 (attempt {reconnectAttempts})</Text>
</View>
)}
{showConnectedFlash && reconnectAttempts === 0 && (
<View style={[s.banner, s.bannerConnected]}>
<Text style={s.bannerText}>Connected ✓</Text>
</View>
)}
{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" },
// SSE reconnect/connected banner
banner: {
paddingHorizontal: 16,
paddingVertical: 6,
alignItems: "center",
},
bannerReconnecting: { backgroundColor: "#92400e" },
bannerConnected: { backgroundColor: "#065f46" },
bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" },
})