From d03f17731d0e27e5f9437d46d2434415e35b47f8 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 23 Apr 2026 15:02:45 +0300 Subject: [PATCH 1/3] fix(web): make changed-files default state configurable --- apps/desktop/src/clientPersistence.test.ts | 1 + apps/web/src/components/ChatView.tsx | 2 + .../chat/MessagesTimeline.browser.tsx | 174 +++++++++++++++- .../components/chat/MessagesTimeline.test.tsx | 1 + .../src/components/chat/MessagesTimeline.tsx | 21 +- .../components/settings/SettingsPanels.tsx | 31 +++ apps/web/src/localApi.test.ts | 2 + apps/web/src/uiStateStore.test.ts | 194 ++++++++++++++---- apps/web/src/uiStateStore.ts | 163 ++++++++------- packages/contracts/src/settings.ts | 2 + 10 files changed, 471 insertions(+), 120 deletions(-) diff --git a/apps/desktop/src/clientPersistence.test.ts b/apps/desktop/src/clientPersistence.test.ts index 192d7ac1064..e8e0e6f5884 100644 --- a/apps/desktop/src/clientPersistence.test.ts +++ b/apps/desktop/src/clientPersistence.test.ts @@ -52,6 +52,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultOpenChangedFiles: true, diffWordWrap: true, favorites: [], sidebarProjectGroupingMode: "repository_path", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c27afda975b..94cca6c25c9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -617,6 +617,7 @@ export default function ChatView(props: ChatViewProps) { ); const timestampFormat = settings.timestampFormat; const autoOpenPlanSidebar = settings.autoOpenPlanSidebar; + const defaultOpenChangedFiles = settings.defaultOpenChangedFiles; const navigate = useNavigate(); const rawSearch = useSearch({ strict: false, @@ -3310,6 +3311,7 @@ export default function ChatView(props: ChatViewProps) { markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} + defaultOpenChangedFiles={defaultOpenChangedFiles} workspaceRoot={activeWorkspaceRoot} onIsAtEndChange={onIsAtEndChange} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index 0eb5c8a1fc0..c975b015939 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -1,12 +1,14 @@ import "../../index.css"; -import { EnvironmentId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, TurnId } from "@t3tools/contracts"; import { createRef } from "react"; import type { LegendListRef } from "@legendapp/list/react"; import { page } from "vitest/browser"; import { afterEach, describe, expect, it, vi } from "vitest"; import { render } from "vitest-browser-react"; +import { useUiStateStore } from "../../uiStateStore"; + const scrollToEndSpy = vi.fn(); const getStateSpy = vi.fn(() => ({ isAtEnd: true })); @@ -68,6 +70,7 @@ function buildProps() { markdownCwd: undefined, resolvedTheme: "dark" as const, timestampFormat: "24-hour" as const, + defaultOpenChangedFiles: true, workspaceRoot: undefined, onIsAtEndChange: vi.fn(), }; @@ -78,6 +81,13 @@ describe("MessagesTimeline", () => { scrollToEndSpy.mockReset(); getStateSpy.mockClear(); vi.restoreAllMocks(); + localStorage.clear(); + useUiStateStore.setState({ + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, + }); document.body.innerHTML = ""; }); @@ -157,4 +167,166 @@ describe("MessagesTimeline", () => { await screen.unmount(); } }); + + it("keeps changed-files collapsed for future turns in the same thread after a manual collapse", async () => { + const props = buildProps(); + const firstAssistantMessageId = MessageId.make("assistant-1"); + const secondAssistantMessageId = MessageId.make("assistant-2"); + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const firstTimelineEntry = { + id: "assistant-entry-1", + kind: "message" as const, + createdAt: "2026-04-13T12:00:00.000Z", + message: { + id: firstAssistantMessageId, + role: "assistant" as const, + text: "Updated files in the first turn", + turnId: firstTurnId, + createdAt: "2026-04-13T12:00:00.000Z", + completedAt: "2026-04-13T12:00:05.000Z", + streaming: false, + }, + }; + const secondTimelineEntry = { + id: "assistant-entry-2", + kind: "message" as const, + createdAt: "2026-04-13T12:01:00.000Z", + message: { + id: secondAssistantMessageId, + role: "assistant" as const, + text: "Updated files in the second turn", + turnId: secondTurnId, + createdAt: "2026-04-13T12:01:00.000Z", + completedAt: "2026-04-13T12:01:05.000Z", + streaming: false, + }, + }; + const screen = await render( + , + ); + + try { + const collapseButton = page.getByRole("button", { name: "Collapse all" }); + await expect.element(collapseButton).toBeVisible(); + await collapseButton.click(); + + await vi.waitFor( + () => { + const expandAllButtons = [...document.querySelectorAll("button")].filter( + (button) => button.textContent?.trim() === "Expand all", + ); + expect(expandAllButtons).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + await screen.rerender( + , + ); + + await vi.waitFor( + () => { + const buttonLabels = [...document.querySelectorAll("button")].map((button) => + button.textContent?.trim(), + ); + expect(buttonLabels.filter((label) => label === "Expand all")).toHaveLength(2); + expect(buttonLabels).not.toContain("Collapse all"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await screen.unmount(); + } + }); + + it("starts changed-files collapsed when the default is disabled", async () => { + const props = { + ...buildProps(), + defaultOpenChangedFiles: false, + }; + const assistantMessageId = MessageId.make("assistant-default-closed"); + const turnId = TurnId.make("turn-default-closed"); + const screen = await render( + , + ); + + try { + await expect.element(page.getByRole("button", { name: "Expand all" })).toBeVisible(); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index a8a53831a2c..18573f8600f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -94,6 +94,7 @@ function buildProps() { markdownCwd: undefined, resolvedTheme: "light" as const, timestampFormat: "locale" as const, + defaultOpenChangedFiles: true, workspaceRoot: undefined, onIsAtEndChange: () => {}, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e4b683592ed..bf91e082555 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -51,7 +51,7 @@ import { type ParsedTerminalContextEntry, } from "~/lib/terminalContext"; import { cn } from "~/lib/utils"; -import { useUiStateStore } from "~/uiStateStore"; +import { getThreadChangedFilesExpanded, useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; @@ -77,6 +77,7 @@ interface TimelineRowSharedState { completionSummary: string | null; timestampFormat: TimestampFormat; routeThreadKey: string; + defaultOpenChangedFiles: boolean; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; @@ -112,6 +113,7 @@ interface MessagesTimelineProps { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; + defaultOpenChangedFiles: boolean; workspaceRoot: string | undefined; onIsAtEndChange: (isAtEnd: boolean) => void; } @@ -140,6 +142,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ markdownCwd, resolvedTheme, timestampFormat, + defaultOpenChangedFiles, workspaceRoot, onIsAtEndChange, }: MessagesTimelineProps) { @@ -200,6 +203,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ completionSummary, timestampFormat, routeThreadKey, + defaultOpenChangedFiles, markdownCwd, resolvedTheme, workspaceRoot, @@ -216,6 +220,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ completionSummary, timestampFormat, routeThreadKey, + defaultOpenChangedFiles, markdownCwd, resolvedTheme, workspaceRoot, @@ -410,6 +415,7 @@ function TimelineRowContent({ row }: { row: TimelineRow }) { @@ -577,11 +583,13 @@ const WorkGroupSection = memo(function WorkGroupSection({ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({ turnSummary, routeThreadKey, + defaultOpenChangedFiles, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary | undefined; routeThreadKey: string; + defaultOpenChangedFiles: boolean; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { @@ -594,6 +602,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( turnSummary={turnSummary} checkpointFiles={checkpointFiles} routeThreadKey={routeThreadKey} + defaultOpenChangedFiles={defaultOpenChangedFiles} resolvedTheme={resolvedTheme} onOpenTurnDiff={onOpenTurnDiff} /> @@ -606,17 +615,19 @@ function AssistantChangedFilesSectionInner({ turnSummary, checkpointFiles, routeThreadKey, + defaultOpenChangedFiles, resolvedTheme, onOpenTurnDiff, }: { turnSummary: TurnDiffSummary; checkpointFiles: TurnDiffSummary["files"]; routeThreadKey: string; + defaultOpenChangedFiles: boolean; resolvedTheme: "light" | "dark"; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { - const allDirectoriesExpanded = useUiStateStore( - (store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true, + const allDirectoriesExpanded = useUiStateStore((store) => + getThreadChangedFilesExpanded(store, routeThreadKey, defaultOpenChangedFiles), ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); @@ -640,7 +651,9 @@ function AssistantChangedFilesSectionInner({ size="xs" variant="outline" data-scroll-anchor-ignore - onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)} + onClick={() => + setExpanded(routeThreadKey, !allDirectoriesExpanded, defaultOpenChangedFiles) + } > {allDirectoriesExpanded ? "Collapse all" : "Expand all"} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 29d09a5cdce..3419a264a8a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -469,6 +469,9 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.timestampFormat !== DEFAULT_UNIFIED_SETTINGS.timestampFormat ? ["Time format"] : []), + ...(settings.defaultOpenChangedFiles !== DEFAULT_UNIFIED_SETTINGS.defaultOpenChangedFiles + ? ["Changed-files default state"] + : []), ...(settings.diffWordWrap !== DEFAULT_UNIFIED_SETTINGS.diffWordWrap ? ["Diff line wrapping"] : []), @@ -500,6 +503,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.defaultOpenChangedFiles, settings.defaultThreadEnvMode, settings.diffWordWrap, settings.enableAssistantStreaming, @@ -898,6 +902,33 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + defaultOpenChangedFiles: DEFAULT_UNIFIED_SETTINGS.defaultOpenChangedFiles, + }) + } + /> + ) : null + } + control={ + + updateSettings({ defaultOpenChangedFiles: Boolean(checked) }) + } + aria-label="Open changed files by default" + /> + } + /> + { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultOpenChangedFiles: true, diffWordWrap: true, favorites: [], sidebarProjectGroupingMode: "repository_path" as const, @@ -591,6 +592,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultOpenChangedFiles: true, diffWordWrap: true, favorites: [], sidebarProjectGroupingMode: "repository_path" as const, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index f63208d9ba7..897310974e8 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearThreadUi, + getThreadChangedFilesExpanded, hydratePersistedProjectState, markThreadUnread, PERSISTED_STATE_KEY, @@ -14,8 +15,29 @@ import { syncProjects, syncThreads, type UiState, + useUiStateStore, } from "./uiStateStore"; +function createLocalStorageStub(): Storage { + const store = new Map(); + return { + clear: () => { + store.clear(); + }, + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + }; +} + function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, @@ -308,12 +330,8 @@ describe("uiStateStore pure functions", () => { [thread2]: "2026-02-25T12:36:00.000Z", }, threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, - [thread2]: { - "turn-2": false, - }, + [thread1]: false, + [thread2]: false, }, }); @@ -323,9 +341,7 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", }); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, + [thread1]: false, }); }); @@ -367,9 +383,7 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", }, threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, + [thread1]: false, }, }); @@ -379,16 +393,14 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { + it("setThreadChangedFilesExpanded stores collapsed state per thread", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState(); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); + const next = setThreadChangedFilesExpanded(initialState, thread1, false, true); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, + [thread1]: false, }); }); @@ -396,39 +408,43 @@ describe("uiStateStore pure functions", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, + [thread1]: false, }, }); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); + const next = setThreadChangedFilesExpanded(initialState, thread1, true, true); expect(next.threadChangedFilesExpandedById).toEqual({}); }); -}); -describe("uiStateStore persistence round-trip", () => { - function createLocalStorageStub(): Storage { - const store = new Map(); - return { - clear: () => { - store.clear(); - }, - getItem: (key) => store.get(key) ?? null, - key: (index) => [...store.keys()][index] ?? null, - get length() { - return store.size; - }, - removeItem: (key) => { - store.delete(key); - }, - setItem: (key, value) => { - store.set(key, value); + it("stores an explicit expanded override when changed-files default to collapsed", () => { + const thread1 = ThreadId.make("thread-1"); + const initialState = makeUiState(); + + const next = setThreadChangedFilesExpanded(initialState, thread1, true, false); + + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: true, + }); + expect(getThreadChangedFilesExpanded(next, thread1, false)).toBe(true); + }); + + it("drops an override when toggled back to the default changed-files state", () => { + const thread1 = ThreadId.make("thread-1"); + const initialState = makeUiState({ + threadChangedFilesExpandedById: { + [thread1]: true, }, - }; - } + }); + const next = setThreadChangedFilesExpanded(initialState, thread1, false, false); + + expect(next.threadChangedFilesExpandedById).toEqual({}); + expect(getThreadChangedFilesExpanded(next, thread1, false)).toBe(false); + }); +}); + +describe("uiStateStore persistence round-trip", () => { let localStorageStub: Storage; beforeEach(() => { @@ -559,4 +575,100 @@ describe("uiStateStore persistence round-trip", () => { expect(rehydrated.projectExpandedById[nextLogicalKey]).toBe(false); }); + it("persists project collapse immediately when using the store actions", () => { + const projectA = { key: "kA", logicalKey: "kA", cwd: "/projA" }; + const projectB = { key: "kB", logicalKey: "kB", cwd: "/projB" }; + + useUiStateStore.setState(makeUiState()); + useUiStateStore.getState().syncProjects([projectA, projectB]); + + localStorageStub.clear(); + useUiStateStore.getState().setProjectExpanded(projectB.logicalKey, false); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(persisted.collapsedProjectCwds).toEqual([projectB.cwd]); + expect(persisted.expandedProjectCwds).toEqual([projectA.cwd]); + }); + + it("persists changed-files collapse immediately when using the store actions", () => { + const threadKey = "environment-local:thread-1"; + + useUiStateStore.setState(makeUiState()); + + localStorageStub.clear(); + useUiStateStore.getState().setThreadChangedFilesExpanded(threadKey, false, true); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(persisted.threadChangedFilesExpandedById).toEqual({ + [threadKey]: false, + }); + }); + + it("persists explicit expanded overrides when changed-files default to collapsed", () => { + const threadKey = "environment-local:thread-1"; + + useUiStateStore.setState(makeUiState()); + + localStorageStub.clear(); + useUiStateStore.getState().setThreadChangedFilesExpanded(threadKey, true, false); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(persisted.threadChangedFilesExpandedById).toEqual({ + [threadKey]: true, + }); + }); + + it("hydrates legacy per-turn changed-files collapse as a collapsed thread preference", async () => { + const threadKey = "environment-local:thread-legacy"; + + localStorageStub.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + collapsedProjectCwds: [], + expandedProjectCwds: [], + threadChangedFilesExpandedById: { + [threadKey]: { + "turn-1": false, + }, + }, + } satisfies PersistedUiState), + ); + + vi.resetModules(); + const { useUiStateStore: rehydratedStore } = await import("./uiStateStore"); + + expect(rehydratedStore.getState().threadChangedFilesExpandedById).toEqual({ + [threadKey]: false, + }); + }); + + it("hydrates explicit expanded thread overrides", async () => { + const threadKey = "environment-local:thread-expanded"; + + localStorageStub.setItem( + PERSISTED_STATE_KEY, + JSON.stringify({ + collapsedProjectCwds: [], + expandedProjectCwds: [], + threadChangedFilesExpandedById: { + [threadKey]: true, + }, + } satisfies PersistedUiState), + ); + + vi.resetModules(); + const { getThreadChangedFilesExpanded: getExpanded, useUiStateStore: rehydratedStore } = + await import("./uiStateStore"); + + expect(getExpanded(rehydratedStore.getState(), threadKey, false)).toBe(true); + }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 8bd65ffc56a..e203e85d4fd 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -19,7 +19,7 @@ export interface PersistedUiState { collapsedProjectCwds?: string[]; expandedProjectCwds?: string[]; projectOrderCwds?: string[]; - threadChangedFilesExpandedById?: Record>; + threadChangedFilesExpandedById?: Record>; } export interface UiProjectState { @@ -29,7 +29,7 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; - threadChangedFilesExpandedById: Record>; + threadChangedFilesExpandedById: Record; } export interface UiState extends UiProjectState, UiThreadState {} @@ -99,32 +99,36 @@ function readPersistedState(): UiState { function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], -): Record> { +): Record { if (!value || typeof value !== "object") { return {}; } - const nextState: Record> = {}; - for (const [threadId, turns] of Object.entries(value)) { - if (!threadId || !turns || typeof turns !== "object") { + const nextState: Record = {}; + for (const [threadId, expanded] of Object.entries(value)) { + if (!threadId) { continue; } - const nextTurns: Record = {}; - for (const [turnId, expanded] of Object.entries(turns)) { - if (turnId && typeof expanded === "boolean" && expanded === false) { - nextTurns[turnId] = false; - } + if (typeof expanded === "boolean") { + nextState[threadId] = expanded; + continue; } - if (Object.keys(nextTurns).length > 0) { - nextState[threadId] = nextTurns; + if (!expanded || typeof expanded !== "object") { + continue; + } + + for (const turnExpanded of Object.values(expanded)) { + if (turnExpanded === false) { + nextState[threadId] = false; + break; + } } } return nextState; } - export function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedCollapsedProjectCwds.clear(); persistedExpandedProjectCwds.clear(); @@ -165,14 +169,7 @@ export function persistState(state: UiState): void { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); - 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]] : []; - }), - ); + const threadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ @@ -216,20 +213,10 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]): } function nestedBooleanRecordsEqual( - left: Record>, - right: Record>, + left: Record, + right: Record, ): boolean { - const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { - return false; - } - for (const [key, value] of leftEntries) { - if (!(key in right) || !recordsEqual(value, right[key]!)) { - return false; - } - } - return true; + return recordsEqual(left, right); } export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { @@ -278,6 +265,8 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput const persistedOrderByCwd = new Map( persistedProjectOrderCwds.map((cwd, index) => [cwd, index] as const), ); + const defaultToCollapsedOnLegacyUpgrade = + persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0; const mappedProjects = projects.map((project, index) => { if (!(project.logicalKey in nextExpandedById)) { const groupCwds = currentProjectCwdsByLogicalKey.get(project.logicalKey) ?? [project.cwd]; @@ -300,10 +289,7 @@ export function syncProjects(state: UiState, projects: readonly SyncProjectInput if (groupCwds.some((cwd) => persistedCollapsedProjectCwds.has(cwd))) { return false; } - if (persistedProjectStateUsesLegacyShape && persistedExpandedProjectCwds.size > 0) { - return false; - } - return true; + return !defaultToCollapsedOnLegacyUpgrade; })(); const expanded = previousExpandedById[project.logicalKey] ?? @@ -483,40 +469,35 @@ export function clearThreadUi(state: UiState, threadId: string): UiState { }; } +export function getThreadChangedFilesExpanded( + state: UiState, + threadId: string, + defaultExpanded: boolean, +): boolean { + return state.threadChangedFilesExpandedById[threadId] ?? defaultExpanded; +} + export function setThreadChangedFilesExpanded( state: UiState, threadId: string, - turnId: string, expanded: boolean, + defaultExpanded: boolean, ): UiState { - const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; - const currentExpanded = currentThreadState[turnId] ?? true; + const currentExpanded = getThreadChangedFilesExpanded(state, threadId, defaultExpanded); if (currentExpanded === expanded) { return state; } - if (expanded) { - if (!(turnId in currentThreadState)) { + if (expanded === defaultExpanded) { + if (!(threadId in state.threadChangedFilesExpandedById)) { 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, - }; - } - + const nextState = { ...state.threadChangedFilesExpandedById }; + delete nextState[threadId]; return { ...state, - threadChangedFilesExpandedById: { - ...state.threadChangedFilesExpandedById, - [threadId]: nextThreadState, - }, + threadChangedFilesExpandedById: nextState, }; } @@ -524,10 +505,7 @@ export function setThreadChangedFilesExpanded( ...state, threadChangedFilesExpandedById: { ...state.threadChangedFilesExpandedById, - [threadId]: { - ...currentThreadState, - [turnId]: false, - }, + [threadId]: expanded, }, }; } @@ -605,7 +583,11 @@ interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; - setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; + setThreadChangedFilesExpanded: ( + threadId: string, + expanded: boolean, + defaultExpanded: boolean, + ) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( @@ -614,22 +596,55 @@ interface UiStateStore extends UiState { ) => void; } -export const useUiStateStore = create((set) => ({ +export const useUiStateStore = create((set, get) => ({ ...readPersistedState(), - syncProjects: (projects) => set((state) => syncProjects(state, projects)), + syncProjects: (projects) => { + const previousState = get(); + set((state) => syncProjects(state, projects)); + if (get() !== previousState) { + persistState(get()); + } + }, syncThreads: (threads) => set((state) => syncThreads(state, threads)), markThreadVisited: (threadId, visitedAt) => set((state) => markThreadVisited(state, threadId, visitedAt)), markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), - clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), - setThreadChangedFilesExpanded: (threadId, turnId, expanded) => - set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), - toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), - setProjectExpanded: (projectId, expanded) => - set((state) => setProjectExpanded(state, projectId, expanded)), - reorderProjects: (draggedProjectIds, targetProjectIds) => - set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)), + clearThreadUi: (threadId) => { + const previousState = get(); + set((state) => clearThreadUi(state, threadId)); + if (get() !== previousState) { + persistState(get()); + } + }, + setThreadChangedFilesExpanded: (threadId, expanded, defaultExpanded) => { + const previousState = get(); + set((state) => setThreadChangedFilesExpanded(state, threadId, expanded, defaultExpanded)); + if (get() !== previousState) { + persistState(get()); + } + }, + toggleProject: (projectId) => { + const previousState = get(); + set((state) => toggleProject(state, projectId)); + if (get() !== previousState) { + persistState(get()); + } + }, + setProjectExpanded: (projectId, expanded) => { + const previousState = get(); + set((state) => setProjectExpanded(state, projectId, expanded)); + if (get() !== previousState) { + persistState(get()); + } + }, + reorderProjects: (draggedProjectIds, targetProjectIds) => { + const previousState = get(); + set((state) => reorderProjects(state, draggedProjectIds, targetProjectIds)); + if (get() !== previousState) { + persistState(get()); + } + }, })); useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2b50957a79b..38d74d7f69d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -34,6 +34,7 @@ export const ClientSettingsSchema = Schema.Struct({ autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + defaultOpenChangedFiles: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), diffWordWrap: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), favorites: Schema.Array( Schema.Struct({ @@ -247,6 +248,7 @@ export const ClientSettingsPatch = Schema.Struct({ autoOpenPlanSidebar: Schema.optionalKey(Schema.Boolean), confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), + defaultOpenChangedFiles: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey( Schema.Array( From 6d4f2a31deb2bb7c83d4bfe407f533721591f604 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 1 May 2026 21:06:23 +0300 Subject: [PATCH 2/3] fix(web): keep changed-files collapse per turn --- .../src/components/chat/MessagesTimeline.tsx | 14 +++- apps/web/src/uiStateStore.test.ts | 67 ++++++++++++------- apps/web/src/uiStateStore.ts | 62 ++++++++++++----- 3 files changed, 99 insertions(+), 44 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index bf91e082555..3789cd86604 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -627,7 +627,12 @@ function AssistantChangedFilesSectionInner({ onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; }) { const allDirectoriesExpanded = useUiStateStore((store) => - getThreadChangedFilesExpanded(store, routeThreadKey, defaultOpenChangedFiles), + getThreadChangedFilesExpanded( + store, + routeThreadKey, + turnSummary.turnId, + defaultOpenChangedFiles, + ), ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); @@ -652,7 +657,12 @@ function AssistantChangedFilesSectionInner({ variant="outline" data-scroll-anchor-ignore onClick={() => - setExpanded(routeThreadKey, !allDirectoriesExpanded, defaultOpenChangedFiles) + setExpanded( + routeThreadKey, + turnSummary.turnId, + !allDirectoriesExpanded, + defaultOpenChangedFiles, + ) } > {allDirectoriesExpanded ? "Collapse all" : "Expand all"} diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 1373aa65764..389e15c84ee 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -353,8 +353,8 @@ describe("uiStateStore pure functions", () => { [thread2]: "2026-02-25T12:36:00.000Z", }, threadChangedFilesExpandedById: { - [thread1]: false, - [thread2]: false, + [thread1]: { "turn-1": false }, + [thread2]: { "turn-1": false }, }, }); @@ -364,7 +364,7 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", }); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: false, + [thread1]: { "turn-1": false }, }); }); @@ -406,7 +406,7 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", }, threadChangedFilesExpandedById: { - [thread1]: false, + [thread1]: { "turn-1": false }, }, }); @@ -416,54 +416,73 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded stores collapsed state per thread", () => { + it("setThreadChangedFilesExpanded stores collapsed state per turn", () => { const thread1 = ThreadId.make("thread-1"); + const turn1 = "turn-1"; const initialState = makeUiState(); - const next = setThreadChangedFilesExpanded(initialState, thread1, false, true); + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, false, true); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: false, + [thread1]: { [turn1]: false }, }); }); - it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { + it("setThreadChangedFilesExpanded removes turn overrides when expanded again", () => { const thread1 = ThreadId.make("thread-1"); + const turn1 = "turn-1"; const initialState = makeUiState({ threadChangedFilesExpandedById: { - [thread1]: false, + [thread1]: { [turn1]: false }, }, }); - const next = setThreadChangedFilesExpanded(initialState, thread1, true, true); + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, true, true); expect(next.threadChangedFilesExpandedById).toEqual({}); }); + it("setThreadChangedFilesExpanded only updates the selected turn", () => { + const thread1 = ThreadId.make("thread-1"); + const initialState = makeUiState({ + threadChangedFilesExpandedById: { + [thread1]: { "turn-1": false, "turn-2": true }, + }, + }); + + const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true, true); + + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: { "turn-2": true }, + }); + }); + it("stores an explicit expanded override when changed-files default to collapsed", () => { const thread1 = ThreadId.make("thread-1"); + const turn1 = "turn-1"; const initialState = makeUiState(); - const next = setThreadChangedFilesExpanded(initialState, thread1, true, false); + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, true, false); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: true, + [thread1]: { [turn1]: true }, }); - expect(getThreadChangedFilesExpanded(next, thread1, false)).toBe(true); + expect(getThreadChangedFilesExpanded(next, thread1, turn1, false)).toBe(true); }); it("drops an override when toggled back to the default changed-files state", () => { const thread1 = ThreadId.make("thread-1"); + const turn1 = "turn-1"; const initialState = makeUiState({ threadChangedFilesExpandedById: { - [thread1]: true, + [thread1]: { [turn1]: true }, }, }); - const next = setThreadChangedFilesExpanded(initialState, thread1, false, false); + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, false, false); expect(next.threadChangedFilesExpandedById).toEqual({}); - expect(getThreadChangedFilesExpanded(next, thread1, false)).toBe(false); + expect(getThreadChangedFilesExpanded(next, thread1, turn1, false)).toBe(false); }); }); @@ -622,14 +641,14 @@ describe("uiStateStore persistence round-trip", () => { useUiStateStore.setState(makeUiState()); localStorageStub.clear(); - useUiStateStore.getState().setThreadChangedFilesExpanded(threadKey, false, true); + useUiStateStore.getState().setThreadChangedFilesExpanded(threadKey, "turn-1", false, true); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; expect(persisted.threadChangedFilesExpandedById).toEqual({ - [threadKey]: false, + [threadKey]: { "turn-1": false }, }); }); @@ -639,18 +658,18 @@ describe("uiStateStore persistence round-trip", () => { useUiStateStore.setState(makeUiState()); localStorageStub.clear(); - useUiStateStore.getState().setThreadChangedFilesExpanded(threadKey, true, false); + useUiStateStore.getState().setThreadChangedFilesExpanded(threadKey, "turn-1", true, false); const persisted = JSON.parse( localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", ) as PersistedUiState; expect(persisted.threadChangedFilesExpandedById).toEqual({ - [threadKey]: true, + [threadKey]: { "turn-1": true }, }); }); - it("hydrates legacy per-turn changed-files collapse as a collapsed thread preference", async () => { + it("hydrates legacy per-turn changed-files collapse as turn preferences", async () => { const threadKey = "environment-local:thread-legacy"; localStorageStub.setItem( @@ -670,11 +689,11 @@ describe("uiStateStore persistence round-trip", () => { const { useUiStateStore: rehydratedStore } = await import("./uiStateStore"); expect(rehydratedStore.getState().threadChangedFilesExpandedById).toEqual({ - [threadKey]: false, + [threadKey]: { "turn-1": false }, }); }); - it("hydrates explicit expanded thread overrides", async () => { + it("ignores obsolete thread-wide changed-files overrides", async () => { const threadKey = "environment-local:thread-expanded"; localStorageStub.setItem( @@ -692,6 +711,6 @@ describe("uiStateStore persistence round-trip", () => { const { getThreadChangedFilesExpanded: getExpanded, useUiStateStore: rehydratedStore } = await import("./uiStateStore"); - expect(getExpanded(rehydratedStore.getState(), threadKey, false)).toBe(true); + expect(getExpanded(rehydratedStore.getState(), threadKey, "turn-1", false)).toBe(false); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index e203e85d4fd..014965a3f2b 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -29,7 +29,7 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; - threadChangedFilesExpandedById: Record; + threadChangedFilesExpandedById: Record>; } export interface UiState extends UiProjectState, UiThreadState {} @@ -99,19 +99,18 @@ function readPersistedState(): UiState { function sanitizePersistedThreadChangedFilesExpanded( value: PersistedUiState["threadChangedFilesExpandedById"], -): Record { +): Record> { if (!value || typeof value !== "object") { return {}; } - const nextState: Record = {}; + const nextState: Record> = {}; for (const [threadId, expanded] of Object.entries(value)) { if (!threadId) { continue; } if (typeof expanded === "boolean") { - nextState[threadId] = expanded; continue; } @@ -119,12 +118,16 @@ function sanitizePersistedThreadChangedFilesExpanded( continue; } - for (const turnExpanded of Object.values(expanded)) { - if (turnExpanded === false) { - nextState[threadId] = false; - break; + const turnState: Record = {}; + for (const [turnId, turnExpanded] of Object.entries(expanded)) { + if (turnId && typeof turnExpanded === "boolean") { + turnState[turnId] = turnExpanded; } } + + if (Object.keys(turnState).length > 0) { + nextState[threadId] = turnState; + } } return nextState; @@ -213,10 +216,17 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]): } function nestedBooleanRecordsEqual( - left: Record, - right: Record, + left: Record>, + right: Record>, ): boolean { - return recordsEqual(left, right); + const leftEntries = Object.entries(left); + if (leftEntries.length !== Object.keys(right).length) { + return false; + } + return leftEntries.every(([key, value]) => { + const rightValue = right[key]; + return rightValue !== undefined && recordsEqual(value, rightValue); + }); } export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { @@ -472,29 +482,39 @@ export function clearThreadUi(state: UiState, threadId: string): UiState { export function getThreadChangedFilesExpanded( state: UiState, threadId: string, + turnId: string, defaultExpanded: boolean, ): boolean { - return state.threadChangedFilesExpandedById[threadId] ?? defaultExpanded; + return state.threadChangedFilesExpandedById[threadId]?.[turnId] ?? defaultExpanded; } export function setThreadChangedFilesExpanded( state: UiState, threadId: string, + turnId: string, expanded: boolean, defaultExpanded: boolean, ): UiState { - const currentExpanded = getThreadChangedFilesExpanded(state, threadId, defaultExpanded); + const currentExpanded = getThreadChangedFilesExpanded(state, threadId, turnId, defaultExpanded); if (currentExpanded === expanded) { return state; } + const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; + if (expanded === defaultExpanded) { - if (!(threadId in state.threadChangedFilesExpandedById)) { + if (!(turnId in currentThreadState)) { return state; } + const nextThreadState = { ...currentThreadState }; + delete nextThreadState[turnId]; const nextState = { ...state.threadChangedFilesExpandedById }; - delete nextState[threadId]; + if (Object.keys(nextThreadState).length === 0) { + delete nextState[threadId]; + } else { + nextState[threadId] = nextThreadState; + } return { ...state, threadChangedFilesExpandedById: nextState, @@ -505,7 +525,10 @@ export function setThreadChangedFilesExpanded( ...state, threadChangedFilesExpandedById: { ...state.threadChangedFilesExpandedById, - [threadId]: expanded, + [threadId]: { + ...currentThreadState, + [turnId]: expanded, + }, }, }; } @@ -585,6 +608,7 @@ interface UiStateStore extends UiState { clearThreadUi: (threadId: string) => void; setThreadChangedFilesExpanded: ( threadId: string, + turnId: string, expanded: boolean, defaultExpanded: boolean, ) => void; @@ -617,9 +641,11 @@ export const useUiStateStore = create((set, get) => ({ persistState(get()); } }, - setThreadChangedFilesExpanded: (threadId, expanded, defaultExpanded) => { + setThreadChangedFilesExpanded: (threadId, turnId, expanded, defaultExpanded) => { const previousState = get(); - set((state) => setThreadChangedFilesExpanded(state, threadId, expanded, defaultExpanded)); + set((state) => + setThreadChangedFilesExpanded(state, threadId, turnId, expanded, defaultExpanded), + ); if (get() !== previousState) { persistState(get()); } From f2eebb9b24574b011f3bf8760819529d498df268 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 1 May 2026 21:12:55 +0300 Subject: [PATCH 3/3] test(web): assert per-turn changed-files collapse --- apps/web/src/components/chat/MessagesTimeline.browser.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index c975b015939..db2218a5686 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.tsx @@ -168,7 +168,7 @@ describe("MessagesTimeline", () => { } }); - it("keeps changed-files collapsed for future turns in the same thread after a manual collapse", async () => { + it("keeps changed-files collapse scoped to the selected turn", async () => { const props = buildProps(); const firstAssistantMessageId = MessageId.make("assistant-1"); const secondAssistantMessageId = MessageId.make("assistant-2"); @@ -271,8 +271,8 @@ describe("MessagesTimeline", () => { const buttonLabels = [...document.querySelectorAll("button")].map((button) => button.textContent?.trim(), ); - expect(buttonLabels.filter((label) => label === "Expand all")).toHaveLength(2); - expect(buttonLabels).not.toContain("Collapse all"); + expect(buttonLabels.filter((label) => label === "Expand all")).toHaveLength(1); + expect(buttonLabels.filter((label) => label === "Collapse all")).toHaveLength(1); }, { timeout: 8_000, interval: 16 }, );