diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx
index 78f86338648..3af28d39b57 100644
--- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx
@@ -9,6 +9,7 @@ import {
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useMemo, useState } from 'react'
+import { ChangedFilesCard } from '@/components/assistant-ui/thread/changed-files-card'
import {
contentHasVisibleText,
messageContentText,
@@ -34,6 +35,10 @@ import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notifyError } from '@/store/notifications'
import { $voicePlayback } from '@/store/voice-playback'
+// Stable empty identity for the settled-parts selector — a fresh [] per render
+// would re-derive the changed-files card on every message re-render.
+const EMPTY_PARTS: readonly unknown[] = []
+
interface MessageActionProps {
messageId: string
/** Lazy accessor — reads the live message text at action time. Passing the
@@ -83,6 +88,21 @@ export const AssistantMessage: FC<{
const getMessageText = useCallback(() => messageContentText(messageRuntime.getState().content), [messageRuntime])
+ // Cursor's changed-files card only appears once the turn settles: while the
+ // agent is still editing, the tool rows narrate each patch and a card that
+ // grew a row per write would thrash the transcript. `[]` while running keeps
+ // this selector referentially stable across the 30 Hz delta stream.
+ //
+ // It also only rides the LAST turn. The card is a "here's what just landed"
+ // summary, not a per-turn artifact: leaving one behind on every reply would
+ // stack a wall of stale cards down the transcript. Sending the next message
+ // retires it — the working tree it describes is already history by then.
+ const settledParts = useAuiState(s => {
+ const isLastMessage = s.thread.messages[s.thread.messages.length - 1]?.id === s.message.id
+
+ return s.message.status?.type === 'running' || !isLastMessage ? EMPTY_PARTS : s.message.parts
+ })
+
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
// Double-click the reply to heart it (iMessage). Undefined while reactions
@@ -134,6 +154,9 @@ export const AssistantMessage: FC<{
{hasVisibleText && !isInterim && (
)}
+ {/* Last thing in the turn — under the action bar, the way Cursor ends a
+ turn on its summary rather than burying it above the controls. */}
+
)
}
diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx
new file mode 100644
index 00000000000..e1b60012dbb
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx
@@ -0,0 +1,60 @@
+import { type FC, useMemo } from 'react'
+
+import { deriveChangedFiles } from '@/components/assistant-ui/thread/changed-files'
+import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
+import { DiffCount } from '@/components/ui/diff-count'
+import { FileTypeIcon } from '@/components/ui/file-type-icon'
+import { useI18n } from '@/i18n'
+import { cn } from '@/lib/utils'
+import { openReviewForPath, revealReview } from '@/store/review'
+
+/**
+ * Cursor-style "N files changed" summary closing out the newest assistant turn:
+ * one row per file it edited with that file's +/-, and a Review action opening
+ * the diff pane (⌘G). A row click opens that file's diff directly.
+ *
+ * Wears the shared `WIDGET_SHELL_CLASS` so it reads as the same panel as the
+ * transcript's other inline widgets rather than inventing its own chrome.
+ */
+export const ChangedFilesCard: FC<{ parts: readonly unknown[] }> = ({ parts }) => {
+ const { t } = useI18n()
+ const copy = t.assistant.thread
+ const files = useMemo(() => deriveChangedFiles(parts), [parts])
+
+ if (files.length === 0) {
+ return null
+ }
+
+ return (
+
+
+ {copy.filesChanged(files.length)}
+
+
+
+ {files.map(file => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/desktop/src/components/assistant-ui/thread/changed-files.ts b/apps/desktop/src/components/assistant-ui/thread/changed-files.ts
new file mode 100644
index 00000000000..1c7146be095
--- /dev/null
+++ b/apps/desktop/src/components/assistant-ui/thread/changed-files.ts
@@ -0,0 +1,69 @@
+// Pure derivation for the assistant message's "N files changed" card: fold a
+// turn's file-edit tool parts into one row per file. No React/DOM.
+
+import {
+ countDiffLineStats,
+ fileEditBasename,
+ fileEditPath,
+ inlineDiffFromResult,
+ isFileEditTool,
+ parseMaybeObject
+} from '@/components/assistant-ui/tool/fallback-model'
+
+export interface ChangedFile {
+ added: number
+ /** Basename, for the row label. */
+ name: string
+ /** Path exactly as the tool reported it (absolute or repo-relative). */
+ path: string
+ removed: number
+}
+
+interface ChangedFilePart {
+ args?: unknown
+ result?: unknown
+ toolName?: unknown
+ type?: unknown
+}
+
+/**
+ * One row per file the turn edited, in first-touched order, with the +/- of
+ * every edit to that file summed. Only landed edits with a diff count: a call
+ * still running has no result, and a failed one changed nothing.
+ */
+export function deriveChangedFiles(parts: readonly unknown[]): ChangedFile[] {
+ const byPath = new Map()
+
+ for (const raw of parts) {
+ const part = (raw ?? {}) as ChangedFilePart
+
+ if (part.type !== 'tool-call' || typeof part.toolName !== 'string' || !isFileEditTool(part.toolName)) {
+ continue
+ }
+
+ const result = parseMaybeObject(part.result)
+ const diff = inlineDiffFromResult(result)
+
+ if (!diff) {
+ continue
+ }
+
+ const path = fileEditPath(parseMaybeObject(part.args), result)
+
+ if (!path) {
+ continue
+ }
+
+ const stats = countDiffLineStats(diff)
+ const existing = byPath.get(path)
+
+ if (existing) {
+ existing.added += stats.added
+ existing.removed += stats.removed
+ } else {
+ byPath.set(path, { added: stats.added, name: fileEditBasename(path), path, removed: stats.removed })
+ }
+ }
+
+ return [...byPath.values()]
+}
diff --git a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
index 886e193702a..f1e26750023 100644
--- a/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
+++ b/apps/desktop/src/components/assistant-ui/tool/fallback-model/index.ts
@@ -57,7 +57,7 @@ export function countDiffLineStats(diff: string): DiffLineStats {
return { added, removed }
}
-function fileEditPath(args: Record, result: Record): string {
+export function fileEditPath(args: Record, result: Record): string {
return (
firstStringField(args, ['path', 'file', 'filepath']) ||
firstStringField(result, ['path', 'file', 'filepath', 'resolved_path']) ||
diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts
index 81dfb232a7c..5514e5f2db9 100644
--- a/apps/desktop/src/i18n/ar.ts
+++ b/apps/desktop/src/i18n/ar.ts
@@ -2289,6 +2289,8 @@ export const ar = defineLocale({
branchNewChat: 'تفريع إلى محادثة جديدة',
react: 'تفاعل',
dismissError: 'تجاهل الخطأ',
+ filesChanged: count => `${count} ملفات تم تغييرها`,
+ reviewChanges: 'مراجعة',
readAloudFailed: 'فشلت القراءة بصوت عال',
preparingAudio: 'جار تجهيز الصوت',
stopReading: 'إيقاف القراءة',
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index c0be4253843..40cfd8faa42 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -2712,6 +2712,8 @@ export const en: Translations = {
branchNewChat: 'Branch in new chat',
react: 'React',
dismissError: 'Dismiss error',
+ filesChanged: count => (count === 1 ? '1 file changed' : `${count} files changed`),
+ reviewChanges: 'Review',
readAloudFailed: 'Read aloud failed',
preparingAudio: 'Preparing audio...',
stopReading: 'Stop reading',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index 642dfd321e5..dae2d281acc 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -2538,6 +2538,8 @@ export const ja = defineLocale({
branchNewChat: '新しいチャットでブランチ',
react: 'リアクション',
dismissError: 'エラーを閉じる',
+ filesChanged: count => `${count} 件のファイルを変更`,
+ reviewChanges: 'レビュー',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 20e32d7de71..ab66d9a60cf 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -2309,6 +2309,8 @@ export interface Translations {
branchNewChat: string
react: string
dismissError: string
+ filesChanged: (count: number) => string
+ reviewChanges: string
readAloudFailed: string
preparingAudio: string
stopReading: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index 895ee96f9be..ceec11cae4e 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -2456,6 +2456,8 @@ export const zhHant = defineLocale({
branchNewChat: '在新聊天中分支',
react: '回應',
dismissError: '关闭错误',
+ filesChanged: count => `${count} 個檔案已變更`,
+ reviewChanges: '檢視',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index d852f796fd5..3d1d18c7045 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -2888,6 +2888,8 @@ export const zh: Translations = {
branchNewChat: '在新对话中分支',
react: '回应',
dismissError: '关闭错误',
+ filesChanged: count => `${count} 个文件已更改`,
+ reviewChanges: '查看',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',
stopReading: '停止朗读',
diff --git a/apps/desktop/src/store/review.ts b/apps/desktop/src/store/review.ts
index 79a70947af3..df1f2696d82 100644
--- a/apps/desktop/src/store/review.ts
+++ b/apps/desktop/src/store/review.ts
@@ -274,8 +274,62 @@ export function toggleReview(): void {
if ($reviewOpen.get()) {
closeReview()
} else {
+ revealReview()
+ }
+}
+
+/**
+ * Open the review pane and bring it into view. Unlike `toggleReview` this never
+ * closes an already-open pane — it's the "take me to the diff" entry point used
+ * by the transcript's changed-files card.
+ */
+export function revealReview(): void {
+ const wasOpen = $reviewOpen.get()
+
+ if (!wasOpen) {
openReview()
- revealTreePane(REVIEW_PANE_ID)
+ }
+
+ if (matchesQuery(SIDEBAR_COLLAPSE_MEDIA_QUERY)) {
+ // The reveal pin is a toggle, so only fire it when the overlay isn't
+ // already slid in — otherwise "show me the diff" would hide the pane.
+ if (!wasOpen) {
+ window.dispatchEvent(new CustomEvent(PANE_TOGGLE_REVEAL_EVENT, { detail: { id: REVIEW_PANE_ID } }))
+ }
+
+ return
+ }
+
+ revealTreePane(REVIEW_PANE_ID)
+}
+
+/** The changed file matching a tool-reported path (absolute or repo-relative). */
+function matchReviewFile(files: readonly HermesReviewFile[], path: string): HermesReviewFile | undefined {
+ const target = path.replace(/\\/g, '/').replace(/\/+$/, '')
+
+ if (!target) {
+ return undefined
+ }
+
+ return files.find(file => {
+ const candidate = file.path.replace(/\\/g, '/')
+
+ return candidate === target || target.endsWith(`/${candidate}`) || candidate.endsWith(`/${target}`)
+ })
+}
+
+/**
+ * Open the review pane on one file's diff. The path comes from a tool call, so
+ * it may be absolute while git reports repo-relative — match on the tail.
+ */
+export async function openReviewForPath(path: string): Promise {
+ revealReview()
+ await refreshReview()
+
+ const file = matchReviewFile($reviewFiles.get(), path)
+
+ if (file) {
+ await selectReviewFile(file)
}
}