| author | |
| committer | |
| log | dd7bc147f799f290eb58578a7f81643ecf9ad52e |
| tree | 28839c9cfd222208d17daf3f607edf30f7f52536 |
| parent | b906ce2d73d025e801877f42a35b7a2f5629806f |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 57 insertions(+), 166 deletions(-)
apps/web/src/components/chat/ChangedFilesTree.test.tsx+11-23| ... | @@ -10,26 +10,21 @@ describe("ChangedFilesCard", () => { | ... | @@ -10,26 +10,21 @@ describe("ChangedFilesCard", () => { |
| 10 | <ChangedFilesCard | 10 | <ChangedFilesCard |
| 11 | turnId={TurnId.make("turn-1")} | 11 | turnId={TurnId.make("turn-1")} |
| 12 | files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]} | 12 | files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]} |
| 13 | expanded | ||
| 14 | showCompactPreview={false} | ||
| 15 | allDirectoriesExpanded | 13 | allDirectoriesExpanded |
| 16 | resolvedTheme="light" | 14 | resolvedTheme="light" |
| 17 | onExpandedChange={() => {}} | ||
| 18 | onToggleAllDirectories={() => {}} | 15 | onToggleAllDirectories={() => {}} |
| 19 | onOpenTurnDiff={() => {}} | 16 | onOpenTurnDiff={() => {}} |
| 20 | />, | 17 | />, |
| 21 | ); | 18 | ); |
| 22 | 19 | ||
| 23 | expect(markup).toContain('data-changed-files-state="expanded"'); | 20 | expect(markup).toContain('data-changed-files-state="tree"'); |
| 24 | expect(markup).toContain('aria-expanded="true"'); | ||
| 25 | expect(markup).toContain('aria-label="Collapse all folders"'); | ||
| 26 | expect(markup).toContain('aria-label="Open diff"'); | 21 | expect(markup).toContain('aria-label="Open diff"'); |
| 27 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); | 22 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); |
| 28 | expect(markup).toContain("1 changed file"); | 23 | expect(markup).toContain("1 changed file"); |
| 29 | expect(markup).not.toContain("1 changed files"); | 24 | expect(markup).not.toContain("1 changed files"); |
| 30 | }); | 25 | }); |
| 31 | 26 | ||
| 32 | it("renders a scope and representative-file preview for a large latest change", () => { | 27 | it("shows collapsed folders and root files together", () => { |
| 33 | const markup = renderToStaticMarkup( | 28 | const markup = renderToStaticMarkup( |
| 34 | <ChangedFilesCard | 29 | <ChangedFilesCard |
| 35 | turnId={TurnId.make("turn-1")} | 30 | turnId={TurnId.make("turn-1")} |
| ... | @@ -44,46 +39,39 @@ describe("ChangedFilesCard", () => { | ... | @@ -44,46 +39,39 @@ describe("ChangedFilesCard", () => { |
| 44 | }, | 39 | }, |
| 45 | { path: "README.md", kind: "modified", additions: 3, deletions: 0 }, | 40 | { path: "README.md", kind: "modified", additions: 3, deletions: 0 }, |
| 46 | ]} | 41 | ]} |
| 47 | expanded={false} | ||
| 48 | showCompactPreview | ||
| 49 | allDirectoriesExpanded={false} | 42 | allDirectoriesExpanded={false} |
| 50 | resolvedTheme="light" | 43 | resolvedTheme="light" |
| 51 | onExpandedChange={() => {}} | ||
| 52 | onToggleAllDirectories={() => {}} | 44 | onToggleAllDirectories={() => {}} |
| 53 | onOpenTurnDiff={() => {}} | 45 | onOpenTurnDiff={() => {}} |
| 54 | />, | 46 | />, |
| 55 | ); | 47 | ); |
| 56 | 48 | ||
| 57 | expect(markup).toContain('data-changed-files-state="preview"'); | 49 | expect(markup).toContain('data-changed-files-state="tree"'); |
| 58 | expect(markup).toContain('aria-expanded="false"'); | 50 | expect(markup).toContain('aria-expanded="false"'); |
| 59 | expect(markup).toContain("apps"); | 51 | expect(markup).toContain("apps/web/src"); |
| 60 | expect(markup).toContain("2 files"); | 52 | expect(markup).not.toContain("App.tsx"); |
| 61 | expect(markup).toContain("packages"); | 53 | expect(markup).toContain("packages/shared/src"); |
| 62 | expect(markup).toContain("root"); | 54 | expect(markup).not.toContain("git.ts"); |
| 63 | expect(markup).toContain("App.tsx"); | ||
| 64 | expect(markup).toContain("git.ts"); | ||
| 65 | expect(markup).toContain("README.md"); | 55 | expect(markup).toContain("README.md"); |
| 66 | expect(markup).toContain("Show all 4 files"); | 56 | expect(markup).not.toContain("Show all"); |
| 67 | expect(markup).not.toContain("App.test.tsx"); | 57 | expect(markup).not.toContain("App.test.tsx"); |
| 68 | }); | 58 | }); |
| 69 | 59 | ||
| 70 | it("keeps older collapsed changes to a one-line receipt", () => { | 60 | it("keeps the folder tree visible when folders are collapsed", () => { |
| 71 | const markup = renderToStaticMarkup( | 61 | const markup = renderToStaticMarkup( |
| 72 | <ChangedFilesCard | 62 | <ChangedFilesCard |
| 73 | turnId={TurnId.make("turn-1")} | 63 | turnId={TurnId.make("turn-1")} |
| 74 | files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]} | 64 | files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]} |
| 75 | expanded={false} | ||
| 76 | showCompactPreview={false} | ||
| 77 | allDirectoriesExpanded={false} | 65 | allDirectoriesExpanded={false} |
| 78 | resolvedTheme="light" | 66 | resolvedTheme="light" |
| 79 | onExpandedChange={() => {}} | ||
| 80 | onToggleAllDirectories={() => {}} | 67 | onToggleAllDirectories={() => {}} |
| 81 | onOpenTurnDiff={() => {}} | 68 | onOpenTurnDiff={() => {}} |
| 82 | />, | 69 | />, |
| 83 | ); | 70 | ); |
| 84 | 71 | ||
| 85 | expect(markup).toContain('data-changed-files-state="collapsed"'); | 72 | expect(markup).toContain('data-changed-files-state="tree"'); |
| 86 | expect(markup).toContain("1 changed file"); | 73 | expect(markup).toContain("1 changed file"); |
| 74 | expect(markup).toContain("apps/web/src"); | ||
| 87 | expect(markup).not.toContain("Show all"); | 75 | expect(markup).not.toContain("Show all"); |
| 88 | expect(markup).not.toContain("App.tsx"); | 76 | expect(markup).not.toContain("App.tsx"); |
| 89 | }); | 77 | }); |
apps/web/src/components/chat/ChangedFilesTree.tsx+35-123| ... | @@ -19,99 +19,59 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | ... | @@ -19,99 +19,59 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 20 | import { Button } from "../ui/button"; | 20 | import { Button } from "../ui/button"; |
| 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | import { | ||
| 23 | changedFileName, | ||
| 24 | selectChangedFilePreview, | ||
| 25 | summarizeChangedFileScopes, | ||
| 26 | } from "./changedFilesPresentation"; | ||
| 27 | 22 | ||
| 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | 23 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 29 | 24 | ||
| 30 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | 25 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 31 | turnId: TurnId; | 26 | turnId: TurnId; |
| 32 | files: ReadonlyArray<TurnDiffFileChange>; | 27 | files: ReadonlyArray<TurnDiffFileChange>; |
| 33 | expanded: boolean; | ||
| 34 | showCompactPreview: boolean; | ||
| 35 | allDirectoriesExpanded: boolean; | 28 | allDirectoriesExpanded: boolean; |
| 36 | resolvedTheme: "light" | "dark"; | 29 | resolvedTheme: "light" | "dark"; |
| 37 | onExpandedChange: (expanded: boolean) => void; | ||
| 38 | onToggleAllDirectories: () => void; | 30 | onToggleAllDirectories: () => void; |
| 39 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 31 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 40 | }) { | 32 | }) { |
| 41 | const { | 33 | const { |
| 42 | turnId, | 34 | turnId, |
| 43 | files, | 35 | files, |
| 44 | expanded, | ||
| 45 | showCompactPreview, | ||
| 46 | allDirectoriesExpanded, | 36 | allDirectoriesExpanded, |
| 47 | resolvedTheme, | 37 | resolvedTheme, |
| 48 | onExpandedChange, | ||
| 49 | onToggleAllDirectories, | 38 | onToggleAllDirectories, |
| 50 | onOpenTurnDiff, | 39 | onOpenTurnDiff, |
| 51 | } = props; | 40 | } = props; |
| 52 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); | 41 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); |
| 53 | const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); | 42 | const hasDirectories = files.some((file) => /[/\\]/.test(file.path)); |
| 54 | const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); | ||
| 55 | const compactPreviewVisible = showCompactPreview && !expanded; | ||
| 56 | 43 | ||
| 57 | return ( | 44 | return ( |
| 58 | <div | 45 | <div |
| 59 | className="@container/changed-files mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32" | 46 | className="@container/changed-files mt-4 rounded-lg bg-secondary dark:bg-input/20" |
| 60 | data-changed-files-state={ | 47 | data-changed-files-state="tree" |
| 61 | expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed" | ||
| 62 | } | ||
| 63 | > | 48 | > |
| 64 | <div | 49 | <div |
| 65 | data-changed-files-header="" | 50 | data-changed-files-header="" |
| 66 | className={cn( | 51 | className="sticky top-2 z-10 flex items-center justify-between gap-2 rounded-t-lg bg-secondary px-3 py-2 dark:bg-[color-mix(in_srgb,var(--input)_20%,var(--background))]" |
| 67 | "flex items-center justify-between gap-2 rounded-xl", | ||
| 68 | expanded && | ||
| 69 | "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--contrast-foreground)_2.5%,var(--background))]", | ||
| 70 | )} | ||
| 71 | > | 52 | > |
| 72 | <button | 53 | <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium text-foreground"> |
| 73 | type="button" | 54 | <span> |
| 74 | aria-expanded={expanded} | 55 | {files.length} changed file{files.length === 1 ? "" : "s"} |
| 75 | data-scroll-anchor-ignore | ||
| 76 | className="group flex min-w-0 flex-1 items-center rounded-xl px-2 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 77 | onClick={() => onExpandedChange(!expanded)} | ||
| 78 | > | ||
| 79 | <span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden"> | ||
| 80 | <ChevronRightIcon | ||
| 81 | aria-hidden="true" | ||
| 82 | className={cn( | ||
| 83 | "size-3.5 shrink-0 text-muted-foreground transition-transform", | ||
| 84 | expanded && "rotate-90", | ||
| 85 | )} | ||
| 86 | /> | ||
| 87 | <span className="flex shrink-0 items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4"> | ||
| 88 | <span> | ||
| 89 | {files.length} changed file{files.length === 1 ? "" : "s"} | ||
| 90 | </span> | ||
| 91 | {hasNonZeroStat(summaryStat) && ( | ||
| 92 | <DiffStatLabel | ||
| 93 | additions={summaryStat.additions} | ||
| 94 | className="text-xs leading-4" | ||
| 95 | deletions={summaryStat.deletions} | ||
| 96 | layout="inline" | ||
| 97 | /> | ||
| 98 | )} | ||
| 99 | </span> | ||
| 100 | <span className="ml-1 hidden min-w-0 flex-1 truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 @[24rem]/changed-files:inline"> | ||
| 101 | {expanded ? "Hide files" : "Show files"} | ||
| 102 | </span> | ||
| 103 | </span> | 56 | </span> |
| 104 | </button> | 57 | {hasNonZeroStat(summaryStat) && ( |
| 105 | <div className="flex shrink-0 items-center gap-1.5 pr-1"> | 58 | <DiffStatLabel |
| 106 | {expanded ? ( | 59 | additions={summaryStat.additions} |
| 60 | deletions={summaryStat.deletions} | ||
| 61 | layout="inline" | ||
| 62 | className="text-xs leading-4" | ||
| 63 | /> | ||
| 64 | )} | ||
| 65 | </div> | ||
| 66 | <div className="flex shrink-0 items-center gap-1"> | ||
| 67 | {hasDirectories && ( | ||
| 107 | <Tooltip> | 68 | <Tooltip> |
| 108 | <TooltipTrigger | 69 | <TooltipTrigger |
| 109 | render={ | 70 | render={ |
| 110 | <Button | 71 | <Button |
| 111 | type="button" | 72 | type="button" |
| 112 | size="icon-xs" | 73 | size="icon-xs" |
| 113 | variant="outline" | 74 | variant="ghost-muted" |
| 114 | className="!size-[22px]" | ||
| 115 | aria-label={ | 75 | aria-label={ |
| 116 | allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" | 76 | allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" |
| 117 | } | 77 | } |
| ... | @@ -130,14 +90,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -130,14 +90,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 130 | {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | 90 | {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} |
| 131 | </TooltipPopup> | 91 | </TooltipPopup> |
| 132 | </Tooltip> | 92 | </Tooltip> |
| 133 | ) : null} | 93 | )} |
| 134 | <Tooltip> | 94 | <Tooltip> |
| 135 | <TooltipTrigger | 95 | <TooltipTrigger |
| 136 | render={ | 96 | render={ |
| 137 | <Button | 97 | <Button |
| 138 | type="button" | 98 | type="button" |
| 139 | size="xs" | 99 | size="xs" |
| 140 | variant="outline" | 100 | variant="ghost-muted" |
| 141 | aria-label="Open diff" | 101 | aria-label="Open diff" |
| 142 | onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} | 102 | onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} |
| 143 | /> | 103 | /> |
| ... | @@ -150,61 +110,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | ... | @@ -150,61 +110,14 @@ export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 150 | </Tooltip> | 110 | </Tooltip> |
| 151 | </div> | 111 | </div> |
| 152 | </div> | 112 | </div> |
| 153 | {expanded ? ( | 113 | <ChangedFilesTree |
| 154 | <ChangedFilesTree | 114 | key={`${turnId}:${allDirectoriesExpanded}`} |
| 155 | key={`changed-files-tree:${turnId}`} | 115 | turnId={turnId} |
| 156 | turnId={turnId} | 116 | files={files} |
| 157 | files={files} | 117 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 158 | allDirectoriesExpanded={allDirectoriesExpanded} | 118 | resolvedTheme={resolvedTheme} |
| 159 | resolvedTheme={resolvedTheme} | 119 | onOpenTurnDiff={onOpenTurnDiff} |
| 160 | onOpenTurnDiff={onOpenTurnDiff} | 120 | /> |
| 161 | /> | ||
| 162 | ) : compactPreviewVisible ? ( | ||
| 163 | <div className="px-2 pb-1.5 pt-1"> | ||
| 164 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground"> | ||
| 165 | {scopeSummary.map((scope, index) => ( | ||
| 166 | <span key={scope.label} className="inline-flex items-center gap-1"> | ||
| 167 | {index > 0 ? <span aria-hidden="true">·</span> : null} | ||
| 168 | <span className="font-mono text-foreground/75">{scope.label}</span> | ||
| 169 | <span> | ||
| 170 | {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} | ||
| 171 | </span> | ||
| 172 | </span> | ||
| 173 | ))} | ||
| 174 | </p> | ||
| 175 | <div className="mt-2 flex flex-wrap items-center gap-1.5"> | ||
| 176 | {previewFiles.map((file) => ( | ||
| 177 | <Tooltip key={file.path}> | ||
| 178 | <TooltipTrigger | ||
| 179 | render={ | ||
| 180 | <button | ||
| 181 | type="button" | ||
| 182 | className="inline-flex max-w-48 items-center gap-1 rounded-md border border-border/70 bg-background/45 px-1.5 py-1 font-mono text-[10px] text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 183 | onClick={() => onOpenTurnDiff(turnId, file.path)} | ||
| 184 | /> | ||
| 185 | } | ||
| 186 | > | ||
| 187 | <PierreEntryIcon | ||
| 188 | pathValue={file.path} | ||
| 189 | kind="file" | ||
| 190 | theme={resolvedTheme} | ||
| 191 | className="size-3 shrink-0 text-muted-foreground/70" | ||
| 192 | /> | ||
| 193 | <span className="truncate">{changedFileName(file.path)}</span> | ||
| 194 | </TooltipTrigger> | ||
| 195 | <TooltipPopup side="top">{file.path}</TooltipPopup> | ||
| 196 | </Tooltip> | ||
| 197 | ))} | ||
| 198 | <button | ||
| 199 | type="button" | ||
| 200 | className="rounded-md px-1.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 201 | onClick={() => onExpandedChange(true)} | ||
| 202 | > | ||
| 203 | Show all {files.length} files | ||
| 204 | </button> | ||
| 205 | </div> | ||
| 206 | </div> | ||
| 207 | ) : null} | ||
| 208 | </div> | 121 | </div> |
| 209 | ); | 122 | ); |
| 210 | }); | 123 | }); |
| ... | @@ -261,7 +174,8 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -261,7 +174,8 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 261 | <button | 174 | <button |
| 262 | type="button" | 175 | type="button" |
| 263 | data-scroll-anchor-ignore | 176 | data-scroll-anchor-ignore |
| 264 | 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" | 177 | aria-expanded={isExpanded} |
| 178 | className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 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" | ||
| 265 | style={{ paddingLeft: `${leftPadding}px` }} | 179 | style={{ paddingLeft: `${leftPadding}px` }} |
| 266 | onClick={() => toggleDirectory(node.path)} | 180 | onClick={() => toggleDirectory(node.path)} |
| 267 | > | 181 | > |
| ... | @@ -287,9 +201,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -287,9 +201,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 287 | )} | 201 | )} |
| 288 | </button> | 202 | </button> |
| 289 | {isExpanded && ( | 203 | {isExpanded && ( |
| 290 | <div className="space-y-0.5"> | 204 | <div>{node.children.map((childNode) => renderTreeNode(childNode, depth + 1))}</div> |
| 291 | {node.children.map((childNode) => renderTreeNode(childNode, depth + 1))} | ||
| 292 | </div> | ||
| 293 | )} | 205 | )} |
| 294 | </div> | 206 | </div> |
| 295 | ); | 207 | ); |
| ... | @@ -299,7 +211,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -299,7 +211,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 299 | <button | 211 | <button |
| 300 | key={`file:${node.path}`} | 212 | key={`file:${node.path}`} |
| 301 | type="button" | 213 | type="button" |
| 302 | 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" | 214 | className="group flex w-full items-center gap-2 rounded-md py-1.5 pr-2 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" |
| 303 | style={{ paddingLeft: `${leftPadding}px` }} | 215 | style={{ paddingLeft: `${leftPadding}px` }} |
| 304 | onClick={() => onOpenTurnDiff(turnId, node.path)} | 216 | onClick={() => onOpenTurnDiff(turnId, node.path)} |
| 305 | > | 217 | > |
| ... | @@ -312,7 +224,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -312,7 +224,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 312 | theme={resolvedTheme} | 224 | theme={resolvedTheme} |
| 313 | className="size-3.5 text-muted-foreground/70" | 225 | className="size-3.5 text-muted-foreground/70" |
| 314 | /> | 226 | /> |
| 315 | <span className="truncate font-mono text-[11px] text-muted-foreground/80 group-hover:text-foreground/90"> | 227 | <span className="truncate font-mono text-xs text-foreground/85 group-hover:text-foreground"> |
| 316 | {node.name} | 228 | {node.name} |
| 317 | </span> | 229 | </span> |
| 318 | {node.stat && ( | 230 | {node.stat && ( |
| ... | @@ -324,7 +236,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { | ... | @@ -324,7 +236,7 @@ export const ChangedFilesTree = memo(function ChangedFilesTree(props: { |
| 324 | ); | 236 | ); |
| 325 | }; | 237 | }; |
| 326 | 238 | ||
| 327 | return <div className="space-y-0.5">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>; | 239 | return <div className="p-2">{treeNodes.map((node) => renderTreeNode(node, 0))}</div>; |
| 328 | }); | 240 | }); |
| 329 | 241 | ||
| 330 | function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] { | 242 | function collectDirectoryPaths(nodes: ReadonlyArray<TurnDiffTreeNode>): string[] { |
apps/web/src/components/chat/MessagesTimeline.test.tsx+1-2| ... | @@ -379,9 +379,8 @@ describe("MessagesTimeline", () => { | ... | @@ -379,9 +379,8 @@ describe("MessagesTimeline", () => { |
| 379 | expect(markup).toContain("sticky top-2 z-10"); | 379 | expect(markup).toContain("sticky top-2 z-10"); |
| 380 | expect(markup).not.toContain("self-start"); | 380 | expect(markup).not.toContain("self-start"); |
| 381 | expect(markup).toContain("whitespace-nowrap"); | 381 | expect(markup).toContain("whitespace-nowrap"); |
| 382 | expect(markup).toContain("!size-[22px]"); | ||
| 383 | expect(markup).toContain("size-3"); | 382 | expect(markup).toContain("size-3"); |
| 384 | expect(markup).toContain('aria-label="Collapse all folders"'); | 383 | expect(markup).not.toContain('aria-label="Collapse all folders"'); |
| 385 | expect(markup).toContain('aria-label="Open diff"'); | 384 | expect(markup).toContain('aria-label="Open diff"'); |
| 386 | expect(markup).toContain("1 changed file"); | 385 | expect(markup).toContain("1 changed file"); |
| 387 | }); | 386 | }); |
apps/web/src/components/chat/MessagesTimeline.tsx+3-13| ... | @@ -111,7 +111,6 @@ import { | ... | @@ -111,7 +111,6 @@ import { |
| 111 | } from "./ExpandedImagePreview"; | 111 | } from "./ExpandedImagePreview"; |
| 112 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 112 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 113 | import { ChangedFilesCard } from "./ChangedFilesTree"; | 113 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 114 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; | ||
| 115 | import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; | 114 | import { CHAT_TIMELINE_ANCHOR_OFFSET } from "./timelineScrollAnchoring"; |
| 116 | import { MessageCopyButton } from "./MessageCopyButton"; | 115 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 117 | import { PierreEntryIcon } from "./PierreEntryIcon"; | 116 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| ... | @@ -2117,30 +2116,21 @@ function AssistantChangedFilesSectionInner({ | ... | @@ -2117,30 +2116,21 @@ function AssistantChangedFilesSectionInner({ |
| 2117 | resolvedTheme: "light" | "dark"; | 2116 | resolvedTheme: "light" | "dark"; |
| 2118 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 2117 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 2119 | }) { | 2118 | }) { |
| 2120 | const activity = use(TimelineRowActivityCtx); | ||
| 2121 | const isLatestTurn = activity.latestTurnId === turnSummary.turnId; | ||
| 2122 | const persistedExpanded = useUiStateStore( | 2119 | const persistedExpanded = useUiStateStore( |
| 2123 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], | 2120 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], |
| 2124 | ); | 2121 | ); |
| 2125 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); | 2122 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 2126 | const [autoExpanded] = useState(() => | 2123 | const allDirectoriesExpanded = persistedExpanded ?? false; |
| 2127 | shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), | ||
| 2128 | ); | ||
| 2129 | const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); | ||
| 2130 | const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); | ||
| 2131 | 2124 | ||
| 2132 | return ( | 2125 | return ( |
| 2133 | <ChangedFilesCard | 2126 | <ChangedFilesCard |
| 2134 | turnId={turnSummary.turnId} | 2127 | turnId={turnSummary.turnId} |
| 2135 | files={checkpointFiles} | 2128 | files={checkpointFiles} |
| 2136 | expanded={expanded} | ||
| 2137 | showCompactPreview={isLatestTurn} | ||
| 2138 | allDirectoriesExpanded={allDirectoriesExpanded} | 2129 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 2139 | resolvedTheme={resolvedTheme} | 2130 | resolvedTheme={resolvedTheme} |
| 2140 | onExpandedChange={(nextExpanded) => | 2131 | onToggleAllDirectories={() => |
| 2141 | setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) | 2132 | setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) |
| 2142 | } | 2133 | } |
| 2143 | onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} | ||
| 2144 | onOpenTurnDiff={onOpenTurnDiff} | 2134 | onOpenTurnDiff={onOpenTurnDiff} |
| 2145 | /> | 2135 | /> |
| 2146 | ); | 2136 | ); |
apps/web/src/uiStateStore.test.ts+4-3| ... | @@ -159,7 +159,7 @@ describe("parsePersistedState", () => { | ... | @@ -159,7 +159,7 @@ describe("parsePersistedState", () => { |
| 159 | invalid: "not-a-date", | 159 | invalid: "not-a-date", |
| 160 | }, | 160 | }, |
| 161 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 161 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 162 | threadChangedFilesExpansionVersion: 1, | 162 | threadChangedFilesExpansionVersion: 2, |
| 163 | threadChangedFilesExpandedById: { | 163 | threadChangedFilesExpandedById: { |
| 164 | "environment:thread-1": { | 164 | "environment:thread-1": { |
| 165 | "turn-1": false, | 165 | "turn-1": false, |
| ... | @@ -186,8 +186,9 @@ describe("parsePersistedState", () => { | ... | @@ -186,8 +186,9 @@ describe("parsePersistedState", () => { |
| 186 | }); | 186 | }); |
| 187 | }); | 187 | }); |
| 188 | 188 | ||
| 189 | it("ignores changed-file expansion values saved with legacy folder semantics", () => { | 189 | it.each([undefined, 1])("ignores changed-file expansion version %s", (version) => { |
| 190 | const parsed = parsePersistedState({ | 190 | const parsed = parsePersistedState({ |
| 191 | ...(version === undefined ? {} : { threadChangedFilesExpansionVersion: version }), | ||
| 191 | threadChangedFilesExpandedById: { | 192 | threadChangedFilesExpandedById: { |
| 192 | "environment:thread-1": { | 193 | "environment:thread-1": { |
| 193 | "turn-1": false, | 194 | "turn-1": false, |
| ... | @@ -296,7 +297,7 @@ describe("uiStateStore persistence", () => { | ... | @@ -296,7 +297,7 @@ describe("uiStateStore persistence", () => { |
| 296 | "environment:thread-1": "2026-02-25T12:35:00.000Z", | 297 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 297 | }, | 298 | }, |
| 298 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 299 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 299 | threadChangedFilesExpansionVersion: 1, | 300 | threadChangedFilesExpansionVersion: 2, |
| 300 | threadChangedFilesExpandedById: { | 301 | threadChangedFilesExpandedById: { |
| 301 | "environment:thread-1": { | 302 | "environment:thread-1": { |
| 302 | "turn-1": false, | 303 | "turn-1": false, |
apps/web/src/uiStateStore.ts+3-2| ... | @@ -3,7 +3,8 @@ import { create } from "zustand"; | ... | @@ -3,7 +3,8 @@ import { create } from "zustand"; |
| 3 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; | 3 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; |
| 4 | 4 | ||
| 5 | export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; | 5 | export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; |
| 6 | const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1; | 6 | // Version 1 stored card visibility, not folder expansion. |
| 7 | const THREAD_CHANGED_FILES_EXPANSION_VERSION = 2; | ||
| 7 | const LEGACY_PERSISTED_STATE_KEYS = [ | 8 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 8 | "t3code:renderer-state:v8", | 9 | "t3code:renderer-state:v8", |
| 9 | "t3code:renderer-state:v7", | 10 | "t3code:renderer-state:v7", |
| ... | @@ -25,7 +26,7 @@ export interface PersistedUiState { | ... | @@ -25,7 +26,7 @@ export interface PersistedUiState { |
| 25 | expandedProjectCwds?: string[]; | 26 | expandedProjectCwds?: string[]; |
| 26 | projectOrderCwds?: string[]; | 27 | projectOrderCwds?: string[]; |
| 27 | defaultAdvertisedEndpointKey?: string | null; | 28 | defaultAdvertisedEndpointKey?: string | null; |
| 28 | threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; | 29 | threadChangedFilesExpansionVersion?: number; |
| 29 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; | 30 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 30 | } | 31 | } |
| 31 | 32 |