{files.map((file) => {
- const headerVisible = visibleItemIds.has(`${file.id}-header`)
- const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id))
+ const headerVisible = showFileHeaders && visibleItemIds.has(`${file.id}-header`)
+ const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id) && !isStructuralMetadata(row))
if (!headerVisible && fileRows.length === 0) return null
const oldPath = file.oldPath ? `a/${file.oldPath}` : '/dev/null'
const newPath = file.newPath ? `b/${file.newPath}` : '/dev/null'
@@ -512,61 +549,72 @@ export function WorkspaceDiffSurface({
aria-selected={selected}
data-diff-row-id={row.id}
data-range-edge={rangeEdge}
- className={`group relative grid min-h-[30px] min-w-full w-max grid-cols-[46px_46px_30px_20px_minmax(max-content,1fr)] items-stretch px-3 ${
+ className={`group relative grid min-w-full w-max items-stretch ${row.kind === 'hunk' ? 'min-h-8' : 'min-h-5'} ${
selected ? 'bg-[var(--color-info-container)]' : rowTone(row)
}`}
+ style={{ gridTemplateColumns: 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)' }}
>
- {selected && (
-
- )}
-
- {row.oldLine ?? ''}
-
-
- {row.newLine ?? ''}
-
-
- {row.selectable && row.side && line !== null && (
-
+
+
)}
-
{row.prefix || ' '}
- {row.selectable && row.text && !usePlainLargePreview
- ?
- : row.text || ' '}
+ {row.prefix || ' '}
+
+ {row.selectable && row.text && !usePlainLargePreview
+ ?
+ : row.text || ' '}
+
{review.selection?.endId === row.id ? renderEditor() : null}
diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx
index 87979bef..28cf93f2 100644
--- a/desktop/src/components/workspace/WorkspacePanel.test.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx
@@ -417,18 +417,27 @@ describe('WorkspacePanel', () => {
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
})
expect(view.queryByRole('tablist', { name: 'Preview tabs' })).toBeNull()
- expect(view.getByTestId('workspace-preview-header').textContent).toContain('src/app.ts')
+ const previewHeader = view.getByTestId('workspace-preview-header')
+ expect(previewHeader.textContent).toContain('src/app.ts')
+ expect(previewHeader.textContent).toContain('+4')
+ expect(previewHeader.textContent).toContain('-1')
+ expect(view.queryByTestId('workspace-review-toolbar')).toBeNull()
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
+ expect(view.getByTestId('workspace-review-layout').className).toContain('overflow-hidden')
+ expect(view.getByTestId('workspace-preview-column').className).toContain('min-h-0')
+ expect(view.getByTestId('workspace-preview-column').className).toContain('overflow-hidden')
+ expect(previewHeader.textContent).not.toContain('DIFF')
expect(view.queryByTestId('workspace-file-navigator')).toBeNull()
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
expect(view.getByTestId('workspace-file-navigator').className).toContain('absolute')
+ expect(view.queryByTestId('workspace-file-navigator-header')).toBeNull()
+ expect(view.queryByText('1 file')).toBeNull()
expect(view.getByTestId('workspace-file-navigator').className).toContain('w-[min(280px,100%)]')
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
const expandedPanel = view.getByTestId('workspace-panel')
expect(expandedPanel.style.width).toBe('860px')
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
- expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})
it.each([
@@ -482,7 +491,7 @@ describe('WorkspacePanel', () => {
expect(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
})
- it('presents one review toolbar and reports filtered changed-file results', async () => {
+ it('keeps navigator controls and filter feedback in one header when no file is open', async () => {
const sessionId = 'session-review-toolbar'
await setWorkspaceState((state) => ({
...state,
@@ -509,16 +518,16 @@ describe('WorkspacePanel', () => {
const view = await renderPanel(sessionId)
- const toolbar = view.getByTestId('workspace-review-toolbar')
- expect(toolbar.getAttribute('aria-label')).toBe('Review workspace')
- expect(toolbar.tagName).toBe('HEADER')
- expect(toolbar.textContent).toContain('claude-code-haha')
- expect(toolbar.textContent).toContain('main')
- expect(toolbar.textContent).toContain('+15')
- expect(toolbar.textContent).toContain('-3')
+ expect(view.queryByTestId('workspace-review-toolbar')).toBeNull()
+ const navigatorHeader = view.getByTestId('workspace-file-navigator-header')
+ expect(navigatorHeader.tagName).toBe('HEADER')
+ expect(navigatorHeader.textContent).toContain('Changed files')
+ expect(view.getByRole('button', { name: /Refresh/ })).toBeTruthy()
+ expect(view.queryByText('claude-code-haha')).toBeNull()
+ expect(view.queryByText('main')).toBeNull()
const filter = view.getByPlaceholderText('Filter files...')
- expect(view.getByText('3 files')).toBeTruthy()
+ expect(view.queryByText('3 files')).toBeNull()
fireEvent.change(filter, { target: { value: 'theme' } })
expect(view.getByText('1 of 3 files')).toBeTruthy()
@@ -566,6 +575,9 @@ describe('WorkspacePanel', () => {
const view = await renderPanel(sessionId, { embedded: true, forceVisible: true })
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-[minmax(0,1fr)_280px]')
+ expect(view.getByTestId('workspace-review-layout').className).toContain('overflow-hidden')
+ expect(view.getByTestId('workspace-preview-column').className).toContain('min-h-0')
+ expect(view.getByTestId('workspace-preview-column').className).toContain('overflow-hidden')
expect(view.getByTestId('workspace-file-navigator').className).not.toContain('absolute')
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
})
@@ -644,7 +656,7 @@ describe('WorkspacePanel', () => {
const oldPath = view.getByText('desktop/src/components/workspace/LegacyWorkspacePanelWithALongName.tsx')
const renamedRow = oldPath.closest('button')
- expect(renamedRow?.className).toContain('min-h-[52px]')
+ expect(renamedRow?.className).toContain('min-h-11')
expect(renamedRow?.className).not.toContain('h-9')
expect(view.getByText('next.ts')).toBeTruthy()
})
@@ -844,7 +856,7 @@ describe('WorkspacePanel', () => {
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
expect(await view.findByText('src')).toBeTruthy()
expect(await view.findByText('README.md')).toBeTruthy()
- expect(view.getByRole('status').textContent).toBe('2 items')
+ expect(view.queryByRole('status')).toBeNull()
expect(view.queryByText('No changes')).toBeNull()
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'readme' } })
@@ -965,7 +977,6 @@ describe('WorkspacePanel', () => {
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true')
})
- expect(view.getAllByText('File').length).toBeGreaterThan(0)
})
it('renders multiple preview tabs and closes only the exact requested tab', async () => {
@@ -1097,13 +1108,13 @@ describe('WorkspacePanel', () => {
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
- expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
+ expect(view.queryByRole('button', { name: 'Changed files' })).toBeNull()
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
})
- it('defers all-files tree loading while the file navigator is hidden behind a preview', async () => {
+ it('keeps a preview navigator scoped to changed files when the previous view was all files', async () => {
getMocks().getWorkspaceTreeMock.mockResolvedValue({
state: 'ok',
path: '',
@@ -1128,7 +1139,12 @@ describe('WorkspacePanel', () => {
repoName: 'repo',
branch: 'main',
isGitRepo: true,
- changedFiles: [],
+ changedFiles: [{
+ path: 'src/app.ts',
+ status: 'modified',
+ additions: 2,
+ deletions: 1,
+ }],
},
},
previewTabsBySession: {
@@ -1156,11 +1172,10 @@ describe('WorkspacePanel', () => {
expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
+ await flushReactWork()
- await waitFor(() => {
- expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-preview-hidden-tree', '')
- })
- expect(view.getByText('src')).toBeTruthy()
+ expect(getMocks().getWorkspaceTreeMock).not.toHaveBeenCalled()
+ expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
})
it('uses theme tokens for the panel, preview header, and code surface in dark mode', async () => {
@@ -1268,7 +1283,8 @@ describe('WorkspacePanel', () => {
expect(highlightedCode).toContain('+line 1')
expect(highlightedCode).toContain('+line 1999')
- expect(highlightedCode).not.toContain('+line 2000')
+ expect(highlightedCode).toContain('+line 2000')
+ expect(highlightedCode).not.toContain('+line 2001')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
@@ -1325,7 +1341,10 @@ describe('WorkspacePanel', () => {
expect(firstRow?.className).toContain('w-max')
expect(firstRow?.className).toContain('min-w-full')
- expect(firstRow?.className).toContain('grid-cols-[46px_46px_30px_20px_minmax(max-content,1fr)]')
+ expect((firstRow as HTMLElement | null)?.style.gridTemplateColumns).toBe(
+ 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)',
+ )
+ expect(firstRow?.querySelector('[data-diff-number-gutter]')).toBeTruthy()
expect(diffSurface.textContent).toContain(longDiffLine)
})
diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx
index a4014eff..2f46919e 100644
--- a/desktop/src/components/workspace/WorkspacePanel.tsx
+++ b/desktop/src/components/workspace/WorkspacePanel.tsx
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
-import { CircleAlert, Code2, File as FileIcon, FileText, FolderOpen, GitCompareArrows, Image as ImageIcon, MessageCircle, PanelRightClose, PanelRightOpen, RefreshCw, Search, Settings2, X, type LucideIcon } from 'lucide-react'
+import { CircleAlert, Code2, File as FileIcon, FileText, FolderOpen, Image as ImageIcon, MessageCircle, PanelRightClose, PanelRightOpen, RefreshCw, Search, Settings2, X, type LucideIcon } from 'lucide-react'
import { Highlight } from 'prism-react-renderer'
import type {
WorkspaceChangedFile,
@@ -823,8 +823,8 @@ function ChangedFileRow({
data-workspace-file-row=""
data-workspace-file-path={file.path}
title={file.path}
- className={`group mx-2 flex w-[calc(100%-16px)] items-center gap-2 rounded-[6px] px-2.5 text-left transition-[background-color,transform] duration-200 ease-out active:scale-[0.99] ${
- file.oldPath ? 'min-h-[52px] py-1.5' : 'h-10'
+ className={`group mx-2 flex w-[calc(100%-16px)] items-center gap-1.5 rounded-[5px] px-2 text-left transition-[background-color,transform] duration-150 ease-out active:scale-[0.99] ${
+ file.oldPath ? 'min-h-11 py-1' : 'h-[30px]'
} ${
active
? 'bg-[var(--color-info-container)] shadow-[inset_3px_0_0_var(--color-info)]'
@@ -832,17 +832,17 @@ function ChangedFileRow({
}`}
>
-
+
{fileName}
-
+
{identity.shortLabel}
- {identity.languageLabel}
+ {identity.languageLabel}
{file.oldPath && (
@@ -851,7 +851,7 @@ function ChangedFileRow({
)}
-
+
+{file.additions}
-{file.deletions}
@@ -1032,7 +1032,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
previewTabs.find((tab) => tab.id === activePreviewTabId) ?? previewTabs[previewTabs.length - 1] ?? null
const hasPreviewTabs = previewTabs.length > 0
const isNavigatorVisible = !hasPreviewTabs || isNavigatorOpen
+ const navigatorView = hasPreviewTabs ? 'changed' : activeView
const activeTreePath = activePreviewTab?.kind === 'file' ? activePreviewTab.path : null
+ const activeChangedFile = activePreviewTab
+ ? status?.changedFiles.find((file) => file.path === activePreviewTab.path) ?? null
+ : null
const filteredChangedFiles = useMemo(
() => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)),
[normalizedFilterQuery, status?.changedFiles],
@@ -1041,27 +1045,17 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
() => groupChangedFiles(filteredChangedFiles),
[filteredChangedFiles],
)
- const changeTotals = useMemo(
- () => (status?.changedFiles ?? []).reduce(
- (totals, file) => ({
- additions: totals.additions + file.additions,
- deletions: totals.deletions + file.deletions,
- }),
- { additions: 0, deletions: 0 },
- ),
- [status?.changedFiles],
- )
const filteredRootEntries = useMemo(
() => rootTree?.state === 'ok'
? rootTree.entries.filter((entry) => treeEntryMatchesFilter(entry, normalizedFilterQuery, treeByPath))
: [],
[normalizedFilterQuery, rootTree, treeByPath],
)
- const visibleEntryCount = activeView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
- const totalEntryCount = activeView === 'changed'
+ const visibleEntryCount = navigatorView === 'changed' ? filteredChangedFiles.length : filteredRootEntries.length
+ const totalEntryCount = navigatorView === 'changed'
? status?.changedFiles.length ?? 0
: rootTree?.state === 'ok' ? rootTree.entries.length : 0
- const filterSummary = activeView === 'changed'
+ const filterSummary = navigatorView === 'changed'
? normalizedFilterQuery
? t('workspace.filteredFilesCount', { visible: visibleEntryCount, total: totalEntryCount })
: t('workspace.filesCount', { count: totalEntryCount })
@@ -1100,9 +1094,9 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
}, [chatState, loadStatus, sessionId, shouldRender, statusLoading])
useEffect(() => {
- if (!shouldRender || !isNavigatorVisible || activeView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
+ if (!shouldRender || !isNavigatorVisible || navigatorView !== 'all' || rootTree || rootTreeLoading || rootTreeError) return
void loadTree(sessionId, '')
- }, [activeView, isNavigatorVisible, loadTree, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
+ }, [isNavigatorVisible, loadTree, navigatorView, rootTree, rootTreeError, rootTreeLoading, sessionId, shouldRender])
useEffect(() => {
if (!previewTabContextMenu && !fileContextMenu) return
@@ -1137,7 +1131,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
if (activePreviewTab) {
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
}
- if (activeView === 'all') {
+ if (navigatorView === 'all') {
void loadTree(sessionId, '')
}
}
@@ -1370,7 +1364,6 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
)
}
- const kindLabel = getPreviewKindLabel(t, activePreviewTab.kind)
const state = activePreviewTab.state ?? 'loading'
const refreshErrorMessage = activePreviewError
|| (activePreviewRefreshState ? getInlineStateMessage(t, activePreviewRefreshState) : null)
@@ -1383,29 +1376,43 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
>
{activePreviewTab.path}
-
- {kindLabel}
-
-
- {activePreviewLoading && state === 'ok' && (
-
+ {activeChangedFile && (
+
+ +{activeChangedFile.additions}
+ -{activeChangedFile.deletions}
+
)}
+
+
+
+ setIsNavigatorOpen((open) => !open)}
+ />
+ {!embedded && (
+ closePanel(sessionId)} />
+ )}
+ {activePreviewLoading && state === 'ok' && (
+
+ )}
+
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
@@ -1435,6 +1442,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
addDiffCommentToChat(activePreviewTab.path, selection, note)}
/>
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
@@ -1584,47 +1592,12 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
}
style={embedded ? undefined : { width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
>
-
-
-
- {status?.repoName || t('workbench.tabTitle')}
-
- {status?.branch && (
- <>
-
- {status.branch}
- >
- )}
-
- +{changeTotals.additions}
- -{changeTotals.deletions}
-
-
-
-
- {hasPreviewTabs && (
- setIsNavigatorOpen((open) => !open)}
- />
- )}
- {!embedded && (
- closePanel(sessionId)} />
- )}
-
-
-
{hasPreviewTabs && (
-
+
{previewTabs.length > 1 ? renderPreviewTabs() : null}
{renderPreviewContent()}
@@ -1635,7 +1608,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
data-testid="workspace-file-navigator"
className={`${hasPreviewTabs ? 'border-l border-[var(--color-text-primary)]/10' : ''} ${hasPreviewTabs && !forceVisible ? 'absolute inset-y-0 right-0 z-20 w-[min(280px,100%)] shadow-[-12px_0_28px_rgba(15,23,42,0.08)]' : ''} flex min-h-0 flex-col bg-[var(--color-surface)]`}
>
-
+ {!hasPreviewTabs && (
+
+ {!hasPreviewTabs && (
+
+
+ {!embedded && (
+ closePanel(sessionId)} />
+ )}
+
+ )}
+
+ )}
-
+
- {activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
+ {navigatorView === 'changed' ? renderChangedView() : renderAllFilesView()}
)}