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
71 changes: 67 additions & 4 deletions apps/web/src/components/chat/ChangedFilesTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,86 @@ describe("ChangedFilesCard", () => {
<ChangedFilesCard
turnId={TurnId.make("turn-1")}
files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]}
expanded
showCompactPreview={false}
allDirectoriesExpanded
resolvedTheme="light"
onExpandedChange={() => {}}
onToggleAllDirectories={() => {}}
onOpenTurnDiff={() => {}}
/>,
);

expect(markup).toContain('class="sticky top-0 z-10');
expect(markup).not.toContain("self-start");
expect(markup).toContain('data-changed-files-state="expanded"');
expect(markup).toContain('aria-expanded="true"');
expect(markup).toContain("whitespace-nowrap");
expect(markup).toContain("!size-[22px]");
expect(markup).toContain("size-3");
expect(markup).toContain('aria-label="Collapse all"');
expect(markup).toContain('aria-label="View diff"');
expect(markup).toContain('aria-label="Collapse all folders"');
expect(markup).toContain('aria-label="Open diff"');
expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"');
expect(markup).toContain("1 changed file");
expect(markup).not.toContain("1 changed files");
});

it("renders a scope and representative-file preview for a large latest change", () => {
const markup = renderToStaticMarkup(
<ChangedFilesCard
turnId={TurnId.make("turn-1")}
files={[
{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 },
{ path: "apps/web/src/App.test.tsx", kind: "modified", additions: 30, deletions: 2 },
{
path: "packages/shared/src/git.ts",
kind: "modified",
additions: 15,
deletions: 4,
},
{ path: "README.md", kind: "modified", additions: 3, deletions: 0 },
]}
expanded={false}
showCompactPreview
allDirectoriesExpanded={false}
resolvedTheme="light"
onExpandedChange={() => {}}
onToggleAllDirectories={() => {}}
onOpenTurnDiff={() => {}}
/>,
);

expect(markup).toContain('data-changed-files-state="preview"');
expect(markup).toContain('aria-expanded="false"');
expect(markup).toContain("apps");
expect(markup).toContain("2 files");
expect(markup).toContain("packages");
expect(markup).toContain("root");
expect(markup).toContain("App.tsx");
expect(markup).toContain("git.ts");
expect(markup).toContain("README.md");
expect(markup).toContain("Show all 4 files");
expect(markup).not.toContain("App.test.tsx");
});

it("keeps older collapsed changes to a one-line receipt", () => {
const markup = renderToStaticMarkup(
<ChangedFilesCard
turnId={TurnId.make("turn-1")}
files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]}
expanded={false}
showCompactPreview={false}
allDirectoriesExpanded={false}
resolvedTheme="light"
onExpandedChange={() => {}}
onToggleAllDirectories={() => {}}
onOpenTurnDiff={() => {}}
/>,
);

expect(markup).toContain('data-changed-files-state="collapsed"');
expect(markup).toContain("1 changed file");
expect(markup).not.toContain("Show all");
expect(markup).not.toContain("App.tsx");
});
});

describe("ChangedFilesTree", () => {
Expand Down
189 changes: 139 additions & 50 deletions apps/web/src/components/chat/ChangedFilesTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
import { PierreEntryIcon } from "./PierreEntryIcon";
import { Button } from "../ui/button";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
changedFileName,
selectChangedFilePreview,
summarizeChangedFileScopes,
} from "./changedFilesPresentation";

const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};

export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
turnId: TurnId;
files: ReadonlyArray<TurnDiffFileChange>;
expanded: boolean;
showCompactPreview: boolean;
allDirectoriesExpanded: boolean;
resolvedTheme: "light" | "dark";
onExpandedChange: (expanded: boolean) => void;
onToggleAllDirectories: () => void;
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
}) {
const {
turnId,
files,
expanded,
showCompactPreview,
allDirectoriesExpanded,
resolvedTheme,
onExpandedChange,
onToggleAllDirectories,
onOpenTurnDiff,
} = props;
const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]);
const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]);
const compactPreviewVisible = showCompactPreview && !expanded;

return (
<div className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 pt-4 dark:border-transparent dark:bg-input/32">
<div className="sticky top-0 z-10 mb-3 flex items-center justify-between gap-2 bg-secondary px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-secondary before:content-[''] dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))] dark:before:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]">
<p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
<span>
{files.length} changed file{files.length === 1 ? "" : "s"}
<div
className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32"
data-changed-files-state={
expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed"
}
>
<div
className={cn(
"flex items-center justify-between gap-2 rounded-xl px-1",
expanded &&
"sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]",
)}
>
<button
type="button"
aria-expanded={expanded}
data-scroll-anchor-ignore
className="group flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onExpandedChange(!expanded)}
>
<ChevronRightIcon
aria-hidden="true"
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
<span className="flex min-w-0 items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
<span>
{files.length} changed file{files.length === 1 ? "" : "s"}
</span>
{hasNonZeroStat(summaryStat) && (
<DiffStatLabel
additions={summaryStat.additions}
className="text-xs leading-4"
deletions={summaryStat.deletions}
layout="inline"
/>
)}
</span>
{hasNonZeroStat(summaryStat) && (
<DiffStatLabel
additions={summaryStat.additions}
className="text-xs leading-4"
deletions={summaryStat.deletions}
layout="inline"
/>
)}
</p>
<span className="ml-1 hidden truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 sm:inline">
{expanded ? "Hide files" : "Show files"}
</span>
</button>
<div className="flex items-center gap-1.5">
{expanded ? (
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
variant="outline"
className="!size-[22px]"
aria-label={
allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"
}
data-scroll-anchor-ignore
onClick={onToggleAllDirectories}
/>
}
>
{allDirectoriesExpanded ? (
<ChevronsDownUpIcon className="size-3" />
) : (
<ChevronsUpDownIcon className="size-3" />
)}
</TooltipTrigger>
<TooltipPopup side="top">
{allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"}
</TooltipPopup>
</Tooltip>
) : null}
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
variant="outline"
className="!size-[22px]"
aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
data-scroll-anchor-ignore
onClick={onToggleAllDirectories}
/>
}
>
{allDirectoriesExpanded ? (
<ChevronsDownUpIcon className="size-3" />
) : (
<ChevronsUpDownIcon className="size-3" />
)}
</TooltipTrigger>
<TooltipPopup side="top">
{allDirectoriesExpanded ? "Collapse all" : "Expand all"}
</TooltipPopup>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<Button
type="button"
size="icon-xs"
size="xs"
variant="outline"
className="!size-[22px]"
aria-label="View diff"
aria-label="Open diff"
onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
/>
}
>
<FileDiffIcon className="size-3" />
<span className="hidden sm:inline">Open diff</span>
</TooltipTrigger>
<TooltipPopup side="top">View diff</TooltipPopup>
<TooltipPopup side="top">Open the full diff</TooltipPopup>
</Tooltip>
</div>
</div>
<ChangedFilesTree
key={`changed-files-tree:${turnId}`}
turnId={turnId}
files={files}
allDirectoriesExpanded={allDirectoriesExpanded}
resolvedTheme={resolvedTheme}
onOpenTurnDiff={onOpenTurnDiff}
/>
{expanded ? (
<ChangedFilesTree
key={`changed-files-tree:${turnId}`}
turnId={turnId}
files={files}
allDirectoriesExpanded={allDirectoriesExpanded}
resolvedTheme={resolvedTheme}
onOpenTurnDiff={onOpenTurnDiff}
/>
) : compactPreviewVisible ? (
<div className="px-2 pb-1.5 pt-1">
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground">
{scopeSummary.map((scope, index) => (
<span key={scope.label} className="inline-flex items-center gap-1">
{index > 0 ? <span aria-hidden="true">·</span> : null}
<span className="font-mono text-foreground/75">{scope.label}</span>
<span>
{scope.fileCount} file{scope.fileCount === 1 ? "" : "s"}
</span>
</span>
))}
</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{previewFiles.map((file) => (
<button
key={file.path}
type="button"
title={file.path}
className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpenTurnDiff(turnId, file.path)}
>
<PierreEntryIcon
pathValue={file.path}
kind="file"
theme={resolvedTheme}
className="size-3 shrink-0 text-muted-foreground/70"
/>
<span className="truncate">{changedFileName(file.path)}</span>
</button>
))}
<button
type="button"
className="rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onExpandedChange(true)}
>
Show all {files.length} files
</button>
</div>
</div>
) : null}
</div>
);
});
Expand Down
10 changes: 8 additions & 2 deletions apps/web/src/components/chat/DiffStatLabel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: {
<>
{showParentheses && <span className="text-muted-foreground/70">(</span>}
<span
role="group"
aria-label={`${additions} additions, ${deletions} deletions`}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
className={cn(
layout === "inline"
? "inline-flex items-center gap-1 tabular-nums align-middle"
: "inline-grid grid-cols-[4ch_4ch] gap-2 text-right tabular-nums align-middle",
className,
)}
>
<span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span>
<span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span>
<span aria-hidden="true" className="font-mono text-success">
+{formatCompactDiffCount(additions)}
</span>
<span aria-hidden="true" className="font-mono text-destructive">
-{formatCompactDiffCount(deletions)}
</span>
</span>
{showParentheses && <span className="text-muted-foreground/70">)</span>}
</>
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ describe("MessagesTimeline", () => {
const markup = renderToStaticMarkup(
<MessagesTimeline
{...buildProps()}
latestTurn={{
turnId,
state: "completed",
startedAt: MESSAGE_CREATED_AT,
completedAt: MESSAGE_CREATED_AT,
}}
timelineEntries={[
{
id: "entry-assistant-with-files",
Expand Down Expand Up @@ -280,13 +286,13 @@ describe("MessagesTimeline", () => {
/>,
);

expect(markup).toContain('class="sticky top-2 z-10');
expect(markup).toContain("sticky top-2 z-10");
expect(markup).not.toContain("self-start");
expect(markup).toContain("whitespace-nowrap");
expect(markup).toContain("!size-[22px]");
expect(markup).toContain("size-3");
expect(markup).toContain('aria-label="Collapse all"');
expect(markup).toContain('aria-label="View diff"');
expect(markup).toContain('aria-label="Collapse all folders"');
expect(markup).toContain('aria-label="Open diff"');
expect(markup).toContain("1 changed file");
});

Expand Down
Loading
Loading