diff --git a/crates/agent-gateway/web/src/app/GatewayApp.tsx b/crates/agent-gateway/web/src/app/GatewayApp.tsx index 54ad789ab..d594000d7 100644 --- a/crates/agent-gateway/web/src/app/GatewayApp.tsx +++ b/crates/agent-gateway/web/src/app/GatewayApp.tsx @@ -119,8 +119,14 @@ function isLocalDraftConversationId(id: string) { return id.trim().startsWith(LOCAL_DRAFT_PREFIX); } +import { + type ChangedFilesActions, + ChangedFilesActionsProvider, +} from "@/components/chat/ChangedFilesCard"; import { HistoryShareModal } from "@/components/chat/HistoryShareModal"; import { GatewayTranscript, type GatewayTranscriptNavHandle } from "@/components/GatewayTranscript"; +import type { GitReviewFocusRequest } from "@/components/project-tools/RightDockContext"; +import { expandedPathsForFileTreePath } from "@/components/project-tools/rightDockModel"; import { buildFloorEntries } from "@/lib/chat-floor-nav/floorModel"; import { useScrollFollow } from "@/lib/chat-scroll/useScrollFollow"; import { parseHistoryShareToken } from "@/lib/historyShare"; @@ -3658,6 +3664,59 @@ export default function GatewayApp() { () => (api ? createGatewayWorkspaceActivityClient(api) : null), [api], ); + // ── 回复末尾「已编辑文件」卡的三个动作 ──────────────────────────────── + const gitReviewFocusNonceRef = useRef(0); + const [gitReviewFocusRequest, setGitReviewFocusRequest] = useState( + null, + ); + const handleGitReviewFocusRequestHandled = useCallback((nonce: number) => { + setGitReviewFocusRequest((current) => (current && current.nonce === nonce ? null : current)); + }, []); + const handleChangedFileOpenDiff = useCallback( + (path: string | null) => { + if (!terminalProjectPathKey) return; + setRightDockOpen(true); + setSettings((prev) => openRightDockSingletonTab(prev, terminalProjectPathKey, "gitReview")); + gitReviewFocusNonceRef.current += 1; + setGitReviewFocusRequest({ + path: (path ?? "").trim(), + nonce: gitReviewFocusNonceRef.current, + }); + }, + [setSettings, terminalProjectPathKey], + ); + const handleChangedFileReveal = useCallback( + (path: string) => { + if (!terminalProjectPathKey) return; + const selectedPath = path + .trim() + .replace(/\\/g, "/") + .replace(/^\/+|\/+$/g, ""); + if (!selectedPath) return; + setRightDockOpen(true); + setSettings((prev) => { + const opened = openRightDockSingletonTab(prev, terminalProjectPathKey, "fileTree"); + const current = getRightDockFileTreeState(opened.customSettings, terminalProjectPathKey); + return updateRightDockFileTreeState(opened, terminalProjectPathKey, { + query: "", + selectedPath, + expandedPaths: Array.from( + new Set([...current.expandedPaths, ...expandedPathsForFileTreePath(selectedPath)]), + ), + bumpRevision: true, + }); + }); + }, + [setSettings, terminalProjectPathKey], + ); + const changedFilesActions = useMemo( + () => ({ + onOpenFile: handleOpenWorkspaceFile, + onRevealInFileTree: handleChangedFileReveal, + onOpenDiff: handleChangedFileOpenDiff, + }), + [handleChangedFileOpenDiff, handleChangedFileReveal, handleOpenWorkspaceFile], + ); // RightDockPanel is memo'd: every callback handed to it must be stable or // the memo boundary is void (see the panel-side context useMemo). const handleRightDockWidthChange = useCallback( @@ -4207,40 +4266,42 @@ export default function GatewayApp() { viewportRef={setTranscriptViewport} className="gateway-transcript-scroll" > - 0} - onOpenSettings={openSettings} - hasMoreHistory={selectedHistoryHasMore} - isLoadingMoreHistory={loadingOlderHistory} - onLoadFullHistory={ - selectedHistoryHasMore ? handleLoadFullHistory : undefined - } - isAgentMode={isAgentMode} - showUsage={isAgentDevExecutionMode} - usageContextWindow={currentModelContextWindow} - workspaceRoot={displayedConversationWorkdir} - gitClient={gitClient} - onLoadUploadedImagePreview={handleLoadUploadedImagePreview} - onResendFromEdit={handleResendFromEdit} - onBranchConversation={handleBranchConversation} - branchPendingMessageId={branchPendingMessageId} - onSuggestionSelect={handleEmptyStateSuggestion} - suggestionsDisabled={isSuggestionTyping} - /> + + 0} + onOpenSettings={openSettings} + hasMoreHistory={selectedHistoryHasMore} + isLoadingMoreHistory={loadingOlderHistory} + onLoadFullHistory={ + selectedHistoryHasMore ? handleLoadFullHistory : undefined + } + isAgentMode={isAgentMode} + showUsage={isAgentDevExecutionMode} + usageContextWindow={currentModelContextWindow} + workspaceRoot={displayedConversationWorkdir} + gitClient={gitClient} + onLoadUploadedImagePreview={handleLoadUploadedImagePreview} + onResendFromEdit={handleResendFromEdit} + onBranchConversation={handleBranchConversation} + branchPendingMessageId={branchPendingMessageId} + onSuggestionSelect={handleEmptyStateSuggestion} + suggestionsDisabled={isSuggestionTyping} + /> + {displayedTranscriptRowCount > 0 && !conversationOpenState.showOverlay ? ( void; + onRevealInFileTree?: (path: string) => void; + /** null = open the review panel without focusing a specific file. */ + onOpenDiff?: (path: string | null) => void; +}; + +const ChangedFilesActionsContext = createContext(null); + +export const ChangedFilesActionsProvider = ChangedFilesActionsContext.Provider; + +export function useChangedFilesActions(): ChangedFilesActions | null { + return useContext(ChangedFilesActionsContext); +} + +function splitPath(path: string): { dir: string; base: string } { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index < 0) return { dir: "", base: normalized }; + return { dir: normalized.slice(0, index + 1), base: normalized.slice(index + 1) }; +} + +const ROW_ACTION_CLASS = + "flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-foreground/[0.07] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none group-hover/changed-file:opacity-100"; + +const ChangedFileRow = memo(function ChangedFileRow({ file }: { file: ChangedFileEntry }) { + const { t } = useLocale(); + const actions = useChangedFilesActions(); + const { dir, base } = splitPath(file.path); + const canOpen = Boolean(actions?.onOpenFile) && !file.deleted; + const FileTypeIcon = getFileTypeIcon(file.path, "file"); + + const pathLabel = ( + + {dir ? {dir} : null} + + {base} + + + ); + + return ( +
+ + {canOpen ? ( + + ) : ( + {pathLabel} + )} + {file.deleted ? ( + + {t("chat.changedFiles.deleted")} + + ) : ( + + )} + {actions?.onRevealInFileTree ? ( + + ) : null} + {actions?.onOpenDiff ? ( + + ) : null} +
+ ); +}); + +export const ChangedFilesCard = memo(function ChangedFilesCard({ + summary, +}: { + summary: ChangedFilesSummary; +}) { + const { t } = useLocale(); + const actions = useChangedFilesActions(); + const title = useMemo(() => { + const key = + summary.files.length === 1 ? "chat.changedFiles.titleOne" : "chat.changedFiles.title"; + return t(key).replace("{count}", String(summary.files.length)); + }, [summary.files.length, t]); + + return ( +
+
+
+ +
+
+ + {title} + + +
+ {actions?.onOpenDiff ? ( + + ) : null} +
+ {/* 最多露出 5 行,更多文件走内部滚动条。 */} +
+ {summary.files.map((file) => ( + + ))} +
+
+ ); +}); diff --git a/crates/agent-gateway/web/src/components/project-tools/RightDockContext.tsx b/crates/agent-gateway/web/src/components/project-tools/RightDockContext.tsx index 8314f44cc..4efe966a9 100644 --- a/crates/agent-gateway/web/src/components/project-tools/RightDockContext.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/RightDockContext.tsx @@ -43,10 +43,22 @@ export type RightDockFileTreeContext = { onRevealInFileTree: (path: string) => void; }; +// One-shot focus request from the chat layer (reply-footer changed-files +// card): switch GitReview to the changes view and select `path`'s diff. +// Consumers must call onFocusRequestHandled(nonce) after applying so the +// request never replays on a later panel remount. +export type GitReviewFocusRequest = { + /** Empty string = just open the changes view without picking a file. */ + path: string; + nonce: number; +}; + export type RightDockGitContext = { onInsertCodeReviewSkill?: () => void; onInsertCommitMention?: (commit: GitCommitContextPayload) => void; onInsertGitFileMention?: (file: GitFileContextPayload) => void; + focusRequest?: GitReviewFocusRequest | null; + onFocusRequestHandled?: (nonce: number) => void; }; export type RightDockSshContext = { diff --git a/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx b/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx index f137cb3a6..1d8fbecc2 100644 --- a/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-gateway/web/src/components/project-tools/RightDockPanel.tsx @@ -29,7 +29,11 @@ import { Button } from "../ui/button"; import type { GitCommitContextPayload, GitFileContextPayload } from "./git-review"; import type { LocalTunnelClient } from "./LocalTunnelPanel"; import { RightDockContent } from "./RightDockContent"; -import { RightDockToolContext, type RightDockToolContextValue } from "./RightDockContext"; +import { + type GitReviewFocusRequest, + RightDockToolContext, + type RightDockToolContextValue, +} from "./RightDockContext"; import { RightDockChooser, RightDockCreateMenu } from "./RightDockLauncher"; import { RightDockTabStrip } from "./RightDockTabStrip"; import { @@ -83,6 +87,8 @@ type RightDockPanelProps = { onInsertCodeReviewSkill?: () => void; onInsertCommitMention?: (commit: GitCommitContextPayload) => void; onInsertGitFileMention?: (file: GitFileContextPayload) => void; + gitReviewFocusRequest?: GitReviewFocusRequest | null; + onGitReviewFocusRequestHandled?: (nonce: number) => void; onClose?: () => void; }; @@ -360,6 +366,8 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onInsertCodeReviewSkill, onInsertCommitMention, onInsertGitFileMention, + gitReviewFocusRequest, + onGitReviewFocusRequestHandled, onClose, } = props; const { t } = useLocale(); @@ -659,6 +667,8 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onInsertCodeReviewSkill, onInsertCommitMention, onInsertGitFileMention, + focusRequest: gitReviewFocusRequest, + onFocusRequestHandled: onGitReviewFocusRequestHandled, }, ssh: { hosts: sshHosts, @@ -682,8 +692,10 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel forgetTerminalSession, gitClient, gitDisabledMessage, + gitReviewFocusRequest, gitWriteEnabled, onFileTreeStateChange, + onGitReviewFocusRequestHandled, onInsertCodeReviewSkill, onInsertCommitMention, onInsertFileMention, diff --git a/crates/agent-gateway/web/src/components/project-tools/git-review/useGitReviewData.ts b/crates/agent-gateway/web/src/components/project-tools/git-review/useGitReviewData.ts index c0a2eb02e..921bc742e 100644 --- a/crates/agent-gateway/web/src/components/project-tools/git-review/useGitReviewData.ts +++ b/crates/agent-gateway/web/src/components/project-tools/git-review/useGitReviewData.ts @@ -1146,6 +1146,23 @@ export function useGitReviewData(options: UseGitReviewDataOptions) { [loadDiffForPath], ); + // Chat-layer focus requests (reply-footer changed-files card): once the + // panel is active and the repository status is ready, jump to the changes + // view and select the requested file's diff. The handled callback retires + // the request so it never replays on a later remount. + const gitFocusRequest = context.git.focusRequest ?? null; + const onGitFocusRequestHandled = context.git.onFocusRequestHandled; + useEffect(() => { + if (!active || !gitFocusRequest) return; + if (state.status !== "ready") return; + setReviewMode("changes"); + const path = gitFocusRequest.path.trim(); + if (path) { + selectPath(path); + } + onGitFocusRequestHandled?.(gitFocusRequest.nonce); + }, [active, gitFocusRequest, onGitFocusRequestHandled, selectPath, state.status]); + const selectCommitRow = useCallback( (commit: GitCommitSummary) => { selectedCommitShaRef.current = commit.sha; diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index fc8fbdefd..4d962c55a 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -127,6 +127,15 @@ export const translations: Record> = { "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,提示词可队列发送...", "chat.inputHintWithSkills": "输入消息,@ 引用文件,/ 引用Skills,提示词可队列发送...", + "chat.changedFiles.title": "已编辑 {count} 个文件", + "chat.changedFiles.titleOne": "已编辑 1 个文件", + "chat.changedFiles.review": "审核", + "chat.changedFiles.open": "打开文件", + "chat.changedFiles.reveal": "在文件树中定位", + "chat.changedFiles.diff": "查看 Diff", + "chat.changedFiles.deleted": "已删除", + "chat.changedFiles.collapse": "折叠文件列表", + "chat.changedFiles.expand": "展开文件列表", "chat.compactingContext": "正在压缩上下文", "chat.compactingContextWait": "正在压缩上下文,请稍候...", "chat.editMessage": "编辑消息", @@ -1990,6 +1999,15 @@ export const translations: Record> = { "chat.inputHint": "Type a message, @ to reference files, prompts can be queued...", "chat.inputHintWithSkills": "Type a message, @ to reference files, / to reference Skills, prompts can be queued...", + "chat.changedFiles.title": "Edited {count} files", + "chat.changedFiles.titleOne": "Edited 1 file", + "chat.changedFiles.review": "Review", + "chat.changedFiles.open": "Open file", + "chat.changedFiles.reveal": "Reveal in file tree", + "chat.changedFiles.diff": "View diff", + "chat.changedFiles.deleted": "Deleted", + "chat.changedFiles.collapse": "Collapse file list", + "chat.changedFiles.expand": "Expand file list", "chat.compactingContext": "Compressing context", "chat.compactingContextWait": "Compressing context, please wait...", "chat.editMessage": "Edit Message", diff --git a/crates/agent-gateway/web/src/lib/chat/changedFiles.ts b/crates/agent-gateway/web/src/lib/chat/changedFiles.ts new file mode 100644 index 000000000..9e7ad41da --- /dev/null +++ b/crates/agent-gateway/web/src/lib/chat/changedFiles.ts @@ -0,0 +1,121 @@ +// Aggregates one assistant reply's Write/Edit/Delete tool trace into the +// reply-footer changed-files card: per-file +N/-N line stats plus a deleted +// flag, deduped by path across every round of the reply. Only settled, +// successful tool results count — a failed or still-streaming operation +// changed nothing yet. +import { deriveFileChangeStats, type FileChangeStats } from "./fileChangeStats"; +import type { UiRound } from "./uiMessages"; + +type ToolBlockItem = Extract["item"]; + +export type ChangedFileEntry = { + /** Tool-reported path (relative to the conversation workdir when possible). */ + path: string; + added: number; + removed: number; + /** The file's final state within this reply is "deleted". */ + deleted: boolean; + /** Tool call id of the last operation touching the file — stable render key. */ + lastToolCallId: string; +}; + +export type ChangedFilesSummary = { + files: ChangedFileEntry[]; + totalAdded: number; + totalRemoved: number; +}; + +const FILE_CHANGE_TOOL_NAMES = new Set(["Write", "Edit", "Delete"]); + +// The card re-aggregates on every streaming delta of a live reply, but the +// tool calls it counts are settled (identity-stable) objects — memoize the +// per-call diff so the 200k-char Edit diff never reruns per delta. +const statsByToolCall = new WeakMap(); + +function statsForToolCall(toolCall: ToolBlockItem["toolCall"]): FileChangeStats | undefined { + const cached = statsByToolCall.get(toolCall); + if (cached !== undefined) return cached ?? undefined; + const stats = deriveFileChangeStats(toolCall) ?? null; + statsByToolCall.set(toolCall, stats); + return stats ?? undefined; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toolResultDetails(item: ToolBlockItem): Record { + const details = item.toolResult?.details; + return details && typeof details === "object" && !Array.isArray(details) + ? (details as Record) + : {}; +} + +function resolveEntryPath(item: ToolBlockItem, details: Record): string { + return ( + readString(details.displayPath) || + readString(details.relativePath) || + readString(details.path) || + readString(item.toolCall.arguments?.path) + ); +} + +// Dedup key: same file edited twice must merge even when one op reported a +// backslash path and the other a forward-slash one. +function normalizePathKey(path: string): string { + return path.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase(); +} + +export function collectChangedFiles( + rounds: readonly Pick[], +): ChangedFilesSummary | null { + const byKey = new Map(); + + for (const round of rounds) { + for (const block of round.blocks) { + if (block.kind !== "tool") continue; + const item = block.item; + const { toolCall, toolResult } = item; + if (!FILE_CHANGE_TOOL_NAMES.has(toolCall.name)) continue; + if (!toolResult || toolResult.isError) continue; + + const details = toolResultDetails(item); + const path = resolveEntryPath(item, details); + if (!path) continue; + + const key = normalizePathKey(path); + const entry = byKey.get(key) ?? { + path, + added: 0, + removed: 0, + deleted: false, + lastToolCallId: "", + }; + + if (toolCall.name === "Delete") { + entry.deleted = true; + } else { + const stats = statsForToolCall(toolCall); + entry.added += stats?.added ?? 0; + entry.removed += stats?.removed ?? 0; + // A Write after a Delete re-creates the file. + entry.deleted = false; + } + + entry.path = path; + entry.lastToolCallId = toolCall.id || entry.lastToolCallId; + byKey.set(key, entry); + } + } + + if (byKey.size === 0) return null; + + const files = Array.from(byKey.values()); + let totalAdded = 0; + let totalRemoved = 0; + for (const file of files) { + totalAdded += file.added; + totalRemoved += file.removed; + } + return { files, totalAdded, totalRemoved }; +} diff --git a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx index 6abe5bc45..6f4c77c0a 100644 --- a/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx +++ b/crates/agent-gateway/web/src/pages/chat/AssistantBubble.tsx @@ -1,4 +1,6 @@ import { memo, useMemo } from "react"; +import { ChangedFilesCard } from "../../components/chat/ChangedFilesCard"; +import { collectChangedFiles } from "../../lib/chat/changedFiles"; import type { UiRound } from "../../lib/chat/uiMessages"; import { AssistantAvatar } from "./assistant-bubble/AssistantAvatar"; import { RoundContent } from "./assistant-bubble/RoundContent"; @@ -57,6 +59,12 @@ export const AssistantBubble = memo(function AssistantBubble(props: { } return null; }, [rounds]); + // 回复末尾的已编辑文件卡:聚合整条回复所有 round 的 Write/Edit/Delete, + // 只在回复结束(流停止)后出现;脱敏视图(分享页隐藏工具内容)不渲染。 + const changedFiles = useMemo( + () => (isStreaming || redactToolContent ? null : collectChangedFiles(rounds)), + [isStreaming, redactToolContent, rounds], + ); return (
@@ -81,6 +89,7 @@ export const AssistantBubble = memo(function AssistantBubble(props: { latestTodoItem={latestTodoItem} /> ))} + {changedFiles ? : null}
); diff --git a/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx b/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx index 36dd6a6ac..0eed0753a 100644 --- a/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx +++ b/crates/agent-gateway/web/src/pages/chat/FileToolArgs.tsx @@ -1,3 +1,5 @@ +import { useChangedFilesActions } from "../../components/chat/ChangedFilesCard"; +import { useLocale } from "../../i18n"; import type { FileToolFieldPreview, FileToolPreview } from "../../lib/chat/toolPreview"; import { EditDiffView } from "./EditDiffView"; import { @@ -56,13 +58,30 @@ function StreamingTextPreviewSurface({ } function PathSurface({ path }: { path: string }) { + const { t } = useLocale(); + const onOpenFile = useChangedFilesActions()?.onOpenFile; return ( - + {onOpenFile ? ( + // 文件引用可点击:与回复末尾变更卡一致,直接打开工作区编辑器。 + + ) : ( + + )} ); } diff --git a/crates/agent-gateway/web/test/changed-files.test.mjs b/crates/agent-gateway/web/test/changed-files.test.mjs new file mode 100644 index 000000000..2ee6836e2 --- /dev/null +++ b/crates/agent-gateway/web/test/changed-files.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const rootDir = fileURLToPath(new URL("../", import.meta.url)); +const loader = createWebModuleLoader({ rootDir }); +const changedFiles = loader.loadModule("src/lib/chat/changedFiles.ts"); + +let toolCallSeq = 0; + +function toolBlock({ name, args, details, isError = false, settled = true }) { + toolCallSeq += 1; + return { + kind: "tool", + item: { + toolCall: { type: "toolCall", id: `call-${toolCallSeq}`, name, arguments: args }, + toolResult: settled + ? { + role: "toolResult", + toolCallId: `call-${toolCallSeq}`, + toolName: name, + content: [], + details: details ?? {}, + isError, + timestamp: 1, + } + : undefined, + }, + }; +} + +test("web collectChangedFiles mirrors the GUI aggregation contract", () => { + const summary = changedFiles.collectChangedFiles([ + { + blocks: [ + toolBlock({ + name: "Write", + args: { path: "src/app.ts", content: "a\nb\nc" }, + details: { kind: "write", relativePath: "src/app.ts" }, + }), + toolBlock({ + name: "Edit", + args: { path: "src\\app.ts", old_string: "a\nb\n", new_string: "a\nb\nc\n" }, + details: { kind: "edit", relativePath: "src/app.ts" }, + }), + toolBlock({ + name: "Delete", + args: { path: "tmp/scratch.md" }, + details: { kind: "delete", relativePath: "tmp/scratch.md" }, + }), + toolBlock({ + name: "Edit", + args: { path: "skip.ts", old_string: "a", new_string: "b" }, + isError: true, + }), + toolBlock({ name: "Bash", args: { command: "ls" } }), + ], + }, + ]); + + assert.ok(summary); + assert.equal(summary.files.length, 2); + assert.deepEqual( + summary.files.map((file) => [file.path, file.added, file.removed, file.deleted]), + [ + ["src/app.ts", 4, 0, false], + ["tmp/scratch.md", 0, 0, true], + ], + ); + assert.equal(summary.totalAdded, 4); + assert.equal(summary.totalRemoved, 0); + assert.equal(changedFiles.collectChangedFiles([{ blocks: [] }]), null); +}); diff --git a/crates/agent-gui/src/components/chat/ChangedFilesCard.tsx b/crates/agent-gui/src/components/chat/ChangedFilesCard.tsx new file mode 100644 index 000000000..f7535edc3 --- /dev/null +++ b/crates/agent-gui/src/components/chat/ChangedFilesCard.tsx @@ -0,0 +1,155 @@ +// Reply-footer changed-files card: lists every file the assistant reply +// wrote/edited/deleted with per-file +N/-N stats, and wires the three +// file-reference actions (open editor / reveal in file tree / view diff). +// Rendered only after the reply settles (never mid-stream). Actions arrive +// through context so transcript row props stay memo-stable; without a +// provider (shared read-only views) the card renders as plain data. +import { createContext, memo, useContext, useMemo } from "react"; +import { useLocale } from "../../i18n"; +import type { ChangedFileEntry, ChangedFilesSummary } from "../../lib/chat/messages/changedFiles"; +import { cn } from "../../lib/shared/utils"; +import { FilePenLine, FolderTree, GitCommitHorizontal } from "../icons"; +import { FileChangeBadge } from "./FileChangeBadge"; +import { getFileTypeIcon } from "./fileTypeIcons"; + +export type ChangedFilesActions = { + onOpenFile?: (path: string) => void; + onRevealInFileTree?: (path: string) => void; + /** null = open the review panel without focusing a specific file. */ + onOpenDiff?: (path: string | null) => void; +}; + +const ChangedFilesActionsContext = createContext(null); + +export const ChangedFilesActionsProvider = ChangedFilesActionsContext.Provider; + +export function useChangedFilesActions(): ChangedFilesActions | null { + return useContext(ChangedFilesActionsContext); +} + +function splitPath(path: string): { dir: string; base: string } { + const normalized = path.replace(/\\/g, "/").replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index < 0) return { dir: "", base: normalized }; + return { dir: normalized.slice(0, index + 1), base: normalized.slice(index + 1) }; +} + +const ROW_ACTION_CLASS = + "flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-all hover:bg-foreground/[0.07] hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none group-hover/changed-file:opacity-100"; + +const ChangedFileRow = memo(function ChangedFileRow({ file }: { file: ChangedFileEntry }) { + const { t } = useLocale(); + const actions = useChangedFilesActions(); + const { dir, base } = splitPath(file.path); + const canOpen = Boolean(actions?.onOpenFile) && !file.deleted; + const FileTypeIcon = getFileTypeIcon(file.path, "file"); + + const pathLabel = ( + + {dir ? {dir} : null} + + {base} + + + ); + + return ( +
+ + {canOpen ? ( + + ) : ( + {pathLabel} + )} + {file.deleted ? ( + + {t("chat.changedFiles.deleted")} + + ) : ( + + )} + {actions?.onRevealInFileTree ? ( + + ) : null} + {actions?.onOpenDiff ? ( + + ) : null} +
+ ); +}); + +export const ChangedFilesCard = memo(function ChangedFilesCard({ + summary, +}: { + summary: ChangedFilesSummary; +}) { + const { t } = useLocale(); + const actions = useChangedFilesActions(); + const title = useMemo(() => { + const key = + summary.files.length === 1 ? "chat.changedFiles.titleOne" : "chat.changedFiles.title"; + return t(key).replace("{count}", String(summary.files.length)); + }, [summary.files.length, t]); + + return ( +
+
+
+ +
+
+ + {title} + + +
+ {actions?.onOpenDiff ? ( + + ) : null} +
+ {/* 最多露出 5 行,更多文件走内部滚动条。 */} +
+ {summary.files.map((file) => ( + + ))} +
+
+ ); +}); diff --git a/crates/agent-gui/src/components/project-tools/RightDockContext.tsx b/crates/agent-gui/src/components/project-tools/RightDockContext.tsx index 8314f44cc..4efe966a9 100644 --- a/crates/agent-gui/src/components/project-tools/RightDockContext.tsx +++ b/crates/agent-gui/src/components/project-tools/RightDockContext.tsx @@ -43,10 +43,22 @@ export type RightDockFileTreeContext = { onRevealInFileTree: (path: string) => void; }; +// One-shot focus request from the chat layer (reply-footer changed-files +// card): switch GitReview to the changes view and select `path`'s diff. +// Consumers must call onFocusRequestHandled(nonce) after applying so the +// request never replays on a later panel remount. +export type GitReviewFocusRequest = { + /** Empty string = just open the changes view without picking a file. */ + path: string; + nonce: number; +}; + export type RightDockGitContext = { onInsertCodeReviewSkill?: () => void; onInsertCommitMention?: (commit: GitCommitContextPayload) => void; onInsertGitFileMention?: (file: GitFileContextPayload) => void; + focusRequest?: GitReviewFocusRequest | null; + onFocusRequestHandled?: (nonce: number) => void; }; export type RightDockSshContext = { diff --git a/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx b/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx index f137cb3a6..1d8fbecc2 100644 --- a/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-gui/src/components/project-tools/RightDockPanel.tsx @@ -29,7 +29,11 @@ import { Button } from "../ui/button"; import type { GitCommitContextPayload, GitFileContextPayload } from "./git-review"; import type { LocalTunnelClient } from "./LocalTunnelPanel"; import { RightDockContent } from "./RightDockContent"; -import { RightDockToolContext, type RightDockToolContextValue } from "./RightDockContext"; +import { + type GitReviewFocusRequest, + RightDockToolContext, + type RightDockToolContextValue, +} from "./RightDockContext"; import { RightDockChooser, RightDockCreateMenu } from "./RightDockLauncher"; import { RightDockTabStrip } from "./RightDockTabStrip"; import { @@ -83,6 +87,8 @@ type RightDockPanelProps = { onInsertCodeReviewSkill?: () => void; onInsertCommitMention?: (commit: GitCommitContextPayload) => void; onInsertGitFileMention?: (file: GitFileContextPayload) => void; + gitReviewFocusRequest?: GitReviewFocusRequest | null; + onGitReviewFocusRequestHandled?: (nonce: number) => void; onClose?: () => void; }; @@ -360,6 +366,8 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onInsertCodeReviewSkill, onInsertCommitMention, onInsertGitFileMention, + gitReviewFocusRequest, + onGitReviewFocusRequestHandled, onClose, } = props; const { t } = useLocale(); @@ -659,6 +667,8 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel onInsertCodeReviewSkill, onInsertCommitMention, onInsertGitFileMention, + focusRequest: gitReviewFocusRequest, + onFocusRequestHandled: onGitReviewFocusRequestHandled, }, ssh: { hosts: sshHosts, @@ -682,8 +692,10 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel forgetTerminalSession, gitClient, gitDisabledMessage, + gitReviewFocusRequest, gitWriteEnabled, onFileTreeStateChange, + onGitReviewFocusRequestHandled, onInsertCodeReviewSkill, onInsertCommitMention, onInsertFileMention, diff --git a/crates/agent-gui/src/components/project-tools/git-review/useGitReviewData.ts b/crates/agent-gui/src/components/project-tools/git-review/useGitReviewData.ts index c0a2eb02e..921bc742e 100644 --- a/crates/agent-gui/src/components/project-tools/git-review/useGitReviewData.ts +++ b/crates/agent-gui/src/components/project-tools/git-review/useGitReviewData.ts @@ -1146,6 +1146,23 @@ export function useGitReviewData(options: UseGitReviewDataOptions) { [loadDiffForPath], ); + // Chat-layer focus requests (reply-footer changed-files card): once the + // panel is active and the repository status is ready, jump to the changes + // view and select the requested file's diff. The handled callback retires + // the request so it never replays on a later remount. + const gitFocusRequest = context.git.focusRequest ?? null; + const onGitFocusRequestHandled = context.git.onFocusRequestHandled; + useEffect(() => { + if (!active || !gitFocusRequest) return; + if (state.status !== "ready") return; + setReviewMode("changes"); + const path = gitFocusRequest.path.trim(); + if (path) { + selectPath(path); + } + onGitFocusRequestHandled?.(gitFocusRequest.nonce); + }, [active, gitFocusRequest, onGitFocusRequestHandled, selectPath, state.status]); + const selectCommitRow = useCallback( (commit: GitCommitSummary) => { selectedCommitShaRef.current = commit.sha; diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index fa2904b92..10d17c1d7 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -150,6 +150,15 @@ export const translations: Record> = { "chat.emptyRound": "(无回复)", "chat.inputHint": "输入消息,@ 引用文件,提示词可队列发送...", "chat.inputHintWithSkills": "输入消息,@ 引用文件,/ 引用Skills,提示词可队列发送...", + "chat.changedFiles.title": "已编辑 {count} 个文件", + "chat.changedFiles.titleOne": "已编辑 1 个文件", + "chat.changedFiles.review": "审核", + "chat.changedFiles.open": "打开文件", + "chat.changedFiles.reveal": "在文件树中定位", + "chat.changedFiles.diff": "查看 Diff", + "chat.changedFiles.deleted": "已删除", + "chat.changedFiles.collapse": "折叠文件列表", + "chat.changedFiles.expand": "展开文件列表", "chat.compactingContext": "正在压缩上下文", "chat.compactingContextWait": "正在压缩上下文,请稍候...", "chat.editMessage": "编辑消息", @@ -2094,6 +2103,15 @@ export const translations: Record> = { "chat.inputHint": "Type a message, @ to reference files, prompts can be queued...", "chat.inputHintWithSkills": "Type a message, @ to reference files, / to reference Skills, prompts can be queued...", + "chat.changedFiles.title": "Edited {count} files", + "chat.changedFiles.titleOne": "Edited 1 file", + "chat.changedFiles.review": "Review", + "chat.changedFiles.open": "Open file", + "chat.changedFiles.reveal": "Reveal in file tree", + "chat.changedFiles.diff": "View diff", + "chat.changedFiles.deleted": "Deleted", + "chat.changedFiles.collapse": "Collapse file list", + "chat.changedFiles.expand": "Expand file list", "chat.compactingContext": "Compressing context", "chat.compactingContextWait": "Compressing context, please wait...", "chat.editMessage": "Edit Message", diff --git a/crates/agent-gui/src/lib/chat/messages/changedFiles.ts b/crates/agent-gui/src/lib/chat/messages/changedFiles.ts new file mode 100644 index 000000000..9e7ad41da --- /dev/null +++ b/crates/agent-gui/src/lib/chat/messages/changedFiles.ts @@ -0,0 +1,121 @@ +// Aggregates one assistant reply's Write/Edit/Delete tool trace into the +// reply-footer changed-files card: per-file +N/-N line stats plus a deleted +// flag, deduped by path across every round of the reply. Only settled, +// successful tool results count — a failed or still-streaming operation +// changed nothing yet. +import { deriveFileChangeStats, type FileChangeStats } from "./fileChangeStats"; +import type { UiRound } from "./uiMessages"; + +type ToolBlockItem = Extract["item"]; + +export type ChangedFileEntry = { + /** Tool-reported path (relative to the conversation workdir when possible). */ + path: string; + added: number; + removed: number; + /** The file's final state within this reply is "deleted". */ + deleted: boolean; + /** Tool call id of the last operation touching the file — stable render key. */ + lastToolCallId: string; +}; + +export type ChangedFilesSummary = { + files: ChangedFileEntry[]; + totalAdded: number; + totalRemoved: number; +}; + +const FILE_CHANGE_TOOL_NAMES = new Set(["Write", "Edit", "Delete"]); + +// The card re-aggregates on every streaming delta of a live reply, but the +// tool calls it counts are settled (identity-stable) objects — memoize the +// per-call diff so the 200k-char Edit diff never reruns per delta. +const statsByToolCall = new WeakMap(); + +function statsForToolCall(toolCall: ToolBlockItem["toolCall"]): FileChangeStats | undefined { + const cached = statsByToolCall.get(toolCall); + if (cached !== undefined) return cached ?? undefined; + const stats = deriveFileChangeStats(toolCall) ?? null; + statsByToolCall.set(toolCall, stats); + return stats ?? undefined; +} + +function readString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function toolResultDetails(item: ToolBlockItem): Record { + const details = item.toolResult?.details; + return details && typeof details === "object" && !Array.isArray(details) + ? (details as Record) + : {}; +} + +function resolveEntryPath(item: ToolBlockItem, details: Record): string { + return ( + readString(details.displayPath) || + readString(details.relativePath) || + readString(details.path) || + readString(item.toolCall.arguments?.path) + ); +} + +// Dedup key: same file edited twice must merge even when one op reported a +// backslash path and the other a forward-slash one. +function normalizePathKey(path: string): string { + return path.replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase(); +} + +export function collectChangedFiles( + rounds: readonly Pick[], +): ChangedFilesSummary | null { + const byKey = new Map(); + + for (const round of rounds) { + for (const block of round.blocks) { + if (block.kind !== "tool") continue; + const item = block.item; + const { toolCall, toolResult } = item; + if (!FILE_CHANGE_TOOL_NAMES.has(toolCall.name)) continue; + if (!toolResult || toolResult.isError) continue; + + const details = toolResultDetails(item); + const path = resolveEntryPath(item, details); + if (!path) continue; + + const key = normalizePathKey(path); + const entry = byKey.get(key) ?? { + path, + added: 0, + removed: 0, + deleted: false, + lastToolCallId: "", + }; + + if (toolCall.name === "Delete") { + entry.deleted = true; + } else { + const stats = statsForToolCall(toolCall); + entry.added += stats?.added ?? 0; + entry.removed += stats?.removed ?? 0; + // A Write after a Delete re-creates the file. + entry.deleted = false; + } + + entry.path = path; + entry.lastToolCallId = toolCall.id || entry.lastToolCallId; + byKey.set(key, entry); + } + } + + if (byKey.size === 0) return null; + + const files = Array.from(byKey.values()); + let totalAdded = 0; + let totalRemoved = 0; + for (const file of files) { + totalAdded += file.added; + totalRemoved += file.removed; + } + return { files, totalAdded, totalRemoved }; +} diff --git a/crates/agent-gui/src/pages/ChatPage.tsx b/crates/agent-gui/src/pages/ChatPage.tsx index 919be5ad7..180a0d040 100644 --- a/crates/agent-gui/src/pages/ChatPage.tsx +++ b/crates/agent-gui/src/pages/ChatPage.tsx @@ -14,6 +14,10 @@ import { useRef, useState, } from "react"; +import { + type ChangedFilesActions, + ChangedFilesActionsProvider, +} from "../components/chat/ChangedFilesCard"; import { HistoryShareModal } from "../components/chat/HistoryShareModal"; import type { MentionComposerCommitMention, @@ -30,7 +34,9 @@ import type { GitCommitContextPayload, GitFileContextPayload, } from "../components/project-tools/git-review"; +import type { GitReviewFocusRequest } from "../components/project-tools/RightDockContext"; import { RightDockPanel } from "../components/project-tools/RightDockPanel"; +import { expandedPathsForFileTreePath } from "../components/project-tools/rightDockModel"; import { Button } from "../components/ui/button"; import { useConfirmDialog } from "../components/ui/confirm-dialog"; import type { WorkspaceCodeEditorOpenRequest } from "../components/workspace-editor/WorkspaceCodeEditorOverlay"; @@ -1651,6 +1657,59 @@ export function ChatPage(props: ChatPageProps) { terminalProjectPathKey, ], ); + // ── 回复末尾「已编辑文件」卡的三个动作 ──────────────────────────────── + const gitReviewFocusNonceRef = useRef(0); + const [gitReviewFocusRequest, setGitReviewFocusRequest] = useState( + null, + ); + const handleGitReviewFocusRequestHandled = useCallback((nonce: number) => { + setGitReviewFocusRequest((current) => (current && current.nonce === nonce ? null : current)); + }, []); + const handleChangedFileOpenDiff = useCallback( + (path: string | null) => { + if (!terminalProjectPathKey) return; + setRightDockOpen(true); + setSettings((prev) => openRightDockSingletonTab(prev, terminalProjectPathKey, "gitReview")); + gitReviewFocusNonceRef.current += 1; + setGitReviewFocusRequest({ + path: (path ?? "").trim(), + nonce: gitReviewFocusNonceRef.current, + }); + }, + [setSettings, terminalProjectPathKey], + ); + const handleChangedFileReveal = useCallback( + (path: string) => { + if (!terminalProjectPathKey) return; + const selectedPath = path + .trim() + .replace(/\\/g, "/") + .replace(/^\/+|\/+$/g, ""); + if (!selectedPath) return; + setRightDockOpen(true); + setSettings((prev) => { + const opened = openRightDockSingletonTab(prev, terminalProjectPathKey, "fileTree"); + const current = getRightDockFileTreeState(opened.customSettings, terminalProjectPathKey); + return updateRightDockFileTreeState(opened, terminalProjectPathKey, { + query: "", + selectedPath, + expandedPaths: Array.from( + new Set([...current.expandedPaths, ...expandedPathsForFileTreePath(selectedPath)]), + ), + bumpRevision: true, + }); + }); + }, + [setSettings, terminalProjectPathKey], + ); + const changedFilesActions = useMemo( + () => ({ + onOpenFile: handleOpenWorkspaceFile, + onRevealInFileTree: handleChangedFileReveal, + onOpenDiff: handleChangedFileOpenDiff, + }), + [handleChangedFileOpenDiff, handleChangedFileReveal, handleOpenWorkspaceFile], + ); const handleOpenSshTerminal = useCallback( (session: TerminalSession, kind: WorkspaceSshTerminalOpenRequest["kind"] = "bash") => { if (session.kind !== "ssh") return; @@ -5561,34 +5620,36 @@ export function ChatPage(props: ChatPageProps) { - + + + (isLive ? null : collectChangedFiles(rounds)), + [isLive, rounds], + ); return (
@@ -72,6 +80,7 @@ export const AssistantBubble = memo(function AssistantBubble(props: { latestTodoItem={latestTodoItem} /> ))} + {changedFiles ? : null}
); diff --git a/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx b/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx index 9107fe093..940f0723e 100644 --- a/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx +++ b/crates/agent-gui/src/pages/chat/components/assistant-bubble/FileToolArgs.tsx @@ -1,3 +1,5 @@ +import { useChangedFilesActions } from "../../../../components/chat/ChangedFilesCard"; +import { useLocale } from "../../../../i18n"; import type { FileToolFieldPreview, FileToolPreview, @@ -59,13 +61,30 @@ function StreamingTextPreviewSurface({ } function PathSurface({ path }: { path: string }) { + const { t } = useLocale(); + const onOpenFile = useChangedFilesActions()?.onOpenFile; return ( - + {onOpenFile ? ( + // 文件引用可点击:与回复末尾变更卡一致,直接打开工作区编辑器。 + + ) : ( + + )} ); } diff --git a/crates/agent-gui/test/chat/changed-files.test.mjs b/crates/agent-gui/test/chat/changed-files.test.mjs new file mode 100644 index 000000000..8cb7388dc --- /dev/null +++ b/crates/agent-gui/test/chat/changed-files.test.mjs @@ -0,0 +1,167 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const changedFiles = loader.loadModule("src/lib/chat/messages/changedFiles.ts"); + +let toolCallSeq = 0; + +function toolBlock({ name, args, details, isError = false, settled = true }) { + toolCallSeq += 1; + return { + kind: "tool", + item: { + toolCall: { type: "toolCall", id: `call-${toolCallSeq}`, name, arguments: args }, + toolResult: settled + ? { + role: "toolResult", + toolCallId: `call-${toolCallSeq}`, + toolName: name, + content: [], + details: details ?? {}, + isError, + timestamp: 1, + } + : undefined, + }, + }; +} + +function round(...blocks) { + return { blocks }; +} + +test("collectChangedFiles aggregates Write/Edit stats per file across rounds", () => { + const summary = changedFiles.collectChangedFiles([ + round( + toolBlock({ + name: "Write", + args: { path: "src/app.ts", content: "a\nb\nc" }, + details: { kind: "write", path: "src/app.ts", relativePath: "src/app.ts" }, + }), + toolBlock({ name: "Read", args: { path: "src/app.ts" }, details: {} }), + ), + round( + toolBlock({ + name: "Edit", + args: { path: "src\\app.ts", old_string: "a\nb\n", new_string: "a\nb\nc\n" }, + details: { kind: "edit", path: "src\\app.ts", relativePath: "src/app.ts" }, + }), + toolBlock({ + name: "Write", + args: { path: "docs/readme.md", content: "hello" }, + details: { kind: "write", path: "docs/readme.md", relativePath: "docs/readme.md" }, + }), + ), + ]); + + assert.ok(summary); + assert.equal(summary.files.length, 2); + const [app, readme] = summary.files; + // Write(3 行) + Edit(真 diff:+1/-0),同文件跨 round 合并。 + assert.equal(app.path, "src/app.ts"); + assert.equal(app.added, 4); + assert.equal(app.removed, 0); + assert.equal(app.deleted, false); + assert.equal(readme.path, "docs/readme.md"); + assert.equal(readme.added, 1); + assert.equal(summary.totalAdded, 5); + assert.equal(summary.totalRemoved, 0); +}); + +test("failed or unsettled operations never count", () => { + assert.equal( + changedFiles.collectChangedFiles([ + round( + toolBlock({ + name: "Write", + args: { path: "a.ts", content: "x" }, + details: { kind: "write", path: "a.ts" }, + isError: true, + }), + toolBlock({ + name: "Edit", + args: { path: "b.ts", old_string: "x", new_string: "y" }, + settled: false, + }), + toolBlock({ name: "Bash", args: { command: "ls" }, details: {} }), + ), + ]), + null, + ); +}); + +test("Delete marks the file deleted and a later Write revives it", () => { + const deletedOnly = changedFiles.collectChangedFiles([ + round( + toolBlock({ + name: "Write", + args: { path: "tmp/task.md", content: "a\nb" }, + details: { kind: "write", path: "tmp/task.md", relativePath: "tmp/task.md" }, + }), + toolBlock({ + name: "Delete", + args: { path: "tmp/task.md" }, + details: { kind: "delete", path: "tmp/task.md", relativePath: "tmp/task.md" }, + }), + ), + ]); + assert.ok(deletedOnly); + assert.equal(deletedOnly.files[0].deleted, true); + + const revived = changedFiles.collectChangedFiles([ + round( + toolBlock({ + name: "Delete", + args: { path: "tmp/task.md" }, + details: { kind: "delete", path: "tmp/task.md" }, + }), + toolBlock({ + name: "Write", + args: { path: "tmp/task.md", content: "fresh" }, + details: { kind: "write", path: "tmp/task.md" }, + }), + ), + ]); + assert.ok(revived); + assert.equal(revived.files[0].deleted, false); + assert.equal(revived.files[0].added, 1); +}); + +test("path falls back to tool arguments when result details lack it", () => { + const summary = changedFiles.collectChangedFiles([ + round( + toolBlock({ + name: "Edit", + args: { path: "lib/util.ts", old_string: "a", new_string: "b" }, + details: {}, + }), + ), + ]); + assert.ok(summary); + assert.equal(summary.files[0].path, "lib/util.ts"); + assert.equal(summary.files[0].added, 1); + assert.equal(summary.files[0].removed, 1); +}); + +test("display path from details wins and dedup ignores slash/case drift", () => { + const summary = changedFiles.collectChangedFiles([ + round( + toolBlock({ + name: "Write", + args: { path: "SRC\\Main.ts", content: "x" }, + details: { kind: "write", path: "C:\\repo\\src\\Main.ts", displayPath: "src/Main.ts" }, + }), + toolBlock({ + name: "Edit", + args: { path: "src/main.ts", old_string: "x\ny\n", new_string: "x\ny\nz\n" }, + details: { kind: "edit", displayPath: "src/Main.ts" }, + }), + ), + ]); + assert.ok(summary); + assert.equal(summary.files.length, 1); + assert.equal(summary.files[0].path, "src/Main.ts"); + assert.equal(summary.files[0].added, 2); +});