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";...@@ -2,7 +2,32 @@ import { TurnId } from "@t3tools/contracts";
2import { renderToStaticMarkup } from "react-dom/server";2import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it } from "vite-plus/test";3import { 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
7describe("ChangedFilesTree", () => {32describe("ChangedFilesTree", () => {
8 it.each([33 it.each([
apps/web/src/components/chat/ChangedFilesTree.tsx+64-32
...@@ -6,11 +6,19 @@ import {...@@ -6,11 +6,19 @@ import {
6 summarizeTurnDiffStats,6 summarizeTurnDiffStats,
7 type TurnDiffTreeNode,7 type TurnDiffTreeNode,
8} from "../../lib/turnDiffTree";8} 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";
10import { cn } from "~/lib/utils";17import { cn } from "~/lib/utils";
11import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";18import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel";
12import { PierreEntryIcon } from "./PierreEntryIcon";19import { PierreEntryIcon } from "./PierreEntryIcon";
13import { Button } from "../ui/button";20import { Button } from "../ui/button";
21import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1422
15const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};23const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {};
1624
...@@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
33 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);41 const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]);
3442
35 return (43 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">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">
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">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))]">
38 <p className="flex items-center gap-1 font-medium text-foreground text-xs leading-4">46 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
39 <span>{files.length} changed files</span>47 <span>
48 {files.length} changed file{files.length === 1 ? "" : "s"}
49 </span>
40 {hasNonZeroStat(summaryStat) && (50 {hasNonZeroStat(summaryStat) && (
41 <DiffStatLabel51 <DiffStatLabel
42 additions={summaryStat.additions}52 additions={summaryStat.additions}
...@@ -47,35 +57,57 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {...@@ -47,35 +57,57 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: {
47 )}57 )}
48 </p>58 </p>
49 <div className="flex items-center gap-1.5">59 <div className="flex items-center gap-1.5">
50 <Button60 <Tooltip>
51 type="button"61 <TooltipTrigger
52 size="xs"62 render={
53 variant="outline"63 <Button
54 data-scroll-anchor-ignore64 type="button"
55 onClick={onToggleAllDirectories}65 size="icon-xs"
56 >66 variant="outline"
57 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}67 className="!size-[22px]"
58 </Button>68 aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
59 <Button69 data-scroll-anchor-ignore
60 type="button"70 onClick={onToggleAllDirectories}
61 size="xs"71 />
62 variant="outline"72 }
63 onClick={() => onOpenTurnDiff(turnId, files[0]?.path)}73 >
64 >74 {allDirectoriesExpanded ? (
65 View diff75 <ChevronsDownUpIcon className="size-3" />
66 </Button>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>
67 </div>101 </div>
68 </div>102 </div>
69 <div className="px-2 pb-2">103 <ChangedFilesTree
70 <ChangedFilesTree104 key={`changed-files-tree:${turnId}`}
71 key={`changed-files-tree:${turnId}`}105 turnId={turnId}
72 turnId={turnId}106 files={files}
73 files={files}107 allDirectoriesExpanded={allDirectoriesExpanded}
74 allDirectoriesExpanded={allDirectoriesExpanded}108 resolvedTheme={resolvedTheme}
75 resolvedTheme={resolvedTheme}109 onOpenTurnDiff={onOpenTurnDiff}
76 onOpenTurnDiff={onOpenTurnDiff}110 />
77 />
78 </div>
79 </div>111 </div>
80 );112 );
81});113});
apps/web/src/components/chat/MessagesTimeline.test.tsx+53-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import { EnvironmentId, MessageId } from "@t3tools/contracts";1import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
2import { createRef, type ReactNode, type Ref } from "react";2import { createRef, type ReactNode, type Ref } from "react";
3import { renderToStaticMarkup } from "react-dom/server";3import { renderToStaticMarkup } from "react-dom/server";
4import { beforeAll, describe, expect, it, vi } from "vite-plus/test";4import { beforeAll, describe, expect, it, vi } from "vite-plus/test";
...@@ -219,6 +219,58 @@ function buildUserTimelineEntry(text: string) {...@@ -219,6 +219,58 @@ function buildUserTimelineEntry(text: string) {
219}219}
220220
221describe("MessagesTimeline", () => {221describe("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
222 it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => {274 it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => {
223 const {275 const {
224 resolveTimelineIsAtEnd,276 resolveTimelineIsAtEnd,
apps/web/src/components/chat/MessagesTimeline.tsx+58-26
...@@ -44,8 +44,11 @@ import {...@@ -44,8 +44,11 @@ import {
44 CheckIcon,44 CheckIcon,
45 ChevronDownIcon,45 ChevronDownIcon,
46 ChevronRightIcon,46 ChevronRightIcon,
47 ChevronsDownUpIcon,
48 ChevronsUpDownIcon,
47 CircleAlertIcon,49 CircleAlertIcon,
48 EyeIcon,50 EyeIcon,
51 FileDiffIcon,
49 GlobeIcon,52 GlobeIcon,
50 HammerIcon,53 HammerIcon,
51 MessageCircleIcon,54 MessageCircleIcon,
...@@ -1274,38 +1277,67 @@ function AssistantChangedFilesSectionInner({...@@ -1274,38 +1277,67 @@ function AssistantChangedFilesSectionInner({
1274 );1277 );
1275 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);1278 const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
1276 const summaryStat = summarizeTurnDiffStats(checkpointFiles);1279 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
1277 const changedFileCountLabel = String(checkpointFiles.length);
12781280
1279 return (1281 return (
1280 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">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">
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-['']">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))]">
1282 <p className="text-[10px] uppercase tracking-[0.12em] text-muted-foreground/65">1284 <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4">
1283 <span>Changed files ({changedFileCountLabel})</span>1285 <span>
1286 {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"}
1287 </span>
1284 {hasNonZeroStat(summaryStat) && (1288 {hasNonZeroStat(summaryStat) && (
1285 <>1289 <DiffStatLabel
1286 <span className="mx-1">•</span>1290 additions={summaryStat.additions}
1287 <DiffStatLabel additions={summaryStat.additions} deletions={summaryStat.deletions} />1291 className="text-xs leading-4"
1288 </>1292 deletions={summaryStat.deletions}
1293 layout="inline"
1294 />
1289 )}1295 )}
1290 </p>1296 </p>
1291 <div className="flex items-center gap-1.5">1297 <div className="flex items-center gap-1.5">
1292 <Button1298 <Tooltip>
1293 type="button"1299 <TooltipTrigger
1294 size="xs"1300 render={
1295 variant="outline"1301 <Button
1296 data-scroll-anchor-ignore1302 type="button"
1297 onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)}1303 size="icon-xs"
1298 >1304 variant="outline"
1299 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}1305 className="!size-[22px]"
1300 </Button>1306 aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"}
1301 <Button1307 data-scroll-anchor-ignore
1302 type="button"1308 onClick={() =>
1303 size="xs"1309 setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)
1304 variant="outline"1310 }
1305 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}1311 />
1306 >1312 }
1307 View diff1313 >
1308 </Button>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>
1309 </div>1341 </div>
1310 </div>1342 </div>
1311 <ChangedFilesTree1343 <ChangedFilesTree