diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9c24d883b508..58754e88eb2a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -63,11 +63,17 @@ import { MAX_THREAD_TERMINAL_COUNT, type ChatMessage, type Thread, + type TurnDiffFileChange, type TurnDiffSummary, } from "../types"; import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { + buildTurnDiffTree, + summarizeTurnDiffStats, + type TurnDiffTreeNode, +} from "../lib/turnDiffTree"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { @@ -333,6 +339,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; theme: "light" | "dark"; + className?: string; }) { const [failedIconUrl, setFailedIconUrl] = useState(null); const iconUrl = useMemo( @@ -343,9 +350,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { if (failed) { return props.kind === "directory" ? ( - + ) : ( - + ); } @@ -354,7 +361,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { src={iconUrl} alt="" aria-hidden="true" - className="size-4 shrink-0" + className={cn("size-4 shrink-0", props.className)} loading="lazy" onError={() => setFailedIconUrl(iconUrl)} /> @@ -2547,6 +2554,7 @@ export default function ChatView({ threadId }: ChatViewProps) { isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} + resolvedTheme={resolvedTheme} /> @@ -3126,6 +3134,156 @@ const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: stri ); }); +function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean { + return stat.additions > 0 || stat.deletions > 0; +} + +const DiffStatLabel = memo(function DiffStatLabel(props: { + additions: number; + deletions: number; + showParentheses?: boolean; +}) { + const { additions, deletions, showParentheses = false } = props; + return ( + <> + {showParentheses && (} + +{additions} + / + -{deletions} + {showParentheses && )} + + ); +}); + +function collectDirectoryPaths(nodes: ReadonlyArray): string[] { + const paths: string[] = []; + for (const node of nodes) { + if (node.kind !== "directory") continue; + paths.push(node.path); + paths.push(...collectDirectoryPaths(node.children)); + } + return paths; +} + +function buildDirectoryExpansionState( + directoryPaths: ReadonlyArray, + expanded: boolean, +): Record { + const expandedState: Record = {}; + for (const directoryPath of directoryPaths) { + expandedState[directoryPath] = expanded; + } + return expandedState; +} + +const ChangedFilesTree = memo(function ChangedFilesTree(props: { + turnId: TurnId; + files: ReadonlyArray; + allDirectoriesExpanded: boolean; + resolvedTheme: "light" | "dark"; + onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; +}) { + const { files, allDirectoriesExpanded, onOpenTurnDiff, resolvedTheme, turnId } = props; + const treeNodes = useMemo(() => buildTurnDiffTree(files), [files]); + const directoryPathsKey = useMemo(() => collectDirectoryPaths(treeNodes).join("\u0000"), [treeNodes]); + const allDirectoryExpansionState = useMemo( + () => + buildDirectoryExpansionState( + directoryPathsKey ? directoryPathsKey.split("\u0000") : [], + allDirectoriesExpanded, + ), + [allDirectoriesExpanded, directoryPathsKey], + ); + const [expandedDirectories, setExpandedDirectories] = useState>( + () => buildDirectoryExpansionState((directoryPathsKey ? directoryPathsKey.split("\u0000") : []), true), + ); + useEffect(() => { + setExpandedDirectories(allDirectoryExpansionState); + }, [allDirectoryExpansionState]); + + const toggleDirectory = useCallback((pathValue: string, fallbackExpanded: boolean) => { + setExpandedDirectories((current) => ({ + ...current, + [pathValue]: !(current[pathValue] ?? fallbackExpanded), + })); + }, []); + + const renderTreeNode = (node: TurnDiffTreeNode, depth: number) => { + const leftPadding = 8 + depth * 14; + if (node.kind === "directory") { + const isExpanded = expandedDirectories[node.path] ?? depth === 0; + return ( +
+ + {isExpanded && ( +
+ {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} +
+ )} +
+ ); + } + + return ( + + ); + }; + + return ( +
+ {treeNodes.map((node) => renderTreeNode(node, 0))} +
+ ); +}); + interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; @@ -3145,6 +3303,7 @@ interface MessagesTimelineProps { isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; markdownCwd: string | undefined; + resolvedTheme: "light" | "dark"; } type TimelineEntry = ReturnType[number]; @@ -3199,6 +3358,7 @@ const MessagesTimeline = memo(function MessagesTimeline({ isRevertingCheckpoint, onImageExpand, markdownCwd, + resolvedTheme, }: MessagesTimelineProps) { const rows = useMemo(() => { const nextRows: TimelineRow[] = []; @@ -3339,6 +3499,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); + const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< + Record + >({}); + const onToggleAllDirectories = useCallback((turnId: TurnId) => { + setAllDirectoriesExpandedByTurnId((current) => ({ + ...current, + [turnId]: !(current[turnId] ?? true), + })); + }, []); const renderRowContent = (row: TimelineRow) => (
@@ -3507,81 +3676,54 @@ const MessagesTimeline = memo(function MessagesTimeline({ if (!turnSummary) return null; const checkpointFiles = turnSummary.files; if (checkpointFiles.length === 0) return null; - const summaryStat = checkpointFiles.reduce( - (acc, file) => { - if ( - typeof file.additions !== "number" || - typeof file.deletions !== "number" - ) { - return acc; - } - return { - additions: acc.additions + file.additions, - deletions: acc.deletions + file.deletions, - }; - }, - { additions: 0, deletions: 0 }, - ); + const summaryStat = summarizeTurnDiffStats(checkpointFiles); const changedFileCountLabel = String(checkpointFiles.length); + const allDirectoriesExpanded = + allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true; return (

Changed files ({changedFileCountLabel}) - {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + {hasNonZeroStat(summaryStat) && ( <> - +{summaryStat.additions} - / - -{summaryStat.deletions} + )}

- -
-
- {checkpointFiles.map((file) => ( - - ))} + {allDirectoriesExpanded ? "Collapse all" : "Expand all"} + + +
+
); })()} diff --git a/apps/web/src/lib/turnDiffTree.test.ts b/apps/web/src/lib/turnDiffTree.test.ts new file mode 100644 index 000000000000..5778dca3aff8 --- /dev/null +++ b/apps/web/src/lib/turnDiffTree.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from "vitest"; + +import { buildTurnDiffTree, summarizeTurnDiffStats } from "./turnDiffTree"; + +describe("summarizeTurnDiffStats", () => { + it("sums only files with numeric additions/deletions", () => { + const stat = summarizeTurnDiffStats([ + { path: "README.md", additions: 3, deletions: 1 }, + { path: "docs/notes.md" }, + { path: "src/index.ts", additions: 5, deletions: 2 }, + ]); + + expect(stat).toEqual({ additions: 8, deletions: 3 }); + }); +}); + +describe("buildTurnDiffTree", () => { + it("builds nested directory nodes with aggregated stats", () => { + const tree = buildTurnDiffTree([ + { path: "src/index.ts", additions: 2, deletions: 1 }, + { path: "src/components/Button.tsx", additions: 4, deletions: 2 }, + { path: "README.md", additions: 1, deletions: 0 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "src", + path: "src", + stat: { additions: 6, deletions: 3 }, + children: [ + { + kind: "directory", + name: "components", + path: "src/components", + stat: { additions: 4, deletions: 2 }, + children: [ + { + kind: "file", + name: "Button.tsx", + path: "src/components/Button.tsx", + stat: { additions: 4, deletions: 2 }, + }, + ], + }, + { + kind: "file", + name: "index.ts", + path: "src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + { + kind: "file", + name: "README.md", + path: "README.md", + stat: { additions: 1, deletions: 0 }, + }, + ]); + }); + + it("keeps files without stat values and excludes them from directory totals", () => { + const tree = buildTurnDiffTree([ + { path: "docs/notes.md" }, + { path: "docs/todo.md", additions: 1, deletions: 1 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "docs", + path: "docs", + stat: { additions: 1, deletions: 1 }, + children: [ + { + kind: "file", + name: "notes.md", + path: "docs/notes.md", + stat: null, + }, + { + kind: "file", + name: "todo.md", + path: "docs/todo.md", + stat: { additions: 1, deletions: 1 }, + }, + ], + }, + ]); + }); + + it("normalizes file paths with windows separators", () => { + const tree = buildTurnDiffTree([{ path: "apps\\web\\src\\index.ts", additions: 2, deletions: 1 }]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "apps/web/src", + path: "apps/web/src", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "file", + name: "index.ts", + path: "apps/web/src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + ]); + }); + + it("compacts only single-directory chains and stops at branch points", () => { + const tree = buildTurnDiffTree([ + { path: "apps/server/src/index.ts", additions: 2, deletions: 1 }, + { path: "apps/server/main.ts", additions: 4, deletions: 0 }, + ]); + + expect(tree).toEqual([ + { + kind: "directory", + name: "apps/server", + path: "apps/server", + stat: { additions: 6, deletions: 1 }, + children: [ + { + kind: "directory", + name: "src", + path: "apps/server/src", + stat: { additions: 2, deletions: 1 }, + children: [ + { + kind: "file", + name: "index.ts", + path: "apps/server/src/index.ts", + stat: { additions: 2, deletions: 1 }, + }, + ], + }, + { + kind: "file", + name: "main.ts", + path: "apps/server/main.ts", + stat: { additions: 4, deletions: 0 }, + }, + ], + }, + ]); + }); + + it("preserves leading/trailing whitespace in path segments", () => { + const tree = buildTurnDiffTree([ + { path: "a/file.ts", additions: 1, deletions: 0 }, + { path: " a/file.ts", additions: 2, deletions: 0 }, + ]); + + expect(tree).toHaveLength(2); + const directoryNodes = tree.filter( + (node): node is Extract<(typeof tree)[number], { kind: "directory" }> => node.kind === "directory", + ); + expect(directoryNodes.map((node) => node.name).toSorted()).toEqual([" a", "a"]); + expect(directoryNodes.map((node) => node.path).toSorted()).toEqual([" a", "a"]); + }); +}); diff --git a/apps/web/src/lib/turnDiffTree.ts b/apps/web/src/lib/turnDiffTree.ts new file mode 100644 index 000000000000..cd9bfc831fbb --- /dev/null +++ b/apps/web/src/lib/turnDiffTree.ts @@ -0,0 +1,172 @@ +import type { TurnDiffFileChange } from "../types"; + +export interface TurnDiffStat { + additions: number; + deletions: number; +} + +export interface TurnDiffTreeDirectoryNode { + kind: "directory"; + name: string; + path: string; + stat: TurnDiffStat; + children: TurnDiffTreeNode[]; +} + +export interface TurnDiffTreeFileNode { + kind: "file"; + name: string; + path: string; + stat: TurnDiffStat | null; +} + +export type TurnDiffTreeNode = TurnDiffTreeDirectoryNode | TurnDiffTreeFileNode; + +interface MutableDirectoryNode { + name: string; + path: string; + stat: TurnDiffStat; + directories: Map; + files: TurnDiffTreeFileNode[]; +} + +const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { numeric: true, sensitivity: "base" }; + +function normalizePathSegments(pathValue: string): string[] { + return pathValue + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); +} + +function compareByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name, undefined, SORT_LOCALE_OPTIONS); +} + +function readStat(file: TurnDiffFileChange): TurnDiffStat | null { + if (typeof file.additions !== "number" || typeof file.deletions !== "number") { + return null; + } + return { + additions: file.additions, + deletions: file.deletions, + }; +} + +function compactDirectoryNode(node: TurnDiffTreeDirectoryNode): TurnDiffTreeDirectoryNode { + const compactedChildren = node.children.map((child) => + child.kind === "directory" ? compactDirectoryNode(child) : child, + ); + + let compactedNode: TurnDiffTreeDirectoryNode = { + ...node, + children: compactedChildren, + }; + + while (compactedNode.children.length === 1 && compactedNode.children[0]?.kind === "directory") { + const onlyChild = compactedNode.children[0]; + compactedNode = { + kind: "directory", + name: `${compactedNode.name}/${onlyChild.name}`, + path: onlyChild.path, + stat: onlyChild.stat, + children: onlyChild.children, + }; + } + + return compactedNode; +} + +function toTreeNodes(directory: MutableDirectoryNode): TurnDiffTreeNode[] { + const subdirectories: TurnDiffTreeDirectoryNode[] = Array.from(directory.directories.values()) + .toSorted(compareByName) + .map((subdirectory) => ({ + kind: "directory", + name: subdirectory.name, + path: subdirectory.path, + stat: { + additions: subdirectory.stat.additions, + deletions: subdirectory.stat.deletions, + }, + children: toTreeNodes(subdirectory), + })) + .map((subdirectory) => compactDirectoryNode(subdirectory)); + + const files = directory.files.toSorted(compareByName); + return [...subdirectories, ...files]; +} + +export function summarizeTurnDiffStats(files: ReadonlyArray): TurnDiffStat { + return files.reduce( + (acc, file) => { + const stat = readStat(file); + if (!stat) return acc; + return { + additions: acc.additions + stat.additions, + deletions: acc.deletions + stat.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); +} + +export function buildTurnDiffTree(files: ReadonlyArray): TurnDiffTreeNode[] { + const root: MutableDirectoryNode = { + name: "", + path: "", + stat: { additions: 0, deletions: 0 }, + directories: new Map(), + files: [], + }; + + for (const file of files) { + const segments = normalizePathSegments(file.path); + if (segments.length === 0) { + continue; + } + + const filePath = segments.join("/"); + const fileName = segments.at(-1); + if (!fileName) { + continue; + } + const stat = readStat(file); + const ancestors: MutableDirectoryNode[] = [root]; + let currentDirectory = root; + + for (const segment of segments.slice(0, -1)) { + const nextPath = currentDirectory.path ? `${currentDirectory.path}/${segment}` : segment; + const existing = currentDirectory.directories.get(segment); + if (existing) { + currentDirectory = existing; + } else { + const created: MutableDirectoryNode = { + name: segment, + path: nextPath, + stat: { additions: 0, deletions: 0 }, + directories: new Map(), + files: [], + }; + currentDirectory.directories.set(segment, created); + currentDirectory = created; + } + ancestors.push(currentDirectory); + } + + currentDirectory.files.push({ + kind: "file", + name: fileName, + path: filePath, + stat, + }); + + if (stat) { + for (const ancestor of ancestors) { + ancestor.stat.additions += stat.additions; + ancestor.stat.deletions += stat.deletions; + } + } + } + + return toTreeNodes(root); +}