Initial commit: OpenCode mobile app (extracted from opencode monorepo)

This commit is contained in:
Ubuntu
2026-05-17 19:52:54 +00:00
commit 1843a7c37e
84 changed files with 9303 additions and 0 deletions

View 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" },
})