Files
opencode-mobile/src/components/chat/MessageBubble.tsx
Den 1a069b53f5 fix(chat): fix 5 message-rendering correctness bugs (#132)
- MessageBubble: memo comparator only checked the last part's `.text`,
  which is always undefined for tool parts, so tool-state/token/cost
  updates never re-rendered when a tool part was last. Replace with a
  full reference-equality sweep over message + all parts (the store
  always replaces changed refs, so this catches every real change).
- DiffView: computeDiff's O(a.length*b.length) LCS table was unbounded,
  risking OOM/ANR on large diffs, and the rendered line list was
  unbounded too. Add a size-guarded fallback (simple truncated
  remove/add diff) and cap the normal path's rendered lines, both with
  a truncation marker. Extracted computeDiff into a plain
  diff-compute.ts module (mirrors src/lib/scroll-config.ts) so it's
  unit-testable with node:test, which can't render .tsx components.
- DiffView: normalize line endings (\r?\n) before diffing so a CRLF
  vs LF mismatch doesn't show a whole file as changed.
- Markdown: the module-scope singleton CustomRenderer's github-slugger
  never reset, so useMarkdown's keys climbed on every streamed token,
  remounting the whole subtree. Scope the renderer per `children` via
  useMemo instead.
- Markdown: theme objects used heading1/heading2/heading3/listItem,
  but react-native-marked's MarkedStyles expects h1/h2/h3/li, so the
  custom heading/list styling was silently dropped. Rename the keys in
  both themes.


Claude-Session: https://claude.ai/code/session_01T12AhSnQVrSxNnvwfCx2z6

Co-authored-by: engineer <engineer@macbookpro.lan>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:52:24 -07:00

174 lines
6.4 KiB
TypeScript

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
// Only wired up for user messages — long-press opens the "Edit message" /
// revert action sheet. Identified by messageID (not a closure over parts)
// so it stays correct even if the memo below bails on a stale render.
onLongPress?: (messageID: string) => void
}
// 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, onLongPress }: 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 (
<TouchableOpacity
activeOpacity={isUser && onLongPress ? 0.7 : 1}
onLongPress={isUser && onLongPress ? () => onLongPress(message.id) : undefined}
disabled={!isUser || !onLongPress}
style={[
s.bubble,
isUser ? s.user : s.assistant,
isUser && isDark && s.userDark,
!isUser && isDark && s.assistantDark,
]}
testID={`chat-bubble-${message.role}`}
>
{/* 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>
)}
</TouchableOpacity>
)
},
(prev, next) => {
// Only re-render if message content actually changed
// This prevents completed messages from re-rendering during streaming.
// The store replaces changed parts/messages with NEW object references,
// so a reference-equality sweep over every part catches every real change
// (including tool parts, which have no `.text`) while still skipping
// unchanged (completed) messages during other messages' streaming.
if (prev.message !== next.message) return false
if (prev.isDark !== next.isDark) return false
if (prev.onLongPress !== next.onLongPress) return false
if (prev.parts.length !== next.parts.length) return false
for (let i = 0; i < prev.parts.length; i++) {
if (prev.parts[i] !== next.parts[i]) return false
}
return true
},
)
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" },
})