| author | |
| committer | |
| log | 51672b6ecb9d927d57d0b12dd071ca0d4a34ef36 |
| tree | 300699e9fdaa714dd1d70120d316f51b708f826c |
| parent | 67a7b1a1d3443beeedc6a3bd7fe9dd875f016b11 |
| signature | Signed by PGP key B5690EEEBB952194 |
9 files changed, 455 insertions(+), 188 deletions(-)
apps/web/src/components/chat/ChangedFilesTree.test.tsx+67-4| ... | @@ -10,23 +10,86 @@ describe("ChangedFilesCard", () => { | ... | @@ -10,23 +10,86 @@ 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} | ||
| 13 | allDirectoriesExpanded | 15 | allDirectoriesExpanded |
| 14 | resolvedTheme="light" | 16 | resolvedTheme="light" |
| 17 | onExpandedChange={() => {}} | ||
| 15 | onToggleAllDirectories={() => {}} | 18 | onToggleAllDirectories={() => {}} |
| 16 | onOpenTurnDiff={() => {}} | 19 | onOpenTurnDiff={() => {}} |
| 17 | />, | 20 | />, |
| 18 | ); | 21 | ); |
| 19 | 22 | ||
| 20 | expect(markup).toContain('class="sticky top-0 z-10'); | 23 | expect(markup).toContain('data-changed-files-state="expanded"'); |
| 21 | expect(markup).not.toContain("self-start"); | 24 | expect(markup).toContain('aria-expanded="true"'); |
| 22 | expect(markup).toContain("whitespace-nowrap"); | 25 | expect(markup).toContain("whitespace-nowrap"); |
| 23 | expect(markup).toContain("!size-[22px]"); | 26 | expect(markup).toContain("!size-[22px]"); |
| 24 | expect(markup).toContain("size-3"); | 27 | expect(markup).toContain("size-3"); |
| 25 | expect(markup).toContain('aria-label="Collapse all"'); | 28 | expect(markup).toContain('aria-label="Collapse all folders"'); |
| 26 | expect(markup).toContain('aria-label="View diff"'); | 29 | expect(markup).toContain('aria-label="Open diff"'); |
| 30 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); | ||
| 27 | expect(markup).toContain("1 changed file"); | 31 | expect(markup).toContain("1 changed file"); |
| 28 | expect(markup).not.toContain("1 changed files"); | 32 | expect(markup).not.toContain("1 changed files"); |
| 29 | }); | 33 | }); |
| 34 | |||
| 35 | it("renders a scope and representative-file preview for a large latest change", () => { | ||
| 36 | const markup = renderToStaticMarkup( | ||
| 37 | <ChangedFilesCard | ||
| 38 | turnId={TurnId.make("turn-1")} | ||
| 39 | files={[ | ||
| 40 | { path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }, | ||
| 41 | { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 30, deletions: 2 }, | ||
| 42 | { | ||
| 43 | path: "packages/shared/src/git.ts", | ||
| 44 | kind: "modified", | ||
| 45 | additions: 15, | ||
| 46 | deletions: 4, | ||
| 47 | }, | ||
| 48 | { path: "README.md", kind: "modified", additions: 3, deletions: 0 }, | ||
| 49 | ]} | ||
| 50 | expanded={false} | ||
| 51 | showCompactPreview | ||
| 52 | allDirectoriesExpanded={false} | ||
| 53 | resolvedTheme="light" | ||
| 54 | onExpandedChange={() => {}} | ||
| 55 | onToggleAllDirectories={() => {}} | ||
| 56 | onOpenTurnDiff={() => {}} | ||
| 57 | />, | ||
| 58 | ); | ||
| 59 | |||
| 60 | expect(markup).toContain('data-changed-files-state="preview"'); | ||
| 61 | expect(markup).toContain('aria-expanded="false"'); | ||
| 62 | expect(markup).toContain("apps"); | ||
| 63 | expect(markup).toContain("2 files"); | ||
| 64 | expect(markup).toContain("packages"); | ||
| 65 | expect(markup).toContain("root"); | ||
| 66 | expect(markup).toContain("App.tsx"); | ||
| 67 | expect(markup).toContain("git.ts"); | ||
| 68 | expect(markup).toContain("README.md"); | ||
| 69 | expect(markup).toContain("Show all 4 files"); | ||
| 70 | expect(markup).not.toContain("App.test.tsx"); | ||
| 71 | }); | ||
| 72 | |||
| 73 | it("keeps older collapsed changes to a one-line receipt", () => { | ||
| 74 | const markup = renderToStaticMarkup( | ||
| 75 | <ChangedFilesCard | ||
| 76 | turnId={TurnId.make("turn-1")} | ||
| 77 | files={[{ path: "apps/web/src/App.tsx", kind: "modified", additions: 120, deletions: 20 }]} | ||
| 78 | expanded={false} | ||
| 79 | showCompactPreview={false} | ||
| 80 | allDirectoriesExpanded={false} | ||
| 81 | resolvedTheme="light" | ||
| 82 | onExpandedChange={() => {}} | ||
| 83 | onToggleAllDirectories={() => {}} | ||
| 84 | onOpenTurnDiff={() => {}} | ||
| 85 | />, | ||
| 86 | ); | ||
| 87 | |||
| 88 | expect(markup).toContain('data-changed-files-state="collapsed"'); | ||
| 89 | expect(markup).toContain("1 changed file"); | ||
| 90 | expect(markup).not.toContain("Show all"); | ||
| 91 | expect(markup).not.toContain("App.tsx"); | ||
| 92 | }); | ||
| 30 | }); | 93 | }); |
| 31 | 94 | ||
| 32 | describe("ChangedFilesTree", () => { | 95 | describe("ChangedFilesTree", () => { |
apps/web/src/components/chat/ChangedFilesTree.tsx+139-50| ... | @@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | ... | @@ -19,95 +19,184 @@ 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"; | ||
| 22 | 27 | ||
| 23 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; | 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 24 | 29 | ||
| 25 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { | 30 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 26 | turnId: TurnId; | 31 | turnId: TurnId; |
| 27 | files: ReadonlyArray<TurnDiffFileChange>; | 32 | files: ReadonlyArray<TurnDiffFileChange>; |
| 33 | expanded: boolean; | ||
| 34 | showCompactPreview: boolean; | ||
| 28 | allDirectoriesExpanded: boolean; | 35 | allDirectoriesExpanded: boolean; |
| 29 | resolvedTheme: "light" | "dark"; | 36 | resolvedTheme: "light" | "dark"; |
| 37 | onExpandedChange: (expanded: boolean) => void; | ||
| 30 | onToggleAllDirectories: () => void; | 38 | onToggleAllDirectories: () => void; |
| 31 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 39 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 32 | }) { | 40 | }) { |
| 33 | const { | 41 | const { |
| 34 | turnId, | 42 | turnId, |
| 35 | files, | 43 | files, |
| 44 | expanded, | ||
| 45 | showCompactPreview, | ||
| 36 | allDirectoriesExpanded, | 46 | allDirectoriesExpanded, |
| 37 | resolvedTheme, | 47 | resolvedTheme, |
| 48 | onExpandedChange, | ||
| 38 | onToggleAllDirectories, | 49 | onToggleAllDirectories, |
| 39 | onOpenTurnDiff, | 50 | onOpenTurnDiff, |
| 40 | } = props; | 51 | } = props; |
| 41 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); | 52 | const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); |
| 53 | const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); | ||
| 54 | const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); | ||
| 55 | const compactPreviewVisible = showCompactPreview && !expanded; | ||
| 42 | 56 | ||
| 43 | return ( | 57 | return ( |
| 44 | <div className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 pt-4 dark:border-transparent dark:bg-input/32"> | 58 | <div |
| 45 | <div className="sticky top-0 z-10 mb-3 flex items-center justify-between gap-2 bg-secondary px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-secondary 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))]"> | 59 | className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32" |
| 46 | <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4"> | 60 | data-changed-files-state={ |
| 47 | <span> | 61 | expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed" |
| 48 | {files.length} changed file{files.length === 1 ? "" : "s"} | 62 | } |
| 63 | > | ||
| 64 | <div | ||
| 65 | className={cn( | ||
| 66 | "flex items-center justify-between gap-2 rounded-xl px-1", | ||
| 67 | expanded && | ||
| 68 | "sticky top-2 z-10 mb-2 bg-secondary dark:bg-[color-mix(in_srgb,var(--foreground)_2.5%,var(--background))]", | ||
| 69 | )} | ||
| 70 | > | ||
| 71 | <button | ||
| 72 | type="button" | ||
| 73 | aria-expanded={expanded} | ||
| 74 | data-scroll-anchor-ignore | ||
| 75 | className="group flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" | ||
| 76 | onClick={() => onExpandedChange(!expanded)} | ||
| 77 | > | ||
| 78 | <ChevronRightIcon | ||
| 79 | aria-hidden="true" | ||
| 80 | className={cn( | ||
| 81 | "size-3.5 shrink-0 text-muted-foreground transition-transform", | ||
| 82 | expanded && "rotate-90", | ||
| 83 | )} | ||
| 84 | /> | ||
| 85 | <span className="flex min-w-0 items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4"> | ||
| 86 | <span> | ||
| 87 | {files.length} changed file{files.length === 1 ? "" : "s"} | ||
| 88 | </span> | ||
| 89 | {hasNonZeroStat(summaryStat) && ( | ||
| 90 | <DiffStatLabel | ||
| 91 | additions={summaryStat.additions} | ||
| 92 | className="text-xs leading-4" | ||
| 93 | deletions={summaryStat.deletions} | ||
| 94 | layout="inline" | ||
| 95 | /> | ||
| 96 | )} | ||
| 49 | </span> | 97 | </span> |
| 50 | {hasNonZeroStat(summaryStat) && ( | 98 | <span className="ml-1 hidden truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 sm:inline"> |
| 51 | <DiffStatLabel | 99 | {expanded ? "Hide files" : "Show files"} |
| 52 | additions={summaryStat.additions} | 100 | </span> |
| 53 | className="text-xs leading-4" | 101 | </button> |
| 54 | deletions={summaryStat.deletions} | ||
| 55 | layout="inline" | ||
| 56 | /> | ||
| 57 | )} | ||
| 58 | </p> | ||
| 59 | <div className="flex items-center gap-1.5"> | 102 | <div className="flex items-center gap-1.5"> |
| 103 | {expanded ? ( | ||
| 104 | <Tooltip> | ||
| 105 | <TooltipTrigger | ||
| 106 | render={ | ||
| 107 | <Button | ||
| 108 | type="button" | ||
| 109 | size="icon-xs" | ||
| 110 | variant="outline" | ||
| 111 | className="!size-[22px]" | ||
| 112 | aria-label={ | ||
| 113 | allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders" | ||
| 114 | } | ||
| 115 | data-scroll-anchor-ignore | ||
| 116 | onClick={onToggleAllDirectories} | ||
| 117 | /> | ||
| 118 | } | ||
| 119 | > | ||
| 120 | {allDirectoriesExpanded ? ( | ||
| 121 | <ChevronsDownUpIcon className="size-3" /> | ||
| 122 | ) : ( | ||
| 123 | <ChevronsUpDownIcon className="size-3" /> | ||
| 124 | )} | ||
| 125 | </TooltipTrigger> | ||
| 126 | <TooltipPopup side="top"> | ||
| 127 | {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} | ||
| 128 | </TooltipPopup> | ||
| 129 | </Tooltip> | ||
| 130 | ) : null} | ||
| 60 | <Tooltip> | 131 | <Tooltip> |
| 61 | <TooltipTrigger | 132 | <TooltipTrigger |
| 62 | render={ | 133 | render={ |
| 63 | <Button | 134 | <Button |
| 64 | type="button" | 135 | type="button" |
| 65 | size="icon-xs" | 136 | size="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" | 137 | variant="outline" |
| 91 | className="!size-[22px]" | 138 | aria-label="Open diff" |
| 92 | aria-label="View diff" | ||
| 93 | onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} | 139 | onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} |
| 94 | /> | 140 | /> |
| 95 | } | 141 | } |
| 96 | > | 142 | > |
| 97 | <FileDiffIcon className="size-3" /> | 143 | <FileDiffIcon className="size-3" /> |
| 144 | <span className="hidden sm:inline">Open diff</span> | ||
| 98 | </TooltipTrigger> | 145 | </TooltipTrigger> |
| 99 | <TooltipPopup side="top">View diff</TooltipPopup> | 146 | <TooltipPopup side="top">Open the full diff</TooltipPopup> |
| 100 | </Tooltip> | 147 | </Tooltip> |
| 101 | </div> | 148 | </div> |
| 102 | </div> | 149 | </div> |
| 103 | <ChangedFilesTree | 150 | {expanded ? ( |
| 104 | key={`changed-files-tree:${turnId}`} | 151 | <ChangedFilesTree |
| 105 | turnId={turnId} | 152 | key={`changed-files-tree:${turnId}`} |
| 106 | files={files} | 153 | turnId={turnId} |
| 107 | allDirectoriesExpanded={allDirectoriesExpanded} | 154 | files={files} |
| 108 | resolvedTheme={resolvedTheme} | 155 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 109 | onOpenTurnDiff={onOpenTurnDiff} | 156 | resolvedTheme={resolvedTheme} |
| 110 | /> | 157 | onOpenTurnDiff={onOpenTurnDiff} |
| 158 | /> | ||
| 159 | ) : compactPreviewVisible ? ( | ||
| 160 | <div className="px-2 pb-1.5 pt-1"> | ||
| 161 | <p className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-[11px] text-muted-foreground"> | ||
| 162 | {scopeSummary.map((scope, index) => ( | ||
| 163 | <span key={scope.label} className="inline-flex items-center gap-1"> | ||
| 164 | {index > 0 ? <span aria-hidden="true">·</span> : null} | ||
| 165 | <span className="font-mono text-foreground/75">{scope.label}</span> | ||
| 166 | <span> | ||
| 167 | {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} | ||
| 168 | </span> | ||
| 169 | </span> | ||
| 170 | ))} | ||
| 171 | </p> | ||
| 172 | <div className="mt-2 flex flex-wrap items-center gap-1.5"> | ||
| 173 | {previewFiles.map((file) => ( | ||
| 174 | <button | ||
| 175 | key={file.path} | ||
| 176 | type="button" | ||
| 177 | title={file.path} | ||
| 178 | 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" | ||
| 179 | onClick={() => onOpenTurnDiff(turnId, file.path)} | ||
| 180 | > | ||
| 181 | <PierreEntryIcon | ||
| 182 | pathValue={file.path} | ||
| 183 | kind="file" | ||
| 184 | theme={resolvedTheme} | ||
| 185 | className="size-3 shrink-0 text-muted-foreground/70" | ||
| 186 | /> | ||
| 187 | <span className="truncate">{changedFileName(file.path)}</span> | ||
| 188 | </button> | ||
| 189 | ))} | ||
| 190 | <button | ||
| 191 | type="button" | ||
| 192 | 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" | ||
| 193 | onClick={() => onExpandedChange(true)} | ||
| 194 | > | ||
| 195 | Show all {files.length} files | ||
| 196 | </button> | ||
| 197 | </div> | ||
| 198 | </div> | ||
| 199 | ) : null} | ||
| 111 | </div> | 200 | </div> |
| 112 | ); | 201 | ); |
| 113 | }); | 202 | }); |
apps/web/src/components/chat/DiffStatLabel.tsx+8-2| ... | @@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { | ... | @@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { |
| 31 | <> | 31 | <> |
| 32 | {showParentheses && <span className="text-muted-foreground/70">(</span>} | 32 | {showParentheses && <span className="text-muted-foreground/70">(</span>} |
| 33 | <span | 33 | <span |
| 34 | role="group" | ||
| 35 | aria-label={`${additions} additions, ${deletions} deletions`} | ||
| 34 | className={cn( | 36 | className={cn( |
| 35 | layout === "inline" | 37 | layout === "inline" |
| 36 | ? "inline-flex items-center gap-1 tabular-nums align-middle" | 38 | ? "inline-flex items-center gap-1 tabular-nums align-middle" |
| ... | @@ -38,8 +40,12 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { | ... | @@ -38,8 +40,12 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { |
| 38 | className, | 40 | className, |
| 39 | )} | 41 | )} |
| 40 | > | 42 | > |
| 41 | <span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span> | 43 | <span aria-hidden="true" className="font-mono text-success"> |
| 42 | <span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span> | 44 | +{formatCompactDiffCount(additions)} |
| 45 | </span> | ||
| 46 | <span aria-hidden="true" className="font-mono text-destructive"> | ||
| 47 | -{formatCompactDiffCount(deletions)} | ||
| 48 | </span> | ||
| 43 | </span> | 49 | </span> |
| 44 | {showParentheses && <span className="text-muted-foreground/70">)</span>} | 50 | {showParentheses && <span className="text-muted-foreground/70">)</span>} |
| 45 | </> | 51 | </> |
apps/web/src/components/chat/MessagesTimeline.test.tsx+9-3| ... | @@ -245,6 +245,12 @@ describe("MessagesTimeline", () => { | ... | @@ -245,6 +245,12 @@ describe("MessagesTimeline", () => { |
| 245 | const markup = renderToStaticMarkup( | 245 | const markup = renderToStaticMarkup( |
| 246 | <MessagesTimeline | 246 | <MessagesTimeline |
| 247 | {...buildProps()} | 247 | {...buildProps()} |
| 248 | latestTurn={{ | ||
| 249 | turnId, | ||
| 250 | state: "completed", | ||
| 251 | startedAt: MESSAGE_CREATED_AT, | ||
| 252 | completedAt: MESSAGE_CREATED_AT, | ||
| 253 | }} | ||
| 248 | timelineEntries={[ | 254 | timelineEntries={[ |
| 249 | { | 255 | { |
| 250 | id: "entry-assistant-with-files", | 256 | id: "entry-assistant-with-files", |
| ... | @@ -280,13 +286,13 @@ describe("MessagesTimeline", () => { | ... | @@ -280,13 +286,13 @@ describe("MessagesTimeline", () => { |
| 280 | />, | 286 | />, |
| 281 | ); | 287 | ); |
| 282 | 288 | ||
| 283 | expect(markup).toContain('class="sticky top-2 z-10'); | 289 | expect(markup).toContain("sticky top-2 z-10"); |
| 284 | expect(markup).not.toContain("self-start"); | 290 | expect(markup).not.toContain("self-start"); |
| 285 | expect(markup).toContain("whitespace-nowrap"); | 291 | expect(markup).toContain("whitespace-nowrap"); |
| 286 | expect(markup).toContain("!size-[22px]"); | 292 | expect(markup).toContain("!size-[22px]"); |
| 287 | expect(markup).toContain("size-3"); | 293 | expect(markup).toContain("size-3"); |
| 288 | expect(markup).toContain('aria-label="Collapse all"'); | 294 | expect(markup).toContain('aria-label="Collapse all folders"'); |
| 289 | expect(markup).toContain('aria-label="View diff"'); | 295 | expect(markup).toContain('aria-label="Open diff"'); |
| 290 | expect(markup).toContain("1 changed file"); | 296 | expect(markup).toContain("1 changed file"); |
| 291 | }); | 297 | }); |
| 292 | 298 |
apps/web/src/components/chat/MessagesTimeline.tsx+27-80| ... | @@ -32,7 +32,6 @@ import { | ... | @@ -32,7 +32,6 @@ import { |
| 32 | workLogEntryIsToolLike, | 32 | workLogEntryIsToolLike, |
| 33 | } from "../../session-logic"; | 33 | } from "../../session-logic"; |
| 34 | import { type TurnDiffSummary } from "../../types"; | 34 | import { type TurnDiffSummary } from "../../types"; |
| 35 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | ||
| 36 | import { | 35 | import { |
| 37 | getRenderablePatch, | 36 | getRenderablePatch, |
| 38 | resolveDiffThemeName, | 37 | resolveDiffThemeName, |
| ... | @@ -44,11 +43,8 @@ import { | ... | @@ -44,11 +43,8 @@ import { |
| 44 | CheckIcon, | 43 | CheckIcon, |
| 45 | ChevronDownIcon, | 44 | ChevronDownIcon, |
| 46 | ChevronRightIcon, | 45 | ChevronRightIcon, |
| 47 | ChevronsDownUpIcon, | ||
| 48 | ChevronsUpDownIcon, | ||
| 49 | CircleAlertIcon, | 46 | CircleAlertIcon, |
| 50 | EyeIcon, | 47 | EyeIcon, |
| 51 | FileDiffIcon, | ||
| 52 | GlobeIcon, | 48 | GlobeIcon, |
| 53 | HammerIcon, | 49 | HammerIcon, |
| 54 | MessageCircleIcon, | 50 | MessageCircleIcon, |
| ... | @@ -65,8 +61,8 @@ import { | ... | @@ -65,8 +61,8 @@ import { |
| 65 | import { Button } from "../ui/button"; | 61 | import { Button } from "../ui/button"; |
| 66 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; | 62 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 67 | import { ProposedPlanCard } from "./ProposedPlanCard"; | 63 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 68 | import { ChangedFilesTree } from "./ChangedFilesTree"; | 64 | import { ChangedFilesCard } from "./ChangedFilesTree"; |
| 69 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | 65 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; |
| 70 | import { MessageCopyButton } from "./MessageCopyButton"; | 66 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 71 | import { | 67 | import { |
| 72 | computeStableMessagesTimelineRows, | 68 | computeStableMessagesTimelineRows, |
| ... | @@ -145,6 +141,7 @@ interface TimelineRowActivityState { | ... | @@ -145,6 +141,7 @@ interface TimelineRowActivityState { |
| 145 | isWorking: boolean; | 141 | isWorking: boolean; |
| 146 | isRevertingCheckpoint: boolean; | 142 | isRevertingCheckpoint: boolean; |
| 147 | activeTurnInProgress: boolean; | 143 | activeTurnInProgress: boolean; |
| 144 | latestTurnId: TurnId | null; | ||
| 148 | } | 145 | } |
| 149 | 146 | ||
| 150 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); | 147 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| ... | @@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ | ... | @@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 454 | isWorking, | 451 | isWorking, |
| 455 | isRevertingCheckpoint, | 452 | isRevertingCheckpoint, |
| 456 | activeTurnInProgress, | 453 | activeTurnInProgress, |
| 454 | latestTurnId: latestTurn?.turnId ?? null, | ||
| 457 | }), | 455 | }), |
| 458 | [activeTurnInProgress, isRevertingCheckpoint, isWorking], | 456 | [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], |
| 459 | ); | 457 | ); |
| 460 | 458 | ||
| 461 | // Stable renderItem — no closure deps. Row components read shared state | 459 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | @@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({ | ... | @@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({ |
| 1278 | resolvedTheme: "light" | "dark"; | 1276 | resolvedTheme: "light" | "dark"; |
| 1279 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; | 1277 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 1280 | }) { | 1278 | }) { |
| 1281 | const allDirectoriesExpanded = useUiStateStore( | 1279 | const activity = use(TimelineRowActivityCtx); |
| 1282 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, | 1280 | const isLatestTurn = activity.latestTurnId === turnSummary.turnId; |
| 1281 | const persistedExpanded = useUiStateStore( | ||
| 1282 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], | ||
| 1283 | ); | 1283 | ); |
| 1284 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); | 1284 | const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); |
| 1285 | const summaryStat = summarizeTurnDiffStats(checkpointFiles); | 1285 | const [autoExpanded] = useState(() => |
| 1286 | shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), | ||
| 1287 | ); | ||
| 1288 | const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); | ||
| 1289 | const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); | ||
| 1286 | 1290 | ||
| 1287 | return ( | 1291 | return ( |
| 1288 | <div className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 pt-4 dark:border-transparent dark:bg-input/32"> | 1292 | <ChangedFilesCard |
| 1289 | <div className="sticky top-2 z-10 mb-3 flex items-center justify-between gap-2 bg-secondary px-2 before:absolute before:inset-x-0 before:-top-4 before:h-4 before:bg-secondary 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))]"> | 1293 | turnId={turnSummary.turnId} |
| 1290 | <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4"> | 1294 | files={checkpointFiles} |
| 1291 | <span> | 1295 | expanded={expanded} |
| 1292 | {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} | 1296 | showCompactPreview={isLatestTurn} |
| 1293 | </span> | 1297 | allDirectoriesExpanded={allDirectoriesExpanded} |
| 1294 | {hasNonZeroStat(summaryStat) && ( | 1298 | resolvedTheme={resolvedTheme} |
| 1295 | <DiffStatLabel | 1299 | onExpandedChange={(nextExpanded) => |
| 1296 | additions={summaryStat.additions} | 1300 | setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) |
| 1297 | className="text-xs leading-4" | 1301 | } |
| 1298 | deletions={summaryStat.deletions} | 1302 | onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} |
| 1299 | layout="inline" | 1303 | onOpenTurnDiff={onOpenTurnDiff} |
| 1300 | /> | 1304 | /> |
| 1301 | )} | ||
| 1302 | </p> | ||
| 1303 | <div className="flex items-center gap-1.5"> | ||
| 1304 | <Tooltip> | ||
| 1305 | <TooltipTrigger | ||
| 1306 | render={ | ||
| 1307 | <Button | ||
| 1308 | type="button" | ||
| 1309 | size="icon-xs" | ||
| 1310 | variant="outline" | ||
| 1311 | className="!size-[22px]" | ||
| 1312 | aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"} | ||
| 1313 | data-scroll-anchor-ignore | ||
| 1314 | onClick={() => | ||
| 1315 | setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) | ||
| 1316 | } | ||
| 1317 | /> | ||
| 1318 | } | ||
| 1319 | > | ||
| 1320 | {allDirectoriesExpanded ? ( | ||
| 1321 | <ChevronsDownUpIcon className="size-3" /> | ||
| 1322 | ) : ( | ||
| 1323 | <ChevronsUpDownIcon className="size-3" /> | ||
| 1324 | )} | ||
| 1325 | </TooltipTrigger> | ||
| 1326 | <TooltipPopup side="top"> | ||
| 1327 | {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | ||
| 1328 | </TooltipPopup> | ||
| 1329 | </Tooltip> | ||
| 1330 | <Tooltip> | ||
| 1331 | <TooltipTrigger | ||
| 1332 | render={ | ||
| 1333 | <Button | ||
| 1334 | type="button" | ||
| 1335 | size="icon-xs" | ||
| 1336 | variant="outline" | ||
| 1337 | className="!size-[22px]" | ||
| 1338 | aria-label="View diff" | ||
| 1339 | onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} | ||
| 1340 | /> | ||
| 1341 | } | ||
| 1342 | > | ||
| 1343 | <FileDiffIcon className="size-3" /> | ||
| 1344 | </TooltipTrigger> | ||
| 1345 | <TooltipPopup side="top">View diff</TooltipPopup> | ||
| 1346 | </Tooltip> | ||
| 1347 | </div> | ||
| 1348 | </div> | ||
| 1349 | <ChangedFilesTree | ||
| 1350 | key={`changed-files-tree:${turnSummary.turnId}`} | ||
| 1351 | turnId={turnSummary.turnId} | ||
| 1352 | files={checkpointFiles} | ||
| 1353 | allDirectoriesExpanded={allDirectoriesExpanded} | ||
| 1354 | resolvedTheme={resolvedTheme} | ||
| 1355 | onOpenTurnDiff={onOpenTurnDiff} | ||
| 1356 | /> | ||
| 1357 | </div> | ||
| 1358 | ); | 1305 | ); |
| 1359 | } | 1306 | } |
| 1360 | 1307 |
apps/web/src/components/chat/changedFilesPresentation.test.ts created+69| ... | @@ -0,0 +1,69 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | changedFileName, | ||
| 5 | selectChangedFilePreview, | ||
| 6 | shouldAutoExpandChangedFiles, | ||
| 7 | summarizeChangedFileScopes, | ||
| 8 | } from "./changedFilesPresentation"; | ||
| 9 | |||
| 10 | describe("changed-files presentation", () => { | ||
| 11 | it("auto-expands only small, low-churn latest changes", () => { | ||
| 12 | const smallFiles = [ | ||
| 13 | { path: "src/a.ts", kind: "modified", additions: 80, deletions: 20 }, | ||
| 14 | { path: "src/b.ts", kind: "modified", additions: 60, deletions: 20 }, | ||
| 15 | ]; | ||
| 16 | |||
| 17 | expect(shouldAutoExpandChangedFiles(smallFiles, true)).toBe(true); | ||
| 18 | expect(shouldAutoExpandChangedFiles(smallFiles, false)).toBe(false); | ||
| 19 | expect( | ||
| 20 | shouldAutoExpandChangedFiles( | ||
| 21 | [{ path: "src/a.ts", kind: "modified", additions: 201, deletions: 0 }], | ||
| 22 | true, | ||
| 23 | ), | ||
| 24 | ).toBe(false); | ||
| 25 | expect( | ||
| 26 | shouldAutoExpandChangedFiles( | ||
| 27 | Array.from({ length: 6 }, (_, index) => ({ | ||
| 28 | path: `src/${index}.ts`, | ||
| 29 | kind: "modified", | ||
| 30 | additions: 1, | ||
| 31 | deletions: 0, | ||
| 32 | })), | ||
| 33 | true, | ||
| 34 | ), | ||
| 35 | ).toBe(false); | ||
| 36 | }); | ||
| 37 | |||
| 38 | it("summarizes the most prominent top-level scopes", () => { | ||
| 39 | const files = [ | ||
| 40 | { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, | ||
| 41 | { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, | ||
| 42 | { path: "apps/server/src/index.ts", kind: "modified", additions: 1, deletions: 0 }, | ||
| 43 | { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, | ||
| 44 | { path: "apps\\mobile\\App.tsx", kind: "modified", additions: 1, deletions: 0 }, | ||
| 45 | ]; | ||
| 46 | |||
| 47 | expect(summarizeChangedFileScopes(files)).toEqual([ | ||
| 48 | { label: "apps", fileCount: 3 }, | ||
| 49 | { label: "root", fileCount: 1 }, | ||
| 50 | { label: "packages", fileCount: 1 }, | ||
| 51 | ]); | ||
| 52 | }); | ||
| 53 | |||
| 54 | it("previews files across different scopes before filling from one scope", () => { | ||
| 55 | const files = [ | ||
| 56 | { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, | ||
| 57 | { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 1, deletions: 0 }, | ||
| 58 | { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, | ||
| 59 | { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, | ||
| 60 | ]; | ||
| 61 | |||
| 62 | expect(selectChangedFilePreview(files).map((file) => file.path)).toEqual([ | ||
| 63 | "apps/web/src/App.tsx", | ||
| 64 | "packages/shared/src/git.ts", | ||
| 65 | "README.md", | ||
| 66 | ]); | ||
| 67 | expect(changedFileName("apps\\web\\src\\App.tsx")).toBe("App.tsx"); | ||
| 68 | }); | ||
| 69 | }); | ||
apps/web/src/components/chat/changedFilesPresentation.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | import { type TurnDiffFileChange } from "../../types"; | ||
| 2 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | ||
| 3 | |||
| 4 | export const CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT = 5; | ||
| 5 | export const CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT = 200; | ||
| 6 | export const CHANGED_FILES_PREVIEW_FILE_LIMIT = 3; | ||
| 7 | export const CHANGED_FILES_PREVIEW_SCOPE_LIMIT = 4; | ||
| 8 | |||
| 9 | export interface ChangedFilesScopeSummary { | ||
| 10 | readonly label: string; | ||
| 11 | readonly fileCount: number; | ||
| 12 | } | ||
| 13 | |||
| 14 | function pathSegments(pathValue: string): string[] { | ||
| 15 | return pathValue | ||
| 16 | .replaceAll("\\", "/") | ||
| 17 | .split("/") | ||
| 18 | .filter((segment) => segment.length > 0); | ||
| 19 | } | ||
| 20 | |||
| 21 | export function changedFileName(pathValue: string): string { | ||
| 22 | return pathSegments(pathValue).at(-1) ?? pathValue; | ||
| 23 | } | ||
| 24 | |||
| 25 | function changedFileScope(pathValue: string): string { | ||
| 26 | const segments = pathSegments(pathValue); | ||
| 27 | return segments.length > 1 ? (segments[0] ?? "root") : "root"; | ||
| 28 | } | ||
| 29 | |||
| 30 | export function shouldAutoExpandChangedFiles( | ||
| 31 | files: ReadonlyArray<TurnDiffFileChange>, | ||
| 32 | isLatestTurn: boolean, | ||
| 33 | ): boolean { | ||
| 34 | if (!isLatestTurn || files.length > CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT) { | ||
| 35 | return false; | ||
| 36 | } | ||
| 37 | const stat = summarizeTurnDiffStats(files); | ||
| 38 | return stat.additions + stat.deletions <= CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT; | ||
| 39 | } | ||
| 40 | |||
| 41 | export function summarizeChangedFileScopes( | ||
| 42 | files: ReadonlyArray<TurnDiffFileChange>, | ||
| 43 | limit = CHANGED_FILES_PREVIEW_SCOPE_LIMIT, | ||
| 44 | ): ChangedFilesScopeSummary[] { | ||
| 45 | const scopes = new Map<string, { fileCount: number; firstIndex: number }>(); | ||
| 46 | files.forEach((file, index) => { | ||
| 47 | const label = changedFileScope(file.path); | ||
| 48 | const current = scopes.get(label); | ||
| 49 | scopes.set(label, { | ||
| 50 | fileCount: (current?.fileCount ?? 0) + 1, | ||
| 51 | firstIndex: current?.firstIndex ?? index, | ||
| 52 | }); | ||
| 53 | }); | ||
| 54 | |||
| 55 | return Array.from(scopes, ([label, scope]) => ({ | ||
| 56 | label, | ||
| 57 | fileCount: scope.fileCount, | ||
| 58 | firstIndex: scope.firstIndex, | ||
| 59 | })) | ||
| 60 | .toSorted( | ||
| 61 | (left, right) => | ||
| 62 | right.fileCount - left.fileCount || | ||
| 63 | left.firstIndex - right.firstIndex || | ||
| 64 | left.label.localeCompare(right.label), | ||
| 65 | ) | ||
| 66 | .slice(0, limit) | ||
| 67 | .map(({ label, fileCount }) => ({ label, fileCount })); | ||
| 68 | } | ||
| 69 | |||
| 70 | export function selectChangedFilePreview( | ||
| 71 | files: ReadonlyArray<TurnDiffFileChange>, | ||
| 72 | limit = CHANGED_FILES_PREVIEW_FILE_LIMIT, | ||
| 73 | ): TurnDiffFileChange[] { | ||
| 74 | const selected: TurnDiffFileChange[] = []; | ||
| 75 | const selectedPaths = new Set<string>(); | ||
| 76 | const selectedScopes = new Set<string>(); | ||
| 77 | |||
| 78 | for (const file of files) { | ||
| 79 | const scope = changedFileScope(file.path); | ||
| 80 | if (selectedScopes.has(scope)) { | ||
| 81 | continue; | ||
| 82 | } | ||
| 83 | selected.push(file); | ||
| 84 | selectedPaths.add(file.path); | ||
| 85 | selectedScopes.add(scope); | ||
| 86 | if (selected.length === limit) { | ||
| 87 | return selected; | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | for (const file of files) { | ||
| 92 | if (selectedPaths.has(file.path)) { | ||
| 93 | continue; | ||
| 94 | } | ||
| 95 | selected.push(file); | ||
| 96 | if (selected.length === limit) { | ||
| 97 | break; | ||
| 98 | } | ||
| 99 | } | ||
| 100 | |||
| 101 | return selected; | ||
| 102 | } | ||
apps/web/src/uiStateStore.test.ts+22-7| ... | @@ -116,7 +116,7 @@ describe("uiStateStore pure functions", () => { | ... | @@ -116,7 +116,7 @@ describe("uiStateStore pure functions", () => { |
| 116 | ); | 116 | ); |
| 117 | }); | 117 | }); |
| 118 | 118 | ||
| 119 | it("stores only collapsed changed-file turns", () => { | 119 | it("stores explicit changed-file expansion choices", () => { |
| 120 | const threadId = ThreadId.make("thread-1"); | 120 | const threadId = ThreadId.make("thread-1"); |
| 121 | const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); | 121 | const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); |
| 122 | 122 | ||
| ... | @@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => { | ... | @@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => { |
| 128 | expect( | 128 | expect( |
| 129 | setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) | 129 | setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) |
| 130 | .threadChangedFilesExpandedById, | 130 | .threadChangedFilesExpandedById, |
| 131 | ).toEqual({}); | 131 | ).toEqual({ |
| 132 | [threadId]: { | ||
| 133 | "turn-1": true, | ||
| 134 | }, | ||
| 135 | }); | ||
| 132 | }); | 136 | }); |
| 133 | 137 | ||
| 134 | it("stores the endpoint preference by stable key", () => { | 138 | it("stores the endpoint preference by stable key", () => { |
| ... | @@ -155,6 +159,7 @@ describe("parsePersistedState", () => { | ... | @@ -155,6 +159,7 @@ describe("parsePersistedState", () => { |
| 155 | invalid: "not-a-date", | 159 | invalid: "not-a-date", |
| 156 | }, | 160 | }, |
| 157 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 161 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 162 | threadChangedFilesExpansionVersion: 1, | ||
| 158 | threadChangedFilesExpandedById: { | 163 | threadChangedFilesExpandedById: { |
| 159 | "environment:thread-1": { | 164 | "environment:thread-1": { |
| 160 | "turn-1": false, | 165 | "turn-1": false, |
| ... | @@ -172,12 +177,25 @@ describe("parsePersistedState", () => { | ... | @@ -172,12 +177,25 @@ describe("parsePersistedState", () => { |
| 172 | "environment:thread-1": "2026-02-25T12:35:00.000Z", | 177 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 173 | }, | 178 | }, |
| 174 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 179 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 180 | threadChangedFilesExpandedById: { | ||
| 181 | "environment:thread-1": { | ||
| 182 | "turn-1": false, | ||
| 183 | "turn-2": true, | ||
| 184 | }, | ||
| 185 | }, | ||
| 186 | }); | ||
| 187 | }); | ||
| 188 | |||
| 189 | it("ignores changed-file expansion values saved with legacy folder semantics", () => { | ||
| 190 | const parsed = parsePersistedState({ | ||
| 175 | threadChangedFilesExpandedById: { | 191 | threadChangedFilesExpandedById: { |
| 176 | "environment:thread-1": { | 192 | "environment:thread-1": { |
| 177 | "turn-1": false, | 193 | "turn-1": false, |
| 178 | }, | 194 | }, |
| 179 | }, | 195 | }, |
| 180 | }); | 196 | }); |
| 197 | |||
| 198 | expect(parsed.threadChangedFilesExpandedById).toEqual({}); | ||
| 181 | }); | 199 | }); |
| 182 | 200 | ||
| 183 | it("migrates legacy CWD project preferences into local alias keys", () => { | 201 | it("migrates legacy CWD project preferences into local alias keys", () => { |
| ... | @@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => { | ... | @@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => { |
| 278 | "environment:thread-1": "2026-02-25T12:35:00.000Z", | 296 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 279 | }, | 297 | }, |
| 280 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", | 298 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 299 | threadChangedFilesExpansionVersion: 1, | ||
| 281 | threadChangedFilesExpandedById: { | 300 | threadChangedFilesExpandedById: { |
| 282 | "environment:thread-1": { | 301 | "environment:thread-1": { |
| 283 | "turn-1": false, | 302 | "turn-1": false, |
| 303 | "turn-2": true, | ||
| 284 | }, | 304 | }, |
| 285 | }, | 305 | }, |
| 286 | }); | 306 | }); |
| 287 | expect(parsePersistedState(persisted)).toEqual({ | 307 | expect(parsePersistedState(persisted)).toEqual({ |
| 288 | ...state, | 308 | ...state, |
| 289 | threadChangedFilesExpandedById: { | ||
| 290 | "environment:thread-1": { | ||
| 291 | "turn-1": false, | ||
| 292 | }, | ||
| 293 | }, | ||
| 294 | }); | 309 | }); |
| 295 | }); | 310 | }); |
| 296 | 311 |
apps/web/src/uiStateStore.ts+12-42| ... | @@ -3,6 +3,7 @@ import { create } from "zustand"; | ... | @@ -3,6 +3,7 @@ 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 | const LEGACY_PERSISTED_STATE_KEYS = [ | 7 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 7 | "t3code:renderer-state:v8", | 8 | "t3code:renderer-state:v8", |
| 8 | "t3code:renderer-state:v7", | 9 | "t3code:renderer-state:v7", |
| ... | @@ -24,6 +25,7 @@ export interface PersistedUiState { | ... | @@ -24,6 +25,7 @@ export interface PersistedUiState { |
| 24 | expandedProjectCwds?: string[]; | 25 | expandedProjectCwds?: string[]; |
| 25 | projectOrderCwds?: string[]; | 26 | projectOrderCwds?: string[]; |
| 26 | defaultAdvertisedEndpointKey?: string | null; | 27 | defaultAdvertisedEndpointKey?: string | null; |
| 28 | threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; | ||
| 27 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; | 29 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 28 | } | 30 | } |
| 29 | 31 | ||
| ... | @@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { | ... | @@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 124 | projectExpandedById, | 126 | projectExpandedById, |
| 125 | projectOrder, | 127 | projectOrder, |
| 126 | threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), | 128 | threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), |
| 127 | threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( | 129 | threadChangedFilesExpandedById: |
| 128 | parsed.threadChangedFilesExpandedById, | 130 | parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION |
| 129 | ), | 131 | ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) |
| 132 | : {}, | ||
| 130 | defaultAdvertisedEndpointKey: | 133 | defaultAdvertisedEndpointKey: |
| 131 | typeof parsed.defaultAdvertisedEndpointKey === "string" && | 134 | typeof parsed.defaultAdvertisedEndpointKey === "string" && |
| 132 | parsed.defaultAdvertisedEndpointKey.length > 0 | 135 | parsed.defaultAdvertisedEndpointKey.length > 0 |
| ... | @@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded( | ... | @@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded( |
| 172 | 175 | ||
| 173 | const nextTurns: Record<string, boolean> = {}; | 176 | const nextTurns: Record<string, boolean> = {}; |
| 174 | for (const [turnId, expanded] of Object.entries(turns)) { | 177 | for (const [turnId, expanded] of Object.entries(turns)) { |
| 175 | if (turnId && typeof expanded === "boolean" && expanded === false) { | 178 | if (turnId && typeof expanded === "boolean") { |
| 176 | nextTurns[turnId] = false; | 179 | nextTurns[turnId] = expanded; |
| 177 | } | 180 | } |
| 178 | } | 181 | } |
| 179 | 182 | ||
| ... | @@ -195,14 +198,6 @@ export function persistState(state: UiState): void { | ... | @@ -195,14 +198,6 @@ export function persistState(state: UiState): void { |
| 195 | ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, | 198 | ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, |
| 196 | ), | 199 | ), |
| 197 | ); | 200 | ); |
| 198 | const threadChangedFilesExpandedById = Object.fromEntries( | ||
| 199 | Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { | ||
| 200 | const nextTurns = Object.fromEntries( | ||
| 201 | Object.entries(turns).filter(([, expanded]) => expanded === false), | ||
| 202 | ); | ||
| 203 | return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; | ||
| 204 | }), | ||
| 205 | ); | ||
| 206 | window.localStorage.setItem( | 201 | window.localStorage.setItem( |
| 207 | PERSISTED_STATE_KEY, | 202 | PERSISTED_STATE_KEY, |
| 208 | JSON.stringify({ | 203 | JSON.stringify({ |
| ... | @@ -210,7 +205,8 @@ export function persistState(state: UiState): void { | ... | @@ -210,7 +205,8 @@ export function persistState(state: UiState): void { |
| 210 | projectOrder: state.projectOrder, | 205 | projectOrder: state.projectOrder, |
| 211 | threadLastVisitedAtById: state.threadLastVisitedAtById, | 206 | threadLastVisitedAtById: state.threadLastVisitedAtById, |
| 212 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, | 207 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, |
| 213 | threadChangedFilesExpandedById, | 208 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, |
| 209 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, | ||
| 214 | } satisfies PersistedUiState), | 210 | } satisfies PersistedUiState), |
| 215 | ); | 211 | ); |
| 216 | if (!legacyKeysCleanedUp) { | 212 | if (!legacyKeysCleanedUp) { |
| ... | @@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded( | ... | @@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded( |
| 281 | expanded: boolean, | 277 | expanded: boolean, |
| 282 | ): UiState { | 278 | ): UiState { |
| 283 | const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; | 279 | const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; |
| 284 | const currentExpanded = currentThreadState[turnId] ?? true; | 280 | if (currentThreadState[turnId] === expanded) { |
| 285 | if (currentExpanded === expanded) { | ||
| 286 | return state; | 281 | return state; |
| 287 | } | 282 | } |
| 288 | 283 | ||
| 289 | if (expanded) { | ||
| 290 | if (!(turnId in currentThreadState)) { | ||
| 291 | return state; | ||
| 292 | } | ||
| 293 | |||
| 294 | const nextThreadState = { ...currentThreadState }; | ||
| 295 | delete nextThreadState[turnId]; | ||
| 296 | if (Object.keys(nextThreadState).length === 0) { | ||
| 297 | const nextState = { ...state.threadChangedFilesExpandedById }; | ||
| 298 | delete nextState[threadId]; | ||
| 299 | return { | ||
| 300 | ...state, | ||
| 301 | threadChangedFilesExpandedById: nextState, | ||
| 302 | }; | ||
| 303 | } | ||
| 304 | |||
| 305 | return { | ||
| 306 | ...state, | ||
| 307 | threadChangedFilesExpandedById: { | ||
| 308 | ...state.threadChangedFilesExpandedById, | ||
| 309 | [threadId]: nextThreadState, | ||
| 310 | }, | ||
| 311 | }; | ||
| 312 | } | ||
| 313 | |||
| 314 | return { | 284 | return { |
| 315 | ...state, | 285 | ...state, |
| 316 | threadChangedFilesExpandedById: { | 286 | threadChangedFilesExpandedById: { |
| 317 | ...state.threadChangedFilesExpandedById, | 287 | ...state.threadChangedFilesExpandedById, |
| 318 | [threadId]: { | 288 | [threadId]: { |
| 319 | ...currentThreadState, | 289 | ...currentThreadState, |
| 320 | [turnId]: false, | 290 | [turnId]: expanded, |
| 321 | }, | 291 | }, |
| 322 | }, | 292 | }, |
| 323 | }; | 293 | }; |