- 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>
174 lines
6.4 KiB
TypeScript
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" },
|
|
})
|