Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
270 changes: 206 additions & 64 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -333,6 +339,7 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
pathValue: string;
kind: "file" | "directory";
theme: "light" | "dark";
className?: string;
}) {
const [failedIconUrl, setFailedIconUrl] = useState<string | null>(null);
const iconUrl = useMemo(
Expand All @@ -343,9 +350,9 @@ const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {

if (failed) {
return props.kind === "directory" ? (
<FolderIcon className="size-4 text-muted-foreground/80" />
<FolderIcon className={cn("size-4 text-muted-foreground/80", props.className)} />
) : (
<FileIcon className="size-4 text-muted-foreground/80" />
<FileIcon className={cn("size-4 text-muted-foreground/80", props.className)} />
);
}

Expand All @@ -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)}
/>
Expand Down Expand Up @@ -2547,6 +2554,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
isRevertingCheckpoint={isRevertingCheckpoint}
onImageExpand={onExpandTimelineImage}
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
/>
</div>

Expand Down Expand Up @@ -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;
}) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused showParentheses prop is dead code

Low Severity

The showParentheses prop on DiffStatLabel is defined with a default of false, but no caller ever passes true. This appears to be leftover scaffolding from the old inline-chip layout that rendered parentheses around per-file stats — a format that no longer exists after this refactor.

Fix in Cursor Fix in Web

const { additions, deletions, showParentheses = false } = props;
return (
<>
{showParentheses && <span className="text-muted-foreground/70">(</span>}
<span className="text-success">+{additions}</span>
<span className="mx-0.5 text-muted-foreground/70">/</span>
<span className="text-destructive">-{deletions}</span>
{showParentheses && <span className="text-muted-foreground/70">)</span>}
</>
);
});

function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): 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<string>,
expanded: boolean,
): Record<string, boolean> {
const expandedState: Record<string, boolean> = {};
for (const directoryPath of directoryPaths) {
expandedState[directoryPath] = expanded;
}
return expandedState;
}

const ChangedFilesTree = memo(function ChangedFilesTree(props: {
turnId: TurnId;
files: ReadonlyArray<TurnDiffFileChange>;
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<Record<string, boolean>>(
() => 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;
Comment thread
cursor[bot] marked this conversation as resolved.
return (
<div key={`dir:${node.path}`}>
<button
type="button"
className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
style={{ paddingLeft: `${leftPadding}px` }}
onClick={() => toggleDirectory(node.path, depth === 0)}
>
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground/70 transition-transform group-hover:text-foreground/80",
isExpanded && "rotate-90",
)}
/>
{isExpanded ? (
<FolderIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
) : (
<FolderClosedIcon className="size-3.5 shrink-0 text-muted-foreground/75" />
)}
<span className="truncate font-mono text-[11px] text-muted-foreground/90 group-hover:text-foreground/90">
{node.name}
</span>
{hasNonZeroStat(node.stat) && (
<span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
<DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
</span>
)}
</button>
{isExpanded && (
<div className="space-y-0.5">
{node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}
</div>
)}
</div>
);
}

return (
<button
key={`file:${node.path}`}
type="button"
className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
style={{ paddingLeft: `${leftPadding}px` }}
onClick={() => onOpenTurnDiff(turnId, node.path)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent path format passed to diff handler

Medium Severity

When a tree file node is clicked, onOpenTurnDiff receives the normalized node.path (backslashes converted to forward slashes, consecutive separators collapsed via normalizePathSegments). However, the "View diff" button still passes the original unnormalized checkpointFiles[0]?.path directly from server data. If paths contain Windows-style backslash separators, the two call sites pass different string representations of the same file to the same handler, which could cause a mismatch in the downstream diff viewer.

Additional Locations (1)

Fix in Cursor Fix in Web

>
<span aria-hidden="true" className="size-3.5 shrink-0" />
<VscodeEntryIcon
pathValue={node.path}
kind="file"
theme={resolvedTheme}
className="size-3.5 text-muted-foreground/70"
/>
<span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90">
{node.name}
</span>
{node.stat && (
<span className="ml-auto shrink-0 font-mono text-[10px] tabular-nums">
<DiffStatLabel additions={node.stat.additions} deletions={node.stat.deletions} />
</span>
)}
</button>
);
};

return (
<div className="space-y-0.5">
{treeNodes.map((node) => renderTreeNode(node, 0))}
</div>
);
});

interface MessagesTimelineProps {
hasMessages: boolean;
isWorking: boolean;
Expand All @@ -3145,6 +3303,7 @@ interface MessagesTimelineProps {
isRevertingCheckpoint: boolean;
onImageExpand: (preview: ExpandedImagePreview) => void;
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
}

type TimelineEntry = ReturnType<typeof deriveTimelineEntries>[number];
Expand Down Expand Up @@ -3199,6 +3358,7 @@ const MessagesTimeline = memo(function MessagesTimeline({
isRevertingCheckpoint,
onImageExpand,
markdownCwd,
resolvedTheme,
}: MessagesTimelineProps) {
const rows = useMemo<TimelineRow[]>(() => {
const nextRows: TimelineRow[] = [];
Expand Down Expand Up @@ -3339,6 +3499,15 @@ const MessagesTimeline = memo(function MessagesTimeline({

const virtualRows = rowVirtualizer.getVirtualItems();
const nonVirtualizedRows = rows.slice(virtualizedRowCount);
const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
Record<string, boolean>
>({});
const onToggleAllDirectories = useCallback((turnId: TurnId) => {
setAllDirectoriesExpandedByTurnId((current) => ({
...current,
[turnId]: !(current[turnId] ?? true),
}));
}, []);

const renderRowContent = (row: TimelineRow) => (
<div className="pb-4">
Expand Down Expand Up @@ -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 (
<div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
<div className="mb-1.5 flex items-center justify-between gap-2">
<p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
<span>Changed files ({changedFileCountLabel})</span>
{(summaryStat.additions > 0 || summaryStat.deletions > 0) && (
{hasNonZeroStat(summaryStat) && (
<>
<span className="mx-1">•</span>
<span className="text-success">+{summaryStat.additions}</span>
<span className="mx-0.5 text-muted-foreground/70">/</span>
<span className="text-destructive">-{summaryStat.deletions}</span>
<DiffStatLabel
additions={summaryStat.additions}
deletions={summaryStat.deletions}
/>
</>
)}
</p>
<Button
type="button"
size="xs"
variant="outline"
onClick={() =>
onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
}
>
View diff
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{checkpointFiles.map((file) => (
<button
key={`${turnSummary.turnId}:${file.path}`}
<div className="flex items-center gap-1.5">
<Button
type="button"
className="rounded-md border border-border/70 bg-background/70 px-2 py-1 font-mono text-[11px] text-muted-foreground/80 transition-colors hover:border-border hover:text-foreground/90"
onClick={() => onOpenTurnDiff(turnSummary.turnId, file.path)}
size="xs"
variant="outline"
onClick={() => onToggleAllDirectories(turnSummary.turnId)}
>
{(() => {
const stat =
typeof file.additions === "number" &&
typeof file.deletions === "number"
? {
additions: file.additions,
deletions: file.deletions,
}
: null;
if (!stat) {
return file.path;
}
return (
<>
<span>{file.path}</span>
<span className="ml-1 text-muted-foreground/70">(</span>
<span className="text-success">+{stat.additions}</span>
<span className="mx-0.5 text-muted-foreground/70">/</span>
<span className="text-destructive">-{stat.deletions}</span>
<span className="text-muted-foreground/70">)</span>
</>
);
})()}
</button>
))}
{allDirectoriesExpanded ? "Collapse all" : "Expand all"}
</Button>
<Button
type="button"
size="xs"
variant="outline"
onClick={() =>
onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
}
>
View diff
</Button>
</div>
</div>
<ChangedFilesTree
key={`changed-files-tree:${turnSummary.turnId}`}
turnId={turnSummary.turnId}
files={checkpointFiles}
allDirectoriesExpanded={allDirectoriesExpanded}
resolvedTheme={resolvedTheme}
onOpenTurnDiff={onOpenTurnDiff}
/>
</div>
);
})()}
Expand Down
Loading