authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-10 01:27:32-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-10 08:27:32+00:00
log0b40ea62e12ea061bbc8e954cf8dee288e9faf56
treee46d6978ddd121acbe2e2d73076c38a3c756873c
parenta4757c2656864cca1397eb50176a8e60fb514618
signature Signed by PGP key B5690EEEBB952194

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

Co-authored-by: ss <ss@barekey.dev> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 118 insertions(+), 53 deletions(-)

apps/web/src/components/chat/ChangedFilesTree.tsx+78-4
...@@ -1,14 +1,85 @@...@@ -1,14 +1,85 @@
1import { type TurnId } from "@t3tools/contracts";1import { type TurnId } from "@t3tools/contracts";
2import { memo, useCallback, useMemo, useState } from "react";2import { memo, useCallback, useMemo, useState } from "react";
3import { type TurnDiffFileChange } from "../../types";3import { type TurnDiffFileChange } from "../../types";
4import { buildTurnDiffTree, type TurnDiffTreeNode } from "../../lib/turnDiffTree";4import {
5 buildTurnDiffTree,
6 summarizeTurnDiffStats,
7 type TurnDiffTreeNode,
8} from "../../lib/turnDiffTree";
5import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";9import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";
6import { cn } from "~/lib/utils";10import { cn } from "~/lib/utils";
7import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";11import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
8import { VscodeEntryIcon } from "./VscodeEntryIcon";12import { VscodeEntryIcon } from "./VscodeEntryIcon";
13import { Button } from "../ui/button";
914
10const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};15const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
1116
17export 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
12export const ChangedFilesTree = memo(function ChangedFilesTree(props: {83export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
13 turnId: TurnId;84 turnId: TurnId;
14 files: ReadonlyArray<TurnDiffFileChange>;85 files: ReadonlyArray<TurnDiffFileChange>;
...@@ -22,6 +93,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -22,6 +93,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
22 () => collectDirectoryPaths(treeNodes).join("\u0000"),93 () => collectDirectoryPaths(treeNodes).join("\u0000"),
23 [treeNodes],94 [treeNodes],
24 );95 );
96 const hasDirectoryNodes = directoryPathsKey.length > 0;
25 const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;97 const expansionStateKey = `${allDirectoriesExpanded ? "expanded" : "collapsed"}\u0000${directoryPathsKey}`;
26 const [directoryExpansionState, setDirectoryExpansionState] = useState<{98 const [directoryExpansionState, setDirectoryExpansionState] = useState<{
27 key: string;99 key: string;
...@@ -60,7 +132,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -60,7 +132,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
60 <button132 <button
61 type="button"133 type="button"
62 data-scroll-anchor-ignore134 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"
64 style={{ paddingLeft: `${leftPadding}px` }}136 style={{ paddingLeft: `${leftPadding}px` }}
65 onClick={() => toggleDirectory(node.path)}137 onClick={() => toggleDirectory(node.path)}
66 >138 >
...@@ -98,11 +170,13 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {...@@ -98,11 +170,13 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: {
98 <button170 <button
99 key={`file:${node.path}`}171 key={`file:${node.path}`}
100 type="button"172 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"
102 style={{ paddingLeft: `${leftPadding}px` }}174 style={{ paddingLeft: `${leftPadding}px` }}
103 onClick={() => onOpenTurnDiff(turnId, node.path)}175 onClick={() => onOpenTurnDiff(turnId, node.path)}
104 >176 >
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}
106 <VscodeEntryIcon180 <VscodeEntryIcon
107 pathValue={node.path}181 pathValue={node.path}
108 kind="file"182 kind="file"
apps/web/src/components/chat/DiffStatLabel.tsx+29-4
...@@ -1,21 +1,46 @@...@@ -1,21 +1,46 @@
1import { memo } from "react";1import { memo } from "react";
2import { cn } from "~/lib/utils";
23
3export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean {4export function hasNonZeroStat(stat: { additions: number; deletions: number }): boolean {
4 return stat.additions > 0 || stat.deletions > 0;5 return stat.additions > 0 || stat.deletions > 0;
5}6}
67
8function 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
7export const DiffStatLabel = memo(function DiffStatLabel(props: {22export const DiffStatLabel = memo(function DiffStatLabel(props: {
8 additions: number;23 additions: number;
9 deletions: number;24 deletions: number;
25 className?: string;
10 showParentheses?: boolean;26 showParentheses?: boolean;
27 layout?: "aligned" | "inline";
11}) {28}) {
12 const { additions, deletions, showParentheses = false } = props;29 const { additions, deletions, className, showParentheses = false, layout = "aligned" } = props;
13 return (30 return (
14 <>31 <>
15 {showParentheses && <span className="text-muted-foreground/70">(</span>}32 {showParentheses && <span className="text-muted-foreground/70">(</span>}
16 <span className="text-success">+{additions}</span>33 <span
17 <span className="mx-0.5 text-muted-foreground/70">/</span>34 className={cn(
18 <span className="text-destructive">-{deletions}</span>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>
19 {showParentheses && <span className="text-muted-foreground/70">)</span>}44 {showParentheses && <span className="text-muted-foreground/70">)</span>}
20 </>45 </>
21 );46 );
apps/web/src/components/chat/MessagesTimeline.tsx+11-45
...@@ -20,7 +20,6 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react";...@@ -20,7 +20,6 @@ import { LegendList, type LegendListRef } from "@legendapp/list/react";
20import { FileDiff } from "@pierre/diffs/react";20import { FileDiff } from "@pierre/diffs/react";
21import { deriveTimelineEntries, formatElapsed } from "../../session-logic";21import { deriveTimelineEntries, formatElapsed } from "../../session-logic";
22import { type TurnDiffSummary } from "../../types";22import { type TurnDiffSummary } from "../../types";
23import { summarizeTurnDiffStats } from "../../lib/turnDiffTree";
24import {23import {
25 getRenderablePatch,24 getRenderablePatch,
26 resolveDiffThemeName,25 resolveDiffThemeName,
...@@ -44,8 +43,7 @@ import {...@@ -44,8 +43,7 @@ import {
44import { Button } from "../ui/button";43import { Button } from "../ui/button";
45import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";44import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview";
46import { ProposedPlanCard } from "./ProposedPlanCard";45import { ProposedPlanCard } from "./ProposedPlanCard";
47import { ChangedFilesTree } from "./ChangedFilesTree";46import { ChangedFilesCard } from "./ChangedFilesTree";
48import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
49import { MessageCopyButton } from "./MessageCopyButton";47import { MessageCopyButton } from "./MessageCopyButton";
50import {48import {
51 computeStableMessagesTimelineRows,49 computeStableMessagesTimelineRows,
...@@ -708,50 +706,18 @@ function AssistantChangedFilesSectionInner({...@@ -708,50 +706,18 @@ function AssistantChangedFilesSectionInner({
708 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,706 (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
709 );707 );
710 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);708 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
711 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
712 const changedFileCountLabel = String(checkpointFiles.length);
713709
714 return (710 return (
715 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">711 <ChangedFilesCard
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-['']">712 turnId={turnSummary.turnId}
717 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">713 files={checkpointFiles}
718 <span>Changed files ({changedFileCountLabel})</span>714 allDirectoriesExpanded={allDirectoriesExpanded}
719 {hasNonZeroStat(summaryStat) && (715 resolvedTheme={resolvedTheme}
720 <>716 onToggleAllDirectories={() =>
721 <span className="mx-1">•</span>717 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
722 <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />718 }
723 </>719 onOpenTurnDiff={onOpenTurnDiff}
724 )}720 />
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>
755 );721 );
756}722}
757723