feat(ui): 添加文件 Diff 查看功能

当 AI 执行 write_file 或 edit_file 工具时,在工具结果中显示 View Diff 按钮,
点击后在 IDE 面板中显示文件修改前后的对比视图。

主要改动:
- core: edit_file/write_file 工具返回 fileDiff 元数据
- ui: 新增 DiffEditor 组件用于显示文件差异
- ui: ChatMessage 添加 View Diff 按钮
- ui: IDE 组件支持 Diff 视图切换
- ui: useChat hook 处理 fileDiff 回调
This commit is contained in:
2025-12-17 21:11:44 +08:00
parent 3320a2a5ba
commit fea5442d53
13 changed files with 470 additions and 22 deletions
@@ -123,9 +123,22 @@ export const editFileTool: ToolWithMetadata = {
output += `\n\n⚠️ 代码检查发现问题,请修复:${result.diagnostics}`; output += `\n\n⚠️ 代码检查发现问题,请修复:${result.diagnostics}`;
} }
// 构建 diff 元数据
const fileName = path.basename(absolutePath);
const metadata: Record<string, unknown> = {
fileDiff: {
path: absolutePath,
name: fileName,
originalContent: result.originalContent ?? '',
newContent: result.newContent ?? '',
operation: 'edit',
},
};
return { return {
success: true, success: true,
output, output,
metadata,
}; };
}, },
}; };
@@ -92,9 +92,22 @@ export const writeFileTool: ToolWithMetadata = {
output += `\n\n⚠️ 代码检查发现问题,请修复:${result.diagnostics}`; output += `\n\n⚠️ 代码检查发现问题,请修复:${result.diagnostics}`;
} }
// 构建 diff 元数据
const fileName = path.basename(absolutePath);
const metadata: Record<string, unknown> = {
fileDiff: {
path: absolutePath,
name: fileName,
originalContent: result.originalContent ?? '',
newContent: result.newContent ?? content,
operation: 'write',
},
};
return { return {
success: true, success: true,
output, output,
metadata,
}; };
}, },
}; };
+3
View File
@@ -28,6 +28,8 @@
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/merge": "^6.10.2",
"@codemirror/state": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -39,6 +41,7 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"codemirror": "^6.0.2",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
+2
View File
@@ -153,6 +153,8 @@ export type {
SystemCommandListResponse, SystemCommandListResponse,
// Active file types // Active file types
ActiveFileInfo, ActiveFileInfo,
// File diff types
FileDiffInfo,
} from './types.js'; } from './types.js';
// API Configuration // API Configuration
+20
View File
@@ -57,6 +57,8 @@ export interface ToolMessagePart {
result?: unknown; result?: unknown;
error?: string; error?: string;
duration?: number; duration?: number;
/** 文件 Diff 信息(write_file/edit_file 工具) */
fileDiff?: FileDiffInfo;
} }
/** /**
@@ -1158,3 +1160,21 @@ export interface ActiveFileInfo {
language: string; language: string;
} }
// ============ 文件 Diff 相关 ============
/** 文件 Diff 信息(用于编辑器显示) */
export interface FileDiffInfo {
/** 文件路径 */
path: string;
/** 文件名 */
name: string;
/** 原始内容(修改前) */
originalContent: string;
/** 新内容(修改后) */
newContent: string;
/** 操作类型 */
operation: 'write' | 'edit';
/** 工具调用 ID */
toolCallId?: string;
}
+28 -4
View File
@@ -14,6 +14,7 @@ import {
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
Loader2, Loader2,
GitCompare,
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useState, forwardRef } from 'react'; import { useState, forwardRef } from 'react';
@@ -22,7 +23,7 @@ import { fadeInUp, smoothTransition } from '../utils/animations';
import { getAgentDisplayName } from '../utils/agent'; import { getAgentDisplayName } from '../utils/agent';
import { Markdown } from './Markdown'; import { Markdown } from './Markdown';
import { FileMentionText } from './FileMentionTag'; import { FileMentionText } from './FileMentionTag';
import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart } from '../api/types.js'; import type { Message, ToolCallInfo, ToolStatus, ToolMessagePart, QuestionMessagePart, FileDiffInfo } from '../api/types.js';
import { AskUserQuestion } from './AskUserQuestion.js'; import { AskUserQuestion } from './AskUserQuestion.js';
interface ChatMessageProps { interface ChatMessageProps {
@@ -31,10 +32,12 @@ interface ChatMessageProps {
isStreaming?: boolean; isStreaming?: boolean;
/** 回答问题的回调(用于 ask_user_question 工具) */ /** 回答问题的回调(用于 ask_user_question 工具) */
onAnswerQuestion?: (questionPartId: string, answers: string[]) => void; onAnswerQuestion?: (questionPartId: string, answers: string[]) => void;
/** 查看文件 Diff 的回调 */
onViewDiff?: (diff: FileDiffInfo) => void;
} }
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>( export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
({ message, isStreaming = false, onAnswerQuestion }, ref) => { ({ message, isStreaming = false, onAnswerQuestion, onViewDiff }, ref) => {
const isUser = message.role === 'user'; const isUser = message.role === 'user';
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -83,7 +86,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
</div> </div>
); );
case 'tool': case 'tool':
return <ToolPartItem key={part.id} part={part} />; return <ToolPartItem key={part.id} part={part} onViewDiff={onViewDiff} />;
case 'question': { case 'question': {
// 问题组件:即使在流式输出时也允许用户回答(除非已回答) // 问题组件:即使在流式输出时也允许用户回答(除非已回答)
const questionPart = part as QuestionMessagePart; const questionPart = part as QuestionMessagePart;
@@ -250,15 +253,21 @@ export function TypingIndicator({ agentName }: TypingIndicatorProps) {
*/ */
interface ToolPartItemProps { interface ToolPartItemProps {
part: ToolMessagePart; part: ToolMessagePart;
/** 查看文件 Diff 的回调 */
onViewDiff?: (diff: FileDiffInfo) => void;
} }
function ToolPartItem({ part }: ToolPartItemProps) { function ToolPartItem({ part, onViewDiff }: ToolPartItemProps) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
const hasDetails = const hasDetails =
Object.keys(part.arguments).length > 0 || Object.keys(part.arguments).length > 0 ||
part.result !== undefined || part.result !== undefined ||
part.error !== undefined; part.error !== undefined;
// 判断是否为文件操作工具且有 diff 数据
const isFileOperation = part.toolName === 'write_file' || part.toolName === 'edit_file';
const hasDiff = isFileOperation && part.fileDiff && part.status === 'completed';
return ( return (
<div className="border border-line rounded-lg overflow-hidden bg-surface-subtle/30"> <div className="border border-line rounded-lg overflow-hidden bg-surface-subtle/30">
{/* 头部:工具名称、状态、时长 */} {/* 头部:工具名称、状态、时长 */}
@@ -278,6 +287,21 @@ function ToolPartItem({ part }: ToolPartItemProps) {
{part.duration && ( {part.duration && (
<span className="text-xs text-fg-subtle">{formatDuration(part.duration)}</span> <span className="text-xs text-fg-subtle">{formatDuration(part.duration)}</span>
)} )}
{/* View Diff 按钮 */}
{hasDiff && onViewDiff && (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={(e) => {
e.stopPropagation();
onViewDiff(part.fileDiff!);
}}
className="px-2 py-0.5 text-xs bg-primary-500/10 text-primary-400 rounded hover:bg-primary-500/20 transition-colors"
>
<GitCompare size={12} className="inline mr-1" />
View Diff
</motion.button>
)}
{hasDetails && ( {hasDetails && (
<span className="text-fg-subtle"> <span className="text-fg-subtle">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />} {expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
+223
View File
@@ -0,0 +1,223 @@
/**
* DiffEditor Component
*
* 基于 CodeMirror Merge View 的 Diff 编辑器
* 用于显示文件修改前后的对比
*/
import { useEffect, useRef, useMemo } from 'react';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { MergeView } from '@codemirror/merge';
import { javascript } from '@codemirror/lang-javascript';
import { python } from '@codemirror/lang-python';
import { json } from '@codemirror/lang-json';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { X, FileCode, GitCompare } from 'lucide-react';
import { motion } from 'framer-motion';
import { cn } from '../utils/cn.js';
import { useTheme } from '../hooks/useTheme.js';
import type { FileDiffInfo } from '../api/types.js';
interface DiffEditorProps {
/** Diff 信息 */
diff: FileDiffInfo;
/** 关闭回调 */
onClose?: () => void;
/** 容器类名 */
className?: string;
}
// 根据文件扩展名获取语言
function getLanguageExtension(filename: string) {
const ext = filename.split('.').pop()?.toLowerCase() || '';
switch (ext) {
case 'js':
case 'jsx':
return javascript({ jsx: true });
case 'ts':
case 'tsx':
return javascript({ jsx: true, typescript: true });
case 'py':
return python();
case 'json':
return json();
case 'html':
case 'htm':
return html();
case 'css':
case 'scss':
case 'less':
return css();
case 'md':
case 'markdown':
return markdown();
default:
return [];
}
}
// 计算变更统计
function computeChangeStats(original: string, modified: string) {
const originalLines = original.split('\n');
const modifiedLines = modified.split('\n');
// 简单统计:计算行数差异
const additions = Math.max(0, modifiedLines.length - originalLines.length);
const deletions = Math.max(0, originalLines.length - modifiedLines.length);
// 更精确的统计需要实际的 diff 算法
// 这里用简化版本
let changed = 0;
const minLen = Math.min(originalLines.length, modifiedLines.length);
for (let i = 0; i < minLen; i++) {
if (originalLines[i] !== modifiedLines[i]) {
changed++;
}
}
return {
additions: additions + changed,
deletions: deletions + changed,
totalLines: modifiedLines.length,
};
}
export function DiffEditor({ diff, onClose, className }: DiffEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mergeViewRef = useRef<MergeView | null>(null);
const { resolvedTheme } = useTheme();
// 语言扩展
const languageExtension = useMemo(
() => getLanguageExtension(diff.name),
[diff.name]
);
// 变更统计
const stats = useMemo(
() => computeChangeStats(diff.originalContent, diff.newContent),
[diff.originalContent, diff.newContent]
);
// ESC 键关闭 Diff 视图
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && onClose) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// 创建 MergeView
useEffect(() => {
if (!containerRef.current) return;
// 清理之前的实例
if (mergeViewRef.current) {
mergeViewRef.current.destroy();
}
// 创建基础扩展
const extensions = [
basicSetup,
EditorView.editable.of(false), // 只读模式
EditorState.readOnly.of(true),
...(Array.isArray(languageExtension) ? languageExtension : [languageExtension]),
];
// 深色主题
if (resolvedTheme === 'dark') {
extensions.push(oneDark);
}
// 创建 MergeView
const mergeView = new MergeView({
a: {
doc: diff.originalContent,
extensions,
},
b: {
doc: diff.newContent,
extensions,
},
parent: containerRef.current,
collapseUnchanged: { margin: 3, minSize: 4 },
gutter: true,
highlightChanges: true,
});
mergeViewRef.current = mergeView;
return () => {
mergeView.destroy();
};
}, [diff.originalContent, diff.newContent, languageExtension, resolvedTheme]);
return (
<div className={cn('flex flex-col h-full bg-surface-base', className)}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-line bg-surface-subtle">
<div className="flex items-center gap-3">
{/* 文件图标和名称 */}
<div className="flex items-center gap-2">
<GitCompare size={16} className="text-primary-500" />
<span className="font-medium text-fg">{diff.name}</span>
<span className="text-xs text-fg-muted">({diff.operation === 'write' ? 'Write' : 'Edit'})</span>
</div>
{/* 变更统计 */}
<div className="flex items-center gap-2 text-xs">
<span className="text-green-500">+{stats.additions}</span>
<span className="text-red-500">-{stats.deletions}</span>
</div>
</div>
{/* 操作按钮 */}
<div className="flex items-center gap-2">
{/* 关闭按钮 */}
{onClose && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={onClose}
className="p-1.5 rounded hover:bg-surface-muted transition-colors"
title="关闭 Diff 视图"
>
<X size={16} className="text-fg-muted" />
</motion.button>
)}
</div>
</div>
{/* 标签栏 */}
<div className="flex border-b border-line bg-surface-subtle">
<div className="flex-1 px-4 py-2 text-sm text-fg-muted border-r border-line">
<FileCode size={14} className="inline mr-2" />
Original
</div>
<div className="flex-1 px-4 py-2 text-sm text-fg-muted">
<FileCode size={14} className="inline mr-2" />
Modified
</div>
</div>
{/* Diff 视图 */}
<div
ref={containerRef}
className="flex-1 overflow-auto"
style={{ minHeight: 0 }}
/>
{/* 路径提示 */}
<div className="px-4 py-1.5 border-t border-line bg-surface-subtle text-xs text-fg-muted truncate">
{diff.path}
</div>
</div>
);
}
+78 -13
View File
@@ -4,13 +4,14 @@
* 整合文件浏览器和代码编辑器的 IDE 组件 * 整合文件浏览器和代码编辑器的 IDE 组件
*/ */
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '../utils/cn.js'; import { cn } from '../utils/cn.js';
import { readFile, getWorkingDirectory } from '../api/client.js'; import { readFile, getWorkingDirectory } from '../api/client.js';
import { FileExplorer } from './FileExplorer.js'; import { FileExplorer } from './FileExplorer.js';
import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js'; import { CodeEditor, getLanguageFromFilename, type EditorTab } from './CodeEditor.js';
import type { ActiveFileInfo } from '../api/types.js'; import { DiffEditor } from './DiffEditor.js';
import type { ActiveFileInfo, FileDiffInfo } from '../api/types.js';
// localStorage 键名 // localStorage 键名
const STORAGE_KEY_TABS = 'ai-assistant-editor-tabs'; const STORAGE_KEY_TABS = 'ai-assistant-editor-tabs';
@@ -28,12 +29,28 @@ interface IDEProps {
sidebarWidth?: number; sidebarWidth?: number;
/** 当前活动文件变化回调 */ /** 当前活动文件变化回调 */
onActiveFileChange?: (file: ActiveFileInfo | null) => void; onActiveFileChange?: (file: ActiveFileInfo | null) => void;
/** 要显示的 Diff 信息(外部传入) */
pendingDiff?: FileDiffInfo | null;
/** Diff 关闭回调 */
onDiffClose?: () => void;
} }
export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEProps) { /** IDE 组件暴露的方法 */
export interface IDEHandle {
/** 显示文件 diff */
showDiff: (diff: FileDiffInfo) => void;
/** 关闭 diff 视图 */
closeDiff: () => void;
}
export const IDE = forwardRef<IDEHandle, IDEProps>(function IDE(
{ className, sidebarWidth = 256, onActiveFileChange, pendingDiff, onDiffClose },
ref
) {
const [tabs, setTabs] = useState<EditorTab[]>([]); const [tabs, setTabs] = useState<EditorTab[]>([]);
const [activeTabId, setActiveTabId] = useState<string | null>(null); const [activeTabId, setActiveTabId] = useState<string | null>(null);
const [workingDirectory, setWorkingDirectory] = useState<string>(''); const [workingDirectory, setWorkingDirectory] = useState<string>('');
const [currentDiff, setCurrentDiff] = useState<FileDiffInfo | null>(null);
const isRestoringTabs = useRef(false); const isRestoringTabs = useRef(false);
const hasRestoredTabs = useRef(false); const hasRestoredTabs = useRef(false);
@@ -235,6 +252,47 @@ export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEPr
); );
}, []); }, []);
// 显示 diff 视图
const showDiff = useCallback((diff: FileDiffInfo) => {
setCurrentDiff(diff);
// 如果文件已在编辑器中打开,更新其内容
setTabs((prev) =>
prev.map((tab) => {
if (tab.path === diff.path) {
return {
...tab,
content: diff.newContent,
originalContent: diff.newContent,
};
}
return tab;
})
);
}, []);
// 关闭 diff 视图
const closeDiff = useCallback(() => {
setCurrentDiff(null);
onDiffClose?.();
}, [onDiffClose]);
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
showDiff,
closeDiff,
}), [showDiff, closeDiff]);
// 处理外部传入的 pendingDiff
useEffect(() => {
if (pendingDiff) {
showDiff(pendingDiff);
}
}, [pendingDiff, showDiff]);
// 判断是否显示 diff 视图
const showDiffView = currentDiff !== null;
return ( return (
<div className={cn('flex h-full', className)}> <div className={cn('flex h-full', className)}>
{/* 文件浏览器 */} {/* 文件浏览器 */}
@@ -245,17 +303,24 @@ export function IDE({ className, sidebarWidth = 256, onActiveFileChange }: IDEPr
<FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} /> <FileExplorer onFileSelect={handleFileSelect} workingDirectory={workingDirectory} />
</div> </div>
{/* 代码编辑器 */} {/* 代码编辑器 / Diff 视图 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<CodeEditor {showDiffView ? (
tabs={tabs} <DiffEditor
activeTabId={activeTabId} diff={currentDiff}
onTabChange={handleTabChange} onClose={closeDiff}
onTabClose={handleTabClose} />
onContentChange={handleContentChange} ) : (
onSave={handleSave} <CodeEditor
/> tabs={tabs}
activeTabId={activeTabId}
onTabChange={handleTabChange}
onTabClose={handleTabClose}
onContentChange={handleContentChange}
onSave={handleSave}
/>
)}
</div> </div>
</div> </div>
); );
} });
+35 -1
View File
@@ -24,6 +24,7 @@ import type {
SubagentToolEndPayload, SubagentToolEndPayload,
SubagentState, SubagentState,
SubagentToolInfo, SubagentToolInfo,
FileDiffInfo,
} from '../api/types.js'; } from '../api/types.js';
interface UseChatOptions { interface UseChatOptions {
@@ -35,6 +36,8 @@ interface UseChatOptions {
onConfigError?: (error: ConfigErrorPayload) => void; onConfigError?: (error: ConfigErrorPayload) => void;
/** 切换会话回调(如 :new 命令创建新会话) */ /** 切换会话回调(如 :new 命令创建新会话) */
onSessionSwitch?: (newSessionId: string) => void; onSessionSwitch?: (newSessionId: string) => void;
/** 文件 diff 回调(当 AI 写入/编辑文件时触发) */
onFileDiff?: (diff: FileDiffInfo) => void;
} }
interface ChatState { interface ChatState {
@@ -54,7 +57,7 @@ interface ChatState {
currentSubagent: SubagentState | null; currentSubagent: SubagentState | null;
} }
export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError, onSessionSwitch }: UseChatOptions) { export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdated, onConfigError, onSessionSwitch, onFileDiff }: UseChatOptions) {
const [state, setState] = useState<ChatState>({ const [state, setState] = useState<ChatState>({
messages: [], messages: [],
isConnected: false, isConnected: false,
@@ -80,11 +83,13 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
const onSessionUpdatedRef = useRef(onSessionUpdated); const onSessionUpdatedRef = useRef(onSessionUpdated);
const onConfigErrorRef = useRef(onConfigError); const onConfigErrorRef = useRef(onConfigError);
const onSessionSwitchRef = useRef(onSessionSwitch); const onSessionSwitchRef = useRef(onSessionSwitch);
const onFileDiffRef = useRef(onFileDiff);
onErrorRef.current = onError; onErrorRef.current = onError;
onSessionNotFoundRef.current = onSessionNotFound; onSessionNotFoundRef.current = onSessionNotFound;
onSessionUpdatedRef.current = onSessionUpdated; onSessionUpdatedRef.current = onSessionUpdated;
onConfigErrorRef.current = onConfigError; onConfigErrorRef.current = onConfigError;
onSessionSwitchRef.current = onSessionSwitch; onSessionSwitchRef.current = onSessionSwitch;
onFileDiffRef.current = onFileDiff;
// 加载历史消息 // 加载历史消息
const loadMessages = useCallback(async () => { const loadMessages = useCallback(async () => {
@@ -244,6 +249,34 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
case 'tool_end': { case 'tool_end': {
const payload = message.payload as ToolEndPayload; const payload = message.payload as ToolEndPayload;
// 检查是否为文件写入/编辑工具,提取 diff 信息
let fileDiffInfo: FileDiffInfo | undefined;
if (payload.status === 'completed' && payload.result) {
const result = payload.result as {
metadata?: {
fileDiff?: {
path: string;
name: string;
originalContent: string;
newContent: string;
operation: 'write' | 'edit';
};
};
};
if (result.metadata?.fileDiff) {
fileDiffInfo = {
...result.metadata.fileDiff,
toolCallId: payload.id,
};
// 异步触发回调,避免阻塞状态更新
if (onFileDiffRef.current) {
setTimeout(() => onFileDiffRef.current?.(fileDiffInfo!), 0);
}
}
}
setState((prev) => { setState((prev) => {
if (!prev.streamingMessage) return prev; if (!prev.streamingMessage) return prev;
@@ -303,6 +336,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
result: payload.result, result: payload.result,
error: payload.error, error: payload.error,
duration: payload.duration, duration: payload.duration,
fileDiff: fileDiffInfo,
} as ToolMessagePart; } as ToolMessagePart;
} }
return part; return part;
+4 -1
View File
@@ -201,6 +201,8 @@ export type {
DiagnosticsResponse, DiagnosticsResponse,
// Active file types // Active file types
ActiveFileInfo, ActiveFileInfo,
// File diff types
FileDiffInfo,
} from './api/client.js'; } from './api/client.js';
// Primitives (shadcn/ui style) // Primitives (shadcn/ui style)
@@ -254,7 +256,8 @@ export { DiagnosticsIndicator, DiagnosticsIndicatorCompact } from './components/
export { SessionPanel } from './components/SessionPanel.js'; export { SessionPanel } from './components/SessionPanel.js';
export { FileExplorer } from './components/FileExplorer.js'; export { FileExplorer } from './components/FileExplorer.js';
export { CodeEditor, getLanguageFromFilename, type EditorTab } from './components/CodeEditor.js'; export { CodeEditor, getLanguageFromFilename, type EditorTab } from './components/CodeEditor.js';
export { IDE } from './components/IDE.js'; export { DiffEditor } from './components/DiffEditor.js';
export { IDE, type IDEHandle } from './components/IDE.js';
export { StatusBar } from './components/StatusBar.js'; export { StatusBar } from './components/StatusBar.js';
export { Resizer } from './components/Resizer.js'; export { Resizer } from './components/Resizer.js';
export { ToolbarOverflowMenu, type ToolbarMenuItem } from './components/ToolbarOverflowMenu.js'; export { ToolbarOverflowMenu, type ToolbarMenuItem } from './components/ToolbarOverflowMenu.js';
+21 -1
View File
@@ -24,6 +24,7 @@ import {
createSession, createSession,
type Session, type Session,
type ActiveFileInfo, type ActiveFileInfo,
type FileDiffInfo,
} from '@ai-assistant/ui'; } from '@ai-assistant/ui';
import { ChatPage } from './pages/Chat'; import { ChatPage } from './pages/Chat';
@@ -53,6 +54,9 @@ export function App() {
return saved !== 'false'; // 默认开启 return saved !== 'false'; // 默认开启
}); });
// Diff 显示状态(当 AI 编辑/写入文件时触发)
const [pendingDiff, setPendingDiff] = useState<FileDiffInfo | null>(null);
// 持久化自动附加开关状态 // 持久化自动附加开关状态
useEffect(() => { useEffect(() => {
localStorage.setItem('ai-assistant-auto-attach-file', String(autoAttachActiveFile)); localStorage.setItem('ai-assistant-auto-attach-file', String(autoAttachActiveFile));
@@ -121,6 +125,16 @@ export function App() {
setCurrentSessionId(newSessionId); setCurrentSessionId(newSessionId);
}, []); }, []);
// 文件 diff 回调(当 AI 编辑/写入文件时触发)
const handleFileDiff = useCallback((diff: FileDiffInfo) => {
setPendingDiff(diff);
}, []);
// Diff 关闭回调
const handleDiffClose = useCallback(() => {
setPendingDiff(null);
}, []);
// 处理面板宽度调整 // 处理面板宽度调整
const handleResize = useCallback((delta: number) => { const handleResize = useCallback((delta: number) => {
setIdePanelWidth((prev) => { setIdePanelWidth((prev) => {
@@ -159,7 +173,11 @@ export function App() {
className="hidden md:flex flex-col" className="hidden md:flex flex-col"
style={{ width: `${idePanelWidth}%` }} style={{ width: `${idePanelWidth}%` }}
> >
<IDE onActiveFileChange={setActiveFile} /> <IDE
onActiveFileChange={setActiveFile}
pendingDiff={pendingDiff}
onDiffClose={handleDiffClose}
/>
</div> </div>
{/* 可拖拽分割线 */} {/* 可拖拽分割线 */}
@@ -191,6 +209,8 @@ export function App() {
activeFile={activeFile} activeFile={activeFile}
autoAttachActiveFile={autoAttachActiveFile} autoAttachActiveFile={autoAttachActiveFile}
onAutoAttachActiveFileToggle={setAutoAttachActiveFile} onAutoAttachActiveFileToggle={setAutoAttachActiveFile}
onFileDiff={handleFileDiff}
onViewDiff={handleFileDiff}
/> />
) : ( ) : (
<div className="flex-1 flex items-center justify-center h-full"> <div className="flex-1 flex items-center justify-center h-full">
+10 -2
View File
@@ -17,6 +17,7 @@ import {
DiagnosticsIndicator, DiagnosticsIndicator,
ToolbarOverflowMenu, ToolbarOverflowMenu,
type ActiveFileInfo, type ActiveFileInfo,
type FileDiffInfo,
} from '@ai-assistant/ui'; } from '@ai-assistant/ui';
interface ChatPageProps { interface ChatPageProps {
@@ -43,6 +44,10 @@ interface ChatPageProps {
autoAttachActiveFile?: boolean; autoAttachActiveFile?: boolean;
/** 自动附加开关变更回调 */ /** 自动附加开关变更回调 */
onAutoAttachActiveFileToggle?: (enabled: boolean) => void; onAutoAttachActiveFileToggle?: (enabled: boolean) => void;
/** 文件 diff 回调(当 AI 写入/编辑文件时触发) */
onFileDiff?: (diff: FileDiffInfo) => void;
/** 查看文件 diff 回调(点击 View Diff 按钮) */
onViewDiff?: (diff: FileDiffInfo) => void;
} }
export function ChatPage({ export function ChatPage({
@@ -63,6 +68,8 @@ export function ChatPage({
activeFile, activeFile,
autoAttachActiveFile, autoAttachActiveFile,
onAutoAttachActiveFileToggle, onAutoAttachActiveFileToggle,
onFileDiff,
onViewDiff,
}: ChatPageProps) { }: ChatPageProps) {
const { const {
messages, messages,
@@ -100,6 +107,7 @@ export function ChatPage({
: undefined, : undefined,
}); });
}, },
onFileDiff,
}); });
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -208,13 +216,13 @@ export function ChatPage({
<AnimatePresence mode="popLayout"> <AnimatePresence mode="popLayout">
{messages.map((message) => ( {messages.map((message) => (
<ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} /> <ChatMessage key={message.id} message={message} onAnswerQuestion={answerQuestion} onViewDiff={onViewDiff ?? onFileDiff} />
))} ))}
</AnimatePresence> </AnimatePresence>
{/* 流式消息 - 复用 ChatMessage 组件 */} {/* 流式消息 - 复用 ChatMessage 组件 */}
{streamingMessage && ( {streamingMessage && (
<ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} /> <ChatMessage message={streamingMessage} isStreaming onAnswerQuestion={answerQuestion} onViewDiff={onViewDiff ?? onFileDiff} />
)} )}
{/* 子 Agent 进度显示 */} {/* 子 Agent 进度显示 */}
+20
View File
@@ -252,6 +252,12 @@ importers:
'@codemirror/lang-python': '@codemirror/lang-python':
specifier: ^6.2.1 specifier: ^6.2.1
version: 6.2.1 version: 6.2.1
'@codemirror/merge':
specifier: ^6.10.2
version: 6.11.2
'@codemirror/state':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/theme-one-dark': '@codemirror/theme-one-dark':
specifier: ^6.1.3 specifier: ^6.1.3
version: 6.1.3 version: 6.1.3
@@ -285,6 +291,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
codemirror:
specifier: ^6.0.2
version: 6.0.2
framer-motion: framer-motion:
specifier: ^12.23.26 specifier: ^12.23.26
version: 12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 12.23.26(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1007,6 +1016,9 @@ packages:
'@codemirror/lint@6.9.2': '@codemirror/lint@6.9.2':
resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==}
'@codemirror/merge@6.11.2':
resolution: {integrity: sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==}
'@codemirror/search@6.5.11': '@codemirror/search@6.5.11':
resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==} resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==}
@@ -5988,6 +6000,14 @@ snapshots:
'@codemirror/view': 6.39.4 '@codemirror/view': 6.39.4
crelt: 1.0.6 crelt: 1.0.6
'@codemirror/merge@6.11.2':
dependencies:
'@codemirror/language': 6.11.3
'@codemirror/state': 6.5.2
'@codemirror/view': 6.39.4
'@lezer/highlight': 1.2.3
style-mod: 4.1.3
'@codemirror/search@6.5.11': '@codemirror/search@6.5.11':
dependencies: dependencies:
'@codemirror/state': 6.5.2 '@codemirror/state': 6.5.2