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(