mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
parent
3b47e0c436
commit
dd4eadcf79
11 changed files with 220 additions and 2 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()]
|
||||
}
|
||||
|
|
@ -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']) ||
|
||||
|
|
|
|||
|
|
@ -2289,6 +2289,8 @@ export const ar = defineLocale({
|
|||
branchNewChat: 'تفريع إلى محادثة جديدة',
|
||||
react: 'تفاعل',
|
||||
dismissError: 'تجاهل الخطأ',
|
||||
filesChanged: count => `${count} ملفات تم تغييرها`,
|
||||
reviewChanges: 'مراجعة',
|
||||
readAloudFailed: 'فشلت القراءة بصوت عال',
|
||||
preparingAudio: 'جار تجهيز الصوت',
|
||||
stopReading: 'إيقاف القراءة',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -2538,6 +2538,8 @@ export const ja = defineLocale({
|
|||
branchNewChat: '新しいチャットでブランチ',
|
||||
react: 'リアクション',
|
||||
dismissError: 'エラーを閉じる',
|
||||
filesChanged: count => `${count} 件のファイルを変更`,
|
||||
reviewChanges: 'レビュー',
|
||||
readAloudFailed: '読み上げに失敗しました',
|
||||
preparingAudio: '音声を準備中...',
|
||||
stopReading: '読み上げを停止',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2456,6 +2456,8 @@ export const zhHant = defineLocale({
|
|||
branchNewChat: '在新聊天中分支',
|
||||
react: '回應',
|
||||
dismissError: '关闭错误',
|
||||
filesChanged: count => `${count} 個檔案已變更`,
|
||||
reviewChanges: '檢視',
|
||||
readAloudFailed: '朗讀失敗',
|
||||
preparingAudio: '正在準備音訊...',
|
||||
stopReading: '停止朗讀',
|
||||
|
|
|
|||
|
|
@ -2888,6 +2888,8 @@ export const zh: Translations = {
|
|||
branchNewChat: '在新对话中分支',
|
||||
react: '回应',
|
||||
dismissError: '关闭错误',
|
||||
filesChanged: count => `${count} 个文件已更改`,
|
||||
reviewChanges: '查看',
|
||||
readAloudFailed: '朗读失败',
|
||||
preparingAudio: '正在准备音频...',
|
||||
stopReading: '停止朗读',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue