fix: default empty workspace changes to file tree

The workspace panel now treats an empty changed-files status as a browsing case, so opening the panel shows the project tree instead of an empty changed-files view. The panel header controls were also reduced to keep the right workspace rail visually proportional in compact layouts.

Constraint: Users still need to manually inspect the empty changed-files view when they explicitly choose it.
Rejected: Always render the empty changed-files state | it makes the opened workspace feel broken when no files changed.
Confidence: high
Scope-risk: narrow
Directive: Do not auto-switch the workspace view after a user has explicitly selected a workspace view.
Tested: cd desktop && bun run test -- src/components/workspace/WorkspacePanel.test.tsx src/stores/workspacePanelStore.test.ts
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Not-tested: Browser E2E visual screenshot pass was not rerun for this small header sizing change.
This commit is contained in:
程序员阿江(Relakkes) 2026-04-30 19:27:22 +08:00
parent 6d56f3c21d
commit ba8c6b8828
4 changed files with 199 additions and 25 deletions

View File

@ -273,6 +273,66 @@ describe('WorkspacePanel', () => {
})
})
it('opens to all files when the current turn has no changed files', async () => {
const statusRequest = deferred<{
state: 'ok'
workDir: string
repoName: string
branch: string
isGitRepo: true
changedFiles: []
}>()
const rootTreeRequest = deferred<{
state: 'ok'
path: ''
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}>()
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise)
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-empty-tree')
})
const view = await renderPanel('session-empty-tree')
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
await act(async () => {
statusRequest.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
await statusRequest.promise
})
await waitFor(() => {
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all')
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '')
})
await act(async () => {
rootTreeRequest.resolve({
state: 'ok',
path: '',
entries: [
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
],
})
await rootTreeRequest.promise
})
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
expect(await view.findByText('src')).toBeTruthy()
expect(await view.findByText('README.md')).toBeTruthy()
expect(view.queryByText('No changes')).toBeNull()
})
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
const statusRequest = deferred<{
state: 'ok'
@ -761,6 +821,43 @@ describe('WorkspacePanel', () => {
expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy()
})
it('keeps the workspace header controls compact', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-compact-header': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-compact-header': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-compact-header')
const viewMenuButton = view.getByRole('button', { name: 'Changed files' })
const refreshButton = view.getByRole('button', { name: 'Refresh workspace' })
const closeButton = view.getByRole('button', { name: 'Close workspace panel' })
expect(viewMenuButton.className).toContain('text-[14px]')
expect(viewMenuButton.className).not.toContain('text-[18px]')
expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]')
expect(refreshButton.className).toContain('h-7 w-7')
expect(closeButton.className).toContain('h-7 w-7')
expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
})
it('shows explicit empty and error states in the changed view', async () => {
await setWorkspaceState((state) => ({
...state,

View File

@ -242,9 +242,9 @@ function ToolbarIconButton({
type="button"
aria-label={label}
onClick={onClick}
className="inline-flex h-8 w-8 items-center justify-center rounded-[9px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="material-symbols-outlined text-[18px]">{icon}</span>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</button>
)
}
@ -937,7 +937,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
<div
className={`${hasPreviewTabs ? 'basis-[32%] min-w-[220px] max-w-[320px]' : 'w-full'} flex h-full shrink-0 flex-col bg-[var(--color-surface)]`}
>
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-[var(--color-border)] px-3">
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] px-2.5">
<div className="relative min-w-0">
<button
type="button"
@ -945,17 +945,17 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
aria-haspopup="menu"
aria-expanded={isViewMenuOpen}
onClick={() => setIsViewMenuOpen((open) => !open)}
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[8px] px-2.5 py-1.5 text-[18px] font-semibold leading-none text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[7px] px-2 py-1 text-[14px] font-semibold leading-5 text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<span className="truncate">
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
</span>
<span className="material-symbols-outlined shrink-0 text-[18px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
<span className="material-symbols-outlined shrink-0 text-[15px] font-normal text-[var(--color-text-tertiary)]">expand_more</span>
</button>
{isViewMenuOpen && (
<div
role="menu"
className="absolute left-0 top-[calc(100%+6px)] z-30 min-w-[132px] overflow-hidden rounded-[10px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
className="absolute left-0 top-[calc(100%+4px)] z-30 min-w-[124px] overflow-hidden rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)]"
>
{(['changed', 'all'] as const).map((view) => {
const selected = activeView === view
@ -965,7 +965,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
type="button"
role="menuitem"
onClick={() => handleSetActiveView(view)}
className={`flex h-8 w-full items-center gap-2 px-3 text-left text-[13px] transition-colors ${
className={`flex h-7 w-full items-center gap-2 px-2.5 text-left text-[12px] transition-colors ${
selected ? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
}`}
>
@ -973,7 +973,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
{view === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
</span>
{selected && (
<span className="material-symbols-outlined text-[15px] text-[var(--color-brand)]">check</span>
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)]">check</span>
)}
</button>
)

View File

@ -120,6 +120,65 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().errors.statusBySession['session-1']).toBeNull()
})
it('defaults an empty changed-files status to the all-files view', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
useWorkspacePanelStore.getState().openPanel('session-empty-changes')
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('changed')
await useWorkspacePanelStore.getState().loadStatus('session-empty-changes')
expect(useWorkspacePanelStore.getState().statusBySession['session-empty-changes']?.changedFiles).toEqual([])
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-changes')).toBe('all')
})
it('keeps the changed-files view when status contains changes', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 2,
deletions: 1,
},
],
})
useWorkspacePanelStore.getState().openPanel('session-has-changes')
await useWorkspacePanelStore.getState().loadStatus('session-has-changes')
expect(useWorkspacePanelStore.getState().getActiveView('session-has-changes')).toBe('changed')
})
it('does not override an explicit changed-files selection when status is empty', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
useWorkspacePanelStore.getState().openPanel('session-explicit-changed')
useWorkspacePanelStore.getState().setActiveView('session-explicit-changed', 'changed')
await useWorkspacePanelStore.getState().loadStatus('session-explicit-changed')
expect(useWorkspacePanelStore.getState().getActiveView('session-explicit-changed')).toBe('changed')
})
it('captures workspace status request errors', async () => {
mocks.getWorkspaceStatusMock.mockRejectedValue(new Error('status failed'))

View File

@ -38,6 +38,7 @@ export type WorkspacePreviewTab = {
export type WorkspacePanelSessionState = {
isOpen: boolean
activeView: WorkspacePanelView
hasUserSelectedView?: boolean
}
type WorkspacePanelLoadingState = {
@ -246,6 +247,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
[sessionId]: {
...getSessionPanelState(state.panelBySession, sessionId),
activeView: view,
hasUserSelectedView: true,
},
},
})),
@ -274,26 +276,42 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
const result = await sessionsApi.getWorkspaceStatus(sessionId)
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return
set((state) => ({
statusBySession: {
...state.statusBySession,
[sessionId]: result,
},
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: false,
set((state) => {
const panel = getSessionPanelState(state.panelBySession, sessionId)
const shouldDefaultToAllFiles =
!panel.hasUserSelectedView
&& panel.activeView === 'changed'
&& result.state === 'ok'
&& result.changedFiles.length === 0
return {
panelBySession: {
...state.panelBySession,
[sessionId]: {
...panel,
activeView: shouldDefaultToAllFiles ? 'all' : panel.activeView,
},
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: result.error ?? null,
...state.statusBySession,
[sessionId]: result,
},
},
}))
loading: {
...state.loading,
statusBySession: {
...state.loading.statusBySession,
[sessionId]: false,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
[sessionId]: result.error ?? null,
},
},
}
})
} catch (error) {
if (!isLatestRequest(statusRequestIds, sessionId, requestId)) return