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.
This commit is contained in:
brooklyn! 2026-07-30 05:11:58 -05:00 committed by GitHub
parent 3b47e0c436
commit dd4eadcf79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 220 additions and 2 deletions

View file

@ -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 && (
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
)}
{/* 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. */}
<ChangedFilesCard parts={settledParts} />
</MessagePrimitive.Root>
)
}

View file

@ -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 (
<div
className={cn(WIDGET_SHELL_CLASS, 'mt-1.5 text-[length:var(--conversation-tool-font-size)]')}
data-slot="aui_changed-files"
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-(--ui-text-primary)">{copy.filesChanged(files.length)}</span>
<button
className="shrink-0 cursor-pointer text-(--ui-text-tertiary) transition-colors hover:text-(--ui-text-primary)"
onClick={revealReview}
type="button"
>
{copy.reviewChanges}
</button>
</div>
<div className="mt-1.5 flex flex-col">
{files.map(file => (
<button
className="row-hover -mx-1.5 flex items-center gap-2 rounded-md px-1.5 py-1 text-left"
key={file.path}
onClick={() => void openReviewForPath(file.path)}
title={file.path}
type="button"
>
<FileTypeIcon className="shrink-0 text-(--ui-text-tertiary)" path={file.path} size="0.875rem" />
<span className="min-w-0 flex-1 truncate text-(--ui-text-secondary)">{file.name}</span>
<DiffCount added={file.added} removed={file.removed} />
</button>
))}
</div>
</div>
)
}

View file

@ -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<string, ChangedFile>()
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()]
}

View file

@ -57,7 +57,7 @@ export function countDiffLineStats(diff: string): DiffLineStats {
return { added, removed }
}
function fileEditPath(args: Record<string, unknown>, result: Record<string, unknown>): string {
export function fileEditPath(args: Record<string, unknown>, result: Record<string, unknown>): string {
return (
firstStringField(args, ['path', 'file', 'filepath']) ||
firstStringField(result, ['path', 'file', 'filepath', 'resolved_path']) ||

View file

@ -2289,6 +2289,8 @@ export const ar = defineLocale({
branchNewChat: 'تفريع إلى محادثة جديدة',
react: 'تفاعل',
dismissError: 'تجاهل الخطأ',
filesChanged: count => `${count} ملفات تم تغييرها`,
reviewChanges: 'مراجعة',
readAloudFailed: 'فشلت القراءة بصوت عال',
preparingAudio: 'جار تجهيز الصوت',
stopReading: 'إيقاف القراءة',

View file

@ -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',

View file

@ -2538,6 +2538,8 @@ export const ja = defineLocale({
branchNewChat: '新しいチャットでブランチ',
react: 'リアクション',
dismissError: 'エラーを閉じる',
filesChanged: count => `${count} 件のファイルを変更`,
reviewChanges: 'レビュー',
readAloudFailed: '読み上げに失敗しました',
preparingAudio: '音声を準備中...',
stopReading: '読み上げを停止',

View file

@ -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

View file

@ -2456,6 +2456,8 @@ export const zhHant = defineLocale({
branchNewChat: '在新聊天中分支',
react: '回應',
dismissError: '关闭错误',
filesChanged: count => `${count} 個檔案已變更`,
reviewChanges: '檢視',
readAloudFailed: '朗讀失敗',
preparingAudio: '正在準備音訊...',
stopReading: '停止朗讀',

View file

@ -2888,6 +2888,8 @@ export const zh: Translations = {
branchNewChat: '在新对话中分支',
react: '回应',
dismissError: '关闭错误',
filesChanged: count => `${count} 个文件已更改`,
reviewChanges: '查看',
readAloudFailed: '朗读失败',
preparingAudio: '正在准备音频...',
stopReading: '停止朗读',

View file

@ -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<void> {
revealReview()
await refreshReview()
const file = matchReviewFile($reviewFiles.get(), path)
if (file) {
await selectReviewFile(file)
}
}