diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index a517c878..e810c544 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -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, diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index c1a8302c..76fad69d 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -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" > - {icon} + {icon} ) } @@ -937,7 +937,7 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
-
+
{isViewMenuOpen && (
{(['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')} {selected && ( - check + check )} ) diff --git a/desktop/src/stores/workspacePanelStore.test.ts b/desktop/src/stores/workspacePanelStore.test.ts index bfa8c4db..3cefd8bf 100644 --- a/desktop/src/stores/workspacePanelStore.test.ts +++ b/desktop/src/stores/workspacePanelStore.test.ts @@ -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')) diff --git a/desktop/src/stores/workspacePanelStore.ts b/desktop/src/stores/workspacePanelStore.ts index 5df7acca..3e7f61c1 100644 --- a/desktop/src/stores/workspacePanelStore.ts +++ b/desktop/src/stores/workspacePanelStore.ts @@ -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((set, get) => [sessionId]: { ...getSessionPanelState(state.panelBySession, sessionId), activeView: view, + hasUserSelectedView: true, }, }, })), @@ -274,26 +276,42 @@ export const useWorkspacePanelStore = create((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