diff --git a/e2e/browser/chat-file-approval.spec.ts b/e2e/browser/chat-file-approval.spec.ts index 15e58173..cb32f351 100644 --- a/e2e/browser/chat-file-approval.spec.ts +++ b/e2e/browser/chat-file-approval.spec.ts @@ -20,7 +20,7 @@ test('path-only file approval stays readable without inventing a diff', async ({ const card = page.locator('[data-help="chat-file-change-preview-path-only"]'); await expect(card).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('修改文件', { exact: true }).first()).toBeVisible(); - await expect(page.getByText('/workspace/notes.md', { exact: true })).toBeVisible(); + await expect(card.getByText('/workspace/notes.md', { exact: true })).toBeVisible(); await expect(page.getByText('仅有路径,无内容预览')).toBeVisible(); await expect(page.getByRole('button', { name: '允许', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '一直允许' })).toBeVisible(); diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 457207a5..1a8da74d 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2776,6 +2776,7 @@ export const en = { failed: "Couldn't open", emptyBody: "Nothing to show", truncatedSuffix: " · Truncated", + viewEdit: "View edits", }, composer: { placeholder: "Message an agent…", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 20348e9d..8cdacf33 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2756,6 +2756,7 @@ export const zh = { failed: "无法打开", emptyBody: "没有内容", truncatedSuffix: " · 已截断", + viewEdit: "查看修改", }, composer: { placeholder: "发给 Agent…", diff --git a/src/pages/chat/ChatEditPreviewPanel.tsx b/src/pages/chat/ChatEditPreviewPanel.tsx new file mode 100644 index 00000000..c3ac4792 --- /dev/null +++ b/src/pages/chat/ChatEditPreviewPanel.tsx @@ -0,0 +1,166 @@ +import { useEffect, useId } from 'react'; +import { PanelRightClose } from 'lucide-react'; +import { SourcePreview } from '@/components/shared/SourcePreview'; +import { CopyableFileName } from '@/components/shared/CopyableFileName'; +import { pathTailLabel } from '@/components/shared/file-name-label'; +import { useI18n } from '@/components/shared/LanguageProvider'; +import { Button } from '@/components/ui/button'; +import { Tip } from '@/components/ui/tooltip'; +import { CHAT_FILE_PREVIEW_MAX_CHARS } from '@/lib/source-preview'; +import { hasEscPriorityOverlay } from '@/lib/skills/preview-keys'; +import { cn } from '@/lib/utils'; +import { + sameEditPath, + turnEditDiffText, + type TurnEditFile, +} from './chat-edit-preview'; + +function fileName(path: string): string { + const parts = path.trim().split(/[/\\]/).filter(Boolean); + return parts[parts.length - 1] ?? path.trim(); +} + +export function ChatTurnEditList({ + files, + selectedPath, + onSelect, +}: { + files: TurnEditFile[]; + selectedPath?: string; + onSelect: (file: TurnEditFile) => void; +}) { + const { t } = useI18n(); + if (files.length === 0) return null; + return ( +
+

{t('chat.preview.viewEdit')}

+ +
+ ); +} + +export function ChatEditPreviewPanel({ + file, + open, + width, + onClose, + className, +}: { + file: TurnEditFile; + open: boolean; + width?: number; + onClose: () => void; + className?: string; +}) { + const { t } = useI18n(); + const titleId = useId(); + const name = fileName(file.path); + const diff = turnEditDiffText(file) ?? ''; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (hasEscPriorityOverlay()) return; + e.preventDefault(); + onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( + + ); +} diff --git a/src/pages/chat/chat-edit-preview.test.ts b/src/pages/chat/chat-edit-preview.test.ts new file mode 100644 index 00000000..c7bdb57f --- /dev/null +++ b/src/pages/chat/chat-edit-preview.test.ts @@ -0,0 +1,278 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { ProcessMap } from '@/lib/chat-process'; +import type { ProcessStep } from '@/lib/types'; +import { ChatTurnEditList } from './ChatEditPreviewPanel'; +import { + extractEditFilesFromSteps, + extractTurnEdits, + formatSimpleDiff, + latestProcessTurn, + sameEditPath, + turnEditDiffText, + turnEditHasInlineDiff, +} from './chat-edit-preview'; + +function tool( + name: string, + status: string, + input?: unknown, + result?: string | null, +): ProcessStep { + return { type: 'tool', name, status, input, result }; +} + +function mapWith(steps: ProcessStep[], turn = 1, agent: 'codex' | 'grok' = 'codex'): ProcessMap { + return { + [`${turn}:${agent}`]: { + turn, + agent, + phase: 'running', + stdout: '', + stderr: '', + updatedAt: turn, + steps, + }, + }; +} + +describe('extractEditFilesFromSteps', () => { + it('keeps live Write paths as 正在修改 and completed as 已修改', () => { + expect( + extractEditFilesFromSteps([ + tool('Write', 'start', { path: 'src/a.ts' }), + ]), + ).toEqual([{ path: 'src/a.ts', status: 'live' }]); + expect( + extractEditFilesFromSteps([ + tool('Write', 'end', { path: 'src/a.ts' }), + ]), + ).toEqual([{ path: 'src/a.ts', status: 'done' }]); + }); + + it('ignores read / execute tools and failed edits', () => { + expect( + extractEditFilesFromSteps([ + tool('Read', 'end', { path: 'README.md' }), + tool('Bash', 'end', { command: 'ls' }), + tool('Write', 'error', { path: 'src/a.ts' }), + ]), + ).toEqual([]); + }); + + it('reads target_file, file_path, and file:// URIs', () => { + expect( + extractEditFilesFromSteps([ + tool('Edit', 'end', { target_file: '/workspace/src/app.ts' }), + tool('StrReplace', 'end', { file_path: 'lib\\b.ts' }), + tool('Delete', 'end', { uri: 'file://localhost/workspace/README.md' }), + ]), + ).toEqual([ + { path: '/workspace/src/app.ts', status: 'done' }, + { path: 'lib\\b.ts', status: 'done' }, + { path: '/workspace/README.md', status: 'done' }, + ]); + }); + + it('dedupes the same path and keeps the later status', () => { + expect( + extractEditFilesFromSteps([ + tool('Write', 'start', { path: 'src/a.ts' }), + tool('StrReplace', 'end', { path: 'src\\a.ts', old_string: 'a', new_string: 'b' }), + ]), + ).toEqual([ + { + path: 'src\\a.ts', + status: 'done', + before: 'a', + after: 'b', + }, + ]); + }); + + it('copies old/new text from StrReplace input', () => { + const files = extractEditFilesFromSteps([ + tool('StrReplace', 'end', { + path: 'src/app.ts', + old_string: "const name = 'old';", + new_string: "const name = 'new';", + }), + ]); + expect(files[0]?.before).toBe("const name = 'old';"); + expect(files[0]?.after).toBe("const name = 'new';"); + expect(turnEditHasInlineDiff(files[0]!)).toBe(true); + }); + + it('copies nested changes[] and fileChanges map', () => { + expect( + extractEditFilesFromSteps([ + tool('apply_patch', 'end', { + changes: [ + { path: 'src/app.ts', before: 'old', after: 'new' }, + { path: 'src/b.ts' }, + ], + }), + ]), + ).toEqual([ + { path: 'src/app.ts', status: 'done', before: 'old', after: 'new' }, + { path: 'src/b.ts', status: 'done' }, + ]); + + const written = extractEditFilesFromSteps([ + tool('apply_patch', 'end', { + fileChanges: { + '/tmp/example.txt': { type: 'add', content: 'ok' }, + }, + }), + ]); + expect(written).toEqual([ + { path: '/tmp/example.txt', status: 'done', after: 'ok' }, + ]); + expect(turnEditHasInlineDiff(written[0]!)).toBe(false); + }); + + it('reads grok operation.diff when it looks like a unified diff', () => { + const files = extractEditFilesFromSteps([ + tool('edit', 'end', { + toolCall: { + kind: 'edit', + rawInput: { + operation: { + type: 'update_file', + path: 'README.md', + diff: '@@ -1,2 +1,3 @@\n hello\n+world\n', + }, + }, + }, + }), + ]); + expect(files[0]?.path).toBe('README.md'); + expect(files[0]?.diff).toContain('@@ -1,2 +1,3 @@'); + expect(turnEditHasInlineDiff(files[0]!)).toBe(true); + }); + + it('does not treat a protocol snippet without old/new as an inline diff', () => { + const files = extractEditFilesFromSteps([ + tool('file_change', 'end', { + item: { + changes: [ + { + path: '/workspace/probe.txt', + kind: { type: 'add' }, + diff: 'FILECHANGE_OK\n', + }, + ], + }, + }), + ]); + expect(files[0]?.path).toBe('/workspace/probe.txt'); + expect(turnEditHasInlineDiff(files[0]!)).toBe(false); + }); + + it('parses JSON tool results and a path on the tool name', () => { + expect( + extractEditFilesFromSteps([ + tool( + 'Write', + 'end', + { path: 'notes.md' }, + JSON.stringify({ old_string: 'a', new_string: 'b' }), + ), + ]), + ).toEqual([ + { path: 'notes.md', status: 'done', before: 'a', after: 'b' }, + ]); + expect( + extractEditFilesFromSteps([tool('Write src/named.ts', 'end')]), + ).toEqual([{ path: 'src/named.ts', status: 'done' }]); + }); +}); + +describe('extractTurnEdits', () => { + it('uses the latest turn and merges agents on that turn', () => { + const processMap: ProcessMap = { + ...mapWith([tool('Write', 'end', { path: 'old.ts' })], 1, 'codex'), + ...mapWith([tool('Write', 'start', { path: 'src/a.ts' })], 2, 'codex'), + ...mapWith([tool('Edit', 'end', { path: 'src/b.ts' })], 2, 'grok'), + }; + expect(latestProcessTurn(processMap)).toBe(2); + expect(extractTurnEdits(processMap).map((file) => file.path)).toEqual([ + 'src/a.ts', + 'src/b.ts', + ]); + expect(extractTurnEdits(processMap, 1)).toEqual([ + { path: 'old.ts', status: 'done' }, + ]); + }); + + it('returns an empty list when there is no process map', () => { + expect(extractTurnEdits({})).toEqual([]); + }); +}); + +describe('simple diff', () => { + it('marks the changed middle lines', () => { + expect(formatSimpleDiff('keep\nold\nend', 'keep\nnew\nend', 'src/a.ts')).toBe( + [ + '--- src/a.ts', + '+++ src/a.ts', + '@@ -2,1 +2,1 @@', + '-old', + '+new', + ].join('\n'), + ); + }); + + it('sameEditPath treats slash variants as one file', () => { + expect(sameEditPath('src\\a.ts', 'src/a.ts')).toBe(true); + expect(sameEditPath('src/a.ts', 'src/b.ts')).toBe(false); + }); + + it('turnEditDiffText prefers a real patch over inventing one', () => { + expect( + turnEditDiffText({ + path: 'README.md', + diff: '@@ -1 +1,2 @@\n hello\n+world\n', + }), + ).toContain('@@ -1 +1,2 @@'); + expect( + turnEditDiffText({ + path: 'a.ts', + before: 'a', + after: 'b', + }), + ).toContain('-a'); + expect( + turnEditDiffText({ + path: 'a.ts', + after: 'only-new', + }), + ).toBeNull(); + }); +}); + +describe('ChatTurnEditList', () => { + it('labels this turn\'s files with 查看修改 / 正在修改 / 已修改', () => { + const html = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + createElement(ChatTurnEditList, { + files: [ + { path: 'src/a.ts', status: 'live' }, + { path: 'src/b.ts', status: 'done' }, + ], + onSelect: () => undefined, + }), + ), + ); + expect(html).toContain('data-help="chat-turn-edits"'); + expect(html).toContain('查看修改'); + expect(html).toContain('正在修改'); + expect(html).toContain('已修改'); + expect(html).toContain('src/a.ts'); + expect(html).toContain('src/b.ts'); + }); +}); diff --git a/src/pages/chat/chat-edit-preview.ts b/src/pages/chat/chat-edit-preview.ts new file mode 100644 index 00000000..af8e0b72 --- /dev/null +++ b/src/pages/chat/chat-edit-preview.ts @@ -0,0 +1,377 @@ +import { + classifyToolAction, + toolActionTone, + type ProcessMap, +} from '@/lib/chat-process'; +import type { ProcessStep } from '@/lib/types'; + +export type TurnEditStatus = 'live' | 'done'; + +export type TurnEditFile = { + path: string; + status: TurnEditStatus; + before?: string; + after?: string; + diff?: string; +}; + +const PATH_KEYS = [ + 'path', + 'file', + 'filePath', + 'file_path', + 'target_file', + 'targetFile', + 'uri', + 'fileUri', + 'file_uri', +] as const; + +const BEFORE_KEYS = ['before', 'oldText', 'old_text', 'old_string'] as const; +const AFTER_KEYS = ['content', 'contents', 'newText', 'new_text', 'new_string', 'after'] as const; + +type ToolStep = Extract; + +type CollectedEdit = { + path: string; + before?: string; + after?: string; + diff?: string; +}; + +export function latestProcessTurn(processMap: ProcessMap): number | null { + let max = Number.NEGATIVE_INFINITY; + for (const view of Object.values(processMap)) { + if (typeof view.turn === 'number' && view.turn > max) max = view.turn; + } + return Number.isFinite(max) ? max : null; +} + +export function sameEditPath(a: string, b: string): boolean { + return normalizeEditPath(a) === normalizeEditPath(b); +} + +export function turnEditHasInlineDiff(file: TurnEditFile): boolean { + return Boolean(turnEditDiffText(file)); +} + +/** Unified diff already on the tool payload, or a simple diff from old+new text. */ +export function turnEditDiffText(file: Pick): string | null { + const patch = file.diff?.trim() ? file.diff : ''; + if (patch && looksLikeUnifiedDiff(patch)) return file.diff ?? patch; + if (file.before != null && file.after != null && file.before !== file.after) { + return formatSimpleDiff(file.before, file.after, file.path); + } + return null; +} + +export function formatSimpleDiff(before: string, after: string, path = ''): string { + const a = before.split('\n'); + const b = after.split('\n'); + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start += 1; + let endA = a.length; + let endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { + endA -= 1; + endB -= 1; + } + const minusCount = Math.max(0, endA - start); + const plusCount = Math.max(0, endB - start); + const lines: string[] = []; + if (path.trim()) { + lines.push(`--- ${path.trim()}`); + lines.push(`+++ ${path.trim()}`); + } + lines.push(`@@ -${start + 1},${minusCount} +${start + 1},${plusCount} @@`); + for (let i = start; i < endA; i += 1) lines.push(`-${a[i]}`); + for (let i = start; i < endB; i += 1) lines.push(`+${b[i]}`); + return lines.join('\n'); +} + +export function extractEditFilesFromSteps(steps: ProcessStep[]): TurnEditFile[] { + const files: TurnEditFile[] = []; + for (const step of steps) { + if (step.type !== 'tool') continue; + if (classifyToolAction(step.name) !== 'edit') continue; + const tone = toolActionTone(step.status); + if (tone === 'failed') continue; + const status: TurnEditStatus = tone === 'live' ? 'live' : 'done'; + const collected = filesFromToolStep(step); + for (const item of collected) { + upsertEditFile(files, { ...item, status }); + } + } + return files; +} + +/** This turn's 正在修改 / 已修改 files. Defaults to the latest turn in the map. */ +export function extractTurnEdits(processMap: ProcessMap, turn?: number): TurnEditFile[] { + const targetTurn = turn ?? latestProcessTurn(processMap); + if (targetTurn == null) return []; + const views = Object.values(processMap) + .filter((view) => view.turn === targetTurn) + .sort((a, b) => a.updatedAt - b.updatedAt); + const files: TurnEditFile[] = []; + for (const view of views) { + for (const file of extractEditFilesFromSteps(view.steps)) { + upsertEditFile(files, file); + } + } + return files; +} + +function filesFromToolStep(step: ToolStep): CollectedEdit[] { + const fromInput = collectEdits(step.input, 0); + const parsedResult = parseMaybeJson(step.result); + const fromResult = collectEdits(parsedResult, 0); + const merged = mergeCollected(fromInput, fromResult); + if (merged.length === 1) { + const orphan = textsFromUnknown(parsedResult); + if (orphan) merged[0] = overlayTexts(merged[0], orphan); + } + if (merged.length > 0) return merged; + const fallback = pathFromUnknown(step.input) ?? pathFromToolName(step.name); + return fallback ? [{ path: fallback }] : []; +} + +function textsFromUnknown(value: unknown): Pick | null { + if (typeof value === 'string' && looksLikeUnifiedDiff(value)) return { diff: value }; + const rec = asRecord(value); + if (!rec) return null; + const before = firstText(rec, BEFORE_KEYS); + const after = firstText(rec, AFTER_KEYS); + const diff = firstText(rec, ['diff', 'patch'] as const); + if (before == null && after == null && diff == null) return null; + const out: Pick = {}; + if (before != null) out.before = before; + if (after != null) out.after = after; + if (diff != null) out.diff = diff; + return out; +} + +function overlayTexts(row: CollectedEdit, extra: Pick): CollectedEdit { + return { + path: row.path, + before: row.before ?? extra.before, + after: row.after ?? extra.after, + diff: row.diff ?? extra.diff, + }; +} + +function collectEdits(value: unknown, depth: number): CollectedEdit[] { + if (depth > 3) return []; + const rec = asRecord(value); + if (!rec) { + const path = typeof value === 'string' ? normalizePath(value) : undefined; + return path && looksLikeFilePath(path) ? [{ path }] : []; + } + + const fromChanges = collectFromArray(rec.changes, depth); + if (fromChanges.length) return fromChanges; + + const fileChanges = asRecord(rec.fileChanges); + if (fileChanges) { + const rows: CollectedEdit[] = []; + for (const [path, row] of Object.entries(fileChanges)) { + rows.push(...pushChange(row, path, depth)); + } + if (rows.length) return rows; + } + + const fromLocations = collectFromArray(rec.locations, depth); + if (fromLocations.length) return fromLocations; + + const fromFiles = collectFilesField(rec.files, depth); + if (fromFiles.length) return fromFiles; + + if (rec.operation != null) { + const fromOperation = pushChange(rec.operation, undefined, depth); + if (fromOperation.length) return fromOperation; + } + + const self = pushChange(rec, undefined, depth); + if (self.length) return self; + + if (rec.item != null) { + const nested = collectEdits(rec.item, depth + 1); + if (nested.length) return nested; + } + + const rawInput = rec.toolCall && asRecord(rec.toolCall)?.rawInput; + if (rawInput != null) { + const nested = collectEdits(rawInput, depth + 1); + if (nested.length) return nested; + } + + if (rec.toolCall != null) { + const nested = collectEdits(rec.toolCall, depth + 1); + if (nested.length) return nested; + } + + return []; +} + +function collectFromArray(value: unknown, depth: number): CollectedEdit[] { + if (!Array.isArray(value)) return []; + const rows: CollectedEdit[] = []; + for (const item of value) { + rows.push(...pushChange(item, typeof item === 'string' ? item : undefined, depth)); + } + return rows; +} + +function collectFilesField(value: unknown, depth: number): CollectedEdit[] { + if (!Array.isArray(value)) return []; + const rows: CollectedEdit[] = []; + for (const item of value) { + if (typeof item === 'string') { + const path = normalizePath(item); + if (path) rows.push({ path }); + continue; + } + rows.push(...pushChange(item, undefined, depth)); + } + return rows; +} + +function pushChange(value: unknown, fallbackPath: string | undefined, depth: number): CollectedEdit[] { + const rec = asRecord(value); + const path = (rec ? pathFromRecord(rec) : undefined) + ?? (fallbackPath ? normalizePath(fallbackPath) : undefined); + if (!path) { + if (rec && depth < 3) return collectEdits(value, depth + 1); + return []; + } + const before = rec ? firstText(rec, BEFORE_KEYS) : undefined; + const after = rec ? firstText(rec, AFTER_KEYS) : undefined; + const diff = rec ? firstText(rec, ['diff', 'patch'] as const) : undefined; + const row: CollectedEdit = { path }; + if (before != null) row.before = before; + if (after != null) row.after = after; + if (diff != null) row.diff = diff; + return [row]; +} + +function mergeCollected(first: CollectedEdit[], second: CollectedEdit[]): CollectedEdit[] { + const out: CollectedEdit[] = []; + for (const item of first) upsertCollected(out, item); + for (const item of second) upsertCollected(out, item); + return out; +} + +function upsertCollected(files: CollectedEdit[], next: CollectedEdit): void { + const index = files.findIndex((row) => sameEditPath(row.path, next.path)); + if (index < 0) { + files.push(next); + return; + } + const prev = files[index]; + files[index] = { + path: next.path || prev.path, + before: next.before ?? prev.before, + after: next.after ?? prev.after, + diff: next.diff ?? prev.diff, + }; +} + +function upsertEditFile(files: TurnEditFile[], next: TurnEditFile): void { + const index = files.findIndex((row) => sameEditPath(row.path, next.path)); + if (index < 0) { + files.push(next); + return; + } + const prev = files[index]; + files[index] = { + path: next.path || prev.path, + status: next.status, + before: next.before ?? prev.before, + after: next.after ?? prev.after, + diff: next.diff ?? prev.diff, + }; +} + +function pathFromUnknown(value: unknown): string | undefined { + if (typeof value === 'string') { + const path = normalizePath(value); + return path && looksLikeFilePath(path) ? path : undefined; + } + const rec = asRecord(value); + return rec ? pathFromRecord(rec) : undefined; +} + +function pathFromRecord(rec: Record): string | undefined { + for (const key of PATH_KEYS) { + const found = firstString(rec[key]); + const path = found ? normalizePath(found) : undefined; + if (path) return path; + } + return undefined; +} + +function pathFromToolName(name: string): string | undefined { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) return undefined; + const last = parts[parts.length - 1]; + if (!last || !looksLikeFilePath(last)) return undefined; + return normalizePath(last); +} + +function looksLikeFilePath(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || /\s/.test(trimmed) && !/[/\\]/.test(trimmed)) return false; + return /[/\\]/.test(trimmed) || /\.[A-Za-z0-9]{1,8}$/.test(trimmed); +} + +function normalizePath(raw: string): string | undefined { + let trimmed = raw.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith('file://')) { + trimmed = trimmed.slice('file://'.length); + if (trimmed.toLowerCase().startsWith('localhost')) { + trimmed = trimmed.slice('localhost'.length); + } + } + return trimmed || undefined; +} + +function normalizeEditPath(path: string): string { + return path.trim().replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function looksLikeUnifiedDiff(text: string): boolean { + return /^(diff --git |--- |\+\+\+ |@@ )/m.test(text); +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + const start = trimmed[0]; + if (start !== '{' && start !== '[') return value; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return value; + } +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function firstText(rec: Record, keys: readonly string[]): string | undefined { + for (const key of keys) { + const found = firstString(rec[key]); + if (found != null) return found; + } + return undefined; +} + +function firstString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value; + if (!Array.isArray(value)) return undefined; + const parts = value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())); + return parts.length > 0 ? parts.join('\n') : undefined; +} diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index 3b25e98a..c9448641 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -119,6 +119,12 @@ describe('chat layout wiring', () => { expect(source('ChatMarkdownPreviewPanel.tsx')).toContain('pathTailLabel'); expect(source('ChatMarkdownPreviewPanel.tsx')).toContain("label={folder}"); expect(source('index.tsx')).toContain('pushChatPreview'); + expect(source('index.tsx')).toContain('extractTurnEdits'); + expect(source('index.tsx')).toContain('ChatTurnEditList'); + expect(source('index.tsx')).toContain('ChatEditPreviewPanel'); + expect(source('index.tsx')).toContain('openChatEditPreview'); + expect(translate('zh', 'chat.preview.viewEdit')).toBe('查看修改'); + expect(translate('en', 'chat.preview.viewEdit')).toBe('View edits'); }); it('opens the turn process in the same right-hand pane', () => { diff --git a/src/pages/chat/chat-preview-model.test.ts b/src/pages/chat/chat-preview-model.test.ts index 791f93ab..76b14e5d 100644 --- a/src/pages/chat/chat-preview-model.test.ts +++ b/src/pages/chat/chat-preview-model.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { chatPreviewCanBack, chatPreviewPath, + isChatEditPreview, isChatFilePreview, isChatProcessInspect, + openChatEditPreview, openChatPreviewRoot, openChatProcessInspect, popChatPreview, @@ -45,4 +47,16 @@ describe('chat preview stack', () => { openChatPreviewRoot('/repo/README.md'), ); }); + + it('opens an edit preview by path without a back stack', () => { + const target = openChatEditPreview('src/a.ts'); + expect(isChatEditPreview(target)).toBe(true); + expect(isChatFilePreview(target)).toBe(false); + expect(chatPreviewPath(target)).toBe('src/a.ts'); + expect(chatPreviewCanBack(target)).toBe(false); + expect(popChatPreview(target)).toBeNull(); + expect(pushChatPreview(target, '/repo/README.md')).toEqual( + openChatPreviewRoot('/repo/README.md'), + ); + }); }); diff --git a/src/pages/chat/chat-preview-model.ts b/src/pages/chat/chat-preview-model.ts index 07c912a5..5eca26ce 100644 --- a/src/pages/chat/chat-preview-model.ts +++ b/src/pages/chat/chat-preview-model.ts @@ -13,7 +13,15 @@ export type ChatProcessInspectTarget = { agent: AgentKey; }; -export type ChatInspectTarget = ChatFilePreviewTarget | ChatProcessInspectTarget; +export type ChatEditPreviewTarget = { + kind: 'edit'; + path: string; +}; + +export type ChatInspectTarget = + | ChatFilePreviewTarget + | ChatProcessInspectTarget + | ChatEditPreviewTarget; /** File-or-process inspect target for the chat right pane. */ export type ChatPreviewTarget = ChatInspectTarget; @@ -30,7 +38,14 @@ export function isChatProcessInspect( return target?.kind === 'process'; } +export function isChatEditPreview( + target: ChatInspectTarget | null | undefined, +): target is ChatEditPreviewTarget { + return target?.kind === 'edit'; +} + export function chatPreviewPath(target: ChatInspectTarget | null | undefined): string { + if (isChatEditPreview(target)) return target.path; if (!isChatFilePreview(target) || !target.stack.length) return ''; return target.stack[target.stack.length - 1] ?? ''; } @@ -57,6 +72,10 @@ export function openChatProcessInspect(turn: number, agent: AgentKey): ChatProce return { kind: 'process', turn, agent }; } +export function openChatEditPreview(path: string): ChatEditPreviewTarget { + return { kind: 'edit', path }; +} + export function pushChatPreview( target: ChatInspectTarget | null | undefined, next: string, diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 50b0c08c..68edabd9 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { MessagesSquare } from 'lucide-react'; import { pageRhythm } from '@/components/layout/page-rhythm'; @@ -36,14 +36,23 @@ import { subscribeChatShortcutKeydown } from './chat-shortcuts'; import { chatModShiftIShouldOpenModel } from './chat-model-labels'; import { formatChatSessionRecord, processUserPromptPreview, type TurnGroup } from './chat-format'; import { chatBusySendMode, grokLegacyContinueKind } from './chat-grok-follow-up'; +import { ChatEditPreviewPanel, ChatTurnEditList } from './ChatEditPreviewPanel'; import { ChatMarkdownPreviewPanel } from './ChatMarkdownPreviewPanel'; import { ChatProcessInspectPanel } from './ChatProcessInspectPanel'; +import { + extractTurnEdits, + sameEditPath, + turnEditHasInlineDiff, + type TurnEditFile, +} from './chat-edit-preview'; import { chatPreviewCanBack, chatPreviewLine, chatPreviewPath, + isChatEditPreview, isChatFilePreview, isChatProcessInspect, + openChatEditPreview, openChatPreviewRoot, openChatProcessInspect, popChatPreview, @@ -117,6 +126,24 @@ export default function ChatPage() { } preview.open(previous); }, [preview.close, preview.open, preview.target]); + const turnEdits = useMemo(() => extractTurnEdits(page.processMap), [page.processMap]); + const openTurnEdit = useCallback( + (file: TurnEditFile) => { + if (turnEditHasInlineDiff(file)) { + preview.open(openChatEditPreview(file.path)); + return; + } + preview.open(openChatPreviewRoot(file.path)); + }, + [preview.open], + ); + const editPreviewPath = isChatEditPreview(preview.target) ? preview.target.path : ''; + const selectedEdit = editPreviewPath + ? turnEdits.find((file) => sameEditPath(file.path, editPreviewPath)) ?? null + : null; + const showEditDiff = Boolean( + selectedEdit && turnEditHasInlineDiff(selectedEdit), + ); useEffect(() => { preview.reset(); @@ -528,6 +555,11 @@ export default function ChatPage() { ); })()} + - {isChatFilePreview(preview.target) ? ( + {isChatFilePreview(preview.target) || (isChatEditPreview(preview.target) && !showEditDiff) ? ( + ) : showEditDiff && selectedEdit ? ( + ) : isChatProcessInspect(preview.target) ? (