authorgravatar for 69873514+sandersonstabo@users.noreply.github.comss <69873514+sandersonstabo@users.noreply.github.com> 2026-07-20 14:45:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-20 12:45:36+00:00
logc8a04bd59b7e1a18124468f1592b4c925839a9de
tree7702d7c14e4972df5735c263807313f4c200973d
parentdb4b2d8a0f23b00f19774dda33e0b02627272c90
signature Signed by PGP key B5690EEEBB952194

Finale: upgrade changed files card to fix various UI issues (#4113)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 201 insertions(+), 60 deletions(-)

apps/web/src/components/chat/ChangedFilesTree.test.tsx+26-1
......@@ -2,7 +2,32 @@ import { TurnId } from "@t3tools/contracts";
22import { renderToStaticMarkup } from "react-dom/server";
33import { describe, expect, it } from "vite-plus/test";
44
5import { ChangedFilesTree } from "./ChangedFilesTree";
5import { ChangedFilesCard, ChangedFilesTree } from "./ChangedFilesTree";
6
7describe("ChangedFilesCard", () => {
8 it("keeps its compact header sticky while preserving singular labels", () => {
9 const markup = renderToStaticMarkup(
10 <ChangedFilesCard
11 turnId={TurnId.make("turn-1")}
12 files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]}
13 allDirectoriesExpanded
14 resolvedTheme="light"
15 onToggleAllDirectories={() => {}}
16 onOpenTurnDiff={() => {}}
17 />,
18 );
19
20 expect(markup).toContain('class="sticky top-0 z-10');
21 expect(markup).not.toContain("self-start");
22 expect(markup).toContain("whitespace-nowrap");
23 expect(markup).toContain("!size-[22px]");
24 expect(markup).toContain("size-3");
25 expect(markup).toContain('aria-label="Collapse all"');
26 expect(markup).toContain('aria-label="View diff"');
27 expect(markup).toContain("1 changed file");
28 expect(markup).not.toContain("1 changed files");
29 });
30});
631
732describe("ChangedFilesTree", () => {
833 it.each([
apps/web/src/components/chat/ChangedFilesTree.tsx+64-32
......@@ -6,11 +6,19 @@ import {
66 summarizeTurnDiffStats,
77 type TurnDiffTreeNode,
88} from "../../lib/turnDiffTree";
9import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react";
9import {
10 ChevronsDownUpIcon,
11 ChevronsUpDownIcon,
12 ChevronRightIcon,
13 FileDiffIcon,
14 FolderIcon,
15 FolderClosedIcon,
16} from "lucide-react";
1017import { cn } from "~/lib/utils";
1118import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
1219import { PierreEntryIcon } from "./PierreEntryIcon";
1320import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1422
1523const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
1624
......@@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
3341 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
3442
3543 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>
44 <div className="mt-4 rounded-2xl border border-input bg-background p-2 pt-4 shadow-xs/5 not-dark:bg-clip-padding dark:bg-input/32">
45 <div className="sticky top-0 z-10 mb-3 flex items-center justify-between gap-2 bg-background px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-background 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))]">
46 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
47 <span>
48 {files.length} changed file{files.length === 1 ? "" : "s"}
49 </span>
4050 {hasNonZeroStat(summaryStat) && (
4151 <DiffStatLabel
4252 additions={summaryStat.additions}
......@@ -47,35 +57,57 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
4757 )}
4858 </p>
4959 <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>
60 <Tooltip>
61 <TooltipTrigger
62 render={
63 <Button
64 type="button"
65 size="icon-xs"
66 variant="outline"
67 className="!size-[22px]"
68 aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
69 data-scroll-anchor-ignore
70 onClick={onToggleAllDirectories}
71 />
72 }
73 >
74 {allDirectoriesExpanded ? (
75 <ChevronsDownUpIcon className="size-3" />
76 ) : (
77 <ChevronsUpDownIcon className="size-3" />
78 )}
79 </TooltipTrigger>
80 <TooltipPopup side="top">
81 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
82 </TooltipPopup>
83 </Tooltip>
84 <Tooltip>
85 <TooltipTrigger
86 render={
87 <Button
88 type="button"
89 size="icon-xs"
90 variant="outline"
91 className="!size-[22px]"
92 aria-label="View diff"
93 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}
94 />
95 }
96 >
97 <FileDiffIcon className="size-3" />
98 </TooltipTrigger>
99 <TooltipPopup side="top">View diff</TooltipPopup>
100 </Tooltip>
67101 </div>
68102 </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>
103 <ChangedFilesTree
104 key={`changed-files-tree:${turnId}`}
105 turnId={turnId}
106 files={files}
107 allDirectoriesExpanded={allDirectoriesExpanded}
108 resolvedTheme={resolvedTheme}
109 onOpenTurnDiff={onOpenTurnDiff}
110 />
79111 </div>
80112 );
81113});
apps/web/src/components/chat/MessagesTimeline.test.tsx+53-1
......@@ -1,4 +1,4 @@
1import { EnvironmentId, MessageId } from "@t3tools/contracts";
1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
22import { createRef, type ReactNode, type Ref } from "react";
33import { renderToStaticMarkup } from "react-dom/server";
44import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
......@@ -219,6 +219,58 @@ function buildUserTimelineEntry(text: string) {
219219}
220220
221221describe("MessagesTimeline", () => {
222 it("keeps assistant changed-files headers sticky below the thread header", async () => {
223 const { MessagesTimeline } = await import("./MessagesTimeline");
224 const assistantMessageId = MessageId.make("message-assistant-with-files");
225 const turnId = TurnId.make("turn-with-files");
226 const markup = renderToStaticMarkup(
227 <MessagesTimeline
228 {...buildProps()}
229 timelineEntries={[
230 {
231 id: "entry-assistant-with-files",
232 kind: "message",
233 createdAt: MESSAGE_CREATED_AT,
234 message: {
235 id: assistantMessageId,
236 role: "assistant",
237 text: "Updated the fixture.",
238 turnId,
239 createdAt: MESSAGE_CREATED_AT,
240 updatedAt: MESSAGE_CREATED_AT,
241 streaming: false,
242 },
243 },
244 ]}
245 turnDiffSummaryByAssistantMessageId={
246 new Map([
247 [
248 assistantMessageId,
249 {
250 turnId,
251 checkpointTurnCount: 1,
252 checkpointRef: CheckpointRef.make("checkpoint-with-files"),
253 status: "ready",
254 files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }],
255 assistantMessageId,
256 completedAt: MESSAGE_CREATED_AT,
257 },
258 ],
259 ])
260 }
261 />,
262 );
263
264 expect(markup).toContain('class="sticky top-2 z-10');
265 expect(markup).not.toContain("self-start");
266 expect(markup).toContain("whitespace-nowrap");
267 expect(markup).toContain("!size-[22px]");
268 expect(markup).toContain("size-3");
269 expect(markup).toContain('aria-label="Collapse all"');
270 expect(markup).toContain('aria-label="View diff"');
271 expect(markup).toContain("1 changed file");
272 });
273
222274 it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => {
223275 const {
224276 resolveTimelineIsAtEnd,
apps/web/src/components/chat/MessagesTimeline.tsx+58-26
......@@ -44,8 +44,11 @@ import {
4444 CheckIcon,
4545 ChevronDownIcon,
4646 ChevronRightIcon,
47 ChevronsDownUpIcon,
48 ChevronsUpDownIcon,
4749 CircleAlertIcon,
4850 EyeIcon,
51 FileDiffIcon,
4952 GlobeIcon,
5053 HammerIcon,
5154 MessageCircleIcon,
......@@ -1274,38 +1277,67 @@ function AssistantChangedFilesSectionInner({
12741277 );
12751278 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
12761279 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
1277 const changedFileCountLabel = String(checkpointFiles.length);
12781280
12791281 return (
1280 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
1281 <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-['']">
1282 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">
1283 <span>Changed files ({changedFileCountLabel})</span>
1282 <div className="mt-4 rounded-2xl border border-input bg-background p-2 pt-4 shadow-xs/5 not-dark:bg-clip-padding dark:bg-input/32">
1283 <div className="sticky top-2 z-10 mb-3 flex items-center justify-between gap-2 bg-background px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-background 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))]">
1284 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
1285 <span>
1286 {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"}
1287 </span>
12841288 {hasNonZeroStat(summaryStat) && (
1285 <>
1286 <span className="mx-1">•</span>
1287 <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />
1288 </>
1289 <DiffStatLabel
1290 additions={summaryStat.additions}
1291 className="text-xs leading-4"
1292 deletions={summaryStat.deletions}
1293 layout="inline"
1294 />
12891295 )}
12901296 </p>
12911297 <div className="flex items-center gap-1.5">
1292 <Button
1293 type="button"
1294 size="xs"
1295 variant="outline"
1296 data-scroll-anchor-ignore
1297 onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)}
1298 >
1299 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1300 </Button>
1301 <Button
1302 type="button"
1303 size="xs"
1304 variant="outline"
1305 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
1306 >
1307 View diff
1308 </Button>
1298 <Tooltip>
1299 <TooltipTrigger
1300 render={
1301 <Button
1302 type="button"
1303 size="icon-xs"
1304 variant="outline"
1305 className="!size-[22px]"
1306 aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1307 data-scroll-anchor-ignore
1308 onClick={() =>
1309 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
1310 }
1311 />
1312 }
1313 >
1314 {allDirectoriesExpanded ? (
1315 <ChevronsDownUpIcon className="size-3" />
1316 ) : (
1317 <ChevronsUpDownIcon className="size-3" />
1318 )}
1319 </TooltipTrigger>
1320 <TooltipPopup side="top">
1321 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1322 </TooltipPopup>
1323 </Tooltip>
1324 <Tooltip>
1325 <TooltipTrigger
1326 render={
1327 <Button
1328 type="button"
1329 size="icon-xs"
1330 variant="outline"
1331 className="!size-[22px]"
1332 aria-label="View diff"
1333 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
1334 />
1335 }
1336 >
1337 <FileDiffIcon className="size-3" />
1338 </TooltipTrigger>
1339 <TooltipPopup side="top">View diff</TooltipPopup>
1340 </Tooltip>
13091341 </div>
13101342 </div>
13111343 <ChangedFilesTree