From dd4eadcf7939d7b80ff9ed04aab04598415eca24 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Thu, 30 Jul 2026 05:11:58 -0500 Subject: [PATCH] A finished turn ends on its changed files (#74732) * feat(desktop): open the review pane on a given file toggleReview is a toggle, so it can't back a "take me to the diff" affordance -- pressing it when the pane is already up hides the thing you asked to see. revealReview is the open-only half (toggleReview now calls it for its own open branch), and openReviewForPath goes one further: refresh, then select the file. A tool reports the path it wrote absolute while git reports repo-relative, so the two are matched on the tail. fileEditPath is exported for the same reason -- the caller needs the same path the tool row derives. * feat(desktop): derive a turn's changed files from its tool parts A finished turn already carries everything the summary needs: each file-edit tool part holds the path it touched and the inline diff it produced. Folding those into one row per file, with repeat edits to a file summed, means the card costs no extra git probe. Only landed edits count -- a call still running has no result, and a failed one changed nothing. * i18n(desktop): copy for the changed-files card * feat(desktop): close a turn on its changed-files card A turn that edited files now ends with a summary panel: one row per file with its +/-, a Review action opening the diff pane, and a row click opening that file's diff. It rides only the newest message. The card describes a working tree, and that tree has moved on by the next turn -- so rather than leaving a trail of stale cards down the transcript, sending the next message retires it. While the turn is still streaming the selector returns a stable empty list, so the tool rows narrate the edits and the delta stream never re-renders the card. --- .../assistant-ui/thread/assistant-message.tsx | 23 +++++++ .../thread/changed-files-card.tsx | 60 ++++++++++++++++ .../assistant-ui/thread/changed-files.ts | 69 +++++++++++++++++++ .../assistant-ui/tool/fallback-model/index.ts | 2 +- apps/desktop/src/i18n/ar.ts | 2 + apps/desktop/src/i18n/en.ts | 2 + apps/desktop/src/i18n/ja.ts | 2 + apps/desktop/src/i18n/types.ts | 2 + apps/desktop/src/i18n/zh-hant.ts | 2 + apps/desktop/src/i18n/zh.ts | 2 + apps/desktop/src/store/review.ts | 56 ++++++++++++++- 11 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/assistant-ui/thread/changed-files-card.tsx create mode 100644 apps/desktop/src/components/assistant-ui/thread/changed-files.ts 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) } }