| 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 | 10 | <ChangedFilesCard |
| 11 | 11 | turnId={TurnId.make("turn-1")} |
| 12 | 12 | files={[{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }]} |
| 13 | expanded | |
| 14 | showCompactPreview={false} | |
| 13 | 15 | allDirectoriesExpanded |
| 14 | 16 | resolvedTheme="light" |
| 17 | onExpandedChange={() => {}} | |
| 15 | 18 | onToggleAllDirectories={() => {}} |
| 16 | 19 | onOpenTurnDiff={() => {}} |
| 17 | 20 | />, |
| 18 | 21 | ); |
| 19 | 22 | |
| 20 | expect(markup).toContain('class="sticky top-0 z-10'); | |
| 21 | expect(markup).not.toContain("self-start"); | |
| 23 | expect(markup).toContain('data-changed-files-state="expanded"'); | |
| 24 | expect(markup).toContain('aria-expanded="true"'); | |
| 22 | 25 | expect(markup).toContain("whitespace-nowrap"); |
| 23 | 26 | expect(markup).toContain("!size-[22px]"); |
| 24 | 27 | expect(markup).toContain("size-3"); |
| 25 | expect(markup).toContain('aria-label="Collapse all"'); | |
| 26 | expect(markup).toContain('aria-label="View diff"'); | |
| 28 | expect(markup).toContain('aria-label="Collapse all folders"'); | |
| 29 | expect(markup).toContain('aria-label="Open diff"'); | |
| 30 | expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); | |
| 27 | 31 | expect(markup).toContain("1 changed file"); |
| 28 | 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 | 95 | describe("ChangedFilesTree", () => { |
apps/web/src/components/chat/ChangedFilesTree.tsx+139-50| ... | ... | @@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; |
| 19 | 19 | import { PierreEntryIcon } from "./PierreEntryIcon"; |
| 20 | 20 | import { Button } from "../ui/button"; |
| 21 | 21 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| 22 | import { | |
| 23 | changedFileName, | |
| 24 | selectChangedFilePreview, | |
| 25 | summarizeChangedFileScopes, | |
| 26 | } from "./changedFilesPresentation"; | |
| 22 | 27 | |
| 23 | 28 | const EMPTY_DIRECTORY_OVERRIDES: Record<string, boolean> = {}; |
| 24 | 29 | |
| 25 | 30 | export const ChangedFilesCard = memo(function ChangedFilesCard(props: { |
| 26 | 31 | turnId: TurnId; |
| 27 | 32 | files: ReadonlyArray<TurnDiffFileChange>; |
| 33 | expanded: boolean; | |
| 34 | showCompactPreview: boolean; | |
| 28 | 35 | allDirectoriesExpanded: boolean; |
| 29 | 36 | resolvedTheme: "light" | "dark"; |
| 37 | onExpandedChange: (expanded: boolean) => void; | |
| 30 | 38 | onToggleAllDirectories: () => void; |
| 31 | 39 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 32 | 40 | }) { |
| 33 | 41 | const { |
| 34 | 42 | turnId, |
| 35 | 43 | files, |
| 44 | expanded, | |
| 45 | showCompactPreview, | |
| 36 | 46 | allDirectoriesExpanded, |
| 37 | 47 | resolvedTheme, |
| 48 | onExpandedChange, | |
| 38 | 49 | onToggleAllDirectories, |
| 39 | 50 | onOpenTurnDiff, |
| 40 | 51 | } = props; |
| 41 | 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 | 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"> | |
| 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))]"> | |
| 46 | <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4"> | |
| 47 | <span> | |
| 48 | {files.length} changed file{files.length === 1 ? "" : "s"} | |
| 58 | <div | |
| 59 | className="mt-4 rounded-2xl border border-border/70 bg-secondary p-2 dark:border-transparent dark:bg-input/32" | |
| 60 | data-changed-files-state={ | |
| 61 | expanded ? "expanded" : compactPreviewVisible ? "preview" : "collapsed" | |
| 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 | 97 | </span> |
| 50 | {hasNonZeroStat(summaryStat) && ( | |
| 51 | <DiffStatLabel | |
| 52 | additions={summaryStat.additions} | |
| 53 | className="text-xs leading-4" | |
| 54 | deletions={summaryStat.deletions} | |
| 55 | layout="inline" | |
| 56 | /> | |
| 57 | )} | |
| 58 | </p> | |
| 98 | <span className="ml-1 hidden truncate text-[11px] text-muted-foreground group-hover:text-foreground/80 sm:inline"> | |
| 99 | {expanded ? "Hide files" : "Show files"} | |
| 100 | </span> | |
| 101 | </button> | |
| 59 | 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 | 131 | <Tooltip> |
| 61 | 132 | <TooltipTrigger |
| 62 | 133 | render={ |
| 63 | 134 | <Button |
| 64 | 135 | type="button" |
| 65 | size="icon-xs" | |
| 66 | variant="outline" | |
| 67 | className="!size-[22px]" | |
| 68 | aria-label={allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 69 | data-scroll-anchor-ignore | |
| 70 | onClick={onToggleAllDirectories} | |
| 71 | /> | |
| 72 | } | |
| 73 | > | |
| 74 | {allDirectoriesExpanded ? ( | |
| 75 | <ChevronsDownUpIcon className="size-3" /> | |
| 76 | ) : ( | |
| 77 | <ChevronsUpDownIcon className="size-3" /> | |
| 78 | )} | |
| 79 | </TooltipTrigger> | |
| 80 | <TooltipPopup side="top"> | |
| 81 | {allDirectoriesExpanded ? "Collapse all" : "Expand all"} | |
| 82 | </TooltipPopup> | |
| 83 | </Tooltip> | |
| 84 | <Tooltip> | |
| 85 | <TooltipTrigger | |
| 86 | render={ | |
| 87 | <Button | |
| 88 | type="button" | |
| 89 | size="icon-xs" | |
| 136 | size="xs" | |
| 90 | 137 | variant="outline" |
| 91 | className="!size-[22px]" | |
| 92 | aria-label="View diff" | |
| 138 | aria-label="Open diff" | |
| 93 | 139 | onClick={() => onOpenTurnDiff(turnId, files[0]?.path)} |
| 94 | 140 | /> |
| 95 | 141 | } |
| 96 | 142 | > |
| 97 | 143 | <FileDiffIcon className="size-3" /> |
| 144 | <span className="hidden sm:inline">Open diff</span> | |
| 98 | 145 | </TooltipTrigger> |
| 99 | <TooltipPopup side="top">View diff</TooltipPopup> | |
| 146 | <TooltipPopup side="top">Open the full diff</TooltipPopup> | |
| 100 | 147 | </Tooltip> |
| 101 | 148 | </div> |
| 102 | 149 | </div> |
| 103 | <ChangedFilesTree | |
| 104 | key={`changed-files-tree:${turnId}`} | |
| 105 | turnId={turnId} | |
| 106 | files={files} | |
| 107 | allDirectoriesExpanded={allDirectoriesExpanded} | |
| 108 | resolvedTheme={resolvedTheme} | |
| 109 | onOpenTurnDiff={onOpenTurnDiff} | |
| 110 | /> | |
| 150 | {expanded ? ( | |
| 151 | <ChangedFilesTree | |
| 152 | key={`changed-files-tree:${turnId}`} | |
| 153 | turnId={turnId} | |
| 154 | files={files} | |
| 155 | allDirectoriesExpanded={allDirectoriesExpanded} | |
| 156 | resolvedTheme={resolvedTheme} | |
| 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 | 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 | 31 | <> |
| 32 | 32 | {showParentheses && <span className="text-muted-foreground/70">(</span>} |
| 33 | 33 | <span |
| 34 | role="group" | |
| 35 | aria-label={`${additions} additions, ${deletions} deletions`} | |
| 34 | 36 | className={cn( |
| 35 | 37 | layout === "inline" |
| 36 | 38 | ? "inline-flex items-center gap-1 tabular-nums align-middle" |
| ... | ... | @@ -38,8 +40,12 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { |
| 38 | 40 | className, |
| 39 | 41 | )} |
| 40 | 42 | > |
| 41 | <span className="font-mono text-success">+{formatCompactDiffCount(additions)}</span> | |
| 42 | <span className="font-mono text-destructive">-{formatCompactDiffCount(deletions)}</span> | |
| 43 | <span aria-hidden="true" className="font-mono text-success"> | |
| 44 | +{formatCompactDiffCount(additions)} | |
| 45 | </span> | |
| 46 | <span aria-hidden="true" className="font-mono text-destructive"> | |
| 47 | -{formatCompactDiffCount(deletions)} | |
| 48 | </span> | |
| 43 | 49 | </span> |
| 44 | 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 | 245 | const markup = renderToStaticMarkup( |
| 246 | 246 | <MessagesTimeline |
| 247 | 247 | {...buildProps()} |
| 248 | latestTurn={{ | |
| 249 | turnId, | |
| 250 | state: "completed", | |
| 251 | startedAt: MESSAGE_CREATED_AT, | |
| 252 | completedAt: MESSAGE_CREATED_AT, | |
| 253 | }} | |
| 248 | 254 | timelineEntries={[ |
| 249 | 255 | { |
| 250 | 256 | id: "entry-assistant-with-files", |
| ... | ... | @@ -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 | 290 | expect(markup).not.toContain("self-start"); |
| 285 | 291 | expect(markup).toContain("whitespace-nowrap"); |
| 286 | 292 | expect(markup).toContain("!size-[22px]"); |
| 287 | 293 | expect(markup).toContain("size-3"); |
| 288 | expect(markup).toContain('aria-label="Collapse all"'); | |
| 289 | expect(markup).toContain('aria-label="View diff"'); | |
| 294 | expect(markup).toContain('aria-label="Collapse all folders"'); | |
| 295 | expect(markup).toContain('aria-label="Open diff"'); | |
| 290 | 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 | 32 | workLogEntryIsToolLike, |
| 33 | 33 | } from "../../session-logic"; |
| 34 | 34 | import { type TurnDiffSummary } from "../../types"; |
| 35 | import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; | |
| 36 | 35 | import { |
| 37 | 36 | getRenderablePatch, |
| 38 | 37 | resolveDiffThemeName, |
| ... | ... | @@ -44,11 +43,8 @@ import { |
| 44 | 43 | CheckIcon, |
| 45 | 44 | ChevronDownIcon, |
| 46 | 45 | ChevronRightIcon, |
| 47 | ChevronsDownUpIcon, | |
| 48 | ChevronsUpDownIcon, | |
| 49 | 46 | CircleAlertIcon, |
| 50 | 47 | EyeIcon, |
| 51 | FileDiffIcon, | |
| 52 | 48 | GlobeIcon, |
| 53 | 49 | HammerIcon, |
| 54 | 50 | MessageCircleIcon, |
| ... | ... | @@ -65,8 +61,8 @@ import { |
| 65 | 61 | import { Button } from "../ui/button"; |
| 66 | 62 | import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; |
| 67 | 63 | import { ProposedPlanCard } from "./ProposedPlanCard"; |
| 68 | import { ChangedFilesTree } from "./ChangedFilesTree"; | |
| 69 | import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; | |
| 64 | import { ChangedFilesCard } from "./ChangedFilesTree"; | |
| 65 | import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; | |
| 70 | 66 | import { MessageCopyButton } from "./MessageCopyButton"; |
| 71 | 67 | import { |
| 72 | 68 | computeStableMessagesTimelineRows, |
| ... | ... | @@ -145,6 +141,7 @@ interface TimelineRowActivityState { |
| 145 | 141 | isWorking: boolean; |
| 146 | 142 | isRevertingCheckpoint: boolean; |
| 147 | 143 | activeTurnInProgress: boolean; |
| 144 | latestTurnId: TurnId | null; | |
| 148 | 145 | } |
| 149 | 146 | |
| 150 | 147 | const TimelineRowCtx = createContext<TimelineRowSharedState>(null!); |
| ... | ... | @@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 454 | 451 | isWorking, |
| 455 | 452 | isRevertingCheckpoint, |
| 456 | 453 | activeTurnInProgress, |
| 454 | latestTurnId: latestTurn?.turnId ?? null, | |
| 457 | 455 | }), |
| 458 | [activeTurnInProgress, isRevertingCheckpoint, isWorking], | |
| 456 | [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], | |
| 459 | 457 | ); |
| 460 | 458 | |
| 461 | 459 | // Stable renderItem — no closure deps. Row components read shared state |
| ... | ... | @@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({ |
| 1278 | 1276 | resolvedTheme: "light" | "dark"; |
| 1279 | 1277 | onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; |
| 1280 | 1278 | }) { |
| 1281 | const allDirectoriesExpanded = useUiStateStore( | |
| 1282 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, | |
| 1279 | const activity = use(TimelineRowActivityCtx); | |
| 1280 | const isLatestTurn = activity.latestTurnId === turnSummary.turnId; | |
| 1281 | const persistedExpanded = useUiStateStore( | |
| 1282 | (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], | |
| 1283 | 1283 | ); |
| 1284 | 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 | 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"> | |
| 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))]"> | |
| 1290 | <p className="flex items-center gap-1 whitespace-nowrap font-medium text-foreground text-xs leading-4"> | |
| 1291 | <span> | |
| 1292 | {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} | |
| 1293 | </span> | |
| 1294 | {hasNonZeroStat(summaryStat) && ( | |
| 1295 | <DiffStatLabel | |
| 1296 | additions={summaryStat.additions} | |
| 1297 | className="text-xs leading-4" | |
| 1298 | deletions={summaryStat.deletions} | |
| 1299 | layout="inline" | |
| 1300 | /> | |
| 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> | |
| 1292 | <ChangedFilesCard | |
| 1293 | turnId={turnSummary.turnId} | |
| 1294 | files={checkpointFiles} | |
| 1295 | expanded={expanded} | |
| 1296 | showCompactPreview={isLatestTurn} | |
| 1297 | allDirectoriesExpanded={allDirectoriesExpanded} | |
| 1298 | resolvedTheme={resolvedTheme} | |
| 1299 | onExpandedChange={(nextExpanded) => | |
| 1300 | setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) | |
| 1301 | } | |
| 1302 | onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} | |
| 1303 | onOpenTurnDiff={onOpenTurnDiff} | |
| 1304 | /> | |
| 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 | 116 | ); |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | it("stores only collapsed changed-file turns", () => { | |
| 119 | it("stores explicit changed-file expansion choices", () => { | |
| 120 | 120 | const threadId = ThreadId.make("thread-1"); |
| 121 | 121 | const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); |
| 122 | 122 | |
| ... | ... | @@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => { |
| 128 | 128 | expect( |
| 129 | 129 | setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) |
| 130 | 130 | .threadChangedFilesExpandedById, |
| 131 | ).toEqual({}); | |
| 131 | ).toEqual({ | |
| 132 | [threadId]: { | |
| 133 | "turn-1": true, | |
| 134 | }, | |
| 135 | }); | |
| 132 | 136 | }); |
| 133 | 137 | |
| 134 | 138 | it("stores the endpoint preference by stable key", () => { |
| ... | ... | @@ -155,6 +159,7 @@ describe("parsePersistedState", () => { |
| 155 | 159 | invalid: "not-a-date", |
| 156 | 160 | }, |
| 157 | 161 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 162 | threadChangedFilesExpansionVersion: 1, | |
| 158 | 163 | threadChangedFilesExpandedById: { |
| 159 | 164 | "environment:thread-1": { |
| 160 | 165 | "turn-1": false, |
| ... | ... | @@ -172,12 +177,25 @@ describe("parsePersistedState", () => { |
| 172 | 177 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 173 | 178 | }, |
| 174 | 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 | 191 | threadChangedFilesExpandedById: { |
| 176 | 192 | "environment:thread-1": { |
| 177 | 193 | "turn-1": false, |
| 178 | 194 | }, |
| 179 | 195 | }, |
| 180 | 196 | }); |
| 197 | ||
| 198 | expect(parsed.threadChangedFilesExpandedById).toEqual({}); | |
| 181 | 199 | }); |
| 182 | 200 | |
| 183 | 201 | it("migrates legacy CWD project preferences into local alias keys", () => { |
| ... | ... | @@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => { |
| 278 | 296 | "environment:thread-1": "2026-02-25T12:35:00.000Z", |
| 279 | 297 | }, |
| 280 | 298 | defaultAdvertisedEndpointKey: "desktop-core:lan:http", |
| 299 | threadChangedFilesExpansionVersion: 1, | |
| 281 | 300 | threadChangedFilesExpandedById: { |
| 282 | 301 | "environment:thread-1": { |
| 283 | 302 | "turn-1": false, |
| 303 | "turn-2": true, | |
| 284 | 304 | }, |
| 285 | 305 | }, |
| 286 | 306 | }); |
| 287 | 307 | expect(parsePersistedState(persisted)).toEqual({ |
| 288 | 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 | 3 | import { normalizeProjectPathForComparison } from "./lib/projectPaths"; |
| 4 | 4 | |
| 5 | 5 | export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; |
| 6 | const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1; | |
| 6 | 7 | const LEGACY_PERSISTED_STATE_KEYS = [ |
| 7 | 8 | "t3code:renderer-state:v8", |
| 8 | 9 | "t3code:renderer-state:v7", |
| ... | ... | @@ -24,6 +25,7 @@ export interface PersistedUiState { |
| 24 | 25 | expandedProjectCwds?: string[]; |
| 25 | 26 | projectOrderCwds?: string[]; |
| 26 | 27 | defaultAdvertisedEndpointKey?: string | null; |
| 28 | threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; | |
| 27 | 29 | threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>; |
| 28 | 30 | } |
| 29 | 31 | |
| ... | ... | @@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { |
| 124 | 126 | projectExpandedById, |
| 125 | 127 | projectOrder, |
| 126 | 128 | threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), |
| 127 | threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( | |
| 128 | parsed.threadChangedFilesExpandedById, | |
| 129 | ), | |
| 129 | threadChangedFilesExpandedById: | |
| 130 | parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION | |
| 131 | ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) | |
| 132 | : {}, | |
| 130 | 133 | defaultAdvertisedEndpointKey: |
| 131 | 134 | typeof parsed.defaultAdvertisedEndpointKey === "string" && |
| 132 | 135 | parsed.defaultAdvertisedEndpointKey.length > 0 |
| ... | ... | @@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded( |
| 172 | 175 | |
| 173 | 176 | const nextTurns: Record<string, boolean> = {}; |
| 174 | 177 | for (const [turnId, expanded] of Object.entries(turns)) { |
| 175 | if (turnId && typeof expanded === "boolean" && expanded === false) { | |
| 176 | nextTurns[turnId] = false; | |
| 178 | if (turnId && typeof expanded === "boolean") { | |
| 179 | nextTurns[turnId] = expanded; | |
| 177 | 180 | } |
| 178 | 181 | } |
| 179 | 182 | |
| ... | ... | @@ -195,14 +198,6 @@ export function persistState(state: UiState): void { |
| 195 | 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 | 201 | window.localStorage.setItem( |
| 207 | 202 | PERSISTED_STATE_KEY, |
| 208 | 203 | JSON.stringify({ |
| ... | ... | @@ -210,7 +205,8 @@ export function persistState(state: UiState): void { |
| 210 | 205 | projectOrder: state.projectOrder, |
| 211 | 206 | threadLastVisitedAtById: state.threadLastVisitedAtById, |
| 212 | 207 | defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, |
| 213 | threadChangedFilesExpandedById, | |
| 208 | threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, | |
| 209 | threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, | |
| 214 | 210 | } satisfies PersistedUiState), |
| 215 | 211 | ); |
| 216 | 212 | if (!legacyKeysCleanedUp) { |
| ... | ... | @@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded( |
| 281 | 277 | expanded: boolean, |
| 282 | 278 | ): UiState { |
| 283 | 279 | const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; |
| 284 | const currentExpanded = currentThreadState[turnId] ?? true; | |
| 285 | if (currentExpanded === expanded) { | |
| 280 | if (currentThreadState[turnId] === expanded) { | |
| 286 | 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 | 284 | return { |
| 315 | 285 | ...state, |
| 316 | 286 | threadChangedFilesExpandedById: { |
| 317 | 287 | ...state.threadChangedFilesExpandedById, |
| 318 | 288 | [threadId]: { |
| 319 | 289 | ...currentThreadState, |
| 320 | [turnId]: false, | |
| 290 | [turnId]: expanded, | |
| 321 | 291 | }, |
| 322 | 292 | }, |
| 323 | 293 | }; |