fix(desktop): refine diff review workbench #1004

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 15:41:35 +08:00
parent f44442033e
commit cc1eb4ee3a
4 changed files with 289 additions and 175 deletions

View File

@ -49,6 +49,52 @@ describe('WorkspaceDiffSurface', () => {
highlightRenderSpy.mockClear()
})
it('keeps one scroll surface while hiding redundant single-file patch chrome', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" hideSingleFileHeader />)
const scrollSurface = screen.getByTestId('workspace-diff-scroll')
expect(scrollSurface.className).toContain('min-h-0')
expect(scrollSurface.className).toContain('overflow-auto')
expect(scrollSurface).toHaveStyle({ containerType: 'inline-size' })
expect(screen.getByTestId('workspace-diff-content').className).toContain('w-max')
expect(screen.queryByTestId('workspace-diff-file-header')).not.toBeInTheDocument()
expect(screen.queryByText('--- a/src/a.ts')).not.toBeInTheDocument()
expect(screen.queryByText('+++ b/src/a.ts')).not.toBeInTheDocument()
expect(screen.getByText('@@ -10,2 +10,3 @@')).toBeInTheDocument()
expect(getCodeRow('const a = 1')).toBeInTheDocument()
})
it('does not spend the visible line limit on hidden single-file patch metadata', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" hideSingleFileHeader lineLimit={2} />)
expect(screen.getByText('@@ -10,2 +10,3 @@')).toBeInTheDocument()
expect(getCodeRow('const a = 1')).toBeInTheDocument()
expect(document.querySelector('[data-row-text="const b = 2"]')).not.toBeInTheDocument()
})
it('uses the Codex-style compact number gutter without a dedicated comment column', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const code = screen.getByTestId('workspace-code')
const row = getCodeRow('const b = 3').closest<HTMLElement>('[data-diff-row-id]')
const gutter = row?.querySelector<HTMLElement>('[data-diff-number-gutter]')
const commentButton = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
expect(code.style.getPropertyValue('--workspace-diff-gutter-width')).toBe('6ch')
expect(row).toHaveStyle({
gridTemplateColumns: 'var(--workspace-diff-gutter-width) minmax(max-content, 1fr)',
})
expect(gutter).toHaveTextContent('11')
expect(gutter).toContainElement(commentButton)
expect(gutter?.querySelector('[data-diff-gutter-utility-slot]')).toContainElement(commentButton)
expect(gutter?.className).toContain('bg-[var(--color-diff-added-bg)]')
expect(getCodeRow('const a = 1').closest('[data-diff-row-id]')?.querySelector('[data-diff-number-gutter]')?.className).toContain('bg-[var(--color-code-bg)]')
expect(commentButton.className).toContain('h-5')
expect(commentButton.className).toContain('w-5')
expect(row?.querySelectorAll('[data-diff-line-number]')).toHaveLength(1)
expect(row?.className).toContain('min-h-5')
})
it('submits a forward range with its source coordinates and quote', () => {
const onAddComment = vi.fn()
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
@ -104,12 +150,23 @@ describe('WorkspaceDiffSurface', () => {
expect(lastRow).toHaveAttribute('aria-selected', 'true')
expect(lastRow).toHaveAttribute('data-range-edge', 'end')
expect(document.querySelectorAll('[data-diff-selection-rail]')).toHaveLength(3)
document.querySelectorAll('[data-diff-selection-rail]').forEach((rail) => {
expect(rail.closest('[data-diff-number-gutter]')).not.toBeNull()
})
expect(firstRow?.className).toContain('bg-[var(--color-info-container)]')
expect(firstRow?.className).not.toContain('bg-[var(--color-diff-added-bg)]')
expect(screen.getByTestId('workspace-code').className).toContain('text-[13px]')
const editor = screen.getByRole('textbox', { name: 'Review comment' })
expect(editor.closest('[data-diff-editor]')?.className).toContain('rounded-[10px]')
expect(editor.className).toContain('var(--color-info)')
const editorContainer = editor.closest('[data-diff-editor]')
expect(editorContainer?.className).toContain('max-w-3xl')
expect(editorContainer?.className).toContain('sticky')
expect(editorContainer).toHaveStyle({ left: 'var(--workspace-diff-gutter-width)' })
expect(editorContainer).toHaveStyle({ width: 'min(48rem, calc(100cqi - var(--workspace-diff-gutter-width) - 0.75rem))' })
expect(editorContainer?.className).not.toContain('ml-[116px]')
expect(editorContainer?.className).not.toContain('min-w-[420px]')
expect(editorContainer).toHaveTextContent('Local comment')
expect(editor.className).toContain('min-h-0')
expect(editor.className).not.toContain('shadow-[inset_0_0_0_1px')
expect(screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })).not.toHaveAttribute('data-selection-focus')
const focusedGutter = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
expect(focusedGutter).toHaveAttribute('data-selection-focus', 'true')
@ -257,14 +314,14 @@ describe('WorkspaceDiffSurface', () => {
expect(lastVisibleButton).toHaveFocus()
expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
expect(screen.getByText('Showing first 5 of 11 loaded lines.')).toBeInTheDocument()
expect(screen.getByText('Showing first 5 of 9 loaded lines.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Show all loaded lines' })).toBeInTheDocument()
})
it('invalidates a hidden selection on collapse while preserving its draft and visible roving target', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" lineLimit={5} />)
fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }))
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
target: { value: 'Keep this collapsed draft' },
})

View File

@ -5,6 +5,7 @@ import {
useMemo,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type MouseEvent,
} from 'react'
@ -110,6 +111,7 @@ export interface WorkspaceDiffSurfaceProps {
path: string
className?: string
lineLimit?: number
hideSingleFileHeader?: boolean
onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
}
@ -136,6 +138,14 @@ function rowTone(row: WorkspaceDiffRow) {
return 'hover:bg-[var(--color-surface-hover)]'
}
function gutterTone(row: WorkspaceDiffRow, selected: boolean) {
if (selected) return 'bg-[var(--color-info-container)]'
if (row.kind === 'addition') return 'bg-[var(--color-diff-added-bg)]'
if (row.kind === 'deletion') return 'bg-[var(--color-diff-removed-bg)]'
if (row.kind === 'hunk') return 'bg-[var(--color-diff-highlight-bg)]'
return 'bg-[var(--color-code-bg)] group-hover:bg-[var(--color-surface-hover)]'
}
function prefixTone(row: WorkspaceDiffRow) {
if (row.kind === 'addition') return 'text-[var(--color-diff-added-text)]'
if (row.kind === 'deletion') return 'text-[var(--color-diff-removed-text)]'
@ -148,19 +158,40 @@ function codeTone(row: WorkspaceDiffRow) {
return ''
}
function isStructuralMetadata(row: WorkspaceDiffRow) {
if (row.kind !== 'metadata') return false
return row.text.startsWith('diff --') || row.text.startsWith('--- ') || row.text.startsWith('+++ ')
}
export function WorkspaceDiffSurface({
value,
path,
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
hideSingleFileHeader = false,
onAddComment,
}: WorkspaceDiffSurfaceProps) {
const t = useTranslation()
const files = useMemo(() => parseWorkspaceDiff(value), [value])
const rows = useMemo(() => files.flatMap((file) => file.rows), [files])
const lineNumberCharacters = useMemo(
() => rows.reduce((maximum, row) => Math.max(
maximum,
row.oldLine === null ? 0 : String(row.oldLine).length,
row.newLine === null ? 0 : String(row.newLine).length,
), 3),
[rows],
)
const codeStyle = {
'--workspace-diff-gutter-width': `${lineNumberCharacters + 3}ch`,
} as CSSProperties
const showFileHeaders = !hideSingleFileHeader || files.length > 1
const displayItemIds = useMemo(
() => files.flatMap((file) => [`${file.id}-header`, ...file.rows.map((row) => row.id)]),
[files],
() => files.flatMap((file) => [
...(showFileHeaders ? [`${file.id}-header`] : []),
...file.rows.filter((row) => !isStructuralMetadata(row)).map((row) => row.id),
]),
[files, showFileHeaders],
)
const [review, setReview] = useState<ReviewState>(emptyReviewState)
const [status, setStatus] = useState<ReviewStatus>(null)
@ -402,21 +433,26 @@ export function WorkspaceDiffSurface({
const renderEditor = () => review.selection && (
<div
data-diff-editor=""
className="my-3 ml-[116px] mr-4 min-w-[420px] rounded-[10px] bg-[var(--color-surface-container-low)] p-3 shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_9%,transparent)]"
className="sticky z-[2] my-1.5 min-w-[280px] max-w-3xl overflow-hidden rounded-[10px] bg-[var(--color-surface-container-low)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_9%,transparent)]"
style={{
left: 'var(--workspace-diff-gutter-width)',
width: 'min(48rem, calc(100cqi - var(--workspace-diff-gutter-width) - 0.75rem))',
}}
>
{status && (
<div role="status" aria-live="polite" className="mb-1.5 text-[11px] text-[var(--color-warning)]">
{t(`workspace.diffReview.${status}`)}
</div>
)}
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] bg-[var(--color-info-container)] text-[var(--color-info)]">
<div className="flex min-h-10 items-center gap-2 px-3 pt-2.5">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-[6px] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]">
<MessageSquare aria-hidden="true" size={14} />
</span>
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">{t('workspace.localComment')}</div>
<div className="ml-auto text-[11px] text-[var(--color-text-tertiary)]">
{sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
</div>
</div>
{status && (
<div role="status" aria-live="polite" className="px-3 pt-1.5 text-[11px] text-[var(--color-warning)]">
{t(`workspace.diffReview.${status}`)}
</div>
)}
<textarea
ref={editorRef}
aria-label={t('workspace.diffReview.editorLabel')}
@ -424,10 +460,10 @@ export function WorkspaceDiffSurface({
placeholder={t('workspace.commentPlaceholder')}
onChange={(event) => setReview((current) => ({ ...current, draft: event.target.value }))}
onKeyDown={handleEditorKeyDown}
rows={3}
className="block min-h-[88px] w-full resize-y rounded-[8px] bg-[var(--color-surface)] px-3 py-2.5 font-[var(--font-body)] text-[13px] leading-5 text-[var(--color-text-primary)] outline-none shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_10%,transparent)] transition-[box-shadow] duration-200 ease-out placeholder:text-[var(--color-text-tertiary)] focus:shadow-[inset_0_0_0_1px_var(--color-info),0_0_0_3px_color-mix(in_srgb,var(--color-info)_12%,transparent)]"
rows={2}
className="block min-h-0 w-full resize-y bg-transparent px-3 py-2 font-[var(--font-body)] text-[13px] leading-5 text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
<div className="mt-2.5 flex items-center justify-end gap-2">
<div className="flex items-center justify-end gap-2 px-2 pb-2">
<button
type="button"
onClick={closeEditor}
@ -450,18 +486,19 @@ export function WorkspaceDiffSurface({
)
return (
<div className={className}>
<div className="relative min-w-max pb-3">
<div data-testid="workspace-diff-scroll" className={className} style={{ containerType: 'inline-size' }}>
<div data-testid="workspace-diff-content" className="relative min-w-full w-max pb-3">
<div
data-workspace-code=""
data-testid="workspace-code"
role="grid"
aria-label={`${path} diff`}
className="m-0 font-[var(--font-mono)] text-[13px] leading-[1.65] text-[var(--color-code-fg)]"
className="m-0 min-w-full font-[var(--font-mono)] text-[13px] leading-5 text-[var(--color-code-fg)]"
style={codeStyle}
>
{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 && (
<span
data-diff-selection-rail=""
aria-hidden="true"
className={`absolute inset-y-0 left-0 w-[3px] bg-[var(--color-info)] ${
rangeEdge === 'single' ? 'rounded-sm' : rangeEdge === 'start' ? 'rounded-t-sm' : rangeEdge === 'end' ? 'rounded-b-sm' : ''
}`}
/>
)}
<span className="select-none self-center text-right text-[11px] text-[var(--color-text-tertiary)]">
{row.oldLine ?? ''}
</span>
<span className="select-none self-center text-right text-[11px] text-[var(--color-text-tertiary)]">
{row.newLine ?? ''}
</span>
<span className="flex h-[30px] w-[30px] items-center justify-center">
{row.selectable && row.side && line !== null && (
<button
ref={(element) => {
if (element) buttonRefs.current.set(row.id, element)
else buttonRefs.current.delete(row.id)
}}
type="button"
aria-label={t('workspace.diffReview.commentLineAria', {
path,
side: sideLabel(row.side),
line,
})}
aria-pressed={selected}
data-selection-focus={selectionFocus ? 'true' : undefined}
tabIndex={row.id === rovingId ? 0 : -1}
onClick={(event) => handleRowClick(event, row)}
onFocus={() => setRovingId(row.id)}
onKeyDown={(event) => handleRowKeyDown(event, row)}
className={`inline-flex h-7 w-7 items-center justify-center rounded-[6px] transition-[color,background-color,opacity,transform] duration-200 ease-out hover:bg-[var(--color-info)] hover:text-[var(--color-surface)] active:scale-[0.96] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--color-info)] ${
selectionFocus ? 'bg-[var(--color-info)] text-[var(--color-surface)] opacity-100' : 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 focus:opacity-100'
<span
data-diff-number-gutter=""
className={`sticky left-0 z-[1] flex min-h-full select-none items-center justify-end pl-[2ch] pr-[1ch] text-right text-[11px] text-[var(--color-text-tertiary)] ${gutterTone(row, selected)}`}
>
{selected && (
<span
data-diff-selection-rail=""
aria-hidden="true"
className={`absolute inset-y-0 left-0 w-[3px] bg-[var(--color-info)] ${
rangeEdge === 'single' ? 'rounded-sm' : rangeEdge === 'start' ? 'rounded-t-sm' : rangeEdge === 'end' ? 'rounded-b-sm' : ''
}`}
/>
)}
<span
data-diff-line-number=""
className={`transition-opacity duration-100 group-hover:opacity-0 group-focus-within:opacity-0 ${selectionFocus ? 'opacity-0' : ''}`}
>
{line ?? ''}
</span>
{row.selectable && row.side && line !== null && (
<span
data-diff-gutter-utility-slot=""
className="absolute inset-y-0 right-0 flex items-center justify-end"
>
{selected ? <MessageSquare aria-hidden="true" size={14} /> : <Plus aria-hidden="true" size={14} />}
</button>
<button
ref={(element) => {
if (element) buttonRefs.current.set(row.id, element)
else buttonRefs.current.delete(row.id)
}}
type="button"
aria-label={t('workspace.diffReview.commentLineAria', {
path,
side: sideLabel(row.side),
line,
})}
aria-pressed={selected}
data-selection-focus={selectionFocus ? 'true' : undefined}
tabIndex={row.id === rovingId ? 0 : -1}
onClick={(event) => handleRowClick(event, row)}
onFocus={() => setRovingId(row.id)}
onKeyDown={(event) => handleRowKeyDown(event, row)}
className={`relative right-[calc(1ch-1.25rem)] inline-flex h-5 w-5 items-center justify-center rounded-[4px] transition-[color,background-color,opacity,transform] duration-100 ease-out before:absolute before:-inset-1 hover:bg-[var(--color-info)] hover:text-[var(--color-surface)] active:scale-[0.96] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-1px] focus-visible:outline-[var(--color-info)] ${
selectionFocus ? 'bg-[var(--color-info)] text-[var(--color-surface)] opacity-100' : 'text-[var(--color-text-tertiary)] opacity-0 group-hover:opacity-100 focus:opacity-100'
}`}
>
{selected ? <MessageSquare aria-hidden="true" size={12} /> : <Plus aria-hidden="true" size={13} />}
</button>
</span>
)}
</span>
<span className={`select-none self-center text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
<span
data-row-text={row.text}
data-selected={selected ? 'true' : undefined}
className={`whitespace-pre self-center pr-6 ${codeTone(row)}`}
className="whitespace-pre self-center pr-6"
>
{row.selectable && row.text && !usePlainLargePreview
? <InlineHighlightedCode value={row.text} language={language} />
: row.text || ' '}
<span className={`inline-block w-[2ch] select-none text-center ${prefixTone(row)}`}>{row.prefix || ' '}</span>
<span className={codeTone(row)}>
{row.selectable && row.text && !usePlainLargePreview
? <InlineHighlightedCode value={row.text} language={language} />
: row.text || ' '}
</span>
</span>
</div>
{review.selection?.endId === row.id ? renderEditor() : null}

View File

@ -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)
})

View File

@ -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({
}`}
>
<span
className="inline-flex h-6 min-w-6 shrink-0 items-center justify-center rounded-[6px] text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-secondary)]"
className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center text-[var(--color-text-tertiary)] transition-colors group-hover:text-[var(--color-text-secondary)]"
title={identity.languageLabel}
>
<IdentityIcon aria-hidden="true" size={14} strokeWidth={1.8} />
</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-1.5">
<div className="flex min-w-0 items-baseline">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{fileName}</span>
<span className="shrink-0 text-[9px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
<span className="sr-only">
{identity.shortLabel}
<span className="sr-only"> {identity.languageLabel}</span>
<span> {identity.languageLabel}</span>
</span>
</div>
{file.oldPath && (
@ -851,7 +851,7 @@ function ChangedFileRow({
</div>
)}
</div>
<div className="shrink-0 text-right font-[var(--font-mono)] text-[11px] leading-4">
<div className="shrink-0 text-right font-[var(--font-mono)] text-[10px] leading-4">
<span className="text-[var(--color-success)]">+{file.additions}</span>
<span className="ml-1 text-[var(--color-error)]">-{file.deletions}</span>
</div>
@ -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
>
<div
data-testid="workspace-preview-header"
className="flex h-11 shrink-0 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-4 text-[12px]"
className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-3 text-[12px]"
>
<FileText size={15} strokeWidth={1.8} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate font-medium text-[var(--color-text-primary)]">{activePreviewTab.path}</span>
<span className="shrink-0 text-[10px] font-medium uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{kindLabel}
</span>
<button
type="button"
aria-label={t('workspace.addToChat')}
onClick={() => addWorkspacePathToChat(activePreviewTab.path)}
className="ml-auto inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-[color,background-color,transform] duration-200 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
<MessageCircle size={14} strokeWidth={1.8} aria-hidden="true" />
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
</button>
{activePreviewLoading && state === 'ok' && (
<RefreshCw
size={13}
className="shrink-0 animate-spin text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
{activeChangedFile && (
<span className="flex shrink-0 items-center gap-1.5 font-[var(--font-mono)] text-[11px] tabular-nums">
<span className="text-[var(--color-success)]">+{activeChangedFile.additions}</span>
<span className="text-[var(--color-error)]">-{activeChangedFile.deletions}</span>
</span>
)}
<div className="ml-auto flex shrink-0 items-center gap-0.5">
<button
type="button"
aria-label={t('workspace.addToChat')}
onClick={() => addWorkspacePathToChat(activePreviewTab.path)}
className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] transition-[color,background-color,transform] duration-200 ease-out hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
<MessageCircle size={14} strokeWidth={1.8} aria-hidden="true" />
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
</button>
<ToolbarIconButton Icon={RefreshCw} label={t('workspace.refresh')} onClick={handleRefresh} />
<ToolbarIconButton
Icon={isNavigatorVisible ? PanelRightClose : PanelRightOpen}
label={isNavigatorVisible ? t('workspace.hideNavigator') : t('workspace.showNavigator')}
onClick={() => setIsNavigatorOpen((open) => !open)}
/>
{!embedded && (
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
)}
{activePreviewLoading && state === 'ok' && (
<RefreshCw
size={13}
className="mx-1 shrink-0 animate-spin text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
)}
</div>
</div>
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
@ -1435,6 +1442,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
<WorkspaceDiffSurface
value={activePreviewTab.diff ?? ''}
path={activePreviewTab.path}
hideSingleFileHeader
onAddComment={(selection, note) => 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 }}
>
<header
data-testid="workspace-review-toolbar"
aria-label={t('workspace.reviewWorkspace')}
className="flex h-12 shrink-0 items-center gap-2.5 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-4"
>
<GitCompareArrows size={16} strokeWidth={1.8} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate text-[13px] font-semibold text-[var(--color-text-primary)]">
{status?.repoName || t('workbench.tabTitle')}
</span>
{status?.branch && (
<>
<span aria-hidden="true" className="h-4 w-px bg-[var(--color-text-primary)]/10" />
<span className="max-w-32 truncate text-[11px] text-[var(--color-text-tertiary)]">{status.branch}</span>
</>
)}
<div className="flex shrink-0 items-center gap-2 font-[var(--font-mono)] text-[11px] tabular-nums">
<span className="text-[var(--color-success)]">+{changeTotals.additions}</span>
<span className="text-[var(--color-error)]">-{changeTotals.deletions}</span>
</div>
<div className="ml-auto flex shrink-0 items-center gap-0.5">
<ToolbarIconButton Icon={RefreshCw} label={t('workspace.refresh')} onClick={handleRefresh} />
{hasPreviewTabs && (
<ToolbarIconButton
Icon={isNavigatorVisible ? PanelRightClose : PanelRightOpen}
label={isNavigatorVisible ? t('workspace.hideNavigator') : t('workspace.showNavigator')}
onClick={() => setIsNavigatorOpen((open) => !open)}
/>
)}
{!embedded && (
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
)}
</div>
</header>
<div
data-testid="workspace-review-layout"
className={`relative grid min-h-0 flex-1 ${hasPreviewTabs && isNavigatorVisible && forceVisible ? 'grid-cols-[minmax(0,1fr)_280px]' : 'grid-cols-1'}`}
className={`relative grid min-h-0 flex-1 overflow-hidden ${hasPreviewTabs && isNavigatorVisible && forceVisible ? 'grid-cols-[minmax(0,1fr)_280px]' : 'grid-cols-1'}`}
>
{hasPreviewTabs && (
<div className="flex min-w-0 flex-col bg-[var(--color-surface)]">
<div data-testid="workspace-preview-column" className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-[var(--color-surface)]">
{previewTabs.length > 1 ? renderPreviewTabs() : null}
{renderPreviewContent()}
</div>
@ -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)]`}
>
<div className="flex h-11 shrink-0 items-center gap-1.5 border-b border-[var(--color-text-primary)]/10 px-3.5">
{!hasPreviewTabs && (
<header
data-testid="workspace-file-navigator-header"
className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-text-primary)]/10 px-3"
>
<div className="relative min-w-0">
<button
type="button"
@ -1679,12 +1656,25 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
</div>
)}
</div>
</div>
{!hasPreviewTabs && (
<div className="ml-auto flex shrink-0 items-center gap-0.5">
<ToolbarIconButton Icon={RefreshCw} label={t('workspace.refresh')} onClick={handleRefresh} />
{!embedded && (
<ToolbarIconButton Icon={X} label={t('workspace.closePanel')} onClick={() => closePanel(sessionId)} />
)}
</div>
)}
</header>
)}
<WorkspaceFilterInput value={filterQuery} onChange={setFilterQuery} summary={filterSummary} />
<WorkspaceFilterInput
value={filterQuery}
onChange={setFilterQuery}
summary={normalizedFilterQuery ? filterSummary : undefined}
/>
<div className="min-h-0 flex-1 overflow-auto py-1.5">
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
{navigatorView === 'changed' ? renderChangedView() : renderAllFilesView()}
</div>
</div>
)}