diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e69286..1a1df61f1a 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -15,6 +15,7 @@ const clientSettings: ClientSettings = { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultOpenChangedFiles: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a7567d6b49..2a2fbf0b90 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -637,6 +637,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, @@ -3574,6 +3575,7 @@ export default function ChatView(props: ChatViewProps) { markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} + defaultOpenChangedFiles={defaultOpenChangedFiles} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} onIsAtEndChange={onIsAtEndChange} diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.tsx index cd5334cc03..7b08602873 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 })); @@ -70,6 +72,7 @@ function buildProps() { markdownCwd: undefined, resolvedTheme: "dark" as const, timestampFormat: "24-hour" as const, + defaultOpenChangedFiles: true, workspaceRoot: undefined, onIsAtEndChange: vi.fn(), }; @@ -101,6 +104,13 @@ describe("MessagesTimeline", () => { scrollToEndSpy.mockReset(); getStateSpy.mockClear(); vi.restoreAllMocks(); + localStorage.clear(); + useUiStateStore.setState({ + projectExpandedById: {}, + projectOrder: [], + threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, + }); document.body.innerHTML = ""; }); @@ -181,6 +191,119 @@ describe("MessagesTimeline", () => { } }); + 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"); + 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(1); + expect(buttonLabels.filter((label) => label === "Collapse all")).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await screen.unmount(); + } + }); + it("starts long user messages collapsed by default", async () => { const screen = await render( { } }); + 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(); + } + }); + it("starts the newest long user prompt collapsed", async () => { const screen = await render( {}, }; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 48fc5ef41e..9327f07938 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -56,7 +56,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"; @@ -78,6 +78,7 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay"; interface TimelineRowSharedState { timestampFormat: TimestampFormat; routeThreadKey: string; + defaultOpenChangedFiles: boolean; markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; workspaceRoot: string | undefined; @@ -126,6 +127,7 @@ interface MessagesTimelineProps { markdownCwd: string | undefined; resolvedTheme: "light" | "dark"; timestampFormat: TimestampFormat; + defaultOpenChangedFiles: boolean; workspaceRoot: string | undefined; skills?: ReadonlyArray>; onIsAtEndChange: (isAtEnd: boolean) => void; @@ -155,6 +157,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ markdownCwd, resolvedTheme, timestampFormat, + defaultOpenChangedFiles, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, onIsAtEndChange, @@ -209,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ () => ({ timestampFormat, routeThreadKey, + defaultOpenChangedFiles, markdownCwd, resolvedTheme, workspaceRoot, @@ -221,6 +225,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ [ timestampFormat, routeThreadKey, + defaultOpenChangedFiles, markdownCwd, resolvedTheme, workspaceRoot, @@ -423,6 +428,7 @@ function AssistantTimelineRow({ row }: { row: Extract @@ -651,11 +657,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; }) { @@ -668,6 +676,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection( turnSummary={turnSummary} checkpointFiles={checkpointFiles} routeThreadKey={routeThreadKey} + defaultOpenChangedFiles={defaultOpenChangedFiles} resolvedTheme={resolvedTheme} onOpenTurnDiff={onOpenTurnDiff} /> @@ -680,17 +689,24 @@ 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, + turnSummary.turnId, + defaultOpenChangedFiles, + ), ); const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded); const summaryStat = summarizeTurnDiffStats(checkpointFiles); @@ -714,7 +730,14 @@ function AssistantChangedFilesSectionInner({ size="xs" variant="outline" data-scroll-anchor-ignore - onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)} + onClick={() => + setExpanded( + routeThreadKey, + turnSummary.turnId, + !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 e7f21da480..d128404964 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -393,6 +393,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.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), @@ -432,6 +435,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.addProjectBaseDirectory, + settings.defaultOpenChangedFiles, settings.defaultThreadEnvMode, settings.diffIgnoreWhitespace, settings.diffWordWrap, @@ -592,6 +596,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, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, @@ -665,6 +666,7 @@ describe("wsApi", () => { autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, + defaultOpenChangedFiles: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffWordWrap: true, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 5d860fbb7f..5734c7bcad 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, markThreadVisited, markThreadUnread, @@ -16,8 +17,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: {}, @@ -345,12 +367,8 @@ describe("uiStateStore pure functions", () => { [thread2]: "2026-02-25T12:36:00.000Z", }, threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, - [thread2]: { - "turn-2": false, - }, + [thread1]: { "turn-1": false }, + [thread2]: { "turn-1": false }, }, }); @@ -360,9 +378,7 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", }); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, - }, + [thread1]: { "turn-1": false }, }); }); @@ -404,9 +420,7 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", }, threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, + [thread1]: { "turn-1": false }, }, }); @@ -416,55 +430,76 @@ describe("uiStateStore pure functions", () => { expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded stores collapsed turns 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, "turn-1", false); + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, false, true); expect(next.threadChangedFilesExpandedById).toEqual({ - [thread1]: { - "turn-1": false, + [thread1]: { [turn1]: false }, + }); + }); + + it("setThreadChangedFilesExpanded removes turn overrides when expanded again", () => { + const thread1 = ThreadId.make("thread-1"); + const turn1 = "turn-1"; + const initialState = makeUiState({ + threadChangedFilesExpandedById: { + [thread1]: { [turn1]: false }, }, }); + + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, true, true); + + expect(next.threadChangedFilesExpandedById).toEqual({}); }); - it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { + it("setThreadChangedFilesExpanded only updates the selected turn", () => { const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ threadChangedFilesExpandedById: { - [thread1]: { - "turn-1": false, - }, + [thread1]: { "turn-1": false, "turn-2": true }, }, }); - const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", 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, turn1, true, false); + + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: { [turn1]: 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]: { [turn1]: true }, + }, + }); + + const next = setThreadChangedFilesExpanded(initialState, thread1, turn1, false, false); expect(next.threadChangedFilesExpandedById).toEqual({}); + expect(getThreadChangedFilesExpanded(next, thread1, turn1, false)).toBe(false); }); }); -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); - }, - }; -} - describe("uiStateStore persistence round-trip", () => { let localStorageStub: Storage; @@ -607,4 +642,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, "turn-1", false, true); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(persisted.threadChangedFilesExpandedById).toEqual({ + [threadKey]: { "turn-1": 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, "turn-1", true, false); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(persisted.threadChangedFilesExpandedById).toEqual({ + [threadKey]: { "turn-1": true }, + }); + }); + + it("hydrates legacy per-turn changed-files collapse as turn preferences", 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]: { "turn-1": false }, + }); + }); + + it("ignores obsolete thread-wide changed-files 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, "turn-1", false)).toBe(false); + }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index b76f5f6859..99590e0628 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -20,7 +20,7 @@ export interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; defaultAdvertisedEndpointKey?: string | null; - threadChangedFilesExpandedById?: Record>; + threadChangedFilesExpandedById?: Record>; } export interface UiProjectState { @@ -116,26 +116,33 @@ function sanitizePersistedThreadChangedFilesExpanded( } const nextState: Record> = {}; - for (const [threadId, turns] of Object.entries(value)) { - if (!threadId || !turns || typeof turns !== "object") { + 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") { + continue; + } + + if (!expanded || typeof expanded !== "object") { + continue; + } + + const turnState: Record = {}; + for (const [turnId, turnExpanded] of Object.entries(expanded)) { + if (turnId && typeof turnExpanded === "boolean") { + turnState[turnId] = turnExpanded; } } - if (Object.keys(nextTurns).length > 0) { - nextState[threadId] = nextTurns; + if (Object.keys(turnState).length > 0) { + nextState[threadId] = turnState; } } return nextState; } - export function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedCollapsedProjectCwds.clear(); persistedExpandedProjectCwds.clear(); @@ -176,14 +183,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({ @@ -232,16 +232,13 @@ function nestedBooleanRecordsEqual( right: Record>, ): boolean { const leftEntries = Object.entries(left); - const rightEntries = Object.entries(right); - if (leftEntries.length !== rightEntries.length) { + if (leftEntries.length !== Object.keys(right).length) { return false; } - for (const [key, value] of leftEntries) { - if (!(key in right) || !recordsEqual(value, right[key]!)) { - return false; - } - } - return true; + return leftEntries.every(([key, value]) => { + const rightValue = right[key]; + return rightValue !== undefined && recordsEqual(value, rightValue); + }); } export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { @@ -290,6 +287,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]; @@ -312,10 +311,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] ?? @@ -495,40 +491,45 @@ export function clearThreadUi(state: UiState, threadId: string): UiState { }; } +export function getThreadChangedFilesExpanded( + state: UiState, + threadId: string, + turnId: string, + defaultExpanded: boolean, +): boolean { + return state.threadChangedFilesExpandedById[threadId]?.[turnId] ?? 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, turnId, defaultExpanded); if (currentExpanded === expanded) { return state; } - if (expanded) { + const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; + + if (expanded === defaultExpanded) { if (!(turnId in currentThreadState)) { return state; } const nextThreadState = { ...currentThreadState }; delete nextThreadState[turnId]; + const nextState = { ...state.threadChangedFilesExpandedById }; if (Object.keys(nextThreadState).length === 0) { - const nextState = { ...state.threadChangedFilesExpandedById }; delete nextState[threadId]; - return { - ...state, - threadChangedFilesExpandedById: nextState, - }; + } else { + nextState[threadId] = nextThreadState; } - return { ...state, - threadChangedFilesExpandedById: { - ...state.threadChangedFilesExpandedById, - [threadId]: nextThreadState, - }, + threadChangedFilesExpandedById: nextState, }; } @@ -538,7 +539,7 @@ export function setThreadChangedFilesExpanded( ...state.threadChangedFilesExpandedById, [threadId]: { ...currentThreadState, - [turnId]: false, + [turnId]: expanded, }, }, }; @@ -628,7 +629,12 @@ 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, + turnId: string, + expanded: boolean, + defaultExpanded: boolean, + ) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; @@ -638,24 +644,64 @@ 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)), - setDefaultAdvertisedEndpointKey: (key) => - set((state) => setDefaultAdvertisedEndpointKey(state, key)), - 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, turnId, expanded, defaultExpanded) => { + const previousState = get(); + set((state) => + setThreadChangedFilesExpanded(state, threadId, turnId, expanded, defaultExpanded), + ); + if (get() !== previousState) { + persistState(get()); + } + }, + setDefaultAdvertisedEndpointKey: (key) => { + const previousState = get(); + set((state) => setDefaultAdvertisedEndpointKey(state, key)); + 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 2d115eed98..945c05bae4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -43,6 +43,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))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -478,6 +479,8 @@ 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), + dismissedProviderUpdateNotificationKeys: Schema.optionalKey(Schema.Array(TrimmedNonEmptyString)), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffWordWrap: Schema.optionalKey(Schema.Boolean), favorites: Schema.optionalKey(