| author | |
| committer | |
| log | c8a04bd59b7e1a18124468f1592b4c925839a9de |
| tree | 7702d7c14e4972df5735c263807313f4c200973d |
| parent | db4b2d8a0f23b00f19774dda33e0b02627272c90 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | 3 | import { describe, expect, it } from "vite-plus/test"; |
| 4 | 4 | |
| 5 | import { ChangedFilesTree } from "./ChangedFilesTree"; | |
| 5 | import { ChangedFilesCard, ChangedFilesTree } from "./ChangedFilesTree"; | |
| 6 | ||
| 7 | describe("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 | }); | |
| 6 | 31 | |
| 7 | 32 | describe("ChangedFilesTree", () => { |
| 8 | 33 | it.each([ |
apps/web/src/components/chat/ChangedFilesTree.tsx+64-32| ... | ... | @@ -6,11 +6,19 @@ import { |
| 6 | 6 | summarizeTurnDiffStats, |
| 7 | 7 | type TurnDiffTreeNode, |
| 8 | 8 | } from "../../lib/turnDiffTree"; |
| 9 | import { ChevronRightIcon, FolderIcon, FolderClosedIcon } from "lucide-react"; | |
| 9 | import { | |
| 10 | ChevronsDownUpIcon, | |
| 11 | ChevronsUpDownIcon, | |
| 12 | ChevronRightIcon, | |
| 13 | FileDiffIcon, | |
| 14 | FolderIcon, | |
| 15 | FolderClosedIcon, | |
| 16 | } from "lucide-react"; | |
| 10 | 17 | import { cn } from "~/lib/utils"; |
| 11 | 18 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 12 | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 13 | 20 | import { Button } from "../ui/button"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 14 | 22 | |
| 15 | 23 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 16 | 24 | |
| ... | ... | @@ -33,10 +41,12 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 33 | 41 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); |
| 34 | 42 | |
| 35 | 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"> | |
| 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> | |
| 40 | 50 | {hasNonZeroStat(summaryStat) && ( |
| 41 | 51 | <DiffStatLabel |
| 42 | 52 | additions={summaryStat.additions} |
| ... | ... | @@ -47,35 +57,57 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 47 | 57 | )} |
| 48 | 58 | </p> |
| 49 | 59 | <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> | |
| 67 | 101 | </div> |
| 68 | 102 | </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 | /> | |
| 79 | 111 | </div> |
| 80 | 112 | ); |
| 81 | 113 | }); |
apps/web/src/components/chat/MessagesTimeline.test.tsx+53-1| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { EnvironmentId, MessageId } from "@t3tools/contracts"; | |
| 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; | |
| 2 | 2 | import { createRef, type ReactNode, type Ref } from "react"; |
| 3 | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | 4 | import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; |
| ... | ... | @@ -219,6 +219,58 @@ function buildUserTimelineEntry(text: string) { |
| 219 | 219 | } |
| 220 | 220 | |
| 221 | 221 | describe("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 | 274 | it("uses LegendList isNearEnd when deciding whether the live edge is visible", async () => { |
| 223 | 275 | const { |
| 224 | 276 | resolveTimelineIsAtEnd, |
apps/web/src/components/chat/MessagesTimeline.tsx+58-26| ... | ... | @@ -44,8 +44,11 @@ import { |
| 44 | 44 | CheckIcon, |
| 45 | 45 | ChevronDownIcon, |
| 46 | 46 | ChevronRightIcon, |
| 47 | ChevronsDownUpIcon, | |
| 48 | ChevronsUpDownIcon, | |
| 47 | 49 | CircleAlertIcon, |
| 48 | 50 | EyeIcon, |
| 51 | FileDiffIcon, | |
| 49 | 52 | GlobeIcon, |
| 50 | 53 | HammerIcon, |
| 51 | 54 | MessageCircleIcon, |
| ... | ... | @@ -1274,38 +1277,67 @@ function AssistantChangedFilesSectionInner({ |
| 1274 | 1277 | ); |
| 1275 | 1278 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 1276 | 1279 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); |
| 1277 | const changedFileCountLabel = String(checkpointFiles.length); | |
| 1278 | 1280 | |
| 1279 | 1281 | 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> | |
| 1284 | 1288 | {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 | /> | |
| 1289 | 1295 | )} |
| 1290 | 1296 | </p> |
| 1291 | 1297 | <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> | |
| 1309 | 1341 | </div> |
| 1310 | 1342 | </div> |
| 1311 | 1343 | <ChangedFilesTree |