diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64d51cc3..963f4de6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### 🐛 Fixed
+- Sidebar: preserve collapsed projects and Space groups across app restarts, while keeping their disclosure choices independent. (#411)
- Terminal: route local desktop image paste to native Pi, Kimi, Codex, Claude Code, and OMP keybindings while preserving text paste. (#409)
- Terminal: execute quick commands after session attachment, preserving one-time execution and cancelling pending input when its window is removed. (#408)
- Workspace: keep Space archive and other operation overlays local to their target Space, preventing unrelated Space backgrounds from tinting window nodes. (#401)
diff --git a/docs/architecture/PERSISTENCE.md b/docs/architecture/PERSISTENCE.md
index 23424bb5..a861ff51 100644
--- a/docs/architecture/PERSISTENCE.md
+++ b/docs/architecture/PERSISTENCE.md
@@ -228,6 +228,14 @@ Flush 失败时必须记录并返回 degraded,不能静默当作 durable shutd
## Write Ownership
+Sidebar project and Space-group disclosure preferences live in settings as
+`sidebarCollapsedWorkspaceIds` and `sidebarCollapsedSpaceGroupIds`. The latter uses
+the sidebar's workspace-scoped group identity (`workspaceId:groupId`). Only `true`
+entries are retained; missing or invalid legacy values default to expanded. Sidebar
+mounting, project switching, and temporary tree absence must not reset these records.
+The existing app-state hydration, write scheduling, and quit flush own their durability;
+the Sidebar does not maintain a second local persistence store.
+
- Workspace/app state:SQLite persistence store。
- Endpoint/mount registry:Worker topology store。
- Approved local roots:approved workspace store。
diff --git a/src/app/renderer/shell/components/Sidebar.persistence.spec.tsx b/src/app/renderer/shell/components/Sidebar.persistence.spec.tsx
new file mode 100644
index 00000000..f76da535
--- /dev/null
+++ b/src/app/renderer/shell/components/Sidebar.persistence.spec.tsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+import { beforeEach, expect, it, vi } from 'vitest'
+import { DEFAULT_AGENT_SETTINGS } from '@contexts/settings/domain/agentSettings'
+import {
+ DEFAULT_WORKSPACE_VIEWPORT,
+ type WorkspaceState,
+} from '@contexts/workspace/presentation/renderer/types'
+import { useAppStore } from '../store/useAppStore'
+import { Sidebar } from './Sidebar'
+
+beforeEach(() => {
+ useAppStore.setState({ agentSettings: DEFAULT_AGENT_SETTINGS })
+})
+
+it('retains project collapse after remount and saves a later explicit expansion', () => {
+ const workspace: WorkspaceState = {
+ id: 'project-a',
+ name: 'Project A',
+ path: '/tmp/project-a',
+ worktreesRoot: '',
+ nodes: [],
+ spaces: [
+ {
+ id: 'space-a',
+ name: 'Space A',
+ directoryPath: '/tmp/project-a',
+ targetMountId: null,
+ nodeIds: [],
+ rect: null,
+ labelColor: null,
+ },
+ ],
+ activeSpaceId: null,
+ spaceArchiveRecords: [],
+ viewport: DEFAULT_WORKSPACE_VIEWPORT,
+ isMinimapVisible: false,
+ }
+ const sidebar = (
+
+ )
+ const first = render(sidebar)
+ const toggle = () => screen.getByTestId('workspace-item-toggle-project-a')
+ fireEvent.click(toggle())
+ expect(toggle().getAttribute('aria-expanded')).toBe('false')
+ first.unmount()
+ render(sidebar)
+ expect(toggle().getAttribute('aria-expanded')).toBe('false')
+ fireEvent.click(toggle())
+ expect(toggle().getAttribute('aria-expanded')).toBe('true')
+ expect(useAppStore.getState().agentSettings).toMatchObject({ sidebarCollapsedWorkspaceIds: {} })
+})
diff --git a/src/app/renderer/shell/components/Sidebar.spec.tsx b/src/app/renderer/shell/components/Sidebar.spec.tsx
index 013283b8..1a338e2e 100644
--- a/src/app/renderer/shell/components/Sidebar.spec.tsx
+++ b/src/app/renderer/shell/components/Sidebar.spec.tsx
@@ -7,6 +7,8 @@ import {
type WorkspaceState,
} from '@contexts/workspace/presentation/renderer/types'
import { Sidebar } from './Sidebar'
+import { useAppStore } from '../store/useAppStore'
+import { DEFAULT_AGENT_SETTINGS } from '@contexts/settings/domain/agentSettings'
const dndState = vi.hoisted(() => ({
draggingId: null as string | null,
onDragStart: null as ((event: { active: { id: string } }) => void) | null,
@@ -179,9 +181,9 @@ function createWorkspace(
spaceArchiveRecords: [],
}
}
-
describe('Sidebar', () => {
beforeEach(() => {
+ useAppStore.setState({ agentSettings: DEFAULT_AGENT_SETTINGS })
dndState.draggingId = null
dndState.onDragStart = null
dndState.onDragEnd = null
diff --git a/src/app/renderer/shell/components/Sidebar.tsx b/src/app/renderer/shell/components/Sidebar.tsx
index e8730736..9318668f 100644
--- a/src/app/renderer/shell/components/Sidebar.tsx
+++ b/src/app/renderer/shell/components/Sidebar.tsx
@@ -30,6 +30,8 @@ import {
type SidebarDragItemData,
} from './SidebarDnd'
import { useSidebarListScroll } from './useSidebarListScroll'
+import { useAppStore } from '../store/useAppStore'
+import { toggleSidebarCollapsedId } from '@contexts/settings/domain/sidebarTreeSettings'
export type SidebarVariant = 'docked' | 'rail' | 'peek'
type SidebarTransition = 'collapsing' | 'expanding' | null
@@ -94,8 +96,13 @@ export function Sidebar({
}),
)
const [activeDragItem, setActiveDragItem] = useState(null)
- const [collapsedWorkspaceIds, setCollapsedWorkspaceIds] = useState>({})
- const [collapsedSpaceGroupIds, setCollapsedSpaceGroupIds] = useState>({})
+ const collapsedWorkspaceIds = useAppStore(
+ state => state.agentSettings.sidebarCollapsedWorkspaceIds,
+ )
+ const collapsedSpaceGroupIds = useAppStore(
+ state => state.agentSettings.sidebarCollapsedSpaceGroupIds,
+ )
+ const setAgentSettings = useAppStore(state => state.setAgentSettings)
const [sidebarTransition, setSidebarTransition] = useState(null)
const previousVariantRef = useRef(variant)
const transitionTimeoutRef = useRef(null)
@@ -184,19 +191,31 @@ export function Sidebar({
],
)
- const handleToggleProject = useCallback((workspaceId: string): void => {
- setCollapsedWorkspaceIds(prev => ({
- ...prev,
- [workspaceId]: prev[workspaceId] !== true,
- }))
- }, [])
+ const handleToggleProject = useCallback(
+ (workspaceId: string): void => {
+ setAgentSettings(prev => ({
+ ...prev,
+ sidebarCollapsedWorkspaceIds: toggleSidebarCollapsedId(
+ prev.sidebarCollapsedWorkspaceIds,
+ workspaceId,
+ ),
+ }))
+ },
+ [setAgentSettings],
+ )
- const handleToggleSpaceGroup = useCallback((groupKey: string): void => {
- setCollapsedSpaceGroupIds(prev => ({
- ...prev,
- [groupKey]: prev[groupKey] !== true,
- }))
- }, [])
+ const handleToggleSpaceGroup = useCallback(
+ (groupKey: string): void => {
+ setAgentSettings(prev => ({
+ ...prev,
+ sidebarCollapsedSpaceGroupIds: toggleSidebarCollapsedId(
+ prev.sidebarCollapsedSpaceGroupIds,
+ groupKey,
+ ),
+ }))
+ },
+ [setAgentSettings],
+ )
const activeDragData = activeDragItem?.data ?? null
const activeDragWorkspaceId = activeDragData?.workspaceId ?? null
diff --git a/src/contexts/settings/domain/agentSettings.defaults.ts b/src/contexts/settings/domain/agentSettings.defaults.ts
index 72365fa6..880b085d 100644
--- a/src/contexts/settings/domain/agentSettings.defaults.ts
+++ b/src/contexts/settings/domain/agentSettings.defaults.ts
@@ -10,6 +10,8 @@ export const DEFAULT_AGENT_SETTINGS: AgentSettings = {
language: DEFAULT_UI_LANGUAGE,
uiTheme: 'dark',
isPrimarySidebarCollapsed: false,
+ sidebarCollapsedWorkspaceIds: {},
+ sidebarCollapsedSpaceGroupIds: {},
workspaceSearchPanelWidth: 420,
defaultProvider: 'codex',
agentProviderOrder: [...AGENT_PROVIDERS],
diff --git a/src/contexts/settings/domain/agentSettings.ts b/src/contexts/settings/domain/agentSettings.ts
index 26a0161a..e38dd245 100644
--- a/src/contexts/settings/domain/agentSettings.ts
+++ b/src/contexts/settings/domain/agentSettings.ts
@@ -12,6 +12,7 @@ import {
type AgentProvider,
} from './agentSettings.providers'
import { normalizeFocusNodeTargetZoom } from './focusNodeTargetZoom'
+import { normalizeSidebarCollapsedIds } from './sidebarTreeSettings'
import { isValidUiLanguage, isValidUiTheme } from './uiSettings'
import {
isValidUpdateChannel,
@@ -382,6 +383,10 @@ export function normalizeAgentSettings(value: unknown): AgentSettings {
language,
uiTheme,
isPrimarySidebarCollapsed,
+ sidebarCollapsedWorkspaceIds: normalizeSidebarCollapsedIds(value.sidebarCollapsedWorkspaceIds),
+ sidebarCollapsedSpaceGroupIds: normalizeSidebarCollapsedIds(
+ value.sidebarCollapsedSpaceGroupIds,
+ ),
workspaceSearchPanelWidth,
defaultProvider,
agentProviderOrder,
diff --git a/src/contexts/settings/domain/agentSettings.types.ts b/src/contexts/settings/domain/agentSettings.types.ts
index 9d65a273..bf568180 100644
--- a/src/contexts/settings/domain/agentSettings.types.ts
+++ b/src/contexts/settings/domain/agentSettings.types.ts
@@ -36,6 +36,8 @@ export interface AgentSettings {
language: UiLanguage
uiTheme: UiTheme
isPrimarySidebarCollapsed: boolean
+ sidebarCollapsedWorkspaceIds: Record
+ sidebarCollapsedSpaceGroupIds: Record
workspaceSearchPanelWidth: number
defaultProvider: AgentProvider
agentProviderOrder: AgentProvider[]
diff --git a/src/contexts/settings/domain/sidebarTreeSettings.ts b/src/contexts/settings/domain/sidebarTreeSettings.ts
new file mode 100644
index 00000000..84039c44
--- /dev/null
+++ b/src/contexts/settings/domain/sidebarTreeSettings.ts
@@ -0,0 +1,20 @@
+export function normalizeSidebarCollapsedIds(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return {}
+ }
+ return Object.fromEntries(
+ Object.entries(value).filter(([id, collapsed]) => id.length > 0 && collapsed === true),
+ )
+}
+
+export function toggleSidebarCollapsedId(
+ previous: Record,
+ id: string,
+): Record {
+ if (previous[id] !== true) {
+ return { ...previous, [id]: true }
+ }
+ const next = { ...previous }
+ delete next[id]
+ return next
+}
diff --git a/tests/e2e/sidebar-tree.persistence.spec.ts b/tests/e2e/sidebar-tree.persistence.spec.ts
new file mode 100644
index 00000000..f45ddd01
--- /dev/null
+++ b/tests/e2e/sidebar-tree.persistence.spec.ts
@@ -0,0 +1,118 @@
+import { expect, test, type ElectronApplication, type Page } from '@playwright/test'
+import {
+ createTestUserDataDir,
+ launchApp,
+ removePathWithRetry,
+ seedWorkspaceState,
+ testWorkspacePath,
+} from './workspace-canvas.helpers'
+import { createRailAgent } from './sidebar-test-fixtures'
+
+async function expectPersistedDisclosure(window: Page, collapsed: boolean): Promise {
+ await expect
+ .poll(async () => {
+ const raw = await window.evaluate(() =>
+ window.opencoveApi.persistence.readWorkspaceStateRaw(),
+ )
+ const state = raw ? JSON.parse(raw) : null
+ return {
+ projects: state?.settings?.sidebarCollapsedWorkspaceIds,
+ spaces: state?.settings?.sidebarCollapsedSpaceGroupIds,
+ }
+ })
+ .toEqual({
+ projects: collapsed ? { 'project-a': true } : {},
+ spaces: collapsed ? { 'project-a:space-0': true } : {},
+ })
+}
+
+async function quitApp(electronApp: ElectronApplication): Promise {
+ const appProcess = electronApp.process()
+ await electronApp.evaluate(({ app }) => app.quit()).catch(() => undefined)
+ await electronApp.close()
+ // The shared close helper bounds Worker cleanup; require actual process exit before reopening.
+ await expect.poll(() => appProcess.exitCode !== null || appProcess.signalCode !== null).toBe(true)
+}
+
+test('restores project and Space group disclosure after two cold restarts', async ({
+ browserName: _,
+}, testInfo) => {
+ const userDataDir = await createTestUserDataDir()
+ let app: ElectronApplication | null = null
+ try {
+ let launched = await launchApp({ userDataDir, cleanupUserDataDir: false })
+ app = launched.electronApp
+ let window = launched.window
+ await seedWorkspaceState(window, {
+ activeWorkspaceId: 'project-a',
+ settings: { standardWindowSizeBucket: 'regular' },
+ workspaces: ['project-a', 'project-b'].map((id, index) => ({
+ id,
+ name: id,
+ path: testWorkspacePath,
+ nodes: [
+ {
+ ...createRailAgent(
+ `${id}-agent`,
+ `${id} agent`,
+ 400,
+ 'Test',
+ '2026-01-01T00:00:00.000Z',
+ ),
+ status: 'stopped' as const,
+ },
+ ],
+ spaces: [
+ {
+ id: `space-${index}`,
+ name: `Space ${index}`,
+ directoryPath: testWorkspacePath,
+ nodeIds: [`${id}-agent`],
+ rect: null,
+ },
+ ],
+ })),
+ })
+ const projectToggle = (id: string) => window.getByTestId(`workspace-item-toggle-${id}`)
+ const spaceToggle = () =>
+ window.getByTestId('workspace-space-item-project-a-space-0').locator('button[aria-expanded]')
+ await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'true')
+ await spaceToggle().click()
+ await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'false')
+ await projectToggle('project-a').click()
+ await expect(projectToggle('project-a')).toHaveAttribute('aria-expanded', 'false')
+ await expect(projectToggle('project-b')).toHaveAttribute('aria-expanded', 'true')
+
+ await expectPersistedDisclosure(window, true)
+ await quitApp(app)
+ app = null
+ launched = await launchApp({ userDataDir, cleanupUserDataDir: false })
+ app = launched.electronApp
+ window = launched.window
+ await expect(projectToggle('project-a')).toHaveAttribute('aria-expanded', 'false')
+ await expect(projectToggle('project-b')).toHaveAttribute('aria-expanded', 'true')
+ await projectToggle('project-a').click()
+ await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'false')
+ await expect(window.getByTestId('workspace-agent-item-project-a-project-a-agent')).toHaveCount(
+ 0,
+ )
+ await testInfo.attach('restored-project-and-space-disclosure', {
+ body: await window.screenshot(),
+ contentType: 'image/png',
+ })
+ await spaceToggle().click()
+ await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'true')
+
+ await expectPersistedDisclosure(window, false)
+ await quitApp(app)
+ app = null
+ launched = await launchApp({ userDataDir, cleanupUserDataDir: false })
+ app = launched.electronApp
+ window = launched.window
+ await expect(projectToggle('project-a')).toHaveAttribute('aria-expanded', 'true')
+ await expect(spaceToggle()).toHaveAttribute('aria-expanded', 'true')
+ } finally {
+ await app?.close().catch(() => undefined)
+ await removePathWithRetry(userDataDir)
+ }
+})
diff --git a/tests/unit/contexts/sidebarTreeSettings.spec.ts b/tests/unit/contexts/sidebarTreeSettings.spec.ts
new file mode 100644
index 00000000..59814ed7
--- /dev/null
+++ b/tests/unit/contexts/sidebarTreeSettings.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest'
+import { normalizeAgentSettings } from '../../../src/contexts/settings/domain/agentSettings'
+
+describe('sidebar tree settings recovery', () => {
+ it('preserves collapsed project and scoped Space identities across normalization', () => {
+ const settings = normalizeAgentSettings({
+ sidebarCollapsedWorkspaceIds: { 'project-a': true, 'project-b': false },
+ sidebarCollapsedSpaceGroupIds: { 'project-a:space-a': true },
+ })
+ expect(settings).toMatchObject({
+ sidebarCollapsedWorkspaceIds: { 'project-a': true },
+ sidebarCollapsedSpaceGroupIds: { 'project-a:space-a': true },
+ })
+ expect(normalizeAgentSettings(JSON.parse(JSON.stringify(settings)))).toEqual(settings)
+ })
+
+ it.each([
+ undefined,
+ null,
+ {},
+ { sidebarCollapsedWorkspaceIds: [], sidebarCollapsedSpaceGroupIds: 1 },
+ ])('restores legacy or malformed settings with expanded trees: %j', input => {
+ expect(normalizeAgentSettings(input)).toMatchObject({
+ sidebarCollapsedWorkspaceIds: {},
+ sidebarCollapsedSpaceGroupIds: {},
+ })
+ })
+
+ it('rejects truthy non-boolean entries and preserves exact stable IDs', () => {
+ expect(
+ normalizeAgentSettings({
+ sidebarCollapsedWorkspaceIds: { keep: true, no: false, invalid: 'true', '': true },
+ sidebarCollapsedSpaceGroupIds: { 'project-a:root': true, 'project-b:root': false },
+ }),
+ ).toMatchObject({
+ sidebarCollapsedWorkspaceIds: { keep: true },
+ sidebarCollapsedSpaceGroupIds: { 'project-a:root': true },
+ })
+ })
+})