From 5394490c43031445ed7f52fe87b20f0839f547d7 Mon Sep 17 00:00:00 2001 From: chliny Date: Mon, 17 Aug 2026 20:42:41 +0800 Subject: [PATCH 1/6] fix: move content viewer actions to block headers --- app/_layout.tsx | 1 + app/content-viewer.tsx | 83 +++++++++++++++++++++ src/components/chat/ContentViewerButton.tsx | 49 ++++++++++++ src/components/chat/DiffView.tsx | 7 ++ src/components/chat/ToolCallCard.tsx | 58 ++++++-------- src/components/chat/index.ts | 1 + src/components/markdown/CodeBlock.tsx | 2 + src/lib/content-viewer.ts | 15 ++++ src/lib/i18n/en.json | 10 ++- src/lib/i18n/zh-Hans.json | 10 ++- 10 files changed, 199 insertions(+), 37 deletions(-) create mode 100644 app/content-viewer.tsx create mode 100644 src/components/chat/ContentViewerButton.tsx create mode 100644 src/lib/content-viewer.ts diff --git a/app/_layout.tsx b/app/_layout.tsx index c669aac9..37f361fd 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -186,6 +186,7 @@ function RootLayout() { }} > + + {t("chat.contentViewer.empty")} + + ) + } + + const copy = async () => { + await Clipboard.setStringAsync(viewer.content) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + return ( + + + + router.back()} style={s.toolbarButton} hitSlop={8}> + + {t("common.back")} + + {viewer.title} + + + {copied ? t("common.copied") : t("common.copy")} + + + + {viewer.language || t("chat.contentViewer.output")} + + + {viewer.content} + + + + + ) +} + +const mono = Platform.OS === "ios" ? "Menlo" : "monospace" +const s = StyleSheet.create({ + screen: { flex: 1, backgroundColor: "#f5f5f5" }, + screenDark: { backgroundColor: "#0a0a0a" }, + toolbar: { minHeight: 64, paddingHorizontal: 14, flexDirection: "row", alignItems: "center", justifyContent: "space-between", backgroundColor: "#fff", borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: "#ddd" }, + toolbarDark: { backgroundColor: "#151515", borderBottomColor: "#333" }, + toolbarButton: { flexDirection: "row", alignItems: "center", gap: 5, minWidth: 72 }, + backText: { fontSize: 13, color: "#111" }, + title: { flex: 1, textAlign: "center", fontSize: 15, fontWeight: "700", color: "#111" }, + copyText: { fontSize: 12, color: "#6d28d9" }, + copyTextDark: { color: "#c4b5fd" }, + content: { flex: 1, margin: 10, borderRadius: 8, overflow: "hidden", backgroundColor: "#fff" }, + contentDark: { backgroundColor: "#1a1a1a" }, + language: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 11, fontWeight: "700", color: "#666", textTransform: "uppercase", backgroundColor: "#e8e8e8" }, + languageDark: { color: "#aaa", backgroundColor: "#2a2a2a" }, + horizontal: { flex: 1 }, + scrollContent: { minWidth: "100%", flexGrow: 1 }, + verticalContent: { padding: 14 }, + code: { fontFamily: mono, fontSize: 13, lineHeight: 20, color: "#171717" }, + codeDark: { color: "#e5e5e5" }, + empty: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff" }, + emptyDark: { backgroundColor: "#0a0a0a" }, + emptyText: { color: "#111" }, + textDark: { color: "#fff" }, +}) diff --git a/src/components/chat/ContentViewerButton.tsx b/src/components/chat/ContentViewerButton.tsx new file mode 100644 index 00000000..d030c8c4 --- /dev/null +++ b/src/components/chat/ContentViewerButton.tsx @@ -0,0 +1,49 @@ +import { Ionicons } from "@expo/vector-icons" +import { useRouter } from "expo-router" +import { Text, TouchableOpacity, StyleSheet } from "react-native" +import { useTranslation } from "react-i18next" +import { setContentViewer } from "../../lib/content-viewer" + +interface Props { + title: string + content: string + language?: string + isDark: boolean +} + +export function ContentViewerButton({ title, content, language, isDark }: Props) { + const router = useRouter() + const { t } = useTranslation() + + if (!content) return null + + return ( + { + setContentViewer({ title, language, content }) + router.push("/content-viewer") + }} + hitSlop={6} + > + + {t("chat.contentViewer.open")} + + ) +} + +const s = StyleSheet.create({ + button: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-end", + gap: 5, + paddingHorizontal: 8, + paddingVertical: 5, + borderRadius: 5, + backgroundColor: "#ede9fe", + }, + buttonDark: { backgroundColor: "#312e81" }, + text: { fontSize: 11, fontWeight: "600", color: "#6d28d9" }, + textDark: { color: "#c4b5fd" }, +}) diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx index 00c66c09..38386730 100644 --- a/src/components/chat/DiffView.tsx +++ b/src/components/chat/DiffView.tsx @@ -1,6 +1,7 @@ import { View, Text, StyleSheet, Platform, ScrollView } from "react-native" import { WIDE_CONTENT_SCROLL_CONFIG } from "../../lib/scroll-config" import { computeDiff } from "./diff-compute" +import { ContentViewerButton } from "./ContentViewerButton" const mono = Platform.OS === "ios" ? "Menlo" : "monospace" @@ -15,8 +16,13 @@ export function DiffView({ before, after, isDark }: Props) { if (lines.length === 0) return null + const fullDiff = lines.map((line) => `${line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}${line.text}`).join("\n") + return ( + + + {lines.map((line, idx) => ( @@ -58,6 +64,7 @@ const s = StyleSheet.create({ marginTop: 6, }, containerDark: { backgroundColor: "#1a1a1a" }, + header: { alignItems: "flex-end", paddingHorizontal: 8, paddingTop: 6 }, line: { flexDirection: "row", diff --git a/src/components/chat/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx index b7361c11..9f45f225 100644 --- a/src/components/chat/ToolCallCard.tsx +++ b/src/components/chat/ToolCallCard.tsx @@ -4,6 +4,7 @@ import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import type { Part } from "../../lib/sdk" import { DiffView } from "./DiffView" +import { ContentViewerButton } from "./ContentViewerButton" const TOOL_ICONS: Record = { read: "glasses-outline", @@ -34,6 +35,17 @@ function statusColor(status: string): string { // --- Tool-specific detail renderers --- +function CodeOutput({ content, title, isDark }: { content: string; title: string; isDark: boolean }) { + return ( + + + + + {content} + + ) +} + function BashDetail({ input, output, isDark }: { input: unknown; output: unknown; isDark: boolean }) { const cmd = typeof input === "object" && input !== null ? (input as Record).command : undefined const out = typeof output === "string" ? output : undefined @@ -41,6 +53,9 @@ function BashDetail({ input, output, isDark }: { input: unknown; output: unknown {typeof cmd === "string" && ( + + + $ {cmd} @@ -48,11 +63,7 @@ function BashDetail({ input, output, isDark }: { input: unknown; output: unknown )} {out !== undefined && out.length > 0 && ( - - - {out} - - + )} ) @@ -86,11 +97,7 @@ function WriteDetail({ input, isDark }: { input: unknown; isDark: boolean }) { )} {typeof content === "string" && content.length > 0 && ( - - - {content} - - + )} ) @@ -126,11 +133,7 @@ function EditDetail({ input, output, isDark }: { input: unknown; output: unknown )} {text && ( - - - {text} - - + )} ) @@ -141,11 +144,7 @@ function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) { return ( {typeof patch === "string" && patch.length > 0 && ( - - - {patch} - - + )} ) @@ -166,11 +165,7 @@ function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unk )} {results && results.length > 0 && ( - - - {results} - - + )} ) @@ -197,11 +192,7 @@ function TaskDetail({ input, isDark }: { input: unknown; isDark: boolean }) { {typeof description === "string" && {description}} {typeof prompt === "string" && prompt.length > 0 && ( - - - {prompt} - - + )} ) @@ -244,11 +235,7 @@ function GenericDetail({ input, output, isDark }: { input: unknown; output: unkn if (!text || text.length === 0) return null return ( - - - {text} - - + ) } @@ -436,6 +423,7 @@ const s = StyleSheet.create({ padding: 10, }, codeBlockDark: { backgroundColor: "#1a1a1a" }, + codeHeader: { alignItems: "flex-end", marginBottom: 5 }, codePre: { fontSize: 12, fontFamily: mono, diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index 4df6472f..1be3dfcb 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -12,3 +12,4 @@ export { ImageAttachments, type Attachment } from "./ImageAttachments" export { DirectorySwitcher } from "./DirectorySwitcher" export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet" export { SessionInfo } from "./SessionInfo" +export { ContentViewerButton } from "./ContentViewerButton" diff --git a/src/components/markdown/CodeBlock.tsx b/src/components/markdown/CodeBlock.tsx index 8265309f..157c9ab6 100644 --- a/src/components/markdown/CodeBlock.tsx +++ b/src/components/markdown/CodeBlock.tsx @@ -2,6 +2,7 @@ import { useState } from "react" import { View, Text, TouchableOpacity, StyleSheet, useColorScheme, Platform, ScrollView } from "react-native" import * as Clipboard from "expo-clipboard" import { WIDE_CONTENT_SCROLL_CONFIG } from "../../lib/scroll-config" +import { ContentViewerButton } from "../chat/ContentViewerButton" interface Props { code: string @@ -27,6 +28,7 @@ export function CodeBlock({ code, language }: Props) { {copied ? "Copied!" : "Copy"} + diff --git a/src/lib/content-viewer.ts b/src/lib/content-viewer.ts new file mode 100644 index 00000000..86acb3c6 --- /dev/null +++ b/src/lib/content-viewer.ts @@ -0,0 +1,15 @@ +export interface ContentViewerState { + title: string + language?: string + content: string +} + +let current: ContentViewerState | null = null + +export function setContentViewer(state: ContentViewerState): void { + current = state +} + +export function getContentViewer(): ContentViewerState | null { + return current +} diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 13519f23..276b0b04 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -6,7 +6,10 @@ "delete": "Delete", "retry": "Retry", "save": "Save", - "shareReport": "Share report" + "shareReport": "Share report", + "back": "Back", + "copy": "Copy", + "copied": "Copied" }, "settings": { "sections": { @@ -408,6 +411,11 @@ "patternOnly": "Pattern: {{pattern}}", "patternWithPath": "Pattern: {{pattern}} in {{path}}" }, + "contentViewer": { + "open": "Open full screen", + "output": "Output", + "empty": "Content is no longer available" + }, "sessionInfo": { "noUsageData": "No usage data yet", "loading": "Loading...", diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 9ba906f7..464c2796 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -6,7 +6,10 @@ "delete": "删除", "retry": "重试", "save": "保存", - "shareReport": "分享报告" + "shareReport": "分享报告", + "back": "返回", + "copy": "复制", + "copied": "已复制" }, "settings": { "sections": { @@ -408,6 +411,11 @@ "patternOnly": "模式:{{pattern}}", "patternWithPath": "模式:{{pattern}},路径:{{path}}" }, + "contentViewer": { + "open": "全屏查看", + "output": "输出", + "empty": "内容已不可用" + }, "sessionInfo": { "noUsageData": "暂无使用数据", "loading": "加载中...", From 5f80c29d6479a310263fc497b0a3285634eecae6 Mon Sep 17 00:00:00 2001 From: chliny Date: Mon, 17 Aug 2026 22:13:10 +0800 Subject: [PATCH 2/6] feat: render colored fullscreen diffs --- app/content-viewer.tsx | 48 +++++++++++++++++++++++++++- src/components/chat/DiffView.tsx | 13 +++++++- src/components/chat/ToolCallCard.tsx | 8 +++-- src/components/chat/diff-compute.ts | 10 ++++++ src/components/chat/patch-compute.ts | 38 ++++++++++++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 src/components/chat/patch-compute.ts diff --git a/app/content-viewer.tsx b/app/content-viewer.tsx index 0f6d8bef..683e47a2 100644 --- a/app/content-viewer.tsx +++ b/app/content-viewer.tsx @@ -7,6 +7,39 @@ import { useTranslation } from "react-i18next" import { useSafeAreaInsets } from "react-native-safe-area-context" import { WIDE_CONTENT_SCROLL_CONFIG } from "../src/lib/scroll-config" import { getContentViewer } from "../src/lib/content-viewer" +import { parseDiffText, type DiffLine } from "../src/components/chat/diff-compute" + +function DiffContent({ lines, isDark }: { lines: DiffLine[]; isDark: boolean }) { + return ( + + {lines.map((line, index) => ( + + + {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "} + + + {line.text} + + + ))} + + ) +} export default function ContentViewerScreen() { const router = useRouter() @@ -24,6 +57,9 @@ export default function ContentViewerScreen() { ) } + const isDiff = viewer.language === "diff" + const diffLines = isDiff ? parseDiffText(viewer.content) : [] + const copy = async () => { await Clipboard.setStringAsync(viewer.content) setCopied(true) @@ -48,7 +84,7 @@ export default function ContentViewerScreen() { {viewer.language || t("chat.contentViewer.output")} - {viewer.content} + {isDiff ? : {viewer.content}} @@ -76,6 +112,16 @@ const s = StyleSheet.create({ verticalContent: { padding: 14 }, code: { fontFamily: mono, fontSize: 13, lineHeight: 20, color: "#171717" }, codeDark: { color: "#e5e5e5" }, + diffLines: { alignSelf: "flex-start", minWidth: "100%" }, + diffLine: { flexDirection: "row", paddingHorizontal: 8, paddingVertical: 1 }, + diffAdd: { backgroundColor: "#dcfce7" }, + diffAddDark: { backgroundColor: "#052e16" }, + diffRemove: { backgroundColor: "#fee2e2" }, + diffRemoveDark: { backgroundColor: "#2a0a0a" }, + diffPrefix: { width: 16, fontSize: 13, fontFamily: mono, lineHeight: 20, color: "#999999" }, + diffPrefixDark: { color: "#666666" }, + diffAddText: { color: "#16a34a" }, + diffRemoveText: { color: "#dc2626" }, empty: { flex: 1, alignItems: "center", justifyContent: "center", backgroundColor: "#fff" }, emptyDark: { backgroundColor: "#0a0a0a" }, emptyText: { color: "#111" }, diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx index 38386730..96b3d2e7 100644 --- a/src/components/chat/DiffView.tsx +++ b/src/components/chat/DiffView.tsx @@ -5,6 +5,12 @@ import { ContentViewerButton } from "./ContentViewerButton" const mono = Platform.OS === "ios" ? "Menlo" : "monospace" +export interface DiffLinesProps { + lines: ReturnType + isDark: boolean + title?: string +} + interface Props { before: string after: string @@ -14,6 +20,11 @@ interface Props { export function DiffView({ before, after, isDark }: Props) { const lines = computeDiff(before, after) + return +} + +export function DiffLinesView({ lines, isDark, title }: DiffLinesProps) { + if (lines.length === 0) return null const fullDiff = lines.map((line) => `${line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}${line.text}`).join("\n") @@ -21,7 +32,7 @@ export function DiffView({ before, after, isDark }: Props) { return ( - + diff --git a/src/components/chat/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx index 9f45f225..74a96b4e 100644 --- a/src/components/chat/ToolCallCard.tsx +++ b/src/components/chat/ToolCallCard.tsx @@ -3,7 +3,8 @@ import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, ScrollView import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import type { Part } from "../../lib/sdk" -import { DiffView } from "./DiffView" +import { DiffLinesView, DiffView } from "./DiffView" +import { computePatchDiff } from "./patch-compute" import { ContentViewerButton } from "./ContentViewerButton" const TOOL_ICONS: Record = { @@ -144,7 +145,7 @@ function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) { return ( {typeof patch === "string" && patch.length > 0 && ( - + )} ) @@ -301,7 +302,8 @@ interface Props { export function ToolCallCard({ tool, isDark }: Props) { const { t } = useTranslation() - const [expanded, setExpanded] = useState(false) + const isCodeChange = tool.tool === "edit" || tool.tool === "write" || tool.tool === "apply_patch" + const [expanded, setExpanded] = useState(isCodeChange) const icon = (tool.tool && TOOL_ICONS[tool.tool]) || "extension-puzzle-outline" const status = tool.state?.status || "pending" const color = statusColor(status) diff --git a/src/components/chat/diff-compute.ts b/src/components/chat/diff-compute.ts index 321115f0..5e3f84ce 100644 --- a/src/components/chat/diff-compute.ts +++ b/src/components/chat/diff-compute.ts @@ -10,6 +10,16 @@ export interface DiffLine { text: string } +// Parse the serialized form used by ContentViewerButton. Each line starts +// with the diff marker added by DiffView: +, -, or a space for context. +export function parseDiffText(content: string): DiffLine[] { + return content.split(/\r?\n/).map((line) => { + if (line.startsWith("+")) return { type: "add", text: line.slice(1) } + if (line.startsWith("-")) return { type: "remove", text: line.slice(1) } + return { type: "context", text: line.startsWith(" ") ? line.slice(1) : line } + }) +} + // Above this size the O(a.length * b.length) LCS table (and the matching // backtrack array) becomes an OOM/ANR risk on-device — a 2000-line file both // sides is a 4,000,000-cell table. Guard on both a hard per-side line count diff --git a/src/components/chat/patch-compute.ts b/src/components/chat/patch-compute.ts new file mode 100644 index 00000000..8ba863ac --- /dev/null +++ b/src/components/chat/patch-compute.ts @@ -0,0 +1,38 @@ +import type { DiffLine } from "./diff-compute" + +// Convert both unified diffs and OpenCode's *** patch format into the same +// line model used by DiffView. Patch headers are kept as context so filenames +// remain visible, while metadata lines and patch delimiters are omitted. +export function computePatchDiff(patch: string): DiffLine[] { + const lines: DiffLine[] = [] + let inHunk = false + + for (const text of patch.split(/\r?\n/)) { + if (text === "*** End Patch") continue + if (text.startsWith("*** Add File:") || text.startsWith("*** Update File:") || text.startsWith("*** Delete File:")) { + lines.push({ type: "context", text }) + inHunk = true + continue + } + if (text.startsWith("@@")) { + lines.push({ type: "context", text }) + inHunk = true + continue + } + if (text.startsWith("+++ ") || text.startsWith("--- ")) { + lines.push({ type: "context", text }) + continue + } + if (text.startsWith("+") && !text.startsWith("+++")) { + lines.push({ type: "add", text: text.slice(1) }) + continue + } + if (text.startsWith("-") && !text.startsWith("---")) { + lines.push({ type: "remove", text: text.slice(1) }) + continue + } + if (inHunk && text.length > 0) lines.push({ type: "context", text: text.startsWith(" ") ? text.slice(1) : text }) + } + + return lines +} From 0ef23ea7e7cd7632bc4514679264adef72a7b332 Mon Sep 17 00:00:00 2001 From: chliny Date: Tue, 18 Aug 2026 16:59:25 +0800 Subject: [PATCH 3/6] fix: render apply patch diff content --- src/components/chat/ToolCallCard.tsx | 4 ++-- src/components/chat/patch-compute.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/chat/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx index 74a96b4e..05dbe1dc 100644 --- a/src/components/chat/ToolCallCard.tsx +++ b/src/components/chat/ToolCallCard.tsx @@ -4,7 +4,7 @@ import { Ionicons } from "@expo/vector-icons" import { useTranslation } from "react-i18next" import type { Part } from "../../lib/sdk" import { DiffLinesView, DiffView } from "./DiffView" -import { computePatchDiff } from "./patch-compute" +import { computePatchDiff, patchTextFromInput } from "./patch-compute" import { ContentViewerButton } from "./ContentViewerButton" const TOOL_ICONS: Record = { @@ -141,7 +141,7 @@ function EditDetail({ input, output, isDark }: { input: unknown; output: unknown } function PatchDetail({ input, isDark }: { input: unknown; isDark: boolean }) { - const patch = typeof input === "object" && input !== null ? (input as Record).patch : undefined + const patch = patchTextFromInput(input) return ( {typeof patch === "string" && patch.length > 0 && ( diff --git a/src/components/chat/patch-compute.ts b/src/components/chat/patch-compute.ts index 8ba863ac..be9fd414 100644 --- a/src/components/chat/patch-compute.ts +++ b/src/components/chat/patch-compute.ts @@ -1,5 +1,15 @@ import type { DiffLine } from "./diff-compute" +export function patchTextFromInput(input: unknown): string | undefined { + if (typeof input === "string") return input + if (typeof input !== "object" || input === null) return undefined + + const value = input as Record + if (typeof value.patchText === "string") return value.patchText + if (typeof value.patch === "string") return value.patch + return undefined +} + // Convert both unified diffs and OpenCode's *** patch format into the same // line model used by DiffView. Patch headers are kept as context so filenames // remain visible, while metadata lines and patch delimiters are omitted. From 169e2e6c88cc8835d3739e19d5bcd8506e273d64 Mon Sep 17 00:00:00 2001 From: chliny Date: Tue, 18 Aug 2026 19:40:02 +0800 Subject: [PATCH 4/6] feat: add task review changes --- app/session/[id].tsx | 11 ++- src/components/chat/MessageBubble.tsx | 9 ++- src/components/chat/ReviewChanges.tsx | 94 +++++++++++++++++++++++ src/components/chat/index.ts | 1 + src/components/chat/patch-compute.test.ts | 32 ++++++++ src/lib/i18n/en.json | 5 ++ src/lib/i18n/zh-Hans.json | 5 ++ src/lib/review-diffs.test.ts | 39 ++++++++++ src/lib/review-diffs.ts | 6 ++ src/lib/sdk.ts | 17 ++++ 10 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 src/components/chat/ReviewChanges.tsx create mode 100644 src/components/chat/patch-compute.test.ts create mode 100644 src/lib/review-diffs.test.ts create mode 100644 src/lib/review-diffs.ts diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 97f885f0..78f70270 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -39,6 +39,7 @@ import { useConnections } from "../../src/stores/connections" import { useAuth } from "../../src/stores/auth" import { useCatalog } from "../../src/stores/catalog" import { useSpeech } from "../../src/lib/speech" +import { reviewDiffsForMessage } from "../../src/lib/review-diffs" // --- Builtin slash commands --- const BUILTIN_COMMANDS: SlashCommand[] = [ @@ -179,14 +180,17 @@ export default function SessionScreen() { // Inverted FlatList: data is reversed (newest first) so newest renders at bottom const messageData = useMemo( - () => - (messages || []) + () => { + const visible = (messages || []) .filter((msg) => !revertMessageID || msg.id.startsWith("temp-") || msg.id < revertMessageID) + return visible .map((msg) => ({ message: msg, parts: (parts && parts[msg.id]) || [], + reviewDiffs: reviewDiffsForMessage(msg, visible), })) - .reverse(), + .reverse() + }, [messages, parts, revertMessageID], ) @@ -675,6 +679,7 @@ export default function SessionScreen() { message={item.message} parts={item.parts} isDark={isDark} + reviewDiffs={item.reviewDiffs} onLongPress={handleMessageLongPress} /> )} diff --git a/src/components/chat/MessageBubble.tsx b/src/components/chat/MessageBubble.tsx index 0f82ecaf..e52ab795 100644 --- a/src/components/chat/MessageBubble.tsx +++ b/src/components/chat/MessageBubble.tsx @@ -4,7 +4,8 @@ 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" +import { ReviewChanges } from "./ReviewChanges" +import type { FileDiff, Message, Part } from "../../lib/sdk" const SCREEN_WIDTH = Dimensions.get("window").width @@ -16,6 +17,7 @@ interface Props { message: Message parts: Part[] isDark: boolean + reviewDiffs?: FileDiff[] // 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. @@ -25,7 +27,7 @@ interface Props { // 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) { + function MessageBubble({ message, parts, isDark, reviewDiffs, onLongPress }: Props) { const isUser = message.role === "user" const textParts = parts.filter((p) => p.type === "text") @@ -101,6 +103,8 @@ export const MessageBubble = memo( ))} + {!isUser && reviewDiffs && reviewDiffs.length > 0 && } + {/* Tokens/cost for assistant messages */} {!isUser && message.tokens && ( @@ -121,6 +125,7 @@ export const MessageBubble = memo( if (prev.message !== next.message) return false if (prev.isDark !== next.isDark) return false if (prev.onLongPress !== next.onLongPress) return false + if (prev.reviewDiffs !== next.reviewDiffs) 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 diff --git a/src/components/chat/ReviewChanges.tsx b/src/components/chat/ReviewChanges.tsx new file mode 100644 index 00000000..af12fb9a --- /dev/null +++ b/src/components/chat/ReviewChanges.tsx @@ -0,0 +1,94 @@ +import { useState } from "react" +import { Ionicons } from "@expo/vector-icons" +import { StyleSheet, Text, TouchableOpacity, View } from "react-native" +import { useTranslation } from "react-i18next" +import type { FileDiff } from "../../lib/sdk" +import { DiffLinesView } from "./DiffView" +import { computePatchDiff } from "./patch-compute" + +interface Props { + diffs: FileDiff[] + isDark: boolean +} + +export function ReviewChanges({ diffs, isDark }: Props) { + const { t } = useTranslation() + const [open, setOpen] = useState>({}) + const files = diffs.filter((diff): diff is FileDiff & { file: string } => typeof diff.file === "string") + + if (files.length === 0) return null + + const additions = files.reduce((sum, diff) => sum + diff.additions, 0) + const deletions = files.reduce((sum, diff) => sum + diff.deletions, 0) + + return ( + + + + + {t("chat.reviewChanges.title")} + {t("chat.reviewChanges.files", { count: files.length })} + + + +{additions} + -{deletions} + + + + {files.map((diff) => { + const expanded = !!open[diff.file] + const canExpand = typeof diff.patch === "string" && diff.patch.length > 0 + return ( + + setOpen((state) => ({ ...state, [diff.file]: !state[diff.file] }))} + > + + {diff.file} + + +{diff.additions} + -{diff.deletions} + + {canExpand && ( + + )} + + {expanded && diff.patch && ( + + + + )} + + ) + })} + + ) +} + +const s = StyleSheet.create({ + container: { marginTop: 10, borderWidth: 1, borderColor: "#ddd6fe", borderRadius: 9, overflow: "hidden", backgroundColor: "#fafaff" }, + containerDark: { borderColor: "#37305c", backgroundColor: "#171725" }, + header: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 10, paddingVertical: 9 }, + headerTitle: { flexDirection: "row", alignItems: "center", gap: 6, flex: 1 }, + title: { fontSize: 13, fontWeight: "700", color: "#3b0764" }, + titleDark: { color: "#ddd6fe" }, + count: { fontSize: 11, color: "#777777" }, + countDark: { color: "#8f8f9d" }, + stats: { flexDirection: "row", gap: 7 }, + additions: { fontSize: 11, fontWeight: "700", color: "#16a34a" }, + deletions: { fontSize: 11, fontWeight: "700", color: "#dc2626" }, + file: { borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: "#e5e5e5" }, + fileDark: { borderTopColor: "#34343f" }, + fileHeader: { flexDirection: "row", alignItems: "center", gap: 7, paddingHorizontal: 10, paddingVertical: 9 }, + path: { flex: 1, fontSize: 12, color: "#262626" }, + pathDark: { color: "#d4d4d4" }, + fileStats: { flexDirection: "row", gap: 6 }, + diff: { paddingHorizontal: 8, paddingBottom: 8 }, +}) diff --git a/src/components/chat/index.ts b/src/components/chat/index.ts index 1be3dfcb..c837773d 100644 --- a/src/components/chat/index.ts +++ b/src/components/chat/index.ts @@ -13,3 +13,4 @@ export { DirectorySwitcher } from "./DirectorySwitcher" export { DirectoryBrowserSheet } from "./DirectoryBrowserSheet" export { SessionInfo } from "./SessionInfo" export { ContentViewerButton } from "./ContentViewerButton" +export { ReviewChanges } from "./ReviewChanges" diff --git a/src/components/chat/patch-compute.test.ts b/src/components/chat/patch-compute.test.ts new file mode 100644 index 00000000..f031d4be --- /dev/null +++ b/src/components/chat/patch-compute.test.ts @@ -0,0 +1,32 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { computePatchDiff, patchTextFromInput } from "./patch-compute.ts" + +test("patchTextFromInput reads the apply_patch patchText field", () => { + assert.equal(patchTextFromInput({ patchText: "*** Begin Patch" }), "*** Begin Patch") + assert.equal(patchTextFromInput({ patch: "legacy" }), "legacy") +}) + +test("computePatchDiff parses OpenCode patches", () => { + const result = computePatchDiff("*** Update File: app.ts\n@@\n const old = 1\n-const removed = true\n+const added = true\n*** End Patch") + + assert.deepEqual(result, [ + { type: "context", text: "*** Update File: app.ts" }, + { type: "context", text: "@@" }, + { type: "context", text: "const old = 1" }, + { type: "remove", text: "const removed = true" }, + { type: "add", text: "const added = true" }, + ]) +}) + +test("computePatchDiff parses unified diff headers", () => { + const result = computePatchDiff("--- a/app.ts\n+++ b/app.ts\n@@ -1 +1 @@\n-old\n+new") + + assert.deepEqual(result, [ + { type: "context", text: "--- a/app.ts" }, + { type: "context", text: "+++ b/app.ts" }, + { type: "context", text: "@@ -1 +1 @@" }, + { type: "remove", text: "old" }, + { type: "add", text: "new" }, + ]) +}) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 276b0b04..32b7479d 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -411,6 +411,11 @@ "patternOnly": "Pattern: {{pattern}}", "patternWithPath": "Pattern: {{pattern}} in {{path}}" }, + "reviewChanges": { + "title": "Changes in this task", + "files_one": "{{count}} file", + "files_other": "{{count}} files" + }, "contentViewer": { "open": "Open full screen", "output": "Output", diff --git a/src/lib/i18n/zh-Hans.json b/src/lib/i18n/zh-Hans.json index 464c2796..2a6021b3 100644 --- a/src/lib/i18n/zh-Hans.json +++ b/src/lib/i18n/zh-Hans.json @@ -411,6 +411,11 @@ "patternOnly": "模式:{{pattern}}", "patternWithPath": "模式:{{pattern}},路径:{{path}}" }, + "reviewChanges": { + "title": "本次任务变更", + "files_one": "{{count}} 个文件", + "files_other": "{{count}} 个文件" + }, "contentViewer": { "open": "全屏查看", "output": "输出", diff --git a/src/lib/review-diffs.test.ts b/src/lib/review-diffs.test.ts new file mode 100644 index 00000000..9d8173cd --- /dev/null +++ b/src/lib/review-diffs.test.ts @@ -0,0 +1,39 @@ +import assert from "node:assert/strict" +import test from "node:test" +import type { Message } from "./sdk.ts" +import { reviewDiffsForMessage } from "./review-diffs.ts" + +const user: Message = { + id: "user-1", + sessionID: "session-1", + role: "user", + time: { created: 1 }, + summary: { + diffs: [{ file: "src/app.ts", patch: "-old\n+new", additions: 1, deletions: 1, status: "modified" }], + }, +} + +test("reviewDiffsForMessage links an assistant reply to its user turn", () => { + const assistant: Message = { + id: "assistant-1", + sessionID: "session-1", + role: "assistant", + parentID: user.id, + time: { created: 2 }, + } + + assert.deepEqual(reviewDiffsForMessage(assistant, [user, assistant]), user.summary?.diffs) +}) + +test("reviewDiffsForMessage ignores unrelated messages", () => { + const assistant: Message = { + id: "assistant-2", + sessionID: "session-1", + role: "assistant", + parentID: "other-user", + time: { created: 3 }, + } + + assert.equal(reviewDiffsForMessage(user, [user, assistant]), undefined) + assert.equal(reviewDiffsForMessage(assistant, [user, assistant]), undefined) +}) diff --git a/src/lib/review-diffs.ts b/src/lib/review-diffs.ts new file mode 100644 index 00000000..edf69544 --- /dev/null +++ b/src/lib/review-diffs.ts @@ -0,0 +1,6 @@ +import type { FileDiff, Message } from "./sdk" + +export function reviewDiffsForMessage(message: Message, messages: Message[]): FileDiff[] | undefined { + if (message.role !== "assistant" || !message.parentID) return undefined + return messages.find((item) => item.id === message.parentID)?.summary?.diffs +} diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index 89ffb68c..26a6f031 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -39,6 +39,7 @@ export interface Session { additions: number deletions: number files: number + diffs?: FileDiff[] } // Present while a message (and everything after it) is pending revert — // the server keeps the underlying messages until the next prompt/summarize @@ -61,6 +62,11 @@ export interface Message { // User message fields agent?: string model?: { providerID: string; modelID: string } + summary?: { + title?: string + body?: string + diffs: FileDiff[] + } // Assistant message fields modelID?: string providerID?: string @@ -75,6 +81,14 @@ export interface Message { finish?: string } +export interface FileDiff { + file?: string + patch?: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + // API returns messages with parts embedded export interface MessageWithParts { info: Message @@ -100,6 +114,9 @@ export interface Part { | "agent" // Text / reasoning part text?: string + // Patch part + hash?: string + files?: string[] // Tool part tool?: string callID?: string From bda64c5456ee258fa445f4d4af83cb7dd86f9f1d Mon Sep 17 00:00:00 2001 From: chliny Date: Tue, 18 Aug 2026 22:27:26 +0800 Subject: [PATCH 5/6] fix: show task review only at session end --- app/session/[id].tsx | 2 +- src/lib/review-diffs.test.ts | 18 +++++++++++++++--- src/lib/review-diffs.ts | 4 ++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 78f70270..36f8ea6e 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -187,7 +187,7 @@ export default function SessionScreen() { .map((msg) => ({ message: msg, parts: (parts && parts[msg.id]) || [], - reviewDiffs: reviewDiffsForMessage(msg, visible), + reviewDiffs: reviewDiffsForMessage(msg, visible, msg.id === visible.at(-1)?.id), })) .reverse() }, diff --git a/src/lib/review-diffs.test.ts b/src/lib/review-diffs.test.ts index 9d8173cd..4b5d3103 100644 --- a/src/lib/review-diffs.test.ts +++ b/src/lib/review-diffs.test.ts @@ -22,7 +22,7 @@ test("reviewDiffsForMessage links an assistant reply to its user turn", () => { time: { created: 2 }, } - assert.deepEqual(reviewDiffsForMessage(assistant, [user, assistant]), user.summary?.diffs) + assert.deepEqual(reviewDiffsForMessage(assistant, [user, assistant], true), user.summary?.diffs) }) test("reviewDiffsForMessage ignores unrelated messages", () => { @@ -34,6 +34,18 @@ test("reviewDiffsForMessage ignores unrelated messages", () => { time: { created: 3 }, } - assert.equal(reviewDiffsForMessage(user, [user, assistant]), undefined) - assert.equal(reviewDiffsForMessage(assistant, [user, assistant]), undefined) + assert.equal(reviewDiffsForMessage(user, [user, assistant], true), undefined) + assert.equal(reviewDiffsForMessage(assistant, [user, assistant], true), undefined) +}) + +test("reviewDiffsForMessage hides changes from earlier assistant replies", () => { + const assistant: Message = { + id: "assistant-1", + sessionID: "session-1", + role: "assistant", + parentID: user.id, + time: { created: 2 }, + } + + assert.equal(reviewDiffsForMessage(assistant, [user, assistant], false), undefined) }) diff --git a/src/lib/review-diffs.ts b/src/lib/review-diffs.ts index edf69544..fbd82419 100644 --- a/src/lib/review-diffs.ts +++ b/src/lib/review-diffs.ts @@ -1,6 +1,6 @@ import type { FileDiff, Message } from "./sdk" -export function reviewDiffsForMessage(message: Message, messages: Message[]): FileDiff[] | undefined { - if (message.role !== "assistant" || !message.parentID) return undefined +export function reviewDiffsForMessage(message: Message, messages: Message[], isLastMessage: boolean): FileDiff[] | undefined { + if (!isLastMessage || message.role !== "assistant" || !message.parentID) return undefined return messages.find((item) => item.id === message.parentID)?.summary?.diffs } From 7ecd168b83feb7715d6fdc4f0773cb92f0fdca22 Mon Sep 17 00:00:00 2001 From: chliny Date: Wed, 19 Aug 2026 08:29:13 +0800 Subject: [PATCH 6/6] fix: optimize diff review display --- app/session/[id].tsx | 2 +- src/components/chat/DiffView.tsx | 10 +++- src/components/chat/ReviewChanges.tsx | 2 +- src/components/chat/diff-compute.test.ts | 10 +++- src/components/chat/patch-compute.test.ts | 8 +++ src/components/chat/patch-compute.ts | 18 ++++++- src/lib/review-diffs.test.ts | 63 +++++++++++++++++++++-- src/lib/review-diffs.ts | 8 ++- 8 files changed, 108 insertions(+), 13 deletions(-) diff --git a/app/session/[id].tsx b/app/session/[id].tsx index 36f8ea6e..78f70270 100644 --- a/app/session/[id].tsx +++ b/app/session/[id].tsx @@ -187,7 +187,7 @@ export default function SessionScreen() { .map((msg) => ({ message: msg, parts: (parts && parts[msg.id]) || [], - reviewDiffs: reviewDiffsForMessage(msg, visible, msg.id === visible.at(-1)?.id), + reviewDiffs: reviewDiffsForMessage(msg, visible), })) .reverse() }, diff --git a/src/components/chat/DiffView.tsx b/src/components/chat/DiffView.tsx index 96b3d2e7..66cb9e3e 100644 --- a/src/components/chat/DiffView.tsx +++ b/src/components/chat/DiffView.tsx @@ -9,6 +9,7 @@ export interface DiffLinesProps { lines: ReturnType isDark: boolean title?: string + maxHeight?: number } interface Props { @@ -23,7 +24,7 @@ export function DiffView({ before, after, isDark }: Props) { return } -export function DiffLinesView({ lines, isDark, title }: DiffLinesProps) { +export function DiffLinesView({ lines, isDark, title, maxHeight }: DiffLinesProps) { if (lines.length === 0) return null @@ -34,7 +35,12 @@ export function DiffLinesView({ lines, isDark, title }: DiffLinesProps) { - + {lines.map((line, idx) => ( {expanded && diff.patch && ( - + )} diff --git a/src/components/chat/diff-compute.test.ts b/src/components/chat/diff-compute.test.ts index 5e9e8b01..b2c23523 100644 --- a/src/components/chat/diff-compute.test.ts +++ b/src/components/chat/diff-compute.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test" import assert from "node:assert/strict" -import { computeDiff } from "./diff-compute.ts" +import { computeDiff, parseDiffText } from "./diff-compute.ts" // GitHub bug: computeDiff split on a literal "\n", so a CRLF `before` diffed // against an LF `after` treated every line as changed (each "line\r" !== @@ -52,3 +52,11 @@ test("computeDiff falls back to a truncated diff for huge inputs instead of hang assert.equal(last?.type, "context") assert.match(last?.text ?? "", /diff too large to display in full/) }) + +test("parseDiffText restores serialized diff line types", () => { + assert.deepEqual(parseDiffText(" context\n-removed\n+added"), [ + { type: "context", text: "context" }, + { type: "remove", text: "removed" }, + { type: "add", text: "added" }, + ]) +}) diff --git a/src/components/chat/patch-compute.test.ts b/src/components/chat/patch-compute.test.ts index f031d4be..f1ad628e 100644 --- a/src/components/chat/patch-compute.test.ts +++ b/src/components/chat/patch-compute.test.ts @@ -30,3 +30,11 @@ test("computePatchDiff parses unified diff headers", () => { { type: "add", text: "new" }, ]) }) + +test("computePatchDiff caps very long patches", () => { + const patch = `*** Update File: large.ts\n@@\n${Array.from({ length: 700 }, (_, index) => `+line ${index}`).join("\n")}` + const result = computePatchDiff(patch) + + assert.equal(result.length, 601) + assert.match(result.at(-1)?.text ?? "", /diff too large to display in full/) +}) diff --git a/src/components/chat/patch-compute.ts b/src/components/chat/patch-compute.ts index be9fd414..33c4856c 100644 --- a/src/components/chat/patch-compute.ts +++ b/src/components/chat/patch-compute.ts @@ -1,5 +1,14 @@ import type { DiffLine } from "./diff-compute" +const MAX_PATCH_LINES = 600 + +function truncationMarker(totalLines: number): DiffLine { + return { + type: "context", + text: `... diff too large to display in full (${totalLines} lines) - view on your computer`, + } +} + export function patchTextFromInput(input: unknown): string | undefined { if (typeof input === "string") return input if (typeof input !== "object" || input === null) return undefined @@ -16,8 +25,14 @@ export function patchTextFromInput(input: unknown): string | undefined { export function computePatchDiff(patch: string): DiffLine[] { const lines: DiffLine[] = [] let inHunk = false + const source = patch.split(/\r?\n/) + let truncated = false - for (const text of patch.split(/\r?\n/)) { + for (const text of source) { + if (lines.length >= MAX_PATCH_LINES) { + truncated = true + break + } if (text === "*** End Patch") continue if (text.startsWith("*** Add File:") || text.startsWith("*** Update File:") || text.startsWith("*** Delete File:")) { lines.push({ type: "context", text }) @@ -44,5 +59,6 @@ export function computePatchDiff(patch: string): DiffLine[] { if (inHunk && text.length > 0) lines.push({ type: "context", text: text.startsWith(" ") ? text.slice(1) : text }) } + if (truncated) lines.push(truncationMarker(source.length)) return lines } diff --git a/src/lib/review-diffs.test.ts b/src/lib/review-diffs.test.ts index 4b5d3103..a1b341ce 100644 --- a/src/lib/review-diffs.test.ts +++ b/src/lib/review-diffs.test.ts @@ -22,7 +22,7 @@ test("reviewDiffsForMessage links an assistant reply to its user turn", () => { time: { created: 2 }, } - assert.deepEqual(reviewDiffsForMessage(assistant, [user, assistant], true), user.summary?.diffs) + assert.deepEqual(reviewDiffsForMessage(assistant, [user, assistant]), user.summary?.diffs) }) test("reviewDiffsForMessage ignores unrelated messages", () => { @@ -34,11 +34,11 @@ test("reviewDiffsForMessage ignores unrelated messages", () => { time: { created: 3 }, } - assert.equal(reviewDiffsForMessage(user, [user, assistant], true), undefined) - assert.equal(reviewDiffsForMessage(assistant, [user, assistant], true), undefined) + assert.equal(reviewDiffsForMessage(user, [user, assistant]), undefined) + assert.equal(reviewDiffsForMessage(assistant, [user, assistant]), undefined) }) -test("reviewDiffsForMessage hides changes from earlier assistant replies", () => { +test("reviewDiffsForMessage hides changes from an earlier turn", () => { const assistant: Message = { id: "assistant-1", sessionID: "session-1", @@ -47,5 +47,58 @@ test("reviewDiffsForMessage hides changes from earlier assistant replies", () => time: { created: 2 }, } - assert.equal(reviewDiffsForMessage(assistant, [user, assistant], false), undefined) + const laterUser: Message = { + ...user, + id: "user-2", + time: { created: 3 }, + } + + assert.equal(reviewDiffsForMessage(assistant, [user, assistant, laterUser]), undefined) +}) + +test("reviewDiffsForMessage uses the last user turn in response order", () => { + const earlierAssistant: Message = { + id: "assistant-early", + sessionID: "session-1", + role: "assistant", + parentID: user.id, + time: { created: 2 }, + } + const laterUser: Message = { + ...user, + id: "user-2", + time: { created: 3 }, + } + + assert.equal(reviewDiffsForMessage(earlierAssistant, [user, earlierAssistant, laterUser]), undefined) +}) + +test("reviewDiffsForMessage keeps the current turn when no newer user exists", () => { + const assistant: Message = { + id: "assistant-1", + sessionID: "session-1", + role: "assistant", + parentID: user.id, + time: { created: 2 }, + } + + assert.deepEqual(reviewDiffsForMessage(assistant, [user, assistant]), user.summary?.diffs) +}) + +test("reviewDiffsForMessage renders once after multiple assistant messages in a turn", () => { + const firstAssistant: Message = { + id: "assistant-1", + sessionID: "session-1", + role: "assistant", + parentID: user.id, + time: { created: 2 }, + } + const lastAssistant: Message = { + ...firstAssistant, + id: "assistant-2", + time: { created: 3 }, + } + + assert.equal(reviewDiffsForMessage(firstAssistant, [user, firstAssistant, lastAssistant]), undefined) + assert.deepEqual(reviewDiffsForMessage(lastAssistant, [user, firstAssistant, lastAssistant]), user.summary?.diffs) }) diff --git a/src/lib/review-diffs.ts b/src/lib/review-diffs.ts index fbd82419..f0bb7e4c 100644 --- a/src/lib/review-diffs.ts +++ b/src/lib/review-diffs.ts @@ -1,6 +1,10 @@ import type { FileDiff, Message } from "./sdk" -export function reviewDiffsForMessage(message: Message, messages: Message[], isLastMessage: boolean): FileDiff[] | undefined { - if (!isLastMessage || message.role !== "assistant" || !message.parentID) return undefined +export function reviewDiffsForMessage(message: Message, messages: Message[]): FileDiff[] | undefined { + if (message.role !== "assistant" || !message.parentID) return undefined + const activeUser = messages.filter((item) => item.role === "user").at(-1) + if (message.parentID !== activeUser?.id) return undefined + const activeAssistants = messages.filter((item) => item.role === "assistant" && item.parentID === activeUser.id) + if (message.id !== activeAssistants.at(-1)?.id) return undefined return messages.find((item) => item.id === message.parentID)?.summary?.diffs }