diff --git a/apps/web/src/components/chat/ChangedFilesTree.test.tsx b/apps/web/src/components/chat/ChangedFilesTree.test.tsx index 120827b34b2..ec5875682fc 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.test.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.test.tsx @@ -10,23 +10,86 @@ describe("ChangedFilesCard", () => { {}} onToggleAllDirectories={() => {}} onOpenTurnDiff={() => {}} />, ); - expect(markup).toContain('class="sticky top-0 z-10'); - expect(markup).not.toContain("self-start"); + expect(markup).toContain('data-changed-files-state="expanded"'); + expect(markup).toContain('aria-expanded="true"'); expect(markup).toContain("whitespace-nowrap"); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); - expect(markup).toContain('aria-label="Collapse all"'); - expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain('aria-label="Collapse all folders"'); + expect(markup).toContain('aria-label="Open diff"'); + expect(markup).toContain('role="group" aria-label="2 additions, 1 deletions"'); expect(markup).toContain("1 changed file"); expect(markup).not.toContain("1 changed files"); }); + + it("renders a scope and representative-file preview for a large latest change", () => { + const markup = renderToStaticMarkup( + {}} + onToggleAllDirectories={() => {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('data-changed-files-state="preview"'); + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("apps"); + expect(markup).toContain("2 files"); + expect(markup).toContain("packages"); + expect(markup).toContain("root"); + expect(markup).toContain("App.tsx"); + expect(markup).toContain("git.ts"); + expect(markup).toContain("README.md"); + expect(markup).toContain("Show all 4 files"); + expect(markup).not.toContain("App.test.tsx"); + }); + + it("keeps older collapsed changes to a one-line receipt", () => { + const markup = renderToStaticMarkup( + {}} + onToggleAllDirectories={() => {}} + onOpenTurnDiff={() => {}} + />, + ); + + expect(markup).toContain('data-changed-files-state="collapsed"'); + expect(markup).toContain("1 changed file"); + expect(markup).not.toContain("Show all"); + expect(markup).not.toContain("App.tsx"); + }); }); describe("ChangedFilesTree", () => { diff --git a/apps/web/src/components/chat/ChangedFilesTree.tsx b/apps/web/src/components/chat/ChangedFilesTree.tsx index 2a357f9a47d..3a705eef36d 100644 --- a/apps/web/src/components/chat/ChangedFilesTree.tsx +++ b/apps/web/src/components/chat/ChangedFilesTree.tsx @@ -19,95 +19,184 @@ import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { + changedFileName, + selectChangedFilePreview, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; const EMPTY_DIRECTORY_OVERRIDES: Record = {}; export const ChangedFilesCard = memo(function ChangedFilesCard(props: { turnId: TurnId; files: ReadonlyArray; + expanded: boolean; + showCompactPreview: boolean; allDirectoriesExpanded: boolean; resolvedTheme: "light" | "dark"; + onExpandedChange: (expanded: boolean) => void; onToggleAllDirectories: () => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const { turnId, files, + expanded, + showCompactPreview, allDirectoriesExpanded, resolvedTheme, + onExpandedChange, onToggleAllDirectories, onOpenTurnDiff, } = props; const summaryStat = useMemo(() => summarizeTurnDiffStats(files), [files]); + const scopeSummary = useMemo(() => summarizeChangedFileScopes(files), [files]); + const previewFiles = useMemo(() => selectChangedFilePreview(files), [files]); + const compactPreviewVisible = showCompactPreview && !expanded; return ( -
-
-

- - {files.length} changed file{files.length === 1 ? "" : "s"} +

+
+
+ {expanded ? ( + + + } + > + {allDirectoriesExpanded ? ( + + ) : ( + + )} + + + {allDirectoriesExpanded ? "Collapse all folders" : "Expand all folders"} + + + ) : null} - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all" : "Expand all"} - - - - onOpenTurnDiff(turnId, files[0]?.path)} /> } > + Open diff - View diff + Open the full diff
- + {expanded ? ( + + ) : compactPreviewVisible ? ( +
+

+ {scopeSummary.map((scope, index) => ( + + {index > 0 ? : null} + {scope.label} + + {scope.fileCount} file{scope.fileCount === 1 ? "" : "s"} + + + ))} +

+
+ {previewFiles.map((file) => ( + + ))} + +
+
+ ) : null}
); }); diff --git a/apps/web/src/components/chat/DiffStatLabel.tsx b/apps/web/src/components/chat/DiffStatLabel.tsx index 8fd7e90e381..6787a8df196 100644 --- a/apps/web/src/components/chat/DiffStatLabel.tsx +++ b/apps/web/src/components/chat/DiffStatLabel.tsx @@ -31,6 +31,8 @@ export const DiffStatLabel = memo(function DiffStatLabel(props: { <> {showParentheses && (} - +{formatCompactDiffCount(additions)} - -{formatCompactDiffCount(deletions)} + + {showParentheses && )} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c0b41527fa5..296a54457a2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -245,6 +245,12 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('class="sticky top-2 z-10'); + expect(markup).toContain("sticky top-2 z-10"); expect(markup).not.toContain("self-start"); expect(markup).toContain("whitespace-nowrap"); expect(markup).toContain("!size-[22px]"); expect(markup).toContain("size-3"); - expect(markup).toContain('aria-label="Collapse all"'); - expect(markup).toContain('aria-label="View diff"'); + expect(markup).toContain('aria-label="Collapse all folders"'); + expect(markup).toContain('aria-label="Open diff"'); expect(markup).toContain("1 changed file"); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 225d2a53177..ae190e00068 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -32,7 +32,6 @@ import { workLogEntryIsToolLike, } from "../../session-logic"; import { type TurnDiffSummary } from "../../types"; -import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; import { getRenderablePatch, resolveDiffThemeName, @@ -44,11 +43,8 @@ import { CheckIcon, ChevronDownIcon, ChevronRightIcon, - ChevronsDownUpIcon, - ChevronsUpDownIcon, CircleAlertIcon, EyeIcon, - FileDiffIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -65,8 +61,8 @@ import { import { Button } from "../ui/button"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./ExpandedImagePreview"; import { ProposedPlanCard } from "./ProposedPlanCard"; -import { ChangedFilesTree } from "./ChangedFilesTree"; -import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; +import { ChangedFilesCard } from "./ChangedFilesTree"; +import { shouldAutoExpandChangedFiles } from "./changedFilesPresentation"; import { MessageCopyButton } from "./MessageCopyButton"; import { computeStableMessagesTimelineRows, @@ -145,6 +141,7 @@ interface TimelineRowActivityState { isWorking: boolean; isRevertingCheckpoint: boolean; activeTurnInProgress: boolean; + latestTurnId: TurnId | null; } const TimelineRowCtx = createContext(null!); @@ -454,8 +451,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, isRevertingCheckpoint, activeTurnInProgress, + latestTurnId: latestTurn?.turnId ?? null, }), - [activeTurnInProgress, isRevertingCheckpoint, isWorking], + [activeTurnInProgress, isRevertingCheckpoint, isWorking, latestTurn?.turnId], ); // Stable renderItem — no closure deps. Row components read shared state @@ -1278,83 +1276,32 @@ function AssistantChangedFilesSectionInner({ resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const allDirectoriesExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + const activity = use(TimelineRowActivityCtx); + const isLatestTurn = activity.latestTurnId === turnSummary.turnId; + const persistedExpanded = useUiStateStore( + (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId], ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); - const summaryStat = summarizeTurnDiffStats(checkpointFiles); + const [autoExpanded] = useState(() => + shouldAutoExpandChangedFiles(checkpointFiles, isLatestTurn), + ); + const [allDirectoriesExpanded, setAllDirectoriesExpanded] = useState(autoExpanded); + const expanded = persistedExpanded ?? (isLatestTurn && autoExpanded); return ( -
-
-

- - {checkpointFiles.length} changed file{checkpointFiles.length === 1 ? "" : "s"} - - {hasNonZeroStat(summaryStat) && ( - - )} -

-
- - - setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded) - } - /> - } - > - {allDirectoriesExpanded ? ( - - ) : ( - - )} - - - {allDirectoriesExpanded ? "Collapse all" : "Expand all"} - - - - onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} - /> - } - > - - - View diff - -
-
- -
+ + setExpanded(routeThreadKey, turnSummary.turnId, nextExpanded) + } + onToggleAllDirectories={() => setAllDirectoriesExpanded((current) => !current)} + onOpenTurnDiff={onOpenTurnDiff} + /> ); } diff --git a/apps/web/src/components/chat/changedFilesPresentation.test.ts b/apps/web/src/components/chat/changedFilesPresentation.test.ts new file mode 100644 index 00000000000..d13445b0226 --- /dev/null +++ b/apps/web/src/components/chat/changedFilesPresentation.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + changedFileName, + selectChangedFilePreview, + shouldAutoExpandChangedFiles, + summarizeChangedFileScopes, +} from "./changedFilesPresentation"; + +describe("changed-files presentation", () => { + it("auto-expands only small, low-churn latest changes", () => { + const smallFiles = [ + { path: "src/a.ts", kind: "modified", additions: 80, deletions: 20 }, + { path: "src/b.ts", kind: "modified", additions: 60, deletions: 20 }, + ]; + + expect(shouldAutoExpandChangedFiles(smallFiles, true)).toBe(true); + expect(shouldAutoExpandChangedFiles(smallFiles, false)).toBe(false); + expect( + shouldAutoExpandChangedFiles( + [{ path: "src/a.ts", kind: "modified", additions: 201, deletions: 0 }], + true, + ), + ).toBe(false); + expect( + shouldAutoExpandChangedFiles( + Array.from({ length: 6 }, (_, index) => ({ + path: `src/${index}.ts`, + kind: "modified", + additions: 1, + deletions: 0, + })), + true, + ), + ).toBe(false); + }); + + it("summarizes the most prominent top-level scopes", () => { + const files = [ + { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps/server/src/index.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps\\mobile\\App.tsx", kind: "modified", additions: 1, deletions: 0 }, + ]; + + expect(summarizeChangedFileScopes(files)).toEqual([ + { label: "apps", fileCount: 3 }, + { label: "root", fileCount: 1 }, + { label: "packages", fileCount: 1 }, + ]); + }); + + it("previews files across different scopes before filling from one scope", () => { + const files = [ + { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, + { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 1, deletions: 0 }, + { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, + { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, + ]; + + expect(selectChangedFilePreview(files).map((file) => file.path)).toEqual([ + "apps/web/src/App.tsx", + "packages/shared/src/git.ts", + "README.md", + ]); + expect(changedFileName("apps\\web\\src\\App.tsx")).toBe("App.tsx"); + }); +}); diff --git a/apps/web/src/components/chat/changedFilesPresentation.ts b/apps/web/src/components/chat/changedFilesPresentation.ts new file mode 100644 index 00000000000..bb3cac6c4b1 --- /dev/null +++ b/apps/web/src/components/chat/changedFilesPresentation.ts @@ -0,0 +1,102 @@ +import { type TurnDiffFileChange } from "../../types"; +import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; + +export const CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT = 5; +export const CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT = 200; +export const CHANGED_FILES_PREVIEW_FILE_LIMIT = 3; +export const CHANGED_FILES_PREVIEW_SCOPE_LIMIT = 4; + +export interface ChangedFilesScopeSummary { + readonly label: string; + readonly fileCount: number; +} + +function pathSegments(pathValue: string): string[] { + return pathValue + .replaceAll("\\", "/") + .split("/") + .filter((segment) => segment.length > 0); +} + +export function changedFileName(pathValue: string): string { + return pathSegments(pathValue).at(-1) ?? pathValue; +} + +function changedFileScope(pathValue: string): string { + const segments = pathSegments(pathValue); + return segments.length > 1 ? (segments[0] ?? "root") : "root"; +} + +export function shouldAutoExpandChangedFiles( + files: ReadonlyArray, + isLatestTurn: boolean, +): boolean { + if (!isLatestTurn || files.length > CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT) { + return false; + } + const stat = summarizeTurnDiffStats(files); + return stat.additions + stat.deletions <= CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT; +} + +export function summarizeChangedFileScopes( + files: ReadonlyArray, + limit = CHANGED_FILES_PREVIEW_SCOPE_LIMIT, +): ChangedFilesScopeSummary[] { + const scopes = new Map(); + files.forEach((file, index) => { + const label = changedFileScope(file.path); + const current = scopes.get(label); + scopes.set(label, { + fileCount: (current?.fileCount ?? 0) + 1, + firstIndex: current?.firstIndex ?? index, + }); + }); + + return Array.from(scopes, ([label, scope]) => ({ + label, + fileCount: scope.fileCount, + firstIndex: scope.firstIndex, + })) + .toSorted( + (left, right) => + right.fileCount - left.fileCount || + left.firstIndex - right.firstIndex || + left.label.localeCompare(right.label), + ) + .slice(0, limit) + .map(({ label, fileCount }) => ({ label, fileCount })); +} + +export function selectChangedFilePreview( + files: ReadonlyArray, + limit = CHANGED_FILES_PREVIEW_FILE_LIMIT, +): TurnDiffFileChange[] { + const selected: TurnDiffFileChange[] = []; + const selectedPaths = new Set(); + const selectedScopes = new Set(); + + for (const file of files) { + const scope = changedFileScope(file.path); + if (selectedScopes.has(scope)) { + continue; + } + selected.push(file); + selectedPaths.add(file.path); + selectedScopes.add(scope); + if (selected.length === limit) { + return selected; + } + } + + for (const file of files) { + if (selectedPaths.has(file.path)) { + continue; + } + selected.push(file); + if (selected.length === limit) { + break; + } + } + + return selected; +} diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 0fbbd79ec27..30450287353 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -116,7 +116,7 @@ describe("uiStateStore pure functions", () => { ); }); - it("stores only collapsed changed-file turns", () => { + it("stores explicit changed-file expansion choices", () => { const threadId = ThreadId.make("thread-1"); const collapsed = setThreadChangedFilesExpanded(makeUiState(), threadId, "turn-1", false); @@ -128,7 +128,11 @@ describe("uiStateStore pure functions", () => { expect( setThreadChangedFilesExpanded(collapsed, threadId, "turn-1", true) .threadChangedFilesExpandedById, - ).toEqual({}); + ).toEqual({ + [threadId]: { + "turn-1": true, + }, + }); }); it("stores the endpoint preference by stable key", () => { @@ -155,6 +159,7 @@ describe("parsePersistedState", () => { invalid: "not-a-date", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, @@ -172,12 +177,25 @@ describe("parsePersistedState", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpandedById: { + "environment:thread-1": { + "turn-1": false, + "turn-2": true, + }, + }, + }); + }); + + it("ignores changed-file expansion values saved with legacy folder semantics", () => { + const parsed = parsePersistedState({ threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, }, }, }); + + expect(parsed.threadChangedFilesExpandedById).toEqual({}); }); it("migrates legacy CWD project preferences into local alias keys", () => { @@ -278,19 +296,16 @@ describe("uiStateStore persistence", () => { "environment:thread-1": "2026-02-25T12:35:00.000Z", }, defaultAdvertisedEndpointKey: "desktop-core:lan:http", + threadChangedFilesExpansionVersion: 1, threadChangedFilesExpandedById: { "environment:thread-1": { "turn-1": false, + "turn-2": true, }, }, }); expect(parsePersistedState(persisted)).toEqual({ ...state, - threadChangedFilesExpandedById: { - "environment:thread-1": { - "turn-1": false, - }, - }, }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 4a97f0542b4..5d744d540a5 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -3,6 +3,7 @@ import { create } from "zustand"; import { normalizeProjectPathForComparison } from "./lib/projectPaths"; export const PERSISTED_STATE_KEY = "t3code:ui-state:v1"; +const THREAD_CHANGED_FILES_EXPANSION_VERSION = 1; const LEGACY_PERSISTED_STATE_KEYS = [ "t3code:renderer-state:v8", "t3code:renderer-state:v7", @@ -24,6 +25,7 @@ export interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; + threadChangedFilesExpansionVersion?: typeof THREAD_CHANGED_FILES_EXPANSION_VERSION; threadChangedFilesExpandedById?: Record>; } @@ -124,9 +126,10 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { projectExpandedById, projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), - threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( - parsed.threadChangedFilesExpandedById, - ), + threadChangedFilesExpandedById: + parsed.threadChangedFilesExpansionVersion === THREAD_CHANGED_FILES_EXPANSION_VERSION + ? sanitizePersistedThreadChangedFilesExpanded(parsed.threadChangedFilesExpandedById) + : {}, defaultAdvertisedEndpointKey: typeof parsed.defaultAdvertisedEndpointKey === "string" && parsed.defaultAdvertisedEndpointKey.length > 0 @@ -172,8 +175,8 @@ function sanitizePersistedThreadChangedFilesExpanded( const nextTurns: Record = {}; for (const [turnId, expanded] of Object.entries(turns)) { - if (turnId && typeof expanded === "boolean" && expanded === false) { - nextTurns[turnId] = false; + if (turnId && typeof expanded === "boolean") { + nextTurns[turnId] = expanded; } } @@ -195,14 +198,6 @@ export function persistState(state: UiState): void { ([key]) => key !== LEGACY_PROJECT_EXPANSION_DEFAULT_KEY, ), ); - const threadChangedFilesExpandedById = Object.fromEntries( - Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { - const nextTurns = Object.fromEntries( - Object.entries(turns).filter(([, expanded]) => expanded === false), - ); - return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; - }), - ); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ @@ -210,7 +205,8 @@ export function persistState(state: UiState): void { projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, - threadChangedFilesExpandedById, + threadChangedFilesExpansionVersion: THREAD_CHANGED_FILES_EXPANSION_VERSION, + threadChangedFilesExpandedById: state.threadChangedFilesExpandedById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -281,43 +277,17 @@ export function setThreadChangedFilesExpanded( expanded: boolean, ): UiState { const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; - const currentExpanded = currentThreadState[turnId] ?? true; - if (currentExpanded === expanded) { + if (currentThreadState[turnId] === expanded) { return state; } - if (expanded) { - if (!(turnId in currentThreadState)) { - return state; - } - - const nextThreadState = { ...currentThreadState }; - delete nextThreadState[turnId]; - if (Object.keys(nextThreadState).length === 0) { - const nextState = { ...state.threadChangedFilesExpandedById }; - delete nextState[threadId]; - return { - ...state, - threadChangedFilesExpandedById: nextState, - }; - } - - return { - ...state, - threadChangedFilesExpandedById: { - ...state.threadChangedFilesExpandedById, - [threadId]: nextThreadState, - }, - }; - } - return { ...state, threadChangedFilesExpandedById: { ...state.threadChangedFilesExpandedById, [threadId]: { ...currentThreadState, - [turnId]: false, + [turnId]: expanded, }, }, };