diff --git a/apps/code/src/renderer/components/TreeDirectoryRow.tsx b/apps/code/src/renderer/components/TreeDirectoryRow.tsx
new file mode 100644
index 000000000..e9d39b8e3
--- /dev/null
+++ b/apps/code/src/renderer/components/TreeDirectoryRow.tsx
@@ -0,0 +1,143 @@
+import { FileIcon } from "@components/ui/FileIcon";
+import { CaretRight, FolderIcon, FolderOpenIcon } from "@phosphor-icons/react";
+import { Box, Flex } from "@radix-ui/themes";
+import type { ReactNode } from "react";
+
+const TREE_ROW_ACTIVE_CLASS = "border-accent-8 border-y bg-accent-4";
+const TREE_ROW_INACTIVE_CLASS = "border-transparent border-y hover:bg-gray-3";
+const TREE_INDENT_PX = 12;
+const CARET_COL_SIZE = 16;
+
+interface TreeDirectoryRowProps {
+ name: string;
+ depth: number;
+ isExpanded: boolean;
+ onToggle: () => void;
+ isActive?: boolean;
+}
+
+export function TreeDirectoryRow({
+ name,
+ depth,
+ isExpanded,
+ onToggle,
+ isActive = false,
+}: TreeDirectoryRowProps) {
+ return (
+
+
+
+
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ {name}
+
+
+ );
+}
+
+interface TreeFileRowProps {
+ fileName: string;
+ depth: number;
+ isActive?: boolean;
+ onClick?: () => void;
+ onDoubleClick?: () => void;
+ onContextMenu?: (e: React.MouseEvent) => void;
+ onMouseEnter?: () => void;
+ onMouseLeave?: () => void;
+ /** Extra content rendered after the filename (badges, buttons, etc.) */
+ trailing?: ReactNode;
+}
+
+export function TreeFileRow({
+ fileName,
+ depth,
+ isActive = false,
+ onClick,
+ onDoubleClick,
+ onContextMenu,
+ onMouseEnter,
+ onMouseLeave,
+ trailing,
+}: TreeFileRowProps) {
+ return (
+
+ {/* Spacer to align with folder caret column */}
+
+
+
+ {fileName}
+
+ {trailing}
+
+ );
+}
diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx
index cda266ac3..990b6e9cd 100644
--- a/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx
+++ b/apps/code/src/renderer/features/task-detail/components/ChangesPanel.tsx
@@ -1,4 +1,4 @@
-import { FileIcon } from "@components/ui/FileIcon";
+import { TreeFileRow } from "@components/TreeDirectoryRow";
import { PanelMessage } from "@components/ui/PanelMessage";
import { Tooltip } from "@components/ui/Tooltip";
import { useExternalApps } from "@features/external-apps/hooks/useExternalApps";
@@ -40,7 +40,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { showMessageBox } from "@utils/dialog";
import { handleExternalAppAction } from "@utils/handleExternalAppAction";
import { logger } from "@utils/logger";
-import { Fragment, useMemo, useState } from "react";
+import { Fragment, useCallback, useMemo, useState } from "react";
+import { ChangesTreeView } from "./ChangesTreeView";
const log = logger.scope("changes-panel");
@@ -57,6 +58,8 @@ interface ChangedFileItemProps {
repoPath?: string;
mainRepoPath?: string;
onStageToggle?: (file: ChangedFile) => void;
+ /** Tree indentation depth (0 = flat list) */
+ depth?: number;
}
function getDiscardInfo(
@@ -128,6 +131,7 @@ function ChangedFileItem({
repoPath,
mainRepoPath,
onStageToggle,
+ depth = 0,
}: ChangedFileItemProps) {
const openReview = usePanelLayoutStore((state) => state.openReview);
const requestScrollToFile = useReviewNavigationStore(
@@ -240,170 +244,127 @@ function ChangedFileItem({
const tooltipContent = `${file.path} - ${indicator.fullLabel}`;
+ const trailing = (
+ <>
+ {hasLineStats && !isToolbarVisible && (
+
+ {(file.linesAdded ?? 0) > 0 && (
+ +{file.linesAdded}
+ )}
+ {(file.linesRemoved ?? 0) > 0 && (
+ -{file.linesRemoved}
+ )}
+
+ )}
+
+ {isToolbarVisible && (handleDiscard || onStageToggle) && (
+
+ {onStageToggle && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ onStageToggle(file);
+ }}
+ >
+ {file.staged ? : }
+
+ )}
+ {handleDiscard && (
+
+
+
+ )}
+
+
+
+
+ e.stopPropagation()}
+ style={{
+ flexShrink: 0,
+ width: "18px",
+ height: "18px",
+ padding: 0,
+ }}
+ >
+
+
+
+
+
+ {detectedApps
+ .filter((app) => app.type !== "terminal")
+ .map((app) => (
+ handleOpenWith(app.id)}
+ >
+
+ {app.icon ? (
+
+ ) : (
+
+ )}
+ {app.name}
+
+
+ ))}
+
+
+
+
+ Copy Path
+
+
+
+
+
+ )}
+
+
+ {indicator.label}
+
+ >
+ );
+
return (
- setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
- className={
- isActive
- ? "border-accent-8 border-y bg-accent-4"
- : "border-transparent border-y hover:bg-gray-3"
- }
- style={{
- cursor: "pointer",
- whiteSpace: "nowrap",
- overflow: "hidden",
- height: "26px",
- paddingLeft: "8px",
- paddingRight: "8px",
- }}
- >
-
-
- {fileName}
-
-
- {file.originalPath
- ? `${file.originalPath} → ${file.path}`
- : file.path}
-
-
- {hasLineStats && !isToolbarVisible && (
-
- {(file.linesAdded ?? 0) > 0 && (
-
- +{file.linesAdded}
-
- )}
- {(file.linesRemoved ?? 0) > 0 && (
-
- -{file.linesRemoved}
-
- )}
-
- )}
-
- {isToolbarVisible && (handleDiscard || onStageToggle) && (
-
- {onStageToggle && (
- {
- e.preventDefault();
- e.stopPropagation();
- onStageToggle(file);
- }}
- >
- {file.staged ? : }
-
- )}
- {handleDiscard && (
-
-
-
- )}
-
-
-
-
- e.stopPropagation()}
- style={{
- flexShrink: 0,
- width: "18px",
- height: "18px",
- padding: 0,
- }}
- >
-
-
-
-
-
- {detectedApps
- .filter((app) => app.type !== "terminal")
- .map((app) => (
- handleOpenWith(app.id)}
- >
-
- {app.icon ? (
-
- ) : (
-
- )}
- {app.name}
-
-
- ))}
-
-
-
-
- Copy Path
-
-
-
-
-
- )}
-
-
- {indicator.label}
-
-
+ trailing={trailing}
+ />
);
}
@@ -424,6 +385,19 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) {
const effectiveFiles = changedFiles;
+ const renderFile = useCallback(
+ (file: ChangedFile, depth: number) => (
+
+ ),
+ [taskId, activeFilePath],
+ );
+
// No branch/PR yet and run is active — show waiting state
if (!prUrl && !effectiveBranch && effectiveFiles.length === 0) {
if (isRunActive) {
@@ -477,14 +451,7 @@ function CloudChangesPanel({ taskId, task }: ChangesPanelProps) {
return (
- {effectiveFiles.map((file) => (
-
- ))}
+
{isRunActive && (
@@ -526,20 +493,51 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) {
const hasStagedFiles = stagedFiles.length > 0;
- const handleStageToggle = async (file: ChangedFile) => {
- if (!repoPath) return;
- const paths = [file.originalPath ?? file.path];
- const endpoint = file.staged
- ? trpcClient.git.unstageFiles
- : trpcClient.git.stageFiles;
- try {
- const result = await endpoint.mutate({ directoryPath: repoPath, paths });
- updateGitCacheFromSnapshot(queryClient, repoPath, result);
- invalidateGitWorkingTreeQueries(repoPath);
- } catch (error) {
- log.error("Failed to toggle staging", { file: file.path, error });
- }
- };
+ const handleStageToggle = useCallback(
+ async (file: ChangedFile) => {
+ if (!repoPath) return;
+ const paths = [file.originalPath ?? file.path];
+ const endpoint = file.staged
+ ? trpcClient.git.unstageFiles
+ : trpcClient.git.stageFiles;
+ try {
+ const result = await endpoint.mutate({
+ directoryPath: repoPath,
+ paths,
+ });
+ updateGitCacheFromSnapshot(queryClient, repoPath, result);
+ invalidateGitWorkingTreeQueries(repoPath);
+ } catch (error) {
+ log.error("Failed to toggle staging", { file: file.path, error });
+ }
+ },
+ [repoPath, queryClient],
+ );
+
+ const renderLocalFile = useCallback(
+ (file: ChangedFile, depth: number) => {
+ const key = makeFileKey(file.staged, file.path);
+ return (
+
+ );
+ },
+ [
+ taskId,
+ repoPath,
+ activeFilePath,
+ workspace?.folderPath,
+ handleStageToggle,
+ ],
+ );
if (!repoPath) {
return No repository path available;
@@ -580,20 +578,7 @@ function LocalChangesPanel({ taskId, task: _task }: ChangesPanelProps) {
)}
- {files.map((file) => {
- const key = makeFileKey(file.staged, file.path);
- return (
-
- );
- })}
+
))}
diff --git a/apps/code/src/renderer/features/task-detail/components/ChangesTreeView.tsx b/apps/code/src/renderer/features/task-detail/components/ChangesTreeView.tsx
new file mode 100644
index 000000000..30b5ac74e
--- /dev/null
+++ b/apps/code/src/renderer/features/task-detail/components/ChangesTreeView.tsx
@@ -0,0 +1,146 @@
+import { TreeDirectoryRow } from "@components/TreeDirectoryRow";
+import type { ChangedFile } from "@shared/types";
+import { useCallback, useMemo, useState } from "react";
+
+export interface TreeNode {
+ name: string;
+ path: string;
+ children: Map;
+ files: ChangedFile[];
+}
+
+export function buildChangesTree(files: ChangedFile[]): TreeNode {
+ const root: TreeNode = { name: "", path: "", children: new Map(), files: [] };
+ for (const file of files) {
+ const parts = file.path.split("/");
+ let node = root;
+ for (let i = 0; i < parts.length - 1; i++) {
+ const part = parts[i];
+ if (!node.children.has(part)) {
+ node.children.set(part, {
+ name: part,
+ path: parts.slice(0, i + 1).join("/"),
+ children: new Map(),
+ files: [],
+ });
+ }
+ const child = node.children.get(part);
+ if (!child) break;
+ node = child;
+ }
+ node.files.push(file);
+ }
+ return root;
+}
+
+/** Collapse single-child directory chains into one node (e.g. "src/utils") */
+export function compactTree(node: TreeNode): TreeNode {
+ const compacted = new Map();
+ for (const [key, child] of node.children) {
+ let current = child;
+ let label = current.name;
+ while (current.children.size === 1 && current.files.length === 0) {
+ const [, only] = [...current.children.entries()][0];
+ label = `${label}/${only.name}`;
+ current = only;
+ }
+ const result = compactTree(current);
+ result.name = label;
+ compacted.set(key, result);
+ }
+ return { ...node, children: compacted };
+}
+
+interface ChangesTreeNodeProps {
+ node: TreeNode;
+ depth: number;
+ collapsedDirs: Set;
+ onToggleDir: (path: string) => void;
+ renderFile: (file: ChangedFile, depth: number) => React.ReactNode;
+}
+
+function ChangesTreeNode({
+ node,
+ depth,
+ collapsedDirs,
+ onToggleDir,
+ renderFile,
+}: ChangesTreeNodeProps) {
+ const isCollapsed = collapsedDirs.has(node.path);
+ const sortedDirs = useMemo(
+ () =>
+ [...node.children.values()].sort((a, b) => a.name.localeCompare(b.name)),
+ [node.children],
+ );
+ const sortedFiles = useMemo(
+ () =>
+ [...node.files].sort((a, b) => {
+ const aName = a.path.split("/").pop() || "";
+ const bName = b.path.split("/").pop() || "";
+ return aName.localeCompare(bName);
+ }),
+ [node.files],
+ );
+
+ return (
+ <>
+ {node.path && (
+ onToggleDir(node.path)}
+ />
+ )}
+ {!isCollapsed && (
+ <>
+ {sortedDirs.map((child) => (
+
+ ))}
+ {sortedFiles.map((file) =>
+ renderFile(file, node.path ? depth + 1 : depth),
+ )}
+ >
+ )}
+ >
+ );
+}
+
+interface ChangesTreeViewProps {
+ files: ChangedFile[];
+ renderFile: (file: ChangedFile, depth: number) => React.ReactNode;
+}
+
+export function ChangesTreeView({ files, renderFile }: ChangesTreeViewProps) {
+ const tree = useMemo(() => compactTree(buildChangesTree(files)), [files]);
+ const [collapsedDirs, setCollapsedDirs] = useState>(new Set());
+
+ const handleToggleDir = useCallback((path: string) => {
+ setCollapsedDirs((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ }, []);
+
+ return (
+
+ );
+}
diff --git a/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx b/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx
index c5c25c9c9..a5e3d64da 100644
--- a/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx
+++ b/apps/code/src/renderer/features/task-detail/components/FileTreePanel.tsx
@@ -1,4 +1,4 @@
-import { FileIcon } from "@components/ui/FileIcon";
+import { TreeDirectoryRow, TreeFileRow } from "@components/TreeDirectoryRow";
import { PanelMessage } from "@components/ui/PanelMessage";
import { usePanelLayoutStore } from "@features/panels/store/panelLayoutStore";
import { isFileTabActiveInTree } from "@features/panels/store/panelStoreHelpers";
@@ -8,12 +8,7 @@ import {
} from "@features/right-sidebar/stores/fileTreeStore";
import { useCwd } from "@features/sidebar/hooks/useCwd";
import { useCloudRunState } from "@features/task-detail/hooks/useCloudRunState";
-import {
- CaretRight,
- Cloud,
- FolderIcon,
- FolderOpenIcon,
-} from "@phosphor-icons/react";
+import { Cloud } from "@phosphor-icons/react";
import { Box, Button, Flex, Spinner, Text } from "@radix-ui/themes";
import { useWorkspace } from "@renderer/features/workspace/hooks/useWorkspace";
import { trpcClient, useTRPC } from "@renderer/trpc/client";
@@ -110,71 +105,28 @@ function LazyTreeItem({
return (
-
+ {isDirectory ? (
- {isDirectory && (
-
- )}
+
- {isDirectory ? (
- isExpanded ? (
-
- ) : (
-
- )
- ) : (
-
- )}
-
- {entry.name}
-
-
+ ) : (
+
+ )}
{isExpanded &&
children?.map((child) => (