feat(desktop): redesign diff review workbench #1004

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 13:33:05 +08:00
parent 9734ad62b1
commit f85b93e9c2
11 changed files with 586 additions and 160 deletions

View File

@ -71,6 +71,18 @@ describe('WorkbenchPanel', () => {
expect(screen.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'false')
})
it('exposes a single compact workbench navigation landmark', () => {
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" />)
const navigation = screen.getByTestId('workbench-navigation')
expect(navigation).toHaveAttribute('aria-label', 'Workbench navigation')
expect(navigation).toContainElement(screen.getByRole('button', { name: 'Back to conversation' }))
expect(navigation).toContainElement(screen.getByRole('tablist', { name: 'Workbench mode' }))
expect(navigation).toContainElement(screen.getByRole('button', { name: 'Close' }))
expect(navigation.className).toContain('h-12')
expect(screen.getByRole('tablist', { name: 'Workbench mode' }).className).not.toContain('border')
})
it('switching to the browser tab calls setMode("browser")', () => {
render(<WorkbenchPanel sessionId={SESSION_ID} />)
expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('workspace')

View File

@ -72,12 +72,16 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
return (
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5">
<header
data-testid="workbench-navigation"
aria-label={t('workbench.navigation')}
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"
>
{isTabVariant && (
<button
type="button"
onClick={handleReturn}
className="inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[12px] font-medium text-[var(--color-text-secondary)] 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-8 shrink-0 items-center gap-1.5 rounded-[7px] px-2 text-[12px] 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] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30"
>
<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />
<span>{t('workbench.backToConversation')}</span>
@ -86,7 +90,7 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
<div
role="tablist"
aria-label={t('workbench.modeSwitch')}
className="inline-flex items-center gap-0.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5"
className="inline-flex items-center gap-0.5 rounded-[8px] bg-[var(--color-surface-container)] p-0.5"
>
{MODE_ITEMS.map(({ mode: itemMode, labelKey, Icon }) => {
const isActive = mode === itemMode
@ -97,9 +101,9 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
role="tab"
aria-selected={isActive}
onClick={() => handleModeSelect(itemMode)}
className={`inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 ${
className={`inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] font-medium transition-[color,background-color,transform] duration-200 ease-out active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30 ${
isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]'
? 'bg-[var(--color-surface)] text-[var(--color-text-primary)] shadow-[0_1px_2px_rgba(15,23,42,0.08)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
@ -117,7 +121,7 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
aria-label={t('workbench.expand')}
title={t('workbench.expand')}
onClick={handleExpand}
className="inline-flex h-7 w-7 shrink-0 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"
className="inline-flex h-7 w-7 shrink-0 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-info)]/30"
>
<Maximize2 size={15} strokeWidth={2} aria-hidden="true" />
</button>
@ -126,12 +130,12 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
type="button"
aria-label={t('workbench.close')}
onClick={handleClose}
className="inline-flex h-7 w-7 shrink-0 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"
className="inline-flex h-7 w-7 shrink-0 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-info)]/30"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
{mode === 'browser' ? (

View File

@ -88,6 +88,53 @@ describe('WorkspaceDiffSurface', () => {
expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
})
it('exposes the selected Shift range on the diff rows and review rail', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' }))
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }), { shiftKey: true })
const firstRow = getCodeRow('const a = 1').closest('[data-diff-row-id]')
const middleRow = getCodeRow('const b = 3').closest('[data-diff-row-id]')
const lastRow = getCodeRow('const c = 4').closest('[data-diff-row-id]')
expect(firstRow).toHaveAttribute('aria-selected', 'true')
expect(firstRow).toHaveAttribute('data-range-edge', 'start')
expect(middleRow).toHaveAttribute('aria-selected', 'true')
expect(middleRow).not.toHaveAttribute('data-range-edge')
expect(lastRow).toHaveAttribute('aria-selected', 'true')
expect(lastRow).toHaveAttribute('data-range-edge', 'end')
expect(document.querySelectorAll('[data-diff-selection-rail]')).toHaveLength(3)
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)')
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')
expect(focusedGutter.className).toContain('text-[var(--color-surface)]')
expect(focusedGutter.className).not.toContain('text-[var(--color-text-tertiary)]')
expect(focusedGutter.className).not.toContain('text-white')
const submit = screen.getByRole('button', { name: 'Submit review comment' })
expect(submit.className).toContain('text-[var(--color-surface)]')
expect(submit.className).not.toContain('text-white')
})
it('cancels an inline review from the visible editor action', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
fireEvent.click(anchor)
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
target: { value: 'This draft should close' },
})
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
expect(anchor).toHaveFocus()
})
it('does not submit an empty review comment', () => {
const onAddComment = vi.fn()
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)

View File

@ -8,7 +8,7 @@ import {
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { CornerDownLeft, MessageSquare, Plus } from 'lucide-react'
import { CornerDownLeft, FileCode2, MessageSquare, Plus } from 'lucide-react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { useTranslation } from '../../i18n'
import {
@ -266,7 +266,8 @@ export function WorkspaceDiffSurface({
}
const handleRowClick = (event: MouseEvent<HTMLButtonElement>, row: WorkspaceDiffRow) => {
activateRow(row, event.shiftKey, true)
if (event.shiftKey) event.currentTarget.focus()
activateRow(row, event.shiftKey, !event.shiftKey)
}
const moveRovingFocus = (row: WorkspaceDiffRow, direction: -1 | 1, extend: boolean) => {
@ -401,35 +402,45 @@ export function WorkspaceDiffSurface({
const renderEditor = () => review.selection && (
<div
data-diff-editor=""
className="min-w-[420px] border-y border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2"
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)]"
>
{status && (
<div role="status" aria-live="polite" className="mb-1.5 text-[11px] text-[var(--color-warning)]">
{t(`workspace.diffReview.${status}`)}
</div>
)}
<div className="flex items-start gap-2 pl-[84px]">
<MessageSquare aria-hidden="true" className="mt-1.5 shrink-0 text-[var(--color-text-tertiary)]" size={14} />
<div className="min-w-0 flex-1">
<div className="mb-1 text-[11px] font-medium text-[var(--color-text-secondary)]">
{sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
</div>
<textarea
ref={editorRef}
aria-label={t('workspace.diffReview.editorLabel')}
value={review.draft}
onChange={(event) => setReview((current) => ({ ...current, draft: event.target.value }))}
onKeyDown={handleEditorKeyDown}
rows={2}
className="block min-h-14 w-full resize-y rounded-[6px] border border-[var(--color-border)] bg-[var(--color-surface-container)] px-2 py-1.5 font-[var(--font-sans)] text-[12px] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
/>
<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)]">
<MessageSquare aria-hidden="true" size={14} />
</span>
<div className="text-[12px] font-semibold text-[var(--color-text-primary)]">
{sideLabel(review.selection.side)} L{review.selection.lineStart}{review.selection.lineEnd === review.selection.lineStart ? '' : `-L${review.selection.lineEnd}`}
</div>
</div>
<textarea
ref={editorRef}
aria-label={t('workspace.diffReview.editorLabel')}
value={review.draft}
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)]"
/>
<div className="mt-2.5 flex items-center justify-end gap-2">
<button
type="button"
onClick={closeEditor}
className="inline-flex h-8 items-center justify-center rounded-[7px] px-3 text-[12px] 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]"
>
{t('common.cancel')}
</button>
<button
type="button"
aria-label={t('workspace.diffReview.submitAria')}
disabled={!review.draft.trim()}
onClick={submitComment}
className="mt-6 inline-flex h-7 min-w-7 items-center justify-center gap-1 rounded-[5px] px-1.5 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] disabled:cursor-not-allowed disabled:opacity-40"
className="inline-flex h-8 min-w-8 items-center justify-center gap-1.5 rounded-[7px] bg-[var(--color-info)] px-3 text-[12px] font-medium text-[var(--color-surface)] transition-[opacity,transform] duration-200 ease-out hover:opacity-90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-35"
>
<CornerDownLeft aria-hidden="true" size={14} />
<span>{t('workspace.diffReview.submit')}</span>
@ -440,11 +451,13 @@ export function WorkspaceDiffSurface({
return (
<div className={className}>
<div className="relative min-w-max py-2">
<div className="relative min-w-max pb-3">
<div
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
role="grid"
aria-label={`${path} diff`}
className="m-0 font-[var(--font-mono)] text-[13px] leading-[1.65] text-[var(--color-code-fg)]"
>
{files.map((file) => {
const headerVisible = visibleItemIds.has(`${file.id}-header`)
@ -453,34 +466,72 @@ export function WorkspaceDiffSurface({
const oldPath = file.oldPath ? `a/${file.oldPath}` : '/dev/null'
const newPath = file.newPath ? `b/${file.newPath}` : '/dev/null'
const language = getLanguageFromPath(file.newPath ?? file.oldPath ?? path)
const fileAdditions = file.rows.filter((row) => row.kind === 'addition').length
const fileDeletions = file.rows.filter((row) => row.kind === 'deletion').length
const displayPath = file.newPath ?? file.oldPath ?? path
const displayName = displayPath.split('/').pop() ?? displayPath
const displayDirectory = displayPath.slice(0, Math.max(0, displayPath.length - displayName.length))
return (
<div key={file.id}>
{headerVisible && (
<div
data-testid="workspace-diff-file-header"
className="min-h-7 border-y border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-1 font-semibold text-[var(--color-text-secondary)]"
className="sticky top-0 z-10 flex h-10 items-center gap-2 border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)]/96 px-4 text-[12px] backdrop-blur"
>
diff --git {oldPath} {newPath}
<FileCode2 aria-hidden="true" size={15} className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 truncate">
{displayDirectory && (
<span className="text-[var(--color-text-tertiary)]">{displayDirectory}</span>
)}
<span className="font-semibold text-[var(--color-text-primary)]">{displayName}</span>
</span>
<span className="ml-auto shrink-0 font-[var(--font-mono)] text-[11px] tabular-nums">
<span className="text-[var(--color-success)]">+{fileAdditions}</span>
<span className="ml-1.5 text-[var(--color-error)]">-{fileDeletions}</span>
</span>
<span className="sr-only">diff --git {oldPath} {newPath}</span>
</div>
)}
{fileRows.map((row) => {
const line = row.side === 'old' ? row.oldLine : row.newLine
const selected = selectedIds.has(row.id)
const selectionFocus = selected && row.id === review.focusId
const rangeEdge = selected
? review.selection?.startId === review.selection?.endId
? 'single'
: row.id === review.selection?.startId
? 'start'
: row.id === review.selection?.endId
? 'end'
: undefined
: undefined
return (
<Fragment key={row.id}>
<div
role="row"
aria-selected={selected}
data-diff-row-id={row.id}
className={`group grid min-h-7 min-w-full w-max grid-cols-[42px_42px_28px_18px_minmax(max-content,1fr)] items-stretch px-3 ${rowTone(row)} ${
selected ? 'outline outline-1 -outline-offset-1 outline-[var(--color-border-focus)]' : ''
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 ${
selected ? 'bg-[var(--color-info-container)]' : rowTone(row)
}`}
>
{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-7 w-7 items-center justify-center">
<span className="flex h-[30px] w-[30px] items-center justify-center">
{row.selectable && row.side && line !== null && (
<button
ref={(element) => {
@ -494,12 +545,13 @@ export function WorkspaceDiffSurface({
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-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[var(--color-border-focus)] ${
selected ? 'text-[var(--color-accent)]' : 'opacity-0 group-hover:opacity-100 focus:opacity-100'
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'
}`}
>
{selected ? <MessageSquare aria-hidden="true" size={14} /> : <Plus aria-hidden="true" size={14} />}

View File

@ -81,10 +81,13 @@ async function flushReactWork() {
})
}
async function renderPanel(sessionId: string) {
async function renderPanel(
sessionId: string,
props: { embedded?: boolean; forceVisible?: boolean } = {},
) {
let view!: ReturnType<typeof render>
await act(async () => {
view = render(<WorkspacePanel sessionId={sessionId} />)
view = render(<WorkspacePanel sessionId={sessionId} {...props} />)
await Promise.resolve()
})
return view
@ -392,7 +395,10 @@ describe('WorkspacePanel', () => {
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
await clickElement(await view.findByText('src/app.ts'))
await waitFor(() => {
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
})
await clickElement(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')!)
await waitFor(() => {
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts')
@ -410,6 +416,14 @@ describe('WorkspacePanel', () => {
await waitFor(() => {
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')
expect(view.getByTestId('workspace-review-layout').className).toContain('grid-cols-1')
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.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))')
@ -461,13 +475,180 @@ describe('WorkspacePanel', () => {
const view = await renderPanel(sessionId)
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
const row = view.getByText('src/app.ts').closest('button')
const row = view.container.querySelector('[data-workspace-file-path="src/app.ts"]')
if (!row) throw new Error('Changed file row was not rendered')
expect(row.getAttribute('aria-current')).toBe('true')
expect(row.className).toContain('bg-[var(--color-surface-selected)]')
expect(row.className).toContain('bg-[var(--color-info-container)]')
expect(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
})
it('presents one review toolbar and reports filtered changed-file results', async () => {
const sessionId = 'session-review-toolbar'
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
[sessionId]: { isOpen: true, activeView: 'changed', hasUserSelectedView: true },
},
statusBySession: {
...state.statusBySession,
[sessionId]: {
state: 'ok',
workDir: '/repo',
repoName: 'claude-code-haha',
branch: 'main',
isGitRepo: true,
changedFiles: [
{ path: 'desktop/src/App.tsx', status: 'modified', additions: 7, deletions: 2 },
{ path: 'desktop/src/theme.css', status: 'modified', additions: 3, deletions: 1 },
{ path: 'docs/review.md', status: 'added', additions: 5, deletions: 0 },
],
},
},
}))
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')
const filter = view.getByPlaceholderText('Filter files...')
expect(view.getByText('3 files')).toBeTruthy()
fireEvent.change(filter, { target: { value: 'theme' } })
expect(view.getByText('1 of 3 files')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="desktop/src/theme.css"]')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="desktop/src/App.tsx"]')).toBeNull()
expect(view.container.querySelector('[data-workspace-file-path="docs/review.md"]')).toBeNull()
})
it('keeps the full workbench tab in a stable diff and 280px navigator split', async () => {
const sessionId = 'session-full-review-workbench'
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
[sessionId]: { isOpen: true, activeView: 'changed', hasUserSelectedView: true },
},
statusBySession: {
...state.statusBySession,
[sessionId]: {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [{ path: 'src/app.ts', status: 'modified', additions: 1, deletions: 1 }],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: [{
id: 'diff:src/app.ts',
path: 'src/app.ts',
kind: 'diff',
title: 'app.ts',
diff: '@@ -1 +1 @@\n-old\n+new',
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: 'diff:src/app.ts',
},
}))
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-file-navigator').className).not.toContain('absolute')
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
})
it('groups changed files by directory without weakening file filtering', async () => {
const sessionId = 'session-grouped-changes'
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
[sessionId]: { isOpen: true, activeView: 'changed', hasUserSelectedView: true },
},
statusBySession: {
...state.statusBySession,
[sessionId]: {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{ path: 'desktop/src/App.tsx', status: 'modified', additions: 7, deletions: 2 },
{ path: 'desktop/src/theme.css', status: 'modified', additions: 3, deletions: 1 },
{ path: 'docs/review.md', status: 'added', additions: 5, deletions: 0 },
],
},
},
}))
const view = await renderPanel(sessionId)
expect(view.getByText('desktop/src')).toBeTruthy()
expect(view.getByText('docs')).toBeTruthy()
expect(view.getByText('App.tsx')).toBeTruthy()
expect(view.getByText('theme.css')).toBeTruthy()
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'theme' } })
expect(view.getByText('desktop/src')).toBeTruthy()
expect(view.queryByText('docs')).toBeNull()
expect(view.getByText('theme.css')).toBeTruthy()
expect(view.queryByText('App.tsx')).toBeNull()
})
it('gives renamed files enough height to show the old path without overlapping the next row', async () => {
const sessionId = 'session-renamed-file-height'
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
[sessionId]: { isOpen: true, activeView: 'changed', hasUserSelectedView: true },
},
statusBySession: {
...state.statusBySession,
[sessionId]: {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'desktop/src/components/workspace/NewWorkspacePanel.tsx',
oldPath: 'desktop/src/components/workspace/LegacyWorkspacePanelWithALongName.tsx',
status: 'renamed',
additions: 2,
deletions: 2,
},
{ path: 'desktop/src/components/workspace/next.ts', status: 'modified', additions: 1, deletions: 0 },
],
},
},
}))
const view = await renderPanel(sessionId)
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).not.toContain('h-9')
expect(view.getByText('next.ts')).toBeTruthy()
})
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
@ -520,7 +701,7 @@ describe('WorkspacePanel', () => {
await waitFor(() => {
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
})
expect(view.getByText('src/Fresh.ts')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="src/Fresh.ts"]')).toBeTruthy()
})
it('loads workspace status when opened while the chat is running', async () => {
@ -558,7 +739,7 @@ describe('WorkspacePanel', () => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open')
})
await waitFor(() => {
expect(view.getByText('src/running.ts')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="src/running.ts"]')).toBeTruthy()
})
expect(view.queryByText('Loading...')).toBeNull()
})
@ -592,11 +773,11 @@ describe('WorkspacePanel', () => {
const view = await renderPanel('session-non-git')
await waitFor(() => {
expect(view.getByText('src/app.ts')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
})
expect(view.queryByText('No matching files')).toBeNull()
await clickElement(view.getByText('src/app.ts'))
await clickElement(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')!)
await waitFor(() => {
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts')
@ -663,7 +844,14 @@ 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.queryByText('No changes')).toBeNull()
fireEvent.change(view.getByPlaceholderText('Filter files...'), { target: { value: 'readme' } })
expect(view.getByRole('status').textContent).toBe('1 of 2 items')
expect(view.queryByText('src')).toBeNull()
expect(view.getByText('README.md')).toBeTruthy()
})
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
@ -911,7 +1099,7 @@ describe('WorkspacePanel', () => {
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
expect(view.getByText('src/app.ts')).toBeTruthy()
expect(view.container.querySelector('[data-workspace-file-path="src/app.ts"]')).toBeTruthy()
expect(view.getByRole('button', { name: 'Hide file navigator' })).toBeTruthy()
})
@ -972,10 +1160,10 @@ describe('WorkspacePanel', () => {
await waitFor(() => {
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-preview-hidden-tree', '')
})
expect(view.getAllByText('src').length).toBeGreaterThanOrEqual(2)
expect(view.getByText('src')).toBeTruthy()
})
it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => {
it('uses theme tokens for the panel, preview header, and code surface in dark mode', async () => {
await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' })
await setWorkspaceState((state) => ({
...state,
@ -1019,13 +1207,17 @@ describe('WorkspacePanel', () => {
const view = await renderPanel('session-dark-theme')
const panel = view.getByTestId('workspace-panel')
const tabList = view.getByRole('tablist', { name: 'Preview tabs' })
const previewHeader = view.getByTestId('workspace-preview-header')
const codeSurface = view.getByTestId('workspace-code')
expect(panel.className).toContain('bg-[var(--color-surface)]')
expect(panel.className).not.toContain('bg-white')
expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]')
expect(tabList.className).not.toContain('bg-white')
expect(view.queryByRole('tablist', { name: 'Preview tabs' })).toBeNull()
expect(previewHeader.className).toContain('bg-[var(--color-surface)]')
expect(previewHeader.className).not.toContain('bg-white')
const addToChatLabel = Array.from(previewHeader.querySelectorAll('span'))
.find((element) => element.textContent === 'Add to chat')
expect(addToChatLabel?.className).toContain('hidden min-[960px]:inline')
expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true)
expect(classNameContains(codeSurface, 'bg-white')).toBe(false)
})
@ -1133,7 +1325,7 @@ describe('WorkspacePanel', () => {
expect(firstRow?.className).toContain('w-max')
expect(firstRow?.className).toContain('min-w-full')
expect(firstRow?.className).toContain('grid-cols-[42px_42px_28px_18px_minmax(max-content,1fr)]')
expect(firstRow?.className).toContain('grid-cols-[46px_46px_30px_20px_minmax(max-content,1fr)]')
expect(diffSurface.textContent).toContain(longDiffLine)
})
@ -2124,10 +2316,10 @@ describe('WorkspacePanel', () => {
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]')
expect(refreshButton.className).toContain('h-8 w-8')
expect(closeButton.className).toContain('h-8 w-8')
expect(refreshButton.querySelector('.lucide-refresh-cw')).toBeTruthy()
expect(closeButton.querySelector('.lucide-x')).toBeTruthy()
})
it('shows explicit empty and error states in the changed view', async () => {
@ -2231,7 +2423,7 @@ describe('WorkspacePanel', () => {
const view = await renderPanel('session-preview-refresh')
expect(view.getByText('cached')).toBeTruthy()
await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
await clickElement(view.getByRole('button', { name: 'Refresh workspace' }))
expect(view.getByText('cached')).toBeTruthy()
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
@ -2310,7 +2502,7 @@ describe('WorkspacePanel', () => {
}))
const view = await renderPanel('session-preview-refresh-error')
await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
await clickElement(view.getByRole('button', { name: '刷新工作区' }))
refresh.resolve({ state: 'missing', path: 'src/a.ts' })
await flushReactWork()

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
import { CircleAlert, Code2, File as FileIcon, FileText, Image as ImageIcon, MessageCircle, RefreshCw, Settings2, type LucideIcon } from 'lucide-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 { Highlight } from 'prism-react-renderer'
import type {
WorkspaceChangedFile,
@ -72,35 +72,35 @@ type FileContextMenuState = {
const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className: string }> = {
modified: {
label: 'M',
className: 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/12 text-[var(--color-warning)]',
className: 'text-[var(--color-warning)]',
},
added: {
label: 'A',
className: 'border-[var(--color-success)]/35 bg-[var(--color-success)]/12 text-[var(--color-success)]',
className: 'text-[var(--color-success)]',
},
deleted: {
label: 'D',
className: 'border-[var(--color-error)]/35 bg-[var(--color-error)]/12 text-[var(--color-error)]',
className: 'text-[var(--color-error)]',
},
renamed: {
label: 'R',
className: 'border-[var(--color-brand)]/35 bg-[var(--color-brand)]/12 text-[var(--color-brand)]',
className: 'text-[var(--color-info)]',
},
untracked: {
label: 'U',
className: 'border-[var(--color-tertiary)]/35 bg-[var(--color-tertiary)]/12 text-[var(--color-tertiary)]',
className: 'text-[var(--color-info)]',
},
copied: {
label: 'C',
className: 'border-[var(--color-secondary)]/35 bg-[var(--color-secondary)]/12 text-[var(--color-secondary)]',
className: 'text-[var(--color-info)]',
},
type_changed: {
label: 'T',
className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]',
className: 'text-[var(--color-text-secondary)]',
},
unknown: {
label: '?',
className: 'border-[var(--color-outline)]/45 bg-[var(--color-outline)]/10 text-[var(--color-text-secondary)]',
className: 'text-[var(--color-text-secondary)]',
},
}
@ -232,6 +232,20 @@ function changedFileMatchesFilter(file: WorkspaceChangedFile, query: string) {
)
}
function groupChangedFiles(files: WorkspaceChangedFile[]) {
const groups = new Map<string, WorkspaceChangedFile[]>()
for (const file of files) {
const normalizedPath = file.path.replace(/\\/g, '/')
const lastSlash = normalizedPath.lastIndexOf('/')
const directory = lastSlash >= 0 ? normalizedPath.slice(0, lastSlash) : ''
const group = groups.get(directory)
if (group) group.push(file)
else groups.set(directory, [file])
}
return Array.from(groups, ([directory, groupedFiles]) => ({ directory, files: groupedFiles }))
.sort((a, b) => a.directory.localeCompare(b.directory))
}
function treeEntryMatchesFilter(
entry: WorkspaceTreeEntry,
query: string,
@ -389,11 +403,11 @@ function PanelMessage({
}
function ToolbarIconButton({
icon,
Icon,
label,
onClick,
}: {
icon: string
Icon: LucideIcon
label: string
onClick: () => void
}) {
@ -402,9 +416,9 @@ function ToolbarIconButton({
type="button"
aria-label={label}
onClick={onClick}
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"
className="inline-flex h-8 w-8 items-center justify-center rounded-[8px] 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-info)]/30"
>
<span className="material-symbols-outlined text-[16px]">{icon}</span>
<Icon size={16} strokeWidth={1.9} aria-hidden="true" />
</button>
)
}
@ -412,16 +426,18 @@ function ToolbarIconButton({
function WorkspaceFilterInput({
value,
onChange,
summary,
}: {
value: string
onChange: (value: string) => void
summary?: string
}) {
const t = useTranslation()
return (
<div className="shrink-0 border-b border-[var(--color-border)] px-3 py-2">
<label className="flex h-8 items-center gap-2 rounded-[9px] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5 text-[var(--color-text-tertiary)] transition-colors focus-within:border-[var(--color-border-focus)] focus-within:ring-2 focus-within:ring-[var(--color-brand)]/10">
<span className="material-symbols-outlined shrink-0 text-[17px]">search</span>
<div className="shrink-0 border-b border-[var(--color-text-primary)]/10 px-3 pb-2.5 pt-2.5">
<label className="flex h-9 items-center gap-2 rounded-[7px] bg-[var(--color-surface-container-low)] px-2.5 text-[var(--color-text-tertiary)] shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--color-text-primary)_8%,transparent)] transition-[background-color,box-shadow] duration-200 ease-out focus-within:bg-[var(--color-surface)] focus-within:shadow-[inset_0_0_0_1px_var(--color-info),0_0_0_3px_color-mix(in_srgb,var(--color-info)_12%,transparent)]">
<Search size={15} strokeWidth={1.9} aria-hidden="true" className="shrink-0" />
<input
value={value}
onChange={(event) => onChange(event.target.value)}
@ -436,10 +452,19 @@ function WorkspaceFilterInput({
onClick={() => onChange('')}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[5px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<span className="material-symbols-outlined text-[13px]">close</span>
<X size={13} strokeWidth={2} aria-hidden="true" />
</button>
)}
</label>
{summary && (
<div
role="status"
aria-live="polite"
className="mt-2 px-0.5 text-[10px] font-medium text-[var(--color-text-tertiary)]"
>
{summary}
</div>
)}
</div>
)
}
@ -449,7 +474,7 @@ function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
const meta = FILE_STATUS_META[status]
return (
<span
className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded border text-[10px] font-semibold ${meta.className}`}
className={`inline-flex h-5 w-4 shrink-0 items-center justify-center font-[var(--font-mono)] text-[10px] font-semibold ${meta.className}`}
aria-label={getWorkspaceStatusLabel(status, t)}
>
{meta.label}
@ -787,6 +812,7 @@ function ChangedFileRow({
}) {
const identity = getFileIdentity(file.path)
const IdentityIcon = FILE_IDENTITY_ICONS[identity.icon]
const fileName = file.path.replace(/\\/g, '/').split('/').pop() || file.path
return (
<button
@ -794,35 +820,42 @@ function ChangedFileRow({
onClick={onClick}
onContextMenu={(event) => onContextMenu(event, file.path)}
aria-current={active ? 'true' : undefined}
className={`mx-2 flex w-[calc(100%-16px)] items-center gap-2.5 rounded-[7px] px-2 py-2 text-left transition-colors ${
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'
} ${
active
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]'
? 'bg-[var(--color-info-container)] shadow-[inset_3px_0_0_var(--color-info)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<FileStatusBadge status={file.status} />
<span
className="inline-flex h-6 min-w-6 shrink-0 items-center justify-center rounded-[6px] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]"
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)]"
title={identity.languageLabel}
>
<IdentityIcon aria-hidden="true" size={14} strokeWidth={1.8} />
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-[13px] font-medium text-[var(--color-text-primary)]">{file.path}</div>
<div className="truncate text-[10px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
{identity.shortLabel}
<span className="sr-only"> {identity.languageLabel}</span>
<div className="flex min-w-0 items-baseline gap-1.5">
<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)]">
{identity.shortLabel}
<span className="sr-only"> {identity.languageLabel}</span>
</span>
</div>
{file.oldPath && (
<div className="truncate text-[11px] text-[var(--color-text-tertiary)]">
<div className="mt-0.5 truncate text-[10px] text-[var(--color-text-tertiary)]">
{file.oldPath}
</div>
)}
</div>
<div className="shrink-0 text-right font-[var(--font-mono)] text-[11px] leading-4">
<div className="text-[var(--color-success)]">+{file.additions}</div>
<div className="text-[var(--color-error)]">-{file.deletions}</div>
<span className="text-[var(--color-success)]">+{file.additions}</span>
<span className="ml-1 text-[var(--color-error)]">-{file.deletions}</span>
</div>
<FileStatusBadge status={file.status} />
</button>
)
}
@ -953,7 +986,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
const addToast = useUIStore((state) => state.addToast)
const [filterQuery, setFilterQuery] = useState('')
const [isViewMenuOpen, setIsViewMenuOpen] = useState(false)
const [isNavigatorOpen, setIsNavigatorOpen] = useState(false)
const [isNavigatorOpen, setIsNavigatorOpen] = useState(forceVisible)
const [previewTabContextMenu, setPreviewTabContextMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const [fileContextMenu, setFileContextMenu] = useState<FileContextMenuState | null>(null)
const width = useWorkspacePanelStore((state) => state.width)
@ -1004,12 +1037,37 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
() => (status?.changedFiles ?? []).filter((file) => changedFileMatchesFilter(file, normalizedFilterQuery)),
[normalizedFilterQuery, status?.changedFiles],
)
const changedFileGroups = useMemo(
() => 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'
? status?.changedFiles.length ?? 0
: rootTree?.state === 'ok' ? rootTree.entries.length : 0
const filterSummary = activeView === 'changed'
? normalizedFilterQuery
? t('workspace.filteredFilesCount', { visible: visibleEntryCount, total: totalEntryCount })
: t('workspace.filesCount', { count: totalEntryCount })
: normalizedFilterQuery
? t('workspace.filteredItemsCount', { visible: visibleEntryCount, total: totalEntryCount })
: t('workspace.itemsCount', { count: totalEntryCount })
const activePreviewRequestKey = activePreviewTab
? makePreviewStateKey(sessionId, activePreviewTab.id)
: null
@ -1076,16 +1134,21 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
const handleRefresh = () => {
void loadStatus(sessionId)
if (activePreviewTab) {
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
}
if (activeView === 'all') {
void loadTree(sessionId, '')
}
}
const handleOpenDiff = (path: string) => {
setIsNavigatorOpen(forceVisible)
void openPreview(sessionId, path, 'diff')
}
const handleOpenFile = (path: string) => {
setIsNavigatorOpen(forceVisible)
void openPreview(sessionId, path, 'file')
}
@ -1220,15 +1283,25 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
}
return (
<div>
{filteredChangedFiles.map((file) => (
<ChangedFileRow
key={`${file.path}:${file.status}:${file.oldPath ?? ''}`}
file={file}
active={activePreviewTabId === `file:${file.path}` || activePreviewTabId === `diff:${file.path}`}
onClick={() => handleOpenDiff(file.path)}
onContextMenu={handleFileContextMenu}
/>
<div className="space-y-1">
{changedFileGroups.map((group) => (
<section key={group.directory || '__root__'}>
{group.directory && (
<div className="flex h-8 items-center gap-1.5 px-3.5 text-[11px] font-medium text-[var(--color-text-tertiary)]">
<FolderOpen size={14} strokeWidth={1.8} aria-hidden="true" className="shrink-0" />
<span className="min-w-0 truncate">{group.directory}</span>
</div>
)}
{group.files.map((file) => (
<ChangedFileRow
key={`${file.path}:${file.status}:${file.oldPath ?? ''}`}
file={file}
active={activePreviewTabId === `file:${file.path}` || activePreviewTabId === `diff:${file.path}`}
onClick={() => handleOpenDiff(file.path)}
onContextMenu={handleFileContextMenu}
/>
))}
</section>
))}
</div>
)
@ -1308,23 +1381,23 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
aria-busy={activePreviewLoading}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex h-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 text-[12px]">
<span className="truncate text-[var(--color-text-tertiary)]">{status?.repoName || 'workspace'}</span>
{activePreviewTab.path.split('/').map((segment, index, segments) => (
<span key={`${segment}:${index}`} className="flex min-w-0 items-center gap-1.5">
<span className="text-[var(--color-text-tertiary)]"></span>
<span className={`truncate ${index === segments.length - 1 ? 'font-semibold text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'}`}>
{segment}
</span>
</span>
))}
<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]"
>
<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-6 shrink-0 items-center gap-1 rounded-[6px] px-1.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
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]"
>
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
<span>{t('workspace.addToChat')}</span>
<MessageCircle size={14} strokeWidth={1.8} aria-hidden="true" />
<span className="hidden min-[960px]:inline">{t('workspace.addToChat')}</span>
</button>
{activePreviewLoading && state === 'ok' && (
<RefreshCw
@ -1333,9 +1406,6 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
aria-hidden="true"
/>
)}
<span className="shrink-0 rounded-[5px] border border-[var(--color-border)] px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">
{kindLabel}
</span>
</div>
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
@ -1392,13 +1462,11 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
const renderPreviewTabs = () => (
<>
<div
className="flex h-11 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3"
>
<div className="flex h-9 shrink-0 items-end border-b border-[var(--color-text-primary)]/10 bg-[var(--color-surface)] px-3">
<div
role="tablist"
aria-label={t('workspace.previewTabs')}
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto bg-[var(--color-surface-container-lowest)]"
className="flex min-w-0 flex-1 items-end gap-3 overflow-x-auto bg-[var(--color-surface)]"
>
{previewTabs.length === 0 ? (
<div className="flex items-center gap-2 px-1.5 text-[12px] text-[var(--color-text-tertiary)]">
@ -1414,10 +1482,10 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
<div
key={tab.id}
onContextMenu={(event) => handlePreviewTabContextMenu(event, tab.id)}
className={`group flex h-8 min-w-[118px] max-w-[210px] shrink-0 items-center gap-2 rounded-[8px] px-2 text-left text-[13px] transition-colors ${
className={`group relative flex h-9 min-w-[96px] max-w-[220px] shrink-0 items-center gap-2 px-1 text-left text-[12px] transition-colors ${
isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
? 'text-[var(--color-text-primary)] after:absolute after:inset-x-0 after:bottom-0 after:h-0.5 after:rounded-full after:bg-[var(--color-info)]'
: 'text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)]'
}`}
>
<button
@ -1451,11 +1519,6 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
})
)}
</div>
<ToolbarIconButton
icon={isNavigatorVisible ? 'right_panel_close' : 'account_tree'}
label={isNavigatorVisible ? t('workspace.hideNavigator') : t('workspace.showNavigator')}
onClick={() => setIsNavigatorOpen((open) => !open)}
/>
</div>
{previewTabContextMenu && (
@ -1516,31 +1579,71 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
data-testid="workspace-panel"
className={
embedded
? 'flex h-full min-h-0 w-full min-w-0 bg-[var(--color-surface)]'
: 'flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]'
? 'flex h-full min-h-0 w-full min-w-0 flex-col bg-[var(--color-surface)]'
: 'flex h-full shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]'
}
style={embedded ? undefined : { width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
>
{hasPreviewTabs && (
<div className={`flex min-w-0 flex-1 flex-col bg-[var(--color-surface)] ${isNavigatorVisible ? 'border-r border-[var(--color-border)]' : ''}`}>
{renderPreviewTabs()}
{renderPreviewContent()}
<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>
)}
{isNavigatorVisible && (
<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-10 shrink-0 items-center gap-1.5 border-b border-[var(--color-border)] px-2.5">
<div className="relative min-w-0">
<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'}`}
>
{hasPreviewTabs && (
<div className="flex min-w-0 flex-col bg-[var(--color-surface)]">
{previewTabs.length > 1 ? renderPreviewTabs() : null}
{renderPreviewContent()}
</div>
)}
{isNavigatorVisible && (
<div
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">
<div className="relative min-w-0">
<button
type="button"
aria-label={activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
aria-haspopup="menu"
aria-expanded={isViewMenuOpen}
onClick={() => setIsViewMenuOpen((open) => !open)}
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"
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-[7px] px-1 py-1 text-[14px] font-semibold leading-5 text-[var(--color-text-primary)] transition-colors hover:text-[var(--color-text-secondary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-info)]/30"
>
<span className="truncate">
{activeView === 'changed' ? t('workspace.changedFiles') : t('workspace.allFiles')}
@ -1575,31 +1678,17 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
})}
</div>
)}
</div>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<ToolbarIconButton
icon="refresh"
label={t('workspace.refresh')}
onClick={handleRefresh}
/>
{!embedded && (
<ToolbarIconButton
icon="close"
label={t('workspace.closePanel')}
onClick={() => closePanel(sessionId)}
/>
)}
<WorkspaceFilterInput value={filterQuery} onChange={setFilterQuery} summary={filterSummary} />
<div className="min-h-0 flex-1 overflow-auto py-1.5">
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
</div>
</div>
<WorkspaceFilterInput value={filterQuery} onChange={setFilterQuery} />
<div className="min-h-0 flex-1 overflow-auto py-2">
{activeView === 'changed' ? renderChangedView() : renderAllFilesView()}
</div>
</div>
)}
)}
</div>
{fileContextMenu && (
<div

View File

@ -135,6 +135,11 @@ export const en = {
'workspace.previewTabs': 'Preview tabs',
'workspace.filterPlaceholder': 'Filter files...',
'workspace.clearFilter': 'Clear file filter',
'workspace.reviewWorkspace': 'Review workspace',
'workspace.filesCount': '{count} files',
'workspace.filteredFilesCount': '{visible} of {total} files',
'workspace.itemsCount': '{count} items',
'workspace.filteredItemsCount': '{visible} of {total} items',
'workspace.refresh': 'Refresh workspace',
'workspace.closePanel': 'Close workspace panel',
'workspace.resizePanel': 'Resize workspace panel',
@ -147,6 +152,7 @@ export const en = {
'workbench.close': 'Close',
'workbench.tabTitle': 'Workbench',
'workbench.backToConversation': 'Back to conversation',
'workbench.navigation': 'Workbench navigation',
'workspace.closeTab': 'Close tab',
'workspace.preview': 'Preview',
'workspace.previewEmpty': 'Select a file to preview.',

View File

@ -137,6 +137,11 @@ export const jp: Record<TranslationKey, string> = {
'workspace.previewTabs': 'プレビュータブ',
'workspace.filterPlaceholder': 'ファイルを絞り込み...',
'workspace.clearFilter': 'ファイルフィルターをクリア',
'workspace.reviewWorkspace': 'ワークスペースをレビュー',
'workspace.filesCount': '{count} ファイル',
'workspace.filteredFilesCount': '{total} 件中 {visible} ファイル',
'workspace.itemsCount': '{count} 項目',
'workspace.filteredItemsCount': '{total} 項目中 {visible} 項目',
'workspace.refresh': 'ワークスペースを更新',
'workspace.closePanel': 'ワークスペースパネルを閉じる',
'workspace.resizePanel': 'ワークスペースパネルのサイズを変更',
@ -149,6 +154,7 @@ export const jp: Record<TranslationKey, string> = {
'workbench.close': '閉じる',
'workbench.tabTitle': 'ワークベンチ',
'workbench.backToConversation': '会話に戻る',
'workbench.navigation': 'ワークベンチナビゲーション',
'workspace.closeTab': 'タブを閉じる',
'workspace.preview': 'プレビュー',
'workspace.previewEmpty': 'プレビューするファイルを選択してください。',

View File

@ -137,6 +137,11 @@ export const kr: Record<TranslationKey, string> = {
'workspace.previewTabs': '미리 보기 탭',
'workspace.filterPlaceholder': '파일 필터링...',
'workspace.clearFilter': '파일 필터 지우기',
'workspace.reviewWorkspace': '작업 공간 검토',
'workspace.filesCount': '파일 {count}개',
'workspace.filteredFilesCount': '파일 {total}개 중 {visible}개',
'workspace.itemsCount': '항목 {count}개',
'workspace.filteredItemsCount': '항목 {total}개 중 {visible}개',
'workspace.refresh': '작업 공간 새로 고침',
'workspace.closePanel': '작업 공간 패널 닫기',
'workspace.resizePanel': '작업 공간 패널 크기 조정',
@ -149,6 +154,7 @@ export const kr: Record<TranslationKey, string> = {
'workbench.close': '닫기',
'workbench.tabTitle': '워크벤치',
'workbench.backToConversation': '대화로 돌아가기',
'workbench.navigation': '워크벤치 탐색',
'workspace.closeTab': '탭 닫기',
'workspace.preview': '미리 보기',
'workspace.previewEmpty': '미리 볼 파일을 선택하세요.',

View File

@ -137,6 +137,11 @@ export const zh: Record<TranslationKey, string> = {
'workspace.previewTabs': '預覽標籤',
'workspace.filterPlaceholder': '篩選檔案...',
'workspace.clearFilter': '清除檔案篩選',
'workspace.reviewWorkspace': '審閱工作區',
'workspace.filesCount': '{count} 個檔案',
'workspace.filteredFilesCount': '{visible} / {total} 個檔案',
'workspace.itemsCount': '{count} 個項目',
'workspace.filteredItemsCount': '{visible} / {total} 個項目',
'workspace.refresh': '重新整理工作區',
'workspace.closePanel': '關閉工作區面板',
'workspace.resizePanel': '調整工作區寬度',
@ -149,6 +154,7 @@ export const zh: Record<TranslationKey, string> = {
'workbench.close': '關閉',
'workbench.tabTitle': '工作臺',
'workbench.backToConversation': '返回對話',
'workbench.navigation': '工作臺導覽',
'workspace.closeTab': '關閉標籤',
'workspace.preview': '預覽',
'workspace.previewEmpty': '選擇一個檔案進行預覽。',

View File

@ -137,6 +137,11 @@ export const zh: Record<TranslationKey, string> = {
'workspace.previewTabs': '预览标签',
'workspace.filterPlaceholder': '筛选文件...',
'workspace.clearFilter': '清除文件筛选',
'workspace.reviewWorkspace': '审阅工作区',
'workspace.filesCount': '{count} 个文件',
'workspace.filteredFilesCount': '{visible} / {total} 个文件',
'workspace.itemsCount': '{count} 个项目',
'workspace.filteredItemsCount': '{visible} / {total} 个项目',
'workspace.refresh': '刷新工作区',
'workspace.closePanel': '关闭工作区面板',
'workspace.resizePanel': '调整工作区宽度',
@ -149,6 +154,7 @@ export const zh: Record<TranslationKey, string> = {
'workbench.close': '关闭',
'workbench.tabTitle': '工作台',
'workbench.backToConversation': '返回对话',
'workbench.navigation': '工作台导航',
'workspace.closeTab': '关闭标签',
'workspace.preview': '预览',
'workspace.previewEmpty': '选择一个文件进行预览。',