History

Extract changed files card with compact aligned diff stats (#3023)

Julius Marminge committed 5 months agov0.0.28-nightly.20260610.520

Commit 0b40ea61 parent a4757c23 files · +118 −53Browse files

Unified · Split

3 files
  1. apps/web/src/components/chat/ChangedFilesTree.tsx +78 −4
  2. apps/web/src/components/chat/DiffStatLabel.tsx +29 −4
  3. apps/web/src/components/chat/MessagesTimeline.tsx +11 −45
apps/web/src/components/chat/ChangedFilesTree.tsx+78−4
@@ -1,14 +1,85 @@
11 import { type TurnId } from "@t3tools/contracts";
22 import { memo, useCallback, useMemo, useState } from "react";
33 import { type TurnDiffFileChange } from "../../types";
4−import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";
4+import {
5+ buildTurnDiffTree,
6+ summarizeTurnDiffStats,
7+ type TurnDiffTreeNode,
8+} from "../../lib/turnDiffTree";
59 import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";
610 import { cn } from "~/lib/utils";
711 import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
812 import { VscodeEntryIcon } from "./VscodeEntryIcon";
13+import { Button } from "../ui/button";
914
1015 const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
1116
17+export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
18+ turnId: TurnId;
19+ files: ReadonlyArray<TurnDiffFileChange>;
20+ allDirectoriesExpanded: boolean;
21+ resolvedTheme: "light" | "dark";
22+ onToggleAllDirectories: () => void;
23+ onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
24+}) {
25+ const {
26+ turnId,
27+ files,
28+ allDirectoriesExpanded,
29+ resolvedTheme,
30+ onToggleAllDirectories,
31+ onOpenTurnDiff,
32+ } = props;
33+ const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
34+
35+ return (
36+ <div className="relative mt-4 rounded-2xl bg-card/40 shadow-xs/5 not-dark:bg-clip-padding after:pointer-events-none after:absolute after:inset-0 after:z-20 after:rounded-2xl after:border after:border-input">
37+ <div className="sticky top-0 z-10 mb-3 flex items-center justify-between gap-2 rounded-t-2xl bg-card/72 p-3 backdrop-blur-md">
38+ <p className="flex items-center gap-1 font-medium text-foreground text-xs leading-4">
39+ <span>{files.length} changed files</span>
40+ {hasNonZeroStat(summaryStat) && (
41+ <DiffStatLabel
42+ additions={summaryStat.additions}
43+ className="text-xs leading-4"
44+ deletions={summaryStat.deletions}
45+ layout="inline"
46+ />
47+ )}
48+ </p>
49+ <div className="flex items-center gap-1.5">
50+ <Button
51+ type="button"
52+ size="xs"
53+ variant="outline"
54+ data-scroll-anchor-ignore
55+ onClick={onToggleAllDirectories}
56+ >
57+ {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
58+ </Button>
59+ <Button
60+ type="button"
61+ size="xs"
62+ variant="outline"
63+ onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
64+ >
65+ View diff
66+ </Button>
67+ </div>
68+ </div>
69+ <div className="px-2 pb-2">
70+ <ChangedFilesTree
71+ key={`changed-files-tree:${turnId}`}
72+ turnId={turnId}
73+ files={files}
74+ allDirectoriesExpanded={allDirectoriesExpanded}
75+ resolvedTheme={resolvedTheme}
76+ onOpenTurnDiff={onOpenTurnDiff}
77+ />
78+ </div>
79+ </div>
80+ );
81+});
82+
1283 export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
1384 turnId: TurnId;
1485 files: ReadonlyArray<TurnDiffFileChange>;
@@ -22,6 +93,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 7 unmodified lines
2293 () => collectDirectoryPaths(treeNodes).join("\u0000"),
2394 [treeNodes],
2495 );
96+ const hasDirectoryNodes = directoryPathsKey.length > 0;
2597 const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;
2698 const [directoryExpansionState, setDirectoryExpansionState] = useState<{
2799 key: string;
@@ -60,7 +132,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 32 unmodified lines
60132 <button
61133 type="button"
62134 data-scroll-anchor-ignore
63− className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
135+ className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
64136 style={{ paddingLeft: `${leftPadding}px` }}
65137 onClick={() => toggleDirectory(node.path)}
66138 >
@@ -98,11 +170,13 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { · 31 unmodified lines
98170 <button
99171 key={`file:${node.path}`}
100172 type="button"
101− className="group flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left hover:bg-background/80"
173+ className="group flex w-full items-center gap-1.5 rounded-xl py-1 pr-3 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background"
102174 style={{ paddingLeft: `${leftPadding}px` }}
103175 onClick={() => onOpenTurnDiff(turnId, node.path)}
104176 >
105− <span aria-hidden="true" className="size-3.5 shrink-0" />
177+ {hasDirectoryNodes || depth > 0 ? (
178+ <span aria-hidden="true" className="size-3.5 shrink-0" />
179+ ) : null}
106180 <VscodeEntryIcon
107181 pathValue={node.path}
108182 kind="file"
apps/web/src/components/chat/DiffStatLabel.tsx+29−4
@@ -1,21 +1,46 @@
11 import { memo } from "react";
2+import { cn } from "~/lib/utils";
23
34 export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean {
45 return stat.additions > 0 || stat.deletions > 0;
56 }
67
8+function formatCompactDiffCount(value: number): string {
9+ if (value < 1000) return String(value);
10+ if (value < 1_000_000) {
11+ const k = value / 1000;
12+ return `${k < 10 ? k.toFixed(1).replace(/\.0$/, "") : Math.round(k)}k`;
13+ }
14+ if (value < 1_000_000_000) {
15+ const m = value / 1_000_000;
16+ return `${m < 10 ? m.toFixed(1).replace(/\.0$/, "") : Math.round(m)}m`;
17+ }
18+ const b = value / 1_000_000_000;
19+ return `${b < 10 ? b.toFixed(1).replace(/\.0$/, "") : Math.round(b)}b`;
20+}
21+
722 export const DiffStatLabel = memo(function DiffStatLabel(props: {
823 additions: number;
924 deletions: number;
25+ className?: string;
1026 showParentheses?: boolean;
27+ layout?: "aligned" | "inline";
1128 }) {
12− const { additions, deletions, showParentheses = false } = props;
29+ const { additions, deletions, className, showParentheses = false, layout = "aligned" } = props;
1330 return (
1431 <>
1532 {showParentheses && <span className="text-muted-foreground/70">(</span>}
16− <span className="text-success">+{additions}</span>
17− <span className="mx-0.5 text-muted-foreground/70">/</span>
18− <span className="text-destructive">-{deletions}</span>
33+ <span
34+ className={cn(
35+ layout === "inline"
36+ ? "inline-flex items-center gap-1 tabular-nums align-middle"
37+ : "inline-grid grid-cols-[4ch_4ch] gap-2 text-right tabular-nums align-middle",
38+ className,
39+ )}
40+ >
41+ <span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span>
42+ <span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span>
43+ </span>
1944 {showParentheses && <span className="text-muted-foreground/70">)</span>}
2045 </>
2146 );
apps/web/src/components/chat/MessagesTimeline.tsx+11−45
@@ -20,7 +20,6 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react"; · 19 unmodified lines
2020 import { FileDiff } from "@pierre/diffs/react";
2121 import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
2222 import { type TurnDiffSummary } from "../../types";
23−import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
2423 import {
2524 getRenderablePatch,
2625 resolveDiffThemeName,
@@ -44,8 +43,7 @@ import { · 17 unmodified lines
4443 import { Button } from "../ui/button";
4544 import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
4645 import { ProposedPlanCard } from "./ProposedPlanCard";
47−import { ChangedFilesTree } from "./ChangedFilesTree";
48−import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
46+import { ChangedFilesCard } from "./ChangedFilesTree";
4947 import { MessageCopyButton } from "./MessageCopyButton";
5048 import {
5149 computeStableMessagesTimelineRows,
@@ -708,50 +706,18 @@ function AssistantChangedFilesSectionInner({ · 656 unmodified lines
708706 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
709707 );
710708 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
711− const summaryStat = summarizeTurnDiffStats(checkpointFiles);
712− const changedFileCountLabel = String(checkpointFiles.length);
713709
714710 return (
715− <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
716− <div className="sticky top-2 z-10 mb-1.5 flex items-center justify-between gap-2 bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:absolute before:inset-x-0 before:-top-2 before:h-2 before:bg-[color-mix(in_srgb,var(--card)_45%,var(--background))] before:content-['']">
717− <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
718− <span>Changed files ({changedFileCountLabel})</span>
719− {hasNonZeroStat(summaryStat) && (
720− <>
721− <span className="mx-1">•</span>
722− <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
723− </>
724− )}
725− </p>
726− <div className="flex items-center gap-1.5">
727− <Button
728− type="button"
729− size="xs"
730− variant="outline"
731− data-scroll-anchor-ignore
732− onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)}
733− >
734− {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
735− </Button>
736− <Button
737− type="button"
738− size="xs"
739− variant="outline"
740− onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
741− >
742− View diff
743− </Button>
744− </div>
745− </div>
746− <ChangedFilesTree
747− key={`changed-files-tree:${turnSummary.turnId}`}
748− turnId={turnSummary.turnId}
749− files={checkpointFiles}
750− allDirectoriesExpanded={allDirectoriesExpanded}
751− resolvedTheme={resolvedTheme}
752− onOpenTurnDiff={onOpenTurnDiff}
753− />
754− </div>
711+ <ChangedFilesCard
712+ turnId={turnSummary.turnId}
713+ files={checkpointFiles}
714+ allDirectoriesExpanded={allDirectoriesExpanded}
715+ resolvedTheme={resolvedTheme}
716+ onToggleAllDirectories={() =>
717+ setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
718+ }
719+ onOpenTurnDiff={onOpenTurnDiff}
720+ />
755721 );
756722 }
757723