| 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,7 +2,32 @@ import { TurnId } from "@t3tools/contracts"; |
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | 2 | import { renderToStaticMarkup } from "react-dom/server"; |
| 3 | import { describe, expect, it } from "vite-plus/test"; | 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 | describe("ChangedFilesTree", () => { | 32 | describe("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"; |
| 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 | import { cn } from "~/lib/utils"; | 17 | import { cn } from "~/lib/utils"; |
| 11 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 18 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 12 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 13 | import { Button } from "../ui/button"; | 20 | import { Button } from "../ui/button"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | ||
| 14 | 22 | ||
| 15 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | 23 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 16 | 24 | ||
| ... | @@ -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]); |
| 34 | 42 | ||
| 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 | <DiffStatLabel | 51 | <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 | <Button | 60 | <Tooltip> |
| 51 | type="button" | 61 | <TooltipTrigger |
| 52 | size="xs" | 62 | render={ |
| 53 | variant="outline" | 63 | <Button |
| 54 | data-scroll-anchor-ignore | 64 | 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 | <Button | 69 | 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 diff | 75 | <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 | <ChangedFilesTree | 104 | 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 @@ |
| 1 | import { EnvironmentId, MessageId } from "@t3tools/contracts"; | 1 | import { CheckpointRef, EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; |
| 2 | import { createRef, type ReactNode, type Ref } from "react"; | 2 | import { createRef, type ReactNode, type Ref } from "react"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; | 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import { beforeAll, describe, expect, it, vi } from "vite-plus/test"; | 4 | import { 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 | } |
| 220 | 220 | ||
| 221 | describe("MessagesTimeline", () => { | 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 | 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); | ||
| 1278 | 1280 | ||
| 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 | <Button | 1298 | <Tooltip> |
| 1293 | type="button" | 1299 | <TooltipTrigger |
| 1294 | size="xs" | 1300 | render={ |
| 1295 | variant="outline" | 1301 | <Button |
| 1296 | data-scroll-anchor-ignore | 1302 | 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 | <Button | 1307 | 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 diff | 1313 | > |
| 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 | <ChangedFilesTree | 1343 | <ChangedFilesTree |