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