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() {
}}
>
+
+ {lines.map((line, index) => (
+
+
+ {line.type === "add" ? "+" : line.type === "remove" ? "-" : " "}
+
+
+ {line.text}
+
+
+ ))}
+
+ )
+}
+
+export default function ContentViewerScreen() {
+ const router = useRouter()
+ const insets = useSafeAreaInsets()
+ const isDark = useColorScheme() === "dark"
+ const { t } = useTranslation()
+ const [copied, setCopied] = useState(false)
+ const viewer = getContentViewer()
+
+ if (!viewer) {
+ return (
+
+ {t("chat.contentViewer.empty")}
+
+ )
+ }
+
+ const isDiff = viewer.language === "diff"
+ const diffLines = isDiff ? parseDiffText(viewer.content) : []
+
+ 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")}
+
+
+ {isDiff ? : {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" },
+ 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" },
+ textDark: { color: "#fff" },
+})
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/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..66cb9e3e 100644
--- a/src/components/chat/DiffView.tsx
+++ b/src/components/chat/DiffView.tsx
@@ -1,9 +1,17 @@
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"
+export interface DiffLinesProps {
+ lines: ReturnType
+ isDark: boolean
+ title?: string
+ maxHeight?: number
+}
+
interface Props {
before: string
after: string
@@ -13,11 +21,26 @@ interface Props {
export function DiffView({ before, after, isDark }: Props) {
const lines = computeDiff(before, after)
+ return
+}
+
+export function DiffLinesView({ lines, isDark, title, maxHeight }: DiffLinesProps) {
+
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) => (
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..74471fa1
--- /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/ToolCallCard.tsx b/src/components/chat/ToolCallCard.tsx
index b7361c11..05dbe1dc 100644
--- a/src/components/chat/ToolCallCard.tsx
+++ b/src/components/chat/ToolCallCard.tsx
@@ -3,7 +3,9 @@ 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, patchTextFromInput } from "./patch-compute"
+import { ContentViewerButton } from "./ContentViewerButton"
const TOOL_ICONS: Record = {
read: "glasses-outline",
@@ -34,6 +36,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 +54,9 @@ function BashDetail({ input, output, isDark }: { input: unknown; output: unknown
{typeof cmd === "string" && (
+
+
+
$
{cmd}
@@ -48,11 +64,7 @@ function BashDetail({ input, output, isDark }: { input: unknown; output: unknown
)}
{out !== undefined && out.length > 0 && (
-
-
- {out}
-
-
+
)}
)
@@ -86,11 +98,7 @@ function WriteDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
)}
{typeof content === "string" && content.length > 0 && (
-
-
- {content}
-
-
+
)}
)
@@ -126,26 +134,18 @@ function EditDetail({ input, output, isDark }: { input: unknown; output: unknown
)}
{text && (
-
-
- {text}
-
-
+
)}
)
}
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 && (
-
-
- {patch}
-
-
+
)}
)
@@ -166,11 +166,7 @@ function GlobGrepDetail({ input, output, isDark }: { input: unknown; output: unk
)}
{results && results.length > 0 && (
-
-
- {results}
-
-
+
)}
)
@@ -197,11 +193,7 @@ function TaskDetail({ input, isDark }: { input: unknown; isDark: boolean }) {
{typeof description === "string" && {description}}
{typeof prompt === "string" && prompt.length > 0 && (
-
-
- {prompt}
-
-
+
)}
)
@@ -244,11 +236,7 @@ function GenericDetail({ input, output, isDark }: { input: unknown; output: unkn
if (!text || text.length === 0) return null
return (
-
-
- {text}
-
-
+
)
}
@@ -314,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)
@@ -436,6 +425,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/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/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/index.ts b/src/components/chat/index.ts
index 4df6472f..c837773d 100644
--- a/src/components/chat/index.ts
+++ b/src/components/chat/index.ts
@@ -12,3 +12,5 @@ export { ImageAttachments, type Attachment } from "./ImageAttachments"
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..f1ad628e
--- /dev/null
+++ b/src/components/chat/patch-compute.test.ts
@@ -0,0 +1,40 @@
+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" },
+ ])
+})
+
+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
new file mode 100644
index 00000000..33c4856c
--- /dev/null
+++ b/src/components/chat/patch-compute.ts
@@ -0,0 +1,64 @@
+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
+
+ 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.
+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 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 })
+ 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 })
+ }
+
+ if (truncated) lines.push(truncationMarker(source.length))
+ return lines
+}
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..32b7479d 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,16 @@
"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",
+ "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..2a6021b3 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,16 @@
"patternOnly": "模式:{{pattern}}",
"patternWithPath": "模式:{{pattern}},路径:{{path}}"
},
+ "reviewChanges": {
+ "title": "本次任务变更",
+ "files_one": "{{count}} 个文件",
+ "files_other": "{{count}} 个文件"
+ },
+ "contentViewer": {
+ "open": "全屏查看",
+ "output": "输出",
+ "empty": "内容已不可用"
+ },
"sessionInfo": {
"noUsageData": "暂无使用数据",
"loading": "加载中...",
diff --git a/src/lib/review-diffs.test.ts b/src/lib/review-diffs.test.ts
new file mode 100644
index 00000000..a1b341ce
--- /dev/null
+++ b/src/lib/review-diffs.test.ts
@@ -0,0 +1,104 @@
+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)
+})
+
+test("reviewDiffsForMessage hides changes from an earlier turn", () => {
+ const assistant: Message = {
+ id: "assistant-1",
+ sessionID: "session-1",
+ role: "assistant",
+ parentID: user.id,
+ time: { created: 2 },
+ }
+
+ 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
new file mode 100644
index 00000000..f0bb7e4c
--- /dev/null
+++ b/src/lib/review-diffs.ts
@@ -0,0 +1,10 @@
+import type { FileDiff, Message } from "./sdk"
+
+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
+}
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