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