Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/PERSISTENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
Expand Down
60 changes: 60 additions & 0 deletions src/app/renderer/shell/components/Sidebar.persistence.spec.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<Sidebar
workspaces={[workspace]}
activeWorkspaceId={workspace.id}
persistNotice={null}
onSelectWorkspace={vi.fn()}
onSelectSpace={vi.fn()}
onOpenProjectContextMenu={vi.fn()}
onSelectAgentNode={vi.fn()}
onReorderWorkspaces={vi.fn()}
/>
)
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: {} })
})
4 changes: 3 additions & 1 deletion src/app/renderer/shell/components/Sidebar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
47 changes: 33 additions & 14 deletions src/app/renderer/shell/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -94,8 +96,13 @@ export function Sidebar({
}),
)
const [activeDragItem, setActiveDragItem] = useState<ActiveSidebarDragItem | null>(null)
const [collapsedWorkspaceIds, setCollapsedWorkspaceIds] = useState<Record<string, boolean>>({})
const [collapsedSpaceGroupIds, setCollapsedSpaceGroupIds] = useState<Record<string, boolean>>({})
const collapsedWorkspaceIds = useAppStore(
state => state.agentSettings.sidebarCollapsedWorkspaceIds,
)
const collapsedSpaceGroupIds = useAppStore(
state => state.agentSettings.sidebarCollapsedSpaceGroupIds,
)
const setAgentSettings = useAppStore(state => state.setAgentSettings)
const [sidebarTransition, setSidebarTransition] = useState<SidebarTransition>(null)
const previousVariantRef = useRef(variant)
const transitionTimeoutRef = useRef<number | null>(null)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/contexts/settings/domain/agentSettings.defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
5 changes: 5 additions & 0 deletions src/contexts/settings/domain/agentSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -382,6 +383,10 @@ export function normalizeAgentSettings(value: unknown): AgentSettings {
language,
uiTheme,
isPrimarySidebarCollapsed,
sidebarCollapsedWorkspaceIds: normalizeSidebarCollapsedIds(value.sidebarCollapsedWorkspaceIds),
sidebarCollapsedSpaceGroupIds: normalizeSidebarCollapsedIds(
value.sidebarCollapsedSpaceGroupIds,
),
workspaceSearchPanelWidth,
defaultProvider,
agentProviderOrder,
Expand Down
2 changes: 2 additions & 0 deletions src/contexts/settings/domain/agentSettings.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface AgentSettings {
language: UiLanguage
uiTheme: UiTheme
isPrimarySidebarCollapsed: boolean
sidebarCollapsedWorkspaceIds: Record<string, boolean>
sidebarCollapsedSpaceGroupIds: Record<string, boolean>
workspaceSearchPanelWidth: number
defaultProvider: AgentProvider
agentProviderOrder: AgentProvider[]
Expand Down
20 changes: 20 additions & 0 deletions src/contexts/settings/domain/sidebarTreeSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function normalizeSidebarCollapsedIds(value: unknown): Record<string, boolean> {
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<string, boolean>,
id: string,
): Record<string, boolean> {
if (previous[id] !== true) {
return { ...previous, [id]: true }
}
const next = { ...previous }
delete next[id]
return next
}
118 changes: 118 additions & 0 deletions tests/e2e/sidebar-tree.persistence.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
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)
}
})
40 changes: 40 additions & 0 deletions tests/unit/contexts/sidebarTreeSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -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 },
})
})
})