Initial commit: OpenCode mobile app (extracted from opencode monorepo)
This commit is contained in:
162
src/components/chat/MessageBubble.tsx
Normal file
162
src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { memo } from "react"
|
||||
import { View, Text, Image, StyleSheet, ScrollView, TouchableOpacity, Dimensions } from "react-native"
|
||||
import { Ionicons } from "@expo/vector-icons"
|
||||
import { Markdown } from "../markdown"
|
||||
import { ToolCallCard } from "./ToolCallCard"
|
||||
import { ReasoningBlock } from "./ReasoningBlock"
|
||||
import type { Message, Part } from "../../lib/sdk"
|
||||
|
||||
const SCREEN_WIDTH = Dimensions.get("window").width
|
||||
|
||||
function isImageMime(mime?: string): boolean {
|
||||
return !!mime && mime.startsWith("image/")
|
||||
}
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
parts: Part[]
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
// TODO: Replace with streamdown-rn once React 19 types PR lands - it has
|
||||
// built-in block-level memoization that eliminates re-renders for stable blocks
|
||||
export const MessageBubble = memo(
|
||||
function MessageBubble({ message, parts, isDark }: Props) {
|
||||
const isUser = message.role === "user"
|
||||
|
||||
const textParts = parts.filter((p) => p.type === "text")
|
||||
const reasoningParts = parts.filter((p) => p.type === "reasoning")
|
||||
const toolParts = parts.filter((p) => p.type === "tool")
|
||||
const fileParts = parts.filter((p) => p.type === "file" && isImageMime(p.mime))
|
||||
const text = textParts.map((p) => p.text).join("\n") || ""
|
||||
const reasoning = reasoningParts.map((p) => p.text).join("\n") || ""
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
s.bubble,
|
||||
isUser ? s.user : s.assistant,
|
||||
isUser && isDark && s.userDark,
|
||||
!isUser && isDark && s.assistantDark,
|
||||
]}
|
||||
>
|
||||
{/* Role indicator */}
|
||||
<View style={s.header}>
|
||||
<Ionicons
|
||||
name={isUser ? "person" : "sparkles"}
|
||||
size={14}
|
||||
color={isUser ? (isDark ? "#ffffff" : "#0a0a0a") : "#8b5cf6"}
|
||||
/>
|
||||
<Text style={[s.role, isUser && s.roleUser, isDark && s.textWhite]}>{isUser ? "You" : "Assistant"}</Text>
|
||||
{message.model && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.model.modelID}</Text>}
|
||||
{!isUser && message.modelID && <Text style={[s.modelTag, isDark && s.modelTagDark]}>{message.modelID}</Text>}
|
||||
</View>
|
||||
|
||||
{/* Image attachments */}
|
||||
{fileParts.length > 0 && (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={s.imageRow}
|
||||
style={s.imageScroll}
|
||||
>
|
||||
{fileParts.map((fp) => (
|
||||
<View key={fp.id} style={s.imageWrap}>
|
||||
<Image source={{ uri: fp.url }} style={s.attachedImage} resizeMode="cover" />
|
||||
{fp.filename && (
|
||||
<Text style={[s.imageLabel, isDark && s.imageLabelDark]} numberOfLines={1}>
|
||||
{fp.filename}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
{/* Reasoning (collapsible) */}
|
||||
{reasoning.length > 0 && <ReasoningBlock text={reasoning} isDark={isDark} />}
|
||||
|
||||
{/* Message text */}
|
||||
{text.length > 0 &&
|
||||
(isUser ? (
|
||||
<Text style={[s.messageText, isDark && s.textWhite]} selectable>
|
||||
{text}
|
||||
</Text>
|
||||
) : (
|
||||
<View style={s.markdownWrap}>
|
||||
<Markdown>{text}</Markdown>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* Tool calls */}
|
||||
{toolParts.map((tool) => (
|
||||
<ToolCallCard key={tool.id} tool={tool} isDark={isDark} />
|
||||
))}
|
||||
|
||||
{/* Tokens/cost for assistant messages */}
|
||||
{!isUser && message.tokens && (
|
||||
<Text style={[s.tokens, isDark && s.tokensDark]}>
|
||||
{message.tokens.input + message.tokens.output} tokens
|
||||
{message.cost ? ` · $${message.cost.toFixed(4)}` : ""}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
(prev, next) => {
|
||||
// Only re-render if message content actually changed
|
||||
// This prevents completed messages from re-rendering during streaming
|
||||
if (prev.message.id !== next.message.id) return false
|
||||
if (prev.isDark !== next.isDark) return false
|
||||
if (prev.parts.length !== next.parts.length) return false
|
||||
// Compare the last part's text content - this is what changes during streaming
|
||||
const prevLast = prev.parts[prev.parts.length - 1]
|
||||
const nextLast = next.parts[next.parts.length - 1]
|
||||
if (!prevLast && !nextLast) return true
|
||||
if (!prevLast || !nextLast) return false
|
||||
return prevLast.type === nextLast.type && prevLast.text === nextLast.text
|
||||
},
|
||||
)
|
||||
|
||||
const s = StyleSheet.create({
|
||||
bubble: { marginBottom: 16, padding: 12, borderRadius: 12, maxWidth: "100%" },
|
||||
user: { backgroundColor: "#f5f5f5", marginLeft: 32 },
|
||||
userDark: { backgroundColor: "#1a1a1a" },
|
||||
assistant: { backgroundColor: "#f0f0ff" },
|
||||
assistantDark: { backgroundColor: "#1a1a2e" },
|
||||
|
||||
header: { flexDirection: "row", alignItems: "center", gap: 6, marginBottom: 8 },
|
||||
role: { fontSize: 13, fontWeight: "600", color: "#666666" },
|
||||
roleUser: { color: "#0a0a0a" },
|
||||
textWhite: { color: "#ffffff" },
|
||||
|
||||
modelTag: {
|
||||
fontSize: 11,
|
||||
color: "#999999",
|
||||
backgroundColor: "#e5e5e5",
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
overflow: "hidden",
|
||||
},
|
||||
modelTagDark: { backgroundColor: "#2a2a2a", color: "#888888" },
|
||||
|
||||
messageText: { fontSize: 15, lineHeight: 22, color: "#0a0a0a" },
|
||||
markdownWrap: { marginHorizontal: -4 },
|
||||
|
||||
tokens: { fontSize: 11, color: "#999999", marginTop: 8 },
|
||||
tokensDark: { color: "#666666" },
|
||||
|
||||
// Images
|
||||
imageScroll: { marginBottom: 8 },
|
||||
imageRow: { gap: 8 },
|
||||
imageWrap: { alignItems: "center" },
|
||||
attachedImage: {
|
||||
width: Math.min(200, SCREEN_WIDTH * 0.5),
|
||||
height: Math.min(200, SCREEN_WIDTH * 0.5),
|
||||
borderRadius: 8,
|
||||
backgroundColor: "#e5e5e5",
|
||||
},
|
||||
imageLabel: { fontSize: 10, color: "#666666", marginTop: 2, maxWidth: 200 },
|
||||
imageLabelDark: { color: "#888888" },
|
||||
})
|
||||
Reference in New Issue
Block a user