feat(desktop): add inline diff review flow #1004

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 10:22:33 +08:00
parent 69c4296a3f
commit 6c6d3c51e3
31 changed files with 2817 additions and 265 deletions

View File

@ -2,10 +2,43 @@
import '@testing-library/jest-dom'
import { fireEvent, render } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { AttachmentGallery } from './AttachmentGallery'
describe('AttachmentGallery', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('renders diff comments as note-first composer cards with side-aware locations', () => {
const view = render(
<AttachmentGallery
variant="composer"
attachments={[{
id: 'diff-comment-1',
type: 'file',
name: 'a.ts',
path: 'src/a.ts',
lineStart: 11,
lineEnd: 12,
diffSide: 'new',
hunkId: 'hunk-1',
note: 'Use a shared helper',
quote: 'const result = buildResult()\nreturn result',
}]}
/>,
)
const card = view.getByTestId('diff-comment-card')
expect(card.textContent).toContain('src/a.ts · new L11-L12')
expect(card.textContent).toContain('Use a shared helper')
expect(card.textContent).toContain('const result = buildResult() return result')
expect(card.textContent?.indexOf('Use a shared helper')).toBeLessThan(
card.textContent?.indexOf('const result = buildResult()') ?? -1,
)
})
it('renders a compact quote preview for selected workspace text', () => {
render(
<AttachmentGallery
@ -90,4 +123,26 @@ describe('AttachmentGallery', () => {
expect(tooltip).toHaveTextContent('这个标题更轻一点')
expect(tooltip.className).toContain('group-hover/selection:visible')
})
it('localizes diff sides and remove actions in Chinese', () => {
useSettingsStore.setState({ locale: 'zh' })
const view = render(
<AttachmentGallery
variant="composer"
onRemove={vi.fn()}
attachments={[{
id: 'diff-comment-zh',
type: 'file',
name: 'a.ts',
path: 'src/a.ts',
lineStart: 11,
diffSide: 'new',
note: '使用共享辅助函数',
}]}
/>,
)
expect(view.getByTestId('diff-comment-card')).toHaveTextContent('src/a.ts · 新 L11')
expect(view.getByRole('button', { name: '移除 a.ts' })).toBeInTheDocument()
})
})

View File

@ -1,4 +1,6 @@
import { useMemo, useState } from 'react'
import { MessageSquare, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { ImageGalleryModal } from './ImageGalleryModal'
export type AttachmentPreview = {
@ -11,6 +13,8 @@ export type AttachmentPreview = {
isDirectory?: boolean
lineStart?: number
lineEnd?: number
diffSide?: 'old' | 'new'
hunkId?: string
note?: string
quote?: string
}
@ -22,6 +26,7 @@ type Props = {
}
export function AttachmentGallery({ attachments, variant = 'message', onRemove }: Props) {
const t = useTranslation()
const [activeImageIndex, setActiveImageIndex] = useState<number | null>(null)
const images = useMemo(
@ -120,7 +125,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
type="button"
onClick={() => onRemove(attachment.id!)}
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-[var(--color-error)] text-[10px] text-white opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Remove ${attachment.name}`}
aria-label={t('attachments.remove', { name: attachment.name })}
>
×
</button>
@ -129,6 +134,57 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
)
}
if (attachment.diffSide) {
const lineRange = attachment.lineStart
? `L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
: ''
const location = [
attachment.path || attachment.name,
'·',
t(`workspace.diffReview.side.${attachment.diffSide}`),
lineRange,
]
.filter(Boolean)
.join(' ')
const note = attachment.note?.trim()
const quotePreview = attachment.quote?.trim().replace(/\s+/g, ' ')
return (
<div
key={attachment.id || `${attachment.name}-${index}`}
data-testid="diff-comment-card"
className="group/diff-comment flex max-w-[min(420px,100%)] min-w-[240px] items-start gap-2 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-2 text-left shadow-[0_1px_2px_rgba(0,0,0,0.04)]"
>
<MessageSquare aria-hidden="true" size={15} className="mt-0.5 shrink-0 text-[var(--color-text-tertiary)]" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-medium text-[var(--color-text-tertiary)]">
{location}
</span>
{note && (
<span className="mt-0.5 block text-[13px] font-medium leading-5 text-[var(--color-text-primary)]">
{note}
</span>
)}
{quotePreview && (
<span className="mt-0.5 block truncate font-[var(--font-mono)] text-[11px] leading-4 text-[var(--color-text-tertiary)]">
{quotePreview}
</span>
)}
</span>
{onRemove && attachment.id && (
<button
type="button"
onClick={() => onRemove(attachment.id!)}
className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
aria-label={t('attachments.remove', { name: attachment.name })}
>
<X aria-hidden="true" size={14} />
</button>
)}
</div>
)
}
const lineLabel = attachment.lineStart
? `:L${attachment.lineStart}${attachment.lineEnd && attachment.lineEnd !== attachment.lineStart ? `-L${attachment.lineEnd}` : ''}`
: ''
@ -164,7 +220,7 @@ export function AttachmentGallery({ attachments, variant = 'message', onRemove }
type="button"
onClick={() => onRemove(attachment.id!)}
className={`${hasQuotePreview ? 'mt-0.5' : 'ml-0.5'} flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]`}
aria-label={`Remove ${attachment.name}`}
aria-label={t('attachments.remove', { name: attachment.name })}
>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>

View File

@ -224,6 +224,35 @@ describe('ChatInput file mentions', () => {
vi.unstubAllGlobals()
})
it('passes diff metadata to the composer card and clears the reference after send', async () => {
act(() => {
useWorkspaceChatContextStore.getState().addReference(sessionId, {
kind: 'code-comment',
path: 'src/a.ts',
absolutePath: '/repo/src/a.ts',
name: 'a.ts',
lineStart: 11,
lineEnd: 12,
diffSide: 'new',
hunkId: 'hunk-1',
note: 'Use a shared helper',
quote: 'const result = buildResult()\nreturn result',
})
})
render(<ChatInput compact />)
expect(screen.getByTestId('diff-comment-card')).toHaveTextContent('src/a.ts · new L11-L12')
expect(screen.getByTestId('diff-comment-card')).toHaveTextContent('Use a shared helper')
fireEvent.keyDown(screen.getByRole('textbox'), { key: 'Enter' })
await waitFor(() => {
expect(useWorkspaceChatContextStore.getState().referencesBySession[sessionId]).toEqual([])
})
expect(screen.queryByTestId('diff-comment-card')).not.toBeInTheDocument()
})
it('keeps unsent composer drafts isolated when switching between session tabs', async () => {
const historySessionId = 'history-session'
useTabStore.setState({

View File

@ -65,6 +65,8 @@ function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Atta
isDirectory: reference.isDirectory,
lineStart: reference.lineStart,
lineEnd: reference.lineEnd,
diffSide: reference.diffSide,
hunkId: reference.hunkId,
note: reference.note,
quote: reference.quote,
}
@ -974,6 +976,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
return (
<div
data-testid="chat-input-shell"
data-session-id={activeTabId ?? undefined}
className={
isHeroComposer
? `bg-[var(--color-surface)] ${isMobileComposer ? 'px-4 pb-3' : 'px-8 pb-4'}`

View File

@ -6,12 +6,13 @@ import { act } from 'react'
// ──────────────────────────────────────────────────────────────────────────────
// Hoisted mocks (vi.hoisted runs before module evaluation)
// ──────────────────────────────────────────────────────────────────────────────
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock } = vi.hoisted(() => {
const { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState } = vi.hoisted(() => {
const openPreviewSpy = vi.fn().mockResolvedValue(undefined)
const browserOpenSpy = vi.fn()
const openTargetSpy = vi.fn().mockResolvedValue(undefined)
const ensureTargetsMock = vi.fn().mockResolvedValue(undefined)
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock }
const panelState = { isOpen: false }
return { openPreviewSpy, browserOpenSpy, openTargetSpy, ensureTargetsMock, panelState }
})
// Mock openTargetStore
@ -49,10 +50,10 @@ vi.mock('../../stores/browserPanelStore', () => ({
// Mock workspacePanelStore
vi.mock('../../stores/workspacePanelStore', () => ({
useWorkspacePanelStore: Object.assign(
(selector: (s: { openPreview: () => Promise<void> }) => unknown) =>
selector({ openPreview: openPreviewSpy }),
(selector: (s: { openPreview: () => Promise<void>; isPanelOpen: () => boolean }) => unknown) =>
selector({ openPreview: openPreviewSpy, isPanelOpen: () => panelState.isOpen }),
{
getState: vi.fn(() => ({ openPreview: openPreviewSpy })),
getState: vi.fn(() => ({ openPreview: openPreviewSpy, isPanelOpen: () => panelState.isOpen })),
},
),
}))
@ -109,7 +110,7 @@ function makeCheckpoint(filesChanged: string[]): SessionTurnCheckpoint {
}
}
function renderCard(filesChanged: string[]) {
function renderCard(filesChanged: string[], isLatest = true) {
const checkpoint = makeCheckpoint(filesChanged)
return render(
<CurrentTurnChangeCard
@ -118,7 +119,7 @@ function renderCard(filesChanged: string[]) {
workDir="/w/proj"
error={null}
isUndoing={false}
isLatest={true}
isLatest={isLatest}
onUndo={vi.fn()}
/>,
)
@ -136,6 +137,7 @@ describe('CurrentTurnChangeCard rich file row (icon / name / type)', () => {
vi.clearAllMocks()
ensureTargetsMock.mockResolvedValue(undefined)
openPreviewSpy.mockResolvedValue(undefined)
panelState.isOpen = false
})
it('renders the filename (not just full path) for each file', () => {
@ -192,14 +194,14 @@ describe('CurrentTurnChangeCard row opens the workspace diff', () => {
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
fireEvent.click(row)
// displayPath is the workDir-relative path (matches the workspace file tree)
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff')
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
})
it('passes the workDir-relative displayPath (not the absolute path) to openPreview', () => {
renderCard(['/w/proj/README.md'])
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
fireEvent.click(row)
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff')
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'README.md', 'diff', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
})
it('clicking an outside-workspace html changed file opens the in-app browser via local-file', () => {
@ -216,7 +218,7 @@ describe('CurrentTurnChangeCard row opens the workspace diff', () => {
renderCard(['/other/place/notes.txt'])
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
fireEvent.click(row)
expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file')
expect(openPreviewSpy).toHaveBeenCalledWith('s1', '/other/place/notes.txt', 'file', expect.objectContaining({ sourceTurnKey: 'msg-1' }))
expect(browserOpenSpy).not.toHaveBeenCalled()
})
@ -264,11 +266,18 @@ describe('CurrentTurnChangeCard open-with buttons', () => {
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
})
it('hides the workspace chevron on rows that already show an open-with button', () => {
it('keeps open-with secondary while every row retains its workspace chevron', () => {
renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts'])
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
expect(screen.getAllByText('chevron_right')).toHaveLength(1)
const rows = screen.getAllByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
expect(rows.every((row) => row.querySelector('.lucide-chevron-right'))).toBe(true)
})
it('shows the same destination chevron on every changed-file row', () => {
const { container } = renderCard(['/w/proj/README.md', '/w/proj/src/main.ts'])
expect(container.querySelectorAll('.lucide-chevron-right')).toHaveLength(2)
})
it('clicking README.md open-with opens menu with workspace preview item', async () => {
@ -363,6 +372,36 @@ describe('CurrentTurnChangeCard open-with buttons', () => {
})
})
describe('CurrentTurnChangeCard conversation continuity', () => {
beforeEach(() => {
vi.clearAllMocks()
panelState.isOpen = false
openPreviewSpy.mockImplementation(async () => {
panelState.isOpen = true
})
})
it('truthfully labels a historical row as opening the current workspace diff', () => {
renderCard(['/w/proj/src/main.ts'], false)
expect(screen.getByText('chat.turnChangesCurrentWorkspaceDiff')).toBeInTheDocument()
})
it('records a stable opener id and semantic turn key before opening the diff', () => {
renderCard(['/w/proj/src/main.ts'])
const row = screen.getByRole('button', { name: /turnChangesOpenInWorkspaceAria/ })
fireEvent.click(row)
expect(row.id).toContain('msg-1')
expect(row).toHaveAttribute('data-source-turn-key', 'msg-1')
expect(openPreviewSpy).toHaveBeenCalledWith('s1', 'src/main.ts', 'diff', {
sourceTurnKey: 'msg-1',
sourceElementId: row.id,
})
})
})
describe('CurrentTurnChangeCard collapse long file lists', () => {
beforeEach(() => {
vi.clearAllMocks()

View File

@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-react'
import type { SessionTurnCheckpoint } from '../../api/sessions'
import { useTranslation, type TranslationKey } from '../../i18n'
import { OpenWithMenu } from '../common/OpenWithMenu'
@ -59,7 +59,12 @@ export function CurrentTurnChangeCard({
? files.slice(0, COLLAPSED_COUNT)
: files
const openChangedFile = useCallback((fileEntry: ChangedFileEntry) => {
const openChangedFile = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
const renderItem = event.currentTarget.closest<HTMLElement>('[data-chat-render-item-key]')
const origin = {
sourceTurnKey: renderItem?.dataset.chatRenderItemKey ?? checkpoint.target.targetUserMessageId,
sourceElementId: event.currentTarget.id,
}
// A changed file outside the workdir (absolute displayPath — e.g. another
// drive) has no checkpoint baseline, so a diff is meaningless. Render html in
// the in-app browser and everything else as a file preview (served by its
@ -69,14 +74,14 @@ export function CurrentTurnChangeCard({
useBrowserPanelStore.getState().open(sessionId, localFileUrl(getServerBaseUrl(), fileEntry.apiPath))
return
}
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file')
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'file', origin)
return
}
// Jump to the right-side workspace and open a diff tab. We pass the workDir-relative
// path (same format the workspace file tree passes to openPreview), so the diff tab
// is keyed/fetched identically to the tree-driven one.
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff')
}, [sessionId, files])
void useWorkspacePanelStore.getState().openPreview(sessionId, fileEntry.displayPath, 'diff', origin)
}, [checkpoint.target.targetUserMessageId, sessionId, files])
const handleOpenWith = useCallback((event: ReactMouseEvent<HTMLButtonElement>, fileEntry: ChangedFileEntry) => {
event.stopPropagation()
@ -113,7 +118,7 @@ export function CurrentTurnChangeCard({
: t('chat.turnChangesHistoricalCardLabel')
const subtitle = isLatest
? t('chat.turnChangesLatestSubtitle')
: t('chat.turnChangesHistoricalSubtitle')
: t('chat.turnChangesCurrentWorkspaceDiff')
const undoLabel = isLatest
? t('chat.turnChangesLatestUndo')
: t('chat.turnChangesHistoricalUndo')
@ -165,7 +170,9 @@ export function CurrentTurnChangeCard({
<div key={fileEntry.apiPath} className="flex items-center gap-2">
<button
type="button"
onClick={() => openChangedFile(fileEntry)}
id={`turn-change-opener-${checkpoint.target.targetUserMessageId}-${encodeURIComponent(fileEntry.apiPath)}`}
data-source-turn-key={checkpoint.target.targetUserMessageId}
onClick={(event) => openChangedFile(event, fileEntry)}
aria-label={t('chat.turnChangesOpenInWorkspaceAria', { path: fileEntry.displayPath })}
title={fileEntry.displayPath}
className="flex min-h-[52px] min-w-0 flex-1 items-center gap-3 rounded-[var(--radius-md)] px-4 text-left transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-brand)]/35"
@ -175,9 +182,7 @@ export function CurrentTurnChangeCard({
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
</span>
{!previewable && (
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
)}
<ChevronRight size={17} strokeWidth={1.9} aria-hidden="true" className="shrink-0 text-[var(--color-text-tertiary)]" />
</button>
{previewable && (
<button

View File

@ -6,6 +6,7 @@ import {
buildVirtualItemOffsets,
getActiveConversationNavigationItemId,
getConversationNavigationTargetScrollTop,
isRenderItemFullyVisibleInChatScroller,
shouldVirtualizeRenderItems,
} from './MessageList'
import type { ConversationNavigationItem } from './ConversationNavigator'
@ -5079,6 +5080,180 @@ describe('MessageList nested tool calls', () => {
).toBeTruthy()
expect(screen.queryByText(/This model does not support images/)).toBeNull()
})
it('restores opener focus without scrolling when its render item remains fully visible', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{ id: 'assistant-origin', type: 'assistant_text', content: 'review result', timestamp: 1 }],
}),
},
})
const { container } = render(<MessageList />)
const opener = screen.getByRole('button', { name: 'Copy reply' })
opener.id = 'origin-opener'
const renderItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="assistant-origin"]')!
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')!
const scrollIntoView = vi.fn()
Object.defineProperty(renderItem, 'scrollIntoView', { configurable: true, value: scrollIntoView })
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
vi.spyOn(renderItem, 'getBoundingClientRect').mockReturnValue({ top: 120, bottom: 480, left: 60, right: 620 } as DOMRect)
await act(async () => {
useWorkspacePanelStore.getState().openPanel(ACTIVE_TAB)
useWorkspacePanelStore.setState({
originBySession: {
[ACTIVE_TAB]: { sourceTurnKey: 'assistant-origin', sourceElementId: 'origin-opener' },
},
})
await Promise.resolve()
})
await act(async () => {
useWorkspacePanelStore.getState().closePanel(ACTIVE_TAB)
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
})
await waitFor(() => expect(document.activeElement).toBe(opener))
expect(scrollIntoView).not.toHaveBeenCalled()
})
it('scrolls a render item clipped by the chat container before restoring opener focus', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{ id: 'assistant-clipped', type: 'assistant_text', content: 'review result', timestamp: 1 }],
}),
},
})
const { container } = render(<MessageList />)
const opener = screen.getByRole('button', { name: 'Copy reply' })
opener.id = 'clipped-origin-opener'
const renderItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="assistant-clipped"]')!
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')!
const scrollIntoView = vi.fn()
Object.defineProperty(renderItem, 'scrollIntoView', { configurable: true, value: scrollIntoView })
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
vi.spyOn(renderItem, 'getBoundingClientRect').mockReturnValue({ top: 80, bottom: 460, left: 60, right: 620 } as DOMRect)
await act(async () => {
useWorkspacePanelStore.getState().openPanel(ACTIVE_TAB)
useWorkspacePanelStore.setState({
originBySession: {
[ACTIVE_TAB]: { sourceTurnKey: 'assistant-clipped', sourceElementId: 'clipped-origin-opener' },
},
})
await Promise.resolve()
})
await act(async () => {
useWorkspacePanelStore.getState().closePanel(ACTIVE_TAB)
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
})
await waitFor(() => expect(document.activeElement).toBe(opener))
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
})
it('uses the semantic render key to remount a virtualized origin before focusing it', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: Array.from({ length: 220 }, (_, index) => ({
id: `virtual-origin-${index}`,
type: 'assistant_text' as const,
content: `virtual transcript ${index}`,
timestamp: index,
})),
}),
},
})
const { container } = render(<MessageList />)
expect(container.querySelector('[data-chat-render-item-key="virtual-origin-0"]')).toBeNull()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
}))
await act(async () => {
useWorkspacePanelStore.getState().openPanel(ACTIVE_TAB)
useWorkspacePanelStore.setState({
originBySession: {
[ACTIVE_TAB]: { sourceTurnKey: 'virtual-origin-0', sourceElementId: 'virtual-origin-opener' },
},
})
await Promise.resolve()
})
await act(async () => {
useWorkspacePanelStore.getState().closePanel(ACTIVE_TAB)
await Promise.resolve()
})
await act(async () => {
frames.shift()?.(0)
await Promise.resolve()
})
const restoredItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="virtual-origin-0"]')
expect(restoredItem).not.toBeNull()
const opener = restoredItem!.querySelector<HTMLButtonElement>('[aria-label="Copy reply"]')!
opener.id = 'virtual-origin-opener'
await act(async () => {
frames.shift()?.(16)
await Promise.resolve()
})
expect(document.activeElement).toBe(opener)
expect(useWorkspacePanelStore.getState().getOrigin(ACTIVE_TAB)).toBeNull()
vi.unstubAllGlobals()
})
it('consumes a closed-panel origin when the source conversation mounts after returning from a workbench tab', async () => {
useChatStore.setState({
sessions: {
[ACTIVE_TAB]: makeSessionState({
messages: [{ id: 'assistant-return', type: 'assistant_text', content: 'returned conversation', timestamp: 1 }],
}),
},
})
useWorkspacePanelStore.setState({
panelBySession: {
[ACTIVE_TAB]: { isOpen: false, activeView: 'changed' },
},
originBySession: {
[ACTIVE_TAB]: { sourceTurnKey: 'assistant-return', sourceElementId: 'return-origin-opener' },
},
})
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
}))
const { container } = render(<MessageList />)
const opener = screen.getByRole('button', { name: 'Copy reply' })
opener.id = 'return-origin-opener'
const renderItem = container.querySelector<HTMLElement>('[data-chat-render-item-key="assistant-return"]')!
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')!
const scrollIntoView = vi.fn()
Object.defineProperty(renderItem, 'scrollIntoView', { configurable: true, value: scrollIntoView })
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
vi.spyOn(renderItem, 'getBoundingClientRect').mockReturnValue({ top: 60, bottom: 460, left: 60, right: 620 } as DOMRect)
for (let attempt = 0; attempt < 20 && document.activeElement !== opener; attempt += 1) {
const frame = frames.shift()
if (!frame) break
await act(async () => {
frame(attempt * 16)
await Promise.resolve()
})
}
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
expect(document.activeElement).toBe(opener)
expect(useWorkspacePanelStore.getState().getOrigin(ACTIVE_TAB)).toBeNull()
vi.unstubAllGlobals()
})
})
describe('shouldVirtualizeRenderItems', () => {
@ -5153,3 +5328,27 @@ describe('conversation navigation layout', () => {
expect(getConversationNavigationTargetScrollTop(items[2]!, offsets, 400, 650)).toBe(250)
})
})
describe('workspace panel origin visibility', () => {
it('does not request scrolling when the render item is fully visible in the chat scroller', () => {
const scroller = document.createElement('div')
scroller.className = 'chat-scroll-area'
const item = document.createElement('div')
scroller.append(item)
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
vi.spyOn(item, 'getBoundingClientRect').mockReturnValue({ top: 120, bottom: 480, left: 60, right: 620 } as DOMRect)
expect(isRenderItemFullyVisibleInChatScroller(item)).toBe(true)
})
it('detects an item clipped by its chat scroller even while inside the window viewport', () => {
const scroller = document.createElement('div')
scroller.className = 'chat-scroll-area'
const item = document.createElement('div')
scroller.append(item)
vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue({ top: 100, bottom: 500, left: 40, right: 640 } as DOMRect)
vi.spyOn(item, 'getBoundingClientRect').mockReturnValue({ top: 80, bottom: 460, left: 60, right: 620 } as DOMRect)
expect(isRenderItemFullyVisibleInChatScroller(item)).toBe(false)
})
})

View File

@ -6,6 +6,7 @@ import { sessionsApi, type SessionTurnCheckpoint } from '../../api/sessions'
import { listPendingPermissions, useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspacePanelStore, type WorkspacePanelOrigin } from '../../stores/workspacePanelStore'
import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
import { useTeamStore } from '../../stores/teamStore'
import { useUIStore } from '../../stores/uiStore'
@ -972,6 +973,18 @@ const CHAT_RENDER_ITEM_CLASS = [
'chat-render-item',
].join(' ')
export function isRenderItemFullyVisibleInChatScroller(renderItem: HTMLElement) {
const scroller = renderItem.closest<HTMLElement>('.chat-scroll-area')
if (!scroller) return false
const itemRect = renderItem.getBoundingClientRect()
const scrollerRect = scroller.getBoundingClientRect()
return itemRect.top >= scrollerRect.top &&
itemRect.bottom <= scrollerRect.bottom &&
itemRect.left >= scrollerRect.left &&
itemRect.right <= scrollerRect.right
}
type SessionScrollSnapshot = {
scrollTop: number
wasAtBottom: boolean
@ -1415,6 +1428,12 @@ const MeasuredRenderItem = memo(function MeasuredRenderItem({
export function MessageList({ sessionId, compact = false }: MessageListProps = {}) {
const activeTabId = useTabStore((s) => s.activeTabId)
const resolvedSessionId = sessionId ?? activeTabId
const isWorkspacePanelOpen = useWorkspacePanelStore((state) =>
resolvedSessionId ? state.isPanelOpen(resolvedSessionId) : false,
)
const workspacePanelOrigin = useWorkspacePanelStore((state) =>
resolvedSessionId ? state.originBySession[resolvedSessionId] ?? null : null,
)
const sessionState = useChatStore((s) =>
resolvedSessionId ? s.sessions[resolvedSessionId] : undefined,
)
@ -1458,6 +1477,8 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
const pendingMeasuredHeightsRef = useRef(false)
const measureFlushFrameRef = useRef<number | null>(null)
const navigationHighlightTimerRef = useRef<number | null>(null)
const workspaceOriginRestoreFrameRef = useRef<number | null>(null)
const workspaceOriginSessionRef = useRef(resolvedSessionId)
const lastAutoScrollAtRef = useRef(0)
const lastContentResizeFollowHeightRef = useRef<number | null>(null)
const shouldAutoScrollRef = useRef(true)
@ -1500,6 +1521,9 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
if (navigationHighlightTimerRef.current !== null) {
window.clearTimeout(navigationHighlightTimerRef.current)
}
if (workspaceOriginRestoreFrameRef.current !== null) {
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
}
}, [])
const syncVirtualViewportFromContainer = useCallback((container: HTMLElement) => {
@ -2180,6 +2204,81 @@ export function MessageList({ sessionId, compact = false }: MessageListProps = {
virtualViewport.viewportHeight,
])
const restoreWorkspacePanelOrigin = useCallback((origin: WorkspacePanelOrigin, attempt = 0) => {
const container = scrollContainerRef.current
const content = scrollContentRef.current
if (!container || !content || !resolvedSessionId) return
const renderItem = [...content.querySelectorAll<HTMLElement>('[data-chat-render-item-key]')]
.find((node) => node.dataset.chatRenderItemKey === origin.sourceTurnKey)
const opener = renderItem
? [...renderItem.querySelectorAll<HTMLElement>('[id]')]
.find((node) => node.id === origin.sourceElementId)
: null
if (renderItem && opener) {
if (!isRenderItemFullyVisibleInChatScroller(renderItem)) {
renderItem.scrollIntoView({ block: 'nearest' })
}
opener.focus({ preventScroll: true })
useWorkspacePanelStore.getState().clearOrigin(resolvedSessionId)
workspaceOriginRestoreFrameRef.current = null
return
}
const renderIndex = renderItemKeys.indexOf(origin.sourceTurnKey)
if (!renderItem && renderIndex >= 0) {
const viewportHeight = container.clientHeight || virtualViewport.viewportHeight || VIRTUAL_DEFAULT_VIEWPORT_HEIGHT
const targetScrollTop = clampNumber(
(virtualTranscriptWindow.offsets[renderIndex] ?? 0) - viewportHeight * CONVERSATION_NAVIGATION_READING_ANCHOR_RATIO,
0,
Math.max(0, virtualTranscriptWindow.totalHeight - viewportHeight),
)
shouldAutoScrollRef.current = false
setScrollTopWithoutLayoutRead(container, targetScrollTop)
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
}
if (attempt >= 7 || renderIndex < 0) {
useWorkspacePanelStore.getState().clearOrigin(resolvedSessionId)
workspaceOriginRestoreFrameRef.current = null
return
}
workspaceOriginRestoreFrameRef.current = requestAnimationFrame(() => {
restoreWorkspacePanelOrigin(origin, attempt + 1)
})
}, [
renderItemKeys,
resolvedSessionId,
virtualTranscriptWindow.offsets,
virtualTranscriptWindow.totalHeight,
virtualViewport.viewportHeight,
])
useEffect(() => {
if (workspaceOriginSessionRef.current !== resolvedSessionId) {
workspaceOriginSessionRef.current = resolvedSessionId
if (workspaceOriginRestoreFrameRef.current !== null) {
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
workspaceOriginRestoreFrameRef.current = null
}
}
if (isWorkspacePanelOpen) {
if (workspaceOriginRestoreFrameRef.current !== null) {
cancelAnimationFrame(workspaceOriginRestoreFrameRef.current)
workspaceOriginRestoreFrameRef.current = null
}
return
}
if (!workspacePanelOrigin || workspaceOriginRestoreFrameRef.current !== null) return
workspaceOriginRestoreFrameRef.current = requestAnimationFrame(() => {
workspaceOriginRestoreFrameRef.current = null
restoreWorkspacePanelOrigin(workspacePanelOrigin)
})
}, [isWorkspacePanelOpen, resolvedSessionId, restoreWorkspacePanelOrigin, workspacePanelOrigin])
const renderTranscriptItem = (item: RenderItem, index: number) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []

View File

@ -119,10 +119,36 @@ describe('WorkbenchPanel', () => {
type: 'workbench',
status: 'idle',
workbenchSessionId: SESSION_ID,
sourceSessionId: SESSION_ID,
},
])
})
it('carries the conversation opener origin into the expanded workbench tab', () => {
useWorkspacePanelStore.setState({
originBySession: {
[SESSION_ID]: {
sourceTurnKey: 'assistant-turn-3',
sourceElementId: 'turn-change-opener-3',
},
},
})
render(<WorkbenchPanel sessionId={SESSION_ID} />)
fireEvent.click(screen.getByRole('button', { name: 'Expand panel' }))
expect(useTabStore.getState().tabs[0]).toMatchObject({
sourceSessionId: SESSION_ID,
sourceTurnKey: 'assistant-turn-3',
sourceElementId: 'turn-change-opener-3',
})
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false)
expect(useWorkspacePanelStore.getState().getOrigin(SESSION_ID)).toEqual({
sourceTurnKey: 'assistant-turn-3',
sourceElementId: 'turn-change-opener-3',
})
})
it('renders the tab variant without a nested expand action', () => {
const handleClose = vi.fn()
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" onClose={handleClose} />)
@ -135,4 +161,15 @@ describe('WorkbenchPanel', () => {
expect(handleClose).toHaveBeenCalledTimes(1)
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true)
})
it('returns the tab variant to its source conversation', () => {
useTabStore.getState().openTab(SESSION_ID, 'Conversation')
const workbenchTabId = useTabStore.getState().openWorkbenchTab(SESSION_ID, 'Workbench')
render(<WorkbenchPanel sessionId={SESSION_ID} variant="tab" />)
fireEvent.click(screen.getByRole('button', { name: 'Back to conversation' }))
expect(useTabStore.getState().activeTabId).toBe(SESSION_ID)
expect(useTabStore.getState().tabs.some((tab) => tab.sessionId === workbenchTabId)).toBe(false)
})
})

View File

@ -1,11 +1,11 @@
import { FolderOpen, Globe, Maximize2, X } from 'lucide-react'
import { ArrowLeft, FolderOpen, Globe, Maximize2, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import {
useWorkspacePanelStore,
type WorkbenchMode,
} from '../../stores/workspacePanelStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useTabStore } from '../../stores/tabStore'
import { WORKBENCH_TAB_PREFIX, useTabStore } from '../../stores/tabStore'
import { WorkspacePanel } from '../workspace/WorkspacePanel'
import { BrowserSurface } from '../browser/BrowserSurface'
@ -45,7 +45,12 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
}
const handleExpand = () => {
useTabStore.getState().openWorkbenchTab(sessionId, t('workbench.tabTitle'))
const origin = useWorkspacePanelStore.getState().getOrigin(sessionId)
useTabStore.getState().openWorkbenchTab(sessionId, t('workbench.tabTitle'), {
sourceSessionId: sessionId,
...(origin ?? {}),
})
closePanel(sessionId)
}
const handleClose = () => {
@ -56,9 +61,28 @@ export function WorkbenchPanel({ sessionId, variant = 'panel', onClose }: Workbe
closePanel(sessionId)
}
const handleReturn = () => {
const store = useTabStore.getState()
const activeTab = store.tabs.find((tab) => tab.sessionId === store.activeTabId)
const tabId = activeTab?.type === 'workbench' && activeTab.workbenchSessionId === sessionId
? activeTab.sessionId
: `${WORKBENCH_TAB_PREFIX}${sessionId}`
store.returnFromWorkbench(tabId)
}
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">
{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"
>
<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />
<span>{t('workbench.backToConversation')}</span>
</button>
)}
<div
role="tablist"
aria-label={t('workbench.modeSwitch')}

View File

@ -1,192 +1,14 @@
import { useEffect, useState } from 'react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { useTranslation } from '../../i18n'
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
export const workspacePrismTheme: PrismTheme = {
plain: {
color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
export function getFileExtension(name: string) {
const cleanName = name.split('/').pop() ?? name
const lastDot = cleanName.lastIndexOf('.')
if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
return cleanName.slice(lastDot + 1).toLowerCase()
}
export function normalizePrismLanguage(language: string) {
const lower = language.toLowerCase()
const map: Record<string, string> = {
text: 'text',
typescript: 'typescript',
ts: 'typescript',
tsx: 'tsx',
javascript: 'javascript',
js: 'javascript',
jsx: 'jsx',
markdown: 'markdown',
md: 'markdown',
html: 'markup',
xml: 'markup',
shell: 'bash',
sh: 'bash',
zsh: 'bash',
diff: 'diff',
}
return map[lower] ?? lower
}
export function getLanguageFromPath(path: string) {
return normalizePrismLanguage(getFileExtension(path) || 'text')
}
export function InlineHighlightedCode({
value,
language,
}: {
value: string
language: string
}) {
return (
<Highlight
theme={workspacePrismTheme}
code={value}
language={normalizePrismLanguage(language)}
>
{({ tokens, getTokenProps }) => (
<>
{(tokens[0] ?? []).map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</>
)}
</Highlight>
)
}
export function WorkspaceDiffSurface({
value,
path,
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
}: {
value: string
path: string
className?: string
lineLimit?: number
}) {
const t = useTranslation()
const [showAllLines, setShowAllLines] = useState(false)
const lines = value.split('\n')
const visibleLines = showAllLines ? lines : lines.slice(0, lineLimit)
const language = getLanguageFromPath(path)
const usePlainLargePreview = showAllLines && lines.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
useEffect(() => {
setShowAllLines(false)
}, [path, value])
return (
<div className={className}>
<div className="relative min-w-max py-2">
<pre
data-workspace-code=""
data-testid="workspace-code"
className="m-0 font-[var(--font-mono)] text-[12px] leading-[1.55] text-[var(--color-code-fg)]"
>
{visibleLines.map((line, index) => {
const isFileHeader = line.startsWith('diff --') || line.startsWith('--- ') || line.startsWith('+++ ')
const isHunk = line.startsWith('@@')
const isAdded = line.startsWith('+') && !line.startsWith('+++')
const isRemoved = line.startsWith('-') && !line.startsWith('---')
const isCodeLine = isAdded || isRemoved || line.startsWith(' ')
const code = isCodeLine ? line.slice(1) : line
const prefix = isCodeLine ? line[0] : ' '
return (
<div
key={index}
className={`grid min-w-full w-max grid-cols-[48px_18px_max-content] gap-2 px-3 ${
isAdded
? 'bg-[var(--color-diff-added-bg)]'
: isRemoved
? 'bg-[var(--color-diff-removed-bg)]'
: isHunk
? 'bg-[var(--color-diff-highlight-bg)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<span className="select-none text-right text-[11px] text-[var(--color-text-tertiary)]">
{index + 1}
</span>
<span
className={`select-none text-center ${
isAdded
? 'text-[var(--color-diff-added-text)]'
: isRemoved
? 'text-[var(--color-diff-removed-text)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{prefix}
</span>
<span
className={`whitespace-pre pr-6 ${
isFileHeader
? 'font-semibold text-[var(--color-text-secondary)]'
: isHunk
? 'font-semibold text-[var(--color-warning)]'
: ''
}`}
>
{isCodeLine && !usePlainLargePreview ? (
code ? <InlineHighlightedCode value={code} language={language} /> : ' '
) : (
code || ' '
)}
</span>
</div>
)
})}
</pre>
{lines.length > lineLimit && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-glass)] px-3 py-2 text-xs text-[var(--color-text-tertiary)] backdrop-blur">
<span>
{showAllLines
? t('workspace.previewAllLines', { total: lines.length })
: t('workspace.previewLineLimit', { count: visibleLines.length, total: lines.length })}
</span>
<button
type="button"
onClick={() => setShowAllLines((current) => !current)}
className="ml-auto rounded-[6px] px-2 py-1 text-[12px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{showAllLines ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
</button>
</div>
)}
</div>
</div>
)
}
export {
getFileExtension,
getLanguageFromPath,
InlineHighlightedCode,
normalizePrismLanguage,
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
WORKSPACE_PREVIEW_LINE_LIMIT,
WorkspaceDiffSurface,
workspacePrismTheme,
} from './WorkspaceDiffSurface'
export type {
WorkspaceDiffCommentSelection,
WorkspaceDiffSurfaceProps,
} from './WorkspaceDiffSurface'

View File

@ -0,0 +1,353 @@
import '@testing-library/jest-dom/vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../../stores/settingsStore'
import { WorkspaceDiffSurface } from './WorkspaceDiffSurface'
import {
WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD,
WORKSPACE_PREVIEW_LINE_LIMIT,
WorkspaceDiffSurface as ExportedWorkspaceDiffSurface,
} from './WorkspaceCodeSurface'
const highlightRenderSpy = vi.hoisted(() => vi.fn())
vi.mock('prism-react-renderer', async (importOriginal) => {
const actual = await importOriginal<typeof import('prism-react-renderer')>()
return {
...actual,
Highlight: (props: ComponentProps<typeof actual.Highlight>) => {
highlightRenderSpy()
return <actual.Highlight {...props} />
},
}
})
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -10,2 +10,3 @@',
' const a = 1',
'-const b = 2',
'+const b = 3',
'+const c = 4',
'@@ -20 +21 @@',
'-old tail',
'+new tail',
].join('\n')
function getCodeRow(text: string) {
const row = document.querySelector(`[data-row-text="${text}"]`)
expect(row).not.toBeNull()
return row!
}
describe('WorkspaceDiffSurface', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
highlightRenderSpy.mockClear()
})
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} />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus()
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }), { shiftKey: true })
expect(screen.getByText('new L11-L12')).toBeInTheDocument()
const rangeEndRow = getCodeRow('const c = 4').closest('[data-diff-row-id]')
const editorContainer = screen.getByRole('textbox', { name: 'Review comment' }).closest('[data-diff-editor]')
expect(rangeEndRow?.nextElementSibling).toBe(editorContainer)
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
const editor = screen.getByRole('textbox', { name: 'Review comment' })
fireEvent.change(editor, { target: { value: 'Use a shared helper' } })
fireEvent.keyDown(editor, { key: 'Enter', metaKey: true })
expect(onAddComment).toHaveBeenCalledWith(expect.objectContaining({
side: 'new',
lineStart: 11,
lineEnd: 12,
quote: 'const b = 3\nconst c = 4',
hunkId: 'file-0-hunk-0',
}), 'Use a shared helper')
})
it('normalizes reverse Shift selection', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' }))
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }), { shiftKey: true })
expect(screen.getByText('new L11-L12')).toBeInTheDocument()
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
expect(getCodeRow('const c = 4')).toHaveAttribute('data-selected', 'true')
})
it('does not submit an empty review comment', () => {
const onAddComment = vi.fn()
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
fireEvent.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Enter', ctrlKey: true })
expect(onAddComment).not.toHaveBeenCalled()
expect(screen.getByRole('textbox', { name: 'Review comment' })).toBeInTheDocument()
})
it('closes on Escape and restores focus to the anchor gutter button', () => {
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.keyDown(screen.getByRole('textbox', { name: 'Review comment' }), { key: 'Escape' })
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
expect(anchor).toHaveFocus()
})
it('resets an incompatible Shift range and announces why', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
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 old line 20' }), { shiftKey: true })
expect(screen.getByText('Selection reset: choose lines from the same side and hunk.')).toBeInTheDocument()
expect(screen.getByText('old L20')).toBeInTheDocument()
})
it('uses one roving tab stop and supports Arrow, Home, End, and activation keys', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const buttons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
const firstButton = buttons[0]!
const secondButton = buttons[1]!
expect(buttons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
act(() => firstButton.focus())
fireEvent.keyDown(firstButton, { key: 'ArrowDown' })
expect(secondButton).toHaveFocus()
expect(secondButton).toHaveAttribute('tabindex', '0')
fireEvent.keyDown(secondButton, { key: 'End' })
expect(buttons.at(-1)).toHaveFocus()
fireEvent.keyDown(buttons.at(-1)!, { key: 'Home' })
expect(firstButton).toHaveFocus()
fireEvent.keyDown(firstButton, { key: ' ' })
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveFocus()
})
it('keeps Shift+Home selection inside the current side and hunk', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
act(() => line11.focus())
fireEvent.keyDown(line11, { key: 'Home', shiftKey: true })
expect(line10).toHaveFocus()
expect(screen.getByText('new L10-L11')).toBeInTheDocument()
expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true')
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
})
it('extends the range with Shift+Arrow and returns focus after submit', () => {
const onAddComment = vi.fn()
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" onAddComment={onAddComment} />)
const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
act(() => anchor.focus())
fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true })
expect(screen.getByText('new L11-L12')).toBeInTheDocument()
const editor = screen.getByRole('textbox', { name: 'Review comment' })
fireEvent.change(editor, { target: { value: 'Keep this focused' } })
fireEvent.keyDown(editor, { key: 'Enter', ctrlKey: true })
expect(onAddComment).toHaveBeenCalledOnce()
expect(anchor).toHaveFocus()
})
it('skips incompatible rows when extending with Shift+Arrow', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const anchor = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
act(() => anchor.focus())
fireEvent.keyDown(anchor, { key: 'ArrowDown', shiftKey: true })
expect(screen.getByText('new L10-L11')).toBeInTheDocument()
expect(getCodeRow('const a = 1')).toHaveAttribute('data-selected', 'true')
expect(getCodeRow('const b = 3')).toHaveAttribute('data-selected', 'true')
})
it('keeps gutter focus for repeatable Shift+Arrow extension and shrinking', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const line10 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 10' })
const line11 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' })
const line12 = screen.getByRole('button', { name: 'Comment on src/a.ts new line 12' })
act(() => line10.focus())
fireEvent.keyDown(line10, { key: 'ArrowDown', shiftKey: true })
expect(line11).toHaveFocus()
fireEvent.keyDown(line11, { key: 'ArrowDown', shiftKey: true })
expect(line12).toHaveFocus()
expect(screen.getByText('new L10-L12')).toBeInTheDocument()
fireEvent.keyDown(line12, { key: 'ArrowUp', shiftKey: true })
expect(line11).toHaveFocus()
expect(screen.getByText('new L10-L11')).toBeInTheDocument()
})
it('keeps roving navigation on mounted rows when the preview is truncated', () => {
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" lineLimit={5} />)
const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
const lastVisibleButton = visibleButtons.at(-1)!
act(() => lastVisibleButton.focus())
fireEvent.keyDown(lastVisibleButton, { key: 'ArrowDown' })
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.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.change(screen.getByRole('textbox', { name: 'Review comment' }), {
target: { value: 'Keep this collapsed draft' },
})
fireEvent.click(screen.getByRole('button', { name: 'Collapse preview' }))
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
expect(screen.getByRole('status')).toHaveTextContent('Select visible lines again')
const visibleButtons = screen.getAllByRole('button', { name: /Comment on src\/a\.ts/ })
expect(visibleButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
expect(visibleButtons[0]).toHaveFocus()
fireEvent.click(visibleButtons[0]!)
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Keep this collapsed draft')
})
it('uses plain text instead of Prism after expanding a diff beyond the large preview threshold', () => {
const additions = Array.from(
{ length: WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD + 1 },
(_, index) => `+const value${index} = ${index}`,
)
const largeDiff = [
'diff --git a/src/large.ts b/src/large.ts',
'--- a/src/large.ts',
'+++ b/src/large.ts',
`@@ -0,0 +1,${additions.length} @@`,
...additions,
].join('\n')
render(<WorkspaceDiffSurface value={largeDiff} path="src/large.ts" lineLimit={1} />)
fireEvent.click(screen.getByRole('button', { name: 'Show all loaded lines' }))
expect(document.querySelector('.token')).not.toBeInTheDocument()
expect(getCodeRow('const value5000 = 5000')).toHaveTextContent('const value5000 = 5000')
})
it('renders parsed file headers and keeps multiple files visually separated', () => {
const multiFileDiff = [
diff,
'diff --git a/src/b.ts b/src/b.ts',
'--- a/src/b.ts',
'+++ b/src/b.ts',
'@@ -1 +1 @@',
'-export const before = true',
'+export const after = true',
].join('\n')
render(<WorkspaceDiffSurface value={multiFileDiff} path="workspace.diff" />)
const headers = screen.getAllByTestId('workspace-diff-file-header')
expect(headers).toHaveLength(2)
expect(headers[0]).toHaveTextContent('diff --git a/src/a.ts b/src/a.ts')
expect(headers[1]).toHaveTextContent('diff --git a/src/b.ts b/src/b.ts')
})
it('renders TypeScript Prism tokens through the compatibility export without a circular runtime failure', () => {
render(<ExportedWorkspaceDiffSurface value={diff} path="src/a.ts" />)
const keyword = screen.getAllByText('const').find((element) => element.classList.contains('keyword'))
expect(keyword).toHaveClass('token', 'keyword')
expect(document.querySelectorAll('[data-row-text="const b = 3"]')).toHaveLength(1)
})
it('renders the complete review flow in Chinese', () => {
useSettingsStore.setState({ locale: 'zh' })
render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
const gutter = screen.getByRole('button', { name: '评论 src/a.ts 的新侧第 11 行' })
fireEvent.click(gutter)
expect(screen.getByRole('textbox', { name: '评审评论' })).toHaveFocus()
expect(screen.getByText('新 L11')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '提交评审评论' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '评论 src/a.ts 的旧侧第 20 行' }), { shiftKey: true })
expect(screen.getByRole('status')).toHaveTextContent('只能选择同一侧、同一变更块中的行')
})
it('does not rerun Prism highlighting for each controlled draft change', () => {
const additions = Array.from(
{ length: WORKSPACE_PREVIEW_LINE_LIMIT - 4 },
(_, index) => `+const value${index + 1} = ${index + 1}`,
)
const nearLimitDiff = [
'diff --git a/src/near-limit.ts b/src/near-limit.ts',
'--- a/src/near-limit.ts',
'+++ b/src/near-limit.ts',
`@@ -0,0 +1,${additions.length} @@`,
...additions,
].join('\n')
render(<WorkspaceDiffSurface value={nearLimitDiff} path="src/near-limit.ts" />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/near-limit.ts new line 1' }))
const highlightCountBeforeTyping = highlightRenderSpy.mock.calls.length
const editor = screen.getByRole('textbox', { name: 'Review comment' })
fireEvent.change(editor, { target: { value: 'a' } })
fireEvent.change(editor, { target: { value: 'ab' } })
fireEvent.change(editor, { target: { value: 'abc' } })
expect(highlightRenderSpy).toHaveBeenCalledTimes(highlightCountBeforeTyping)
expect(editor).toHaveValue('abc')
})
it('preserves draft text but invalidates its selection when the diff changes', () => {
const { rerender } = render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
target: { value: 'Draft survives refresh' },
})
rerender(<WorkspaceDiffSurface value={`${diff}\n`} path="src/a.ts" />)
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
expect(screen.getByText('Diff changed. Select lines again to submit this comment.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('Draft survives refresh')
})
it('resets the editor and draft when the path changes', () => {
const { rerender } = render(<WorkspaceDiffSurface value={diff} path="src/a.ts" />)
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
fireEvent.change(screen.getByRole('textbox', { name: 'Review comment' }), {
target: { value: 'Discard on another file' },
})
rerender(<WorkspaceDiffSurface value={diff} path="src/b.ts" />)
expect(screen.queryByRole('textbox', { name: 'Review comment' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Comment on src/b.ts new line 11' }))
expect(screen.getByRole('textbox', { name: 'Review comment' })).toHaveValue('')
})
})

View File

@ -0,0 +1,556 @@
import {
Fragment,
memo,
useEffect,
useMemo,
useRef,
useState,
type KeyboardEvent,
type MouseEvent,
} from 'react'
import { CornerDownLeft, MessageSquare, Plus } from 'lucide-react'
import { Highlight, type PrismTheme } from 'prism-react-renderer'
import { useTranslation } from '../../i18n'
import {
getCompatibleDiffRange,
parseWorkspaceDiff,
type WorkspaceDiffRow,
type WorkspaceDiffSelection,
} from './workspaceDiffModel'
export const WORKSPACE_PREVIEW_LINE_LIMIT = 2000
export const WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD = 5000
export const workspacePrismTheme: PrismTheme = {
plain: {
color: 'var(--color-code-fg)',
backgroundColor: 'transparent',
},
styles: [
{ types: ['comment', 'prolog', 'doctype', 'cdata'], style: { color: 'var(--color-code-comment)', fontStyle: 'italic' } },
{ types: ['string', 'attr-value', 'template-string'], style: { color: 'var(--color-code-string)' } },
{ types: ['keyword', 'selector', 'important', 'atrule'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['function'], style: { color: 'var(--color-code-function)' } },
{ types: ['tag'], style: { color: 'var(--color-code-keyword)' } },
{ types: ['number', 'boolean'], style: { color: 'var(--color-code-number)' } },
{ types: ['operator'], style: { color: 'var(--color-code-fg)' } },
{ types: ['punctuation'], style: { color: 'var(--color-code-punctuation)' } },
{ types: ['variable', 'parameter'], style: { color: 'var(--color-code-fg)' } },
{ types: ['property', 'attr-name'], style: { color: 'var(--color-code-property)' } },
{ types: ['builtin', 'class-name', 'constant', 'symbol'], style: { color: 'var(--color-code-type)' } },
{ types: ['inserted'], style: { color: 'var(--color-code-inserted)' } },
{ types: ['deleted'], style: { color: 'var(--color-code-deleted)' } },
],
}
export function getFileExtension(name: string) {
const cleanName = name.split('/').pop() ?? name
const lastDot = cleanName.lastIndexOf('.')
if (lastDot <= 0 || lastDot === cleanName.length - 1) return ''
return cleanName.slice(lastDot + 1).toLowerCase()
}
export function normalizePrismLanguage(language: string) {
const lower = language.toLowerCase()
const map: Record<string, string> = {
text: 'text',
typescript: 'typescript',
ts: 'typescript',
tsx: 'tsx',
javascript: 'javascript',
js: 'javascript',
jsx: 'jsx',
markdown: 'markdown',
md: 'markdown',
html: 'markup',
xml: 'markup',
shell: 'bash',
sh: 'bash',
zsh: 'bash',
diff: 'diff',
}
return map[lower] ?? lower
}
export function getLanguageFromPath(path: string) {
return normalizePrismLanguage(getFileExtension(path) || 'text')
}
export const InlineHighlightedCode = memo(function InlineHighlightedCode({
value,
language,
}: {
value: string
language: string
}) {
return (
<Highlight theme={workspacePrismTheme} code={value} language={normalizePrismLanguage(language)}>
{({ tokens, getTokenProps }) => (
<>
{(tokens[0] ?? []).map((token, tokenIndex) => {
const { key: tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex })
return <span key={String(tokenKey)} {...tokenProps} />
})}
</>
)}
</Highlight>
)
})
export interface WorkspaceDiffCommentSelection {
side: 'old' | 'new'
lineStart: number
lineEnd: number
quote: string
hunkId: string
}
export interface WorkspaceDiffSurfaceProps {
value: string
path: string
className?: string
lineLimit?: number
onAddComment?: (selection: WorkspaceDiffCommentSelection, note: string) => void
}
interface ReviewState {
anchorId: string | null
focusId: string | null
selection: WorkspaceDiffSelection | null
draft: string
}
type ReviewStatus = 'selectionReset' | 'diffChanged' | 'collapsedSelection' | null
const emptyReviewState: ReviewState = {
anchorId: null,
focusId: null,
selection: null,
draft: '',
}
function rowTone(row: WorkspaceDiffRow) {
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 '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)]'
return 'text-[var(--color-text-tertiary)]'
}
function codeTone(row: WorkspaceDiffRow) {
if (row.kind === 'metadata') return 'font-semibold text-[var(--color-text-secondary)]'
if (row.kind === 'hunk') return 'font-semibold text-[var(--color-warning)]'
return ''
}
export function WorkspaceDiffSurface({
value,
path,
className = 'min-h-0 flex-1 overflow-auto bg-[var(--color-code-bg)]',
lineLimit = WORKSPACE_PREVIEW_LINE_LIMIT,
onAddComment,
}: WorkspaceDiffSurfaceProps) {
const t = useTranslation()
const files = useMemo(() => parseWorkspaceDiff(value), [value])
const rows = useMemo(() => files.flatMap((file) => file.rows), [files])
const displayItemIds = useMemo(
() => files.flatMap((file) => [`${file.id}-header`, ...file.rows.map((row) => row.id)]),
[files],
)
const [review, setReview] = useState<ReviewState>(emptyReviewState)
const [status, setStatus] = useState<ReviewStatus>(null)
const [showAllRows, setShowAllRows] = useState(false)
const visibleItemIds = useMemo(
() => new Set(showAllRows ? displayItemIds : displayItemIds.slice(0, lineLimit)),
[displayItemIds, lineLimit, showAllRows],
)
const visibleRows = useMemo(() => rows.filter((row) => visibleItemIds.has(row.id)), [rows, visibleItemIds])
const selectableRows = useMemo(() => visibleRows.filter((row) => row.selectable), [visibleRows])
const usePlainLargePreview = showAllRows && rows.length > WORKSPACE_PLAIN_TEXT_LINE_THRESHOLD
const [rovingId, setRovingId] = useState<string | null>(() => rows.find((row) => row.selectable)?.id ?? null)
const buttonRefs = useRef(new Map<string, HTMLButtonElement>())
const editorRef = useRef<HTMLTextAreaElement>(null)
const shouldFocusEditor = useRef(false)
const pendingRovingFocus = useRef<string | null>(null)
const previousPath = useRef(path)
const previousValue = useRef(value)
const selectedIds = new Set(review.selection?.rowIds ?? [])
const sideLabel = (side: 'old' | 'new') => t(`workspace.diffReview.side.${side}`)
useEffect(() => {
const pathChanged = previousPath.current !== path
const valueChanged = previousValue.current !== value
previousPath.current = path
previousValue.current = value
if (pathChanged) {
setReview(emptyReviewState)
setStatus(null)
setShowAllRows(false)
setRovingId(selectableRows[0]?.id ?? null)
return
}
if (valueChanged) {
setReview((current) => ({
...current,
anchorId: null,
focusId: null,
selection: null,
}))
setStatus(review.draft ? 'diffChanged' : null)
setRovingId(selectableRows[0]?.id ?? null)
}
}, [path, review.draft, selectableRows, value])
useEffect(() => {
if (!rovingId || !selectableRows.some((row) => row.id === rovingId)) {
setRovingId(selectableRows[0]?.id ?? null)
}
const pendingId = pendingRovingFocus.current
if (pendingId && selectableRows.some((row) => row.id === pendingId)) {
pendingRovingFocus.current = null
setRovingId(pendingId)
buttonRefs.current.get(pendingId)?.focus()
}
}, [rovingId, selectableRows])
useEffect(() => {
if (review.selection && shouldFocusEditor.current) {
shouldFocusEditor.current = false
editorRef.current?.focus()
}
}, [review.selection])
const focusButton = (id: string | null) => {
if (id) buttonRefs.current.get(id)?.focus()
}
const selectSingleRow = (row: WorkspaceDiffRow, resetStatus: ReviewStatus = null, focusEditor = false) => {
const selection = getCompatibleDiffRange(rows, row.id, row.id)
if (!selection) return
shouldFocusEditor.current = focusEditor
setReview((current) => ({
...current,
anchorId: row.id,
focusId: row.id,
selection,
}))
setStatus(resetStatus)
}
const extendSelection = (row: WorkspaceDiffRow, focusEditor = false) => {
if (!review.anchorId) {
selectSingleRow(row, null, focusEditor)
return
}
const selection = getCompatibleDiffRange(rows, review.anchorId, row.id)
if (!selection) {
selectSingleRow(row, 'selectionReset', focusEditor)
return
}
shouldFocusEditor.current = focusEditor
setReview((current) => ({ ...current, focusId: row.id, selection }))
setStatus(null)
}
const activateRow = (row: WorkspaceDiffRow, extend: boolean, focusEditor: boolean) => {
setRovingId(row.id)
if (extend) extendSelection(row, focusEditor)
else selectSingleRow(row, null, focusEditor)
}
const handleRowClick = (event: MouseEvent<HTMLButtonElement>, row: WorkspaceDiffRow) => {
activateRow(row, event.shiftKey, true)
}
const moveRovingFocus = (row: WorkspaceDiffRow, direction: -1 | 1, extend: boolean) => {
const currentIndex = selectableRows.findIndex((candidate) => candidate.id === row.id)
const anchorRow = review.anchorId
? selectableRows.find((candidate) => candidate.id === review.anchorId) ?? row
: row
let target = selectableRows[currentIndex + direction]
if (extend) {
let targetIndex = currentIndex + direction
while (target && (target.side !== anchorRow.side || target.hunkId !== anchorRow.hunkId)) {
targetIndex += direction
target = selectableRows[targetIndex]
}
}
if (!target) return
setRovingId(target.id)
focusButton(target.id)
if (extend && !review.anchorId) {
const selection = getCompatibleDiffRange(rows, row.id, target.id)
if (selection) {
setReview((current) => ({
...current,
anchorId: row.id,
focusId: target.id,
selection,
}))
setStatus(null)
} else {
selectSingleRow(target, 'selectionReset')
}
} else if (extend) {
extendSelection(target)
}
}
const handleRowKeyDown = (event: KeyboardEvent<HTMLButtonElement>, row: WorkspaceDiffRow) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
moveRovingFocus(row, event.key === 'ArrowDown' ? 1 : -1, event.shiftKey)
return
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
const navigationRows = event.shiftKey
? selectableRows.filter((candidate) => (
candidate.side === row.side && candidate.hunkId === row.hunkId
))
: selectableRows
const target = event.key === 'Home' ? navigationRows[0] : navigationRows.at(-1)
if (target) {
setRovingId(target.id)
focusButton(target.id)
if (event.shiftKey && !review.anchorId) {
const selection = getCompatibleDiffRange(rows, row.id, target.id)
if (selection) {
setReview((current) => ({
...current,
anchorId: row.id,
focusId: target.id,
selection,
}))
setStatus(null)
}
} else if (event.shiftKey) {
extendSelection(target)
}
}
return
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
activateRow(row, event.shiftKey, true)
}
}
const closeEditor = () => {
const restoreId = review.anchorId
setReview((current) => ({ ...current, anchorId: null, focusId: null, selection: null }))
setStatus(null)
focusButton(restoreId)
}
const submitComment = () => {
const note = review.draft.trim()
if (!note || !review.selection) return
const { side, lineStart, lineEnd, quote, hunkId } = review.selection
onAddComment?.({ side, lineStart, lineEnd, quote, hunkId }, note)
const restoreId = review.anchorId
setReview(emptyReviewState)
setStatus(null)
focusButton(restoreId)
}
const handleEditorKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === 'Escape') {
event.preventDefault()
closeEditor()
return
}
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
submitComment()
}
}
const toggleRows = () => {
if (!showAllRows) {
setShowAllRows(true)
return
}
const collapsedItemIds = new Set(displayItemIds.slice(0, lineLimit))
const collapsedSelectableRows = rows.filter((row) => row.selectable && collapsedItemIds.has(row.id))
const nextRovingId = collapsedSelectableRows[0]?.id ?? null
const selectionWillBeHidden = review.selection?.rowIds.some((id) => !collapsedItemIds.has(id)) ?? false
if (selectionWillBeHidden) {
setReview((current) => ({
...current,
anchorId: null,
focusId: null,
selection: null,
}))
setStatus('collapsedSelection')
}
setRovingId(nextRovingId)
pendingRovingFocus.current = nextRovingId
setShowAllRows(false)
}
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"
>
{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>
<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"
>
<CornerDownLeft aria-hidden="true" size={14} />
<span>{t('workspace.diffReview.submit')}</span>
</button>
</div>
</div>
)
return (
<div className={className}>
<div className="relative min-w-max py-2">
<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)]"
>
{files.map((file) => {
const headerVisible = visibleItemIds.has(`${file.id}-header`)
const fileRows = file.rows.filter((row) => visibleItemIds.has(row.id))
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'
const language = getLanguageFromPath(file.newPath ?? file.oldPath ?? path)
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)]"
>
diff --git {oldPath} {newPath}
</div>
)}
{fileRows.map((row) => {
const line = row.side === 'old' ? row.oldLine : row.newLine
const selected = selectedIds.has(row.id)
return (
<Fragment key={row.id}>
<div
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)]' : ''
}`}
>
<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">
{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}
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'
}`}
>
{selected ? <MessageSquare aria-hidden="true" size={14} /> : <Plus aria-hidden="true" size={14} />}
</button>
)}
</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)}`}
>
{row.selectable && row.text && !usePlainLargePreview
? <InlineHighlightedCode value={row.text} language={language} />
: row.text || ' '}
</span>
</div>
{review.selection?.endId === row.id ? renderEditor() : null}
</Fragment>
)
})}
</div>
)
})}
</div>
{status && !review.selection && (
<div className="sticky bottom-0 border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2">
<div role="status" aria-live="polite" className="text-[11px] text-[var(--color-warning)]">
{t(`workspace.diffReview.${status}`)}
</div>
</div>
)}
{displayItemIds.length > lineLimit && (
<div className="sticky bottom-0 flex items-center gap-3 border-t border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">
<span>
{showAllRows
? t('workspace.previewAllLines', { total: displayItemIds.length })
: t('workspace.previewLineLimit', { count: visibleItemIds.size, total: displayItemIds.length })}
</span>
<button
type="button"
onClick={toggleRows}
className="ml-auto h-7 rounded-[5px] px-2 text-[11px] font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
{showAllRows ? t('workspace.collapsePreview') : t('workspace.showAllLoadedLines')}
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -417,6 +417,57 @@ describe('WorkspacePanel', () => {
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})
it.each([
['diff:src/app.ts', 'modified', 'Modified'],
['file:src/app.ts', 'added', 'Added'],
] as const)('marks the changed row active for %s and localizes its %s status', async (activeTabId, status, statusLabel) => {
const sessionId = `session-active-${status}`
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, additions: 2, deletions: 1 }],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: [{
id: activeTabId,
path: 'src/app.ts',
kind: activeTabId.startsWith('diff:') ? 'diff' : 'file',
title: 'app.ts',
state: 'ok',
...(activeTabId.startsWith('diff:')
? { diff: '@@ -1 +1 @@\n-old\n+new' }
: { content: 'const app = true', language: 'typescript', size: 16 }),
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
[sessionId]: activeTabId,
},
}))
const view = await renderPanel(sessionId)
await clickElement(view.getByRole('button', { name: 'Show file navigator' }))
const row = view.getByText('src/app.ts').closest('button')
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(view.getByLabelText(statusLabel).textContent).toBe(status === 'modified' ? 'M' : 'A')
})
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
@ -1024,11 +1075,12 @@ describe('WorkspacePanel', () => {
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('+line 1')
expect(highlightedCode).toContain('+line 2000')
expect(highlightedCode).not.toContain('+line 2001')
expect(highlightedCode).toContain('+line 1999')
expect(highlightedCode).not.toContain('+line 2000')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2001')
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
@ -1077,11 +1129,11 @@ describe('WorkspacePanel', () => {
const view = await renderPanel('session-wide-diff')
const diffSurface = view.getByTestId('workspace-code')
const firstRow = diffSurface.querySelector('div')
const firstRow = diffSurface.querySelector('[data-diff-row-id]')
expect(firstRow?.className).toContain('w-max')
expect(firstRow?.className).toContain('min-w-full')
expect(firstRow?.className).toContain('grid-cols-[48px_18px_max-content]')
expect(firstRow?.className).toContain('grid-cols-[42px_42px_28px_18px_minmax(max-content,1fr)]')
expect(diffSurface.textContent).toContain(longDiffLine)
})
@ -1696,6 +1748,87 @@ describe('WorkspacePanel', () => {
])
})
it('adds a side-aware diff comment, keeps the diff open, and focuses the composer', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-diff-comment': {
isOpen: true,
activeView: 'changed',
},
},
statusBySession: {
...state.statusBySession,
'session-diff-comment': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-diff-comment': [{
id: 'diff:src/a.ts',
path: 'src/a.ts',
kind: 'diff',
title: 'a.ts',
diff: '@@ -10,2 +11,2 @@\n-const result = makeResult()\n-return result\n+const result = buildResult()\n+return result',
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-diff-comment': 'diff:src/a.ts',
},
}))
const otherComposerShell = document.createElement('div')
otherComposerShell.dataset.testid = 'chat-input-shell'
otherComposerShell.dataset.sessionId = 'another-session'
otherComposerShell.append(document.createElement('textarea'))
document.body.append(otherComposerShell)
const composerShell = document.createElement('div')
composerShell.dataset.testid = 'chat-input-shell'
composerShell.dataset.sessionId = 'session-diff-comment'
const composer = document.createElement('textarea')
composerShell.append(composer)
document.body.append(composerShell)
const view = await renderPanel('session-diff-comment')
await clickElement(view.getByRole('button', { name: 'Comment on src/a.ts new line 11' }))
const editor = view.getByRole('textbox', { name: 'Review comment' })
await act(() => {
fireEvent.change(editor, { target: { value: 'Use a shared helper' } })
})
await clickElement(view.getByRole('button', { name: 'Submit review comment' }))
await waitFor(() => expect(document.activeElement).toBe(composer))
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-diff-comment']).toMatchObject([
{
kind: 'code-comment',
path: 'src/a.ts',
absolutePath: '/repo/src/a.ts',
name: 'a.ts',
lineStart: 11,
lineEnd: 11,
diffSide: 'new',
hunkId: expect.any(String),
note: 'Use a shared helper',
quote: 'const result = buildResult()',
},
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-diff-comment']).toBe('diff:src/a.ts')
expect(view.getByTestId('workspace-code')).toBeTruthy()
composerShell.remove()
otherComposerShell.remove()
})
it('adds selected code from a preview to the chat context without requiring a note', async () => {
await setWorkspaceState((state) => ({
...state,
@ -2068,4 +2201,137 @@ describe('WorkspacePanel', () => {
expect(view.getByText('status failed')).toBeTruthy()
})
})
it('keeps a loaded diff visible and marks the preview busy while it refreshes', async () => {
const refresh = deferred<{ state: 'ok'; path: string; diff: string }>()
getMocks().getWorkspaceDiffMock.mockReturnValue(refresh.promise)
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-preview-refresh': { isOpen: true, activeView: 'changed' },
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-preview-refresh': [{
id: 'diff:src/a.ts',
path: 'src/a.ts',
kind: 'diff',
title: 'a.ts',
state: 'ok',
diff: '@@ -1 +1 @@\n-old\n+cached',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-preview-refresh': 'diff:src/a.ts',
},
}))
const view = await renderPanel('session-preview-refresh')
expect(view.getByText('cached')).toBeTruthy()
await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
expect(view.getByText('cached')).toBeTruthy()
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
refresh.resolve({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+latest',
})
await flushReactWork()
expect(view.getByText('latest')).toBeTruthy()
expect(view.queryByText('cached')).toBeNull()
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('false')
})
it('localizes an initial missing preview without describing it as a refresh failure', async () => {
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-initial-missing-zh': { isOpen: true, activeView: 'changed' },
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-initial-missing-zh': [{
id: 'diff:src/missing.ts',
path: 'src/missing.ts',
kind: 'diff',
title: 'missing.ts',
state: 'missing',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-initial-missing-zh': 'diff:src/missing.ts',
},
}))
const view = await renderPanel('session-initial-missing-zh')
expect(view.getByText('文件不存在。')).toBeTruthy()
expect(view.queryByText(/refresh/i)).toBeNull()
expect(view.queryByRole('button', { name: '重试' })).toBeNull()
})
it('localizes stale diff state and completes retry after a non-ok refresh omits an error', async () => {
const refresh = deferred<{ state: 'missing'; path: string }>()
const retry = deferred<{ state: 'ok'; path: string; diff: string }>()
getMocks().getWorkspaceDiffMock
.mockReturnValueOnce(refresh.promise)
.mockReturnValueOnce(retry.promise)
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-preview-refresh-error': { isOpen: true, activeView: 'changed' },
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-preview-refresh-error': [{
id: 'diff:src/a.ts',
path: 'src/a.ts',
kind: 'diff',
title: 'a.ts',
state: 'ok',
diff: '@@ -1 +1 @@\n-old\n+cached',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-preview-refresh-error': 'diff:src/a.ts',
},
}))
const view = await renderPanel('session-preview-refresh-error')
await clickElement(view.getByRole('tab', { name: /a\.ts/ }))
refresh.resolve({ state: 'missing', path: 'src/a.ts' })
await flushReactWork()
expect(view.getByText('cached')).toBeTruthy()
expect(view.getByRole('alert').textContent).toContain('文件不存在。')
expect(view.getByRole('alert').textContent).not.toMatch(/refresh/i)
await clickElement(view.getByRole('button', { name: '重试' }))
expect(view.queryByRole('alert')).toBeNull()
expect(view.getByText('cached')).toBeTruthy()
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('true')
retry.resolve({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+recovered',
})
await flushReactWork()
expect(view.getByText('recovered')).toBeTruthy()
expect(view.queryByText('cached')).toBeNull()
expect(view.getByTestId('workspace-preview-content').getAttribute('aria-busy')).toBe('false')
})
})

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent } from 'react'
import { MessageCircle } from 'lucide-react'
import { CircleAlert, Code2, File as FileIcon, FileText, Image as ImageIcon, MessageCircle, RefreshCw, Settings2, type LucideIcon } from 'lucide-react'
import { Highlight } from 'prism-react-renderer'
import type {
WorkspaceChangedFile,
@ -27,8 +27,10 @@ import {
WORKSPACE_PREVIEW_LINE_LIMIT,
WorkspaceDiffSurface,
workspacePrismTheme,
type WorkspaceDiffCommentSelection,
} from './WorkspaceCodeSurface'
import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith'
import { getFileIdentity, getWorkspaceStatusLabel, type WorkspaceFileIdentity } from './fileIdentity'
type WorkspacePanelProps = {
sessionId: string
@ -102,6 +104,14 @@ const FILE_STATUS_META: Record<WorkspaceFileStatus, { label: string; className:
},
}
const FILE_IDENTITY_ICONS: Record<WorkspaceFileIdentity['icon'], LucideIcon> = {
code: Code2,
config: Settings2,
document: FileText,
image: ImageIcon,
file: FileIcon,
}
const EMPTY_TREE_BY_PATH: Record<string, WorkspaceTreeResult | undefined> = {}
const EMPTY_PREVIEW_TABS: WorkspacePreviewTab[] = []
const EMPTY_EXPANDED_PATHS: string[] = []
@ -435,11 +445,12 @@ function WorkspaceFilterInput({
}
function FileStatusBadge({ status }: { status: WorkspaceFileStatus }) {
const t = useTranslation()
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}`}
aria-label={status}
aria-label={getWorkspaceStatusLabel(status, t)}
>
{meta.label}
</span>
@ -765,23 +776,43 @@ function ImagePreview({ tab }: { tab: WorkspacePreviewTab }) {
function ChangedFileRow({
file,
active,
onClick,
onContextMenu,
}: {
file: WorkspaceChangedFile
active: boolean
onClick: () => void
onContextMenu: (event: MouseEvent, path: string) => void
}) {
const identity = getFileIdentity(file.path)
const IdentityIcon = FILE_IDENTITY_ICONS[identity.icon]
return (
<button
type="button"
onClick={onClick}
onContextMenu={(event) => onContextMenu(event, file.path)}
className="mx-2 flex w-[calc(100%-16px)] items-center gap-3 rounded-[7px] px-2 py-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
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 ${
active
? 'bg-[var(--color-surface-selected)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]'
: '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)]"
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>
{file.oldPath && (
<div className="truncate text-[11px] text-[var(--color-text-tertiary)]">
{file.oldPath}
@ -988,6 +1019,9 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
const activePreviewError = useWorkspacePanelStore((state) =>
activePreviewRequestKey ? state.errors.previewByTabId[activePreviewRequestKey] ?? null : null,
)
const activePreviewRefreshState = useWorkspacePanelStore((state) =>
activePreviewRequestKey ? state.errors.previewRefreshStateByTabId[activePreviewRequestKey] ?? null : null,
)
useEffect(() => {
const previous = refreshLifecycleRef.current
@ -1078,6 +1112,31 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
})
}
const addDiffCommentToChat = (
path: string,
selection: WorkspaceDiffCommentSelection,
note: string,
) => {
addWorkspaceReference(sessionId, {
kind: 'code-comment',
path,
absolutePath: resolveWorkspaceAttachmentPath(status?.workDir, path),
name: path.split('/').pop() || path,
lineStart: selection.lineStart,
lineEnd: selection.lineEnd,
diffSide: selection.side,
hunkId: selection.hunkId,
note,
quote: selection.quote,
})
requestAnimationFrame(() => {
const composer = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="chat-input-shell"]'),
).find((element) => element.dataset.sessionId === sessionId)
composer?.querySelector<HTMLTextAreaElement>('textarea:not([disabled])')?.focus()
})
}
const addSelectionToChat = (path: string, selection: WorkspaceTextSelection) => {
addWorkspaceReference(sessionId, {
kind: 'code-selection',
@ -1166,6 +1225,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
<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}
/>
@ -1239,9 +1299,15 @@ 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)
return (
<>
<div
data-testid="workspace-preview-content"
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) => (
@ -1260,12 +1326,38 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
<span aria-hidden="true" className="material-symbols-outlined text-[14px]">person_add</span>
<span>{t('workspace.addToChat')}</span>
</button>
{activePreviewLoading && state === 'ok' && (
<RefreshCw
size={13}
className="shrink-0 animate-spin text-[var(--color-text-tertiary)]"
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>
{activePreviewLoading || state === 'loading' ? (
{state === 'ok' && refreshErrorMessage && !activePreviewLoading && (
<div
role="alert"
className="flex shrink-0 items-center gap-2 border-b border-[var(--color-error)]/20 bg-[var(--color-error)]/6 px-3 py-2 text-[11px] text-[var(--color-error)]"
>
<CircleAlert size={15} className="shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate">{refreshErrorMessage}</span>
<button
type="button"
onClick={() => {
void openPreview(sessionId, activePreviewTab.path, activePreviewTab.kind)
}}
className="shrink-0 rounded-[6px] border border-[var(--color-error)]/30 px-2 py-1 font-medium hover:bg-[var(--color-error)]/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-error)]/25"
>
{t('common.retry')}
</button>
</div>
)}
{state === 'loading' || (activePreviewLoading && state !== 'ok') ? (
<PanelMessage icon="progress_activity" message={t('workspace.previewState.loading')} />
) : state === 'ok' && activePreviewTab.previewType === 'image' ? (
<ImagePreview tab={activePreviewTab} />
@ -1273,6 +1365,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
<WorkspaceDiffSurface
value={activePreviewTab.diff ?? ''}
path={activePreviewTab.path}
onAddComment={(selection, note) => addDiffCommentToChat(activePreviewTab.path, selection, note)}
/>
) : state === 'ok' && isMarkdownPreview(activePreviewTab) ? (
<MarkdownSurface
@ -1293,7 +1386,7 @@ export function WorkspacePanel({ sessionId, embedded = false, forceVisible = fal
message={getInlineStateMessage(t, state, activePreviewError || activePreviewTab.error || null)}
/>
)}
</>
</div>
)
}

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { translate, type Locale } from '../../i18n'
import { getFileIdentity, getWorkspaceStatusLabel } from './fileIdentity'
describe('getFileIdentity', () => {
it.each([
['src/App.tsx', { shortLabel: 'TSX', languageLabel: 'TypeScript React', icon: 'code' }],
['main.py', { shortLabel: 'PY', languageLabel: 'Python', icon: 'code' }],
['cmd/main.go', { shortLabel: 'GO', languageLabel: 'Go', icon: 'code' }],
['src/lib.rs', { shortLabel: 'RS', languageLabel: 'Rust', icon: 'code' }],
['config.yaml', { shortLabel: 'YAML', languageLabel: 'YAML', icon: 'config' }],
['docker/Dockerfile', { shortLabel: 'DKR', languageLabel: 'Dockerfile', icon: 'config' }],
['Makefile', { shortLabel: 'MAKE', languageLabel: 'Makefile', icon: 'config' }],
['.gitignore', { shortLabel: 'GIT', languageLabel: 'Git ignore', icon: 'config' }],
['notes', { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' }],
['assets/hero.png', { shortLabel: 'IMG', languageLabel: 'PNG image', icon: 'image' }],
])('recognizes %s', (path, expected) => {
expect(getFileIdentity(path)).toEqual(expected)
})
})
describe('getWorkspaceStatusLabel', () => {
const locales: Locale[] = ['en', 'zh', 'zh-TW', 'jp', 'kr']
it.each(locales)('returns readable labels for every status in %s', (locale) => {
const t = (key: Parameters<typeof translate>[1]) => translate(locale, key)
const labels = [
'modified',
'added',
'deleted',
'renamed',
'untracked',
'copied',
'type_changed',
'unknown',
].map((status) => getWorkspaceStatusLabel(status as Parameters<typeof getWorkspaceStatusLabel>[0], t))
expect(labels.every((label) => label.length > 0 && !label.startsWith('workspace.status.'))).toBe(true)
})
})

View File

@ -0,0 +1,95 @@
import type { WorkspaceFileStatus } from '../../api/sessions'
import type { TranslationKey } from '../../i18n'
export type WorkspaceFileIdentity = {
shortLabel: string
languageLabel: string
icon: 'code' | 'config' | 'document' | 'image' | 'file'
}
type Translate = (key: TranslationKey) => string
const SPECIAL_BASENAMES: Record<string, WorkspaceFileIdentity> = {
dockerfile: { shortLabel: 'DKR', languageLabel: 'Dockerfile', icon: 'config' },
makefile: { shortLabel: 'MAKE', languageLabel: 'Makefile', icon: 'config' },
'.gitignore': { shortLabel: 'GIT', languageLabel: 'Git ignore', icon: 'config' },
'.gitattributes': { shortLabel: 'GIT', languageLabel: 'Git attributes', icon: 'config' },
'.editorconfig': { shortLabel: 'CFG', languageLabel: 'EditorConfig', icon: 'config' },
license: { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' },
readme: { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' },
}
const EXTENSIONS: Record<string, WorkspaceFileIdentity> = {
ts: { shortLabel: 'TS', languageLabel: 'TypeScript', icon: 'code' },
tsx: { shortLabel: 'TSX', languageLabel: 'TypeScript React', icon: 'code' },
js: { shortLabel: 'JS', languageLabel: 'JavaScript', icon: 'code' },
jsx: { shortLabel: 'JSX', languageLabel: 'JavaScript React', icon: 'code' },
py: { shortLabel: 'PY', languageLabel: 'Python', icon: 'code' },
go: { shortLabel: 'GO', languageLabel: 'Go', icon: 'code' },
rs: { shortLabel: 'RS', languageLabel: 'Rust', icon: 'code' },
java: { shortLabel: 'JAVA', languageLabel: 'Java', icon: 'code' },
kt: { shortLabel: 'KT', languageLabel: 'Kotlin', icon: 'code' },
swift: { shortLabel: 'SWIFT', languageLabel: 'Swift', icon: 'code' },
c: { shortLabel: 'C', languageLabel: 'C', icon: 'code' },
cc: { shortLabel: 'C++', languageLabel: 'C++', icon: 'code' },
cpp: { shortLabel: 'C++', languageLabel: 'C++', icon: 'code' },
h: { shortLabel: 'H', languageLabel: 'C header', icon: 'code' },
css: { shortLabel: 'CSS', languageLabel: 'CSS', icon: 'code' },
scss: { shortLabel: 'SCSS', languageLabel: 'SCSS', icon: 'code' },
html: { shortLabel: 'HTML', languageLabel: 'HTML', icon: 'code' },
sh: { shortLabel: 'SH', languageLabel: 'Shell script', icon: 'code' },
bash: { shortLabel: 'SH', languageLabel: 'Bash script', icon: 'code' },
zsh: { shortLabel: 'ZSH', languageLabel: 'Z shell script', icon: 'code' },
yaml: { shortLabel: 'YAML', languageLabel: 'YAML', icon: 'config' },
yml: { shortLabel: 'YAML', languageLabel: 'YAML', icon: 'config' },
json: { shortLabel: 'JSON', languageLabel: 'JSON', icon: 'config' },
toml: { shortLabel: 'TOML', languageLabel: 'TOML', icon: 'config' },
ini: { shortLabel: 'INI', languageLabel: 'INI configuration', icon: 'config' },
env: { shortLabel: 'ENV', languageLabel: 'Environment configuration', icon: 'config' },
md: { shortLabel: 'MD', languageLabel: 'Markdown', icon: 'document' },
markdown: { shortLabel: 'MD', languageLabel: 'Markdown', icon: 'document' },
txt: { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' },
png: { shortLabel: 'IMG', languageLabel: 'PNG image', icon: 'image' },
jpg: { shortLabel: 'IMG', languageLabel: 'JPEG image', icon: 'image' },
jpeg: { shortLabel: 'IMG', languageLabel: 'JPEG image', icon: 'image' },
gif: { shortLabel: 'IMG', languageLabel: 'GIF image', icon: 'image' },
webp: { shortLabel: 'IMG', languageLabel: 'WebP image', icon: 'image' },
svg: { shortLabel: 'SVG', languageLabel: 'SVG image', icon: 'image' },
}
export function getFileIdentity(path: string): WorkspaceFileIdentity {
const basename = path.replace(/\\/g, '/').split('/').pop() || path
const normalizedBasename = basename.toLowerCase()
const special = SPECIAL_BASENAMES[normalizedBasename]
if (special) return special
const extensionIndex = normalizedBasename.lastIndexOf('.')
const extension = extensionIndex >= 0 ? normalizedBasename.slice(extensionIndex + 1) : ''
const known = EXTENSIONS[extension]
if (known) return known
if (!extension) {
return { shortLabel: 'TXT', languageLabel: 'Plain text', icon: 'document' }
}
return {
shortLabel: extension.slice(0, 4).toUpperCase(),
languageLabel: `${extension.toUpperCase()} file`,
icon: 'file',
}
}
const STATUS_KEYS: Record<WorkspaceFileStatus, TranslationKey> = {
modified: 'workspace.status.modified',
added: 'workspace.status.added',
deleted: 'workspace.status.deleted',
renamed: 'workspace.status.renamed',
untracked: 'workspace.status.untracked',
copied: 'workspace.status.copied',
type_changed: 'workspace.status.typeChanged',
unknown: 'workspace.status.unknown',
}
export function getWorkspaceStatusLabel(status: WorkspaceFileStatus, t: Translate) {
return t(STATUS_KEYS[status])
}

View File

@ -0,0 +1,150 @@
import { describe, expect, it } from 'vitest'
import { getCompatibleDiffRange, parseWorkspaceDiff } from './workspaceDiffModel'
const diff = [
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -10,2 +10,3 @@',
' const a = 1',
'-const b = 2',
'+const b = 3',
'+const c = 4',
'@@ -20 +21 @@',
'-old tail',
'+new tail',
].join('\n')
describe('parseWorkspaceDiff', () => {
it('assigns source coordinates and sides to selectable hunk rows', () => {
const file = parseWorkspaceDiff(diff)[0]!
expect(file.rows.filter((row) => row.selectable).map((row) => ({
kind: row.kind,
oldLine: row.oldLine,
newLine: row.newLine,
side: row.side,
}))).toEqual([
{ kind: 'context', oldLine: 10, newLine: 10, side: 'new' },
{ kind: 'deletion', oldLine: 11, newLine: null, side: 'old' },
{ kind: 'addition', oldLine: null, newLine: 11, side: 'new' },
{ kind: 'addition', oldLine: null, newLine: 12, side: 'new' },
{ kind: 'deletion', oldLine: 20, newLine: null, side: 'old' },
{ kind: 'addition', oldLine: null, newLine: 21, side: 'new' },
])
})
it('keeps malformed headers, binary markers, and no-newline markers as metadata', () => {
const file = parseWorkspaceDiff([
'diff --git a/image.png b/image.png',
'--- a/image.png',
'+++ b/image.png',
'@@ malformed @@',
'Binary files a/image.png and b/image.png differ',
'\\ No newline at end of file',
].join('\n'))[0]!
expect(file.rows).toHaveLength(5)
expect(file.rows.every((row) => row.kind === 'metadata' && !row.selectable)).toBe(true)
})
it('keeps parsing a hunk after a no-newline marker', () => {
const file = parseWorkspaceDiff([
'diff --git a/a.txt b/a.txt',
'@@ -1 +1 @@',
'-before',
'\\ No newline at end of file',
'+after',
].join('\n'))[0]!
expect(file.rows.map((row) => row.kind)).toEqual(['hunk', 'deletion', 'metadata', 'addition'])
expect(file.rows[2]).toMatchObject({ selectable: false })
expect(file.rows[3]).toMatchObject({ newLine: 1, side: 'new', selectable: true })
})
it('treats hunk content beginning with file-header markers as code', () => {
const file = parseWorkspaceDiff([
'diff --git a/docs/a.md b/docs/a.md',
'--- a/docs/a.md',
'+++ b/docs/a.md',
'@@ -5,2 +5,2 @@',
'--- old heading',
'+++ new heading',
' tail',
].join('\n'))[0]!
expect({ oldPath: file.oldPath, newPath: file.newPath }).toEqual({
oldPath: 'docs/a.md',
newPath: 'docs/a.md',
})
expect(file.rows.filter((row) => row.selectable).map((row) => ({
kind: row.kind,
text: row.text,
oldLine: row.oldLine,
newLine: row.newLine,
side: row.side,
}))).toEqual([
{ kind: 'deletion', text: '-- old heading', oldLine: 5, newLine: null, side: 'old' },
{ kind: 'addition', text: '++ new heading', oldLine: null, newLine: 5, side: 'new' },
{ kind: 'context', text: 'tail', oldLine: 6, newLine: 6, side: 'new' },
])
})
})
describe('getCompatibleDiffRange', () => {
it('normalizes forward and reverse ranges on the same hunk and side', () => {
const file = parseWorkspaceDiff(diff)[0]!
const newRows = file.rows.filter((row) => row.selectable && row.hunkId === 'file-0-hunk-0' && row.side === 'new')
const expected = {
hunkId: 'file-0-hunk-0',
side: 'new',
startId: newRows[0]!.id,
endId: newRows[2]!.id,
rowIds: newRows.map((row) => row.id),
lineStart: 10,
lineEnd: 12,
quote: 'const a = 1\nconst b = 3\nconst c = 4',
}
expect(getCompatibleDiffRange(file.rows, newRows[0]!.id, newRows[2]!.id)).toEqual(expected)
expect(getCompatibleDiffRange(file.rows, newRows[2]!.id, newRows[0]!.id)).toEqual(expected)
})
it('normalizes forward and reverse ranges on the old side', () => {
const file = parseWorkspaceDiff([
'diff --git a/src/a.ts b/src/a.ts',
'--- a/src/a.ts',
'+++ b/src/a.ts',
'@@ -30,3 +30 @@',
'-old first',
'-old second',
'-old third',
'+new value',
].join('\n'))[0]!
const oldRows = file.rows.filter((row) => row.selectable && row.side === 'old')
const expected = {
hunkId: 'file-0-hunk-0',
side: 'old',
startId: oldRows[0]!.id,
endId: oldRows[2]!.id,
rowIds: oldRows.map((row) => row.id),
lineStart: 30,
lineEnd: 32,
quote: 'old first\nold second\nold third',
}
expect(getCompatibleDiffRange(file.rows, oldRows[0]!.id, oldRows[2]!.id)).toEqual(expected)
expect(getCompatibleDiffRange(file.rows, oldRows[2]!.id, oldRows[0]!.id)).toEqual(expected)
})
it('rejects ranges across sides or hunks', () => {
const file = parseWorkspaceDiff(diff)[0]!
const selectable = file.rows.filter((row) => row.selectable)
const context = selectable.find((row) => row.kind === 'context')!
const deletion = selectable.find((row) => row.kind === 'deletion')!
const secondHunkAddition = selectable.find((row) => row.hunkId === 'file-0-hunk-1' && row.kind === 'addition')!
expect(getCompatibleDiffRange(file.rows, context.id, deletion.id)).toBeNull()
expect(getCompatibleDiffRange(file.rows, context.id, secondHunkAddition.id)).toBeNull()
})
})

View File

@ -0,0 +1,213 @@
export type WorkspaceDiffSide = 'old' | 'new'
export type WorkspaceDiffRowKind = 'metadata' | 'hunk' | 'context' | 'deletion' | 'addition'
export interface WorkspaceDiffRow {
id: string
kind: WorkspaceDiffRowKind
text: string
prefix: string
hunkId: string | null
oldLine: number | null
newLine: number | null
side: WorkspaceDiffSide | null
selectable: boolean
}
export interface WorkspaceDiffFile {
id: string
oldPath: string | null
newPath: string | null
rows: WorkspaceDiffRow[]
}
export interface WorkspaceDiffSelection {
hunkId: string
side: WorkspaceDiffSide
startId: string
endId: string
rowIds: string[]
lineStart: number
lineEnd: number
quote: string
}
const hunkHeaderPattern = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
function pathFromHeader(line: string, prefix: '--- ' | '+++ '): string | null {
const path = line.slice(prefix.length).split('\t', 1)[0] ?? ''
if (path === '/dev/null') return null
if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2)
return path
}
export function parseWorkspaceDiff(value: string): WorkspaceDiffFile[] {
if (!value) return []
const files: WorkspaceDiffFile[] = []
let file: WorkspaceDiffFile | null = null
let hunkId: string | null = null
let hunkIndex = 0
let oldLine = 0
let newLine = 0
const startFile = (oldPath: string | null = null, newPath: string | null = null) => {
file = {
id: `file-${files.length}`,
oldPath,
newPath,
rows: [],
}
files.push(file)
hunkId = null
hunkIndex = 0
}
const addRow = (
kind: WorkspaceDiffRowKind,
text: string,
prefix = '',
coordinates: Pick<WorkspaceDiffRow, 'oldLine' | 'newLine' | 'side' | 'selectable'> = {
oldLine: null,
newLine: null,
side: null,
selectable: false,
},
) => {
if (!file) startFile()
const currentFile = file as WorkspaceDiffFile
currentFile.rows.push({
id: `${currentFile.id}-row-${currentFile.rows.length}`,
kind,
text,
prefix,
hunkId,
...coordinates,
})
}
for (const line of value.split('\n')) {
const fileHeader = /^diff --git a\/(.+) b\/(.+)$/.exec(line)
if (fileHeader) {
startFile(fileHeader[1], fileHeader[2])
continue
}
if (!file) startFile()
const currentFile = file!
if (!hunkId && line.startsWith('--- ')) {
currentFile.oldPath = pathFromHeader(line, '--- ')
addRow('metadata', line)
continue
}
if (!hunkId && line.startsWith('+++ ')) {
currentFile.newPath = pathFromHeader(line, '+++ ')
addRow('metadata', line)
continue
}
const hunkHeader = hunkHeaderPattern.exec(line)
if (hunkHeader) {
hunkId = `${currentFile.id}-hunk-${hunkIndex}`
hunkIndex += 1
oldLine = Number(hunkHeader[1])
newLine = Number(hunkHeader[3])
addRow('hunk', line)
continue
}
if (line === '\\ No newline at end of file') {
addRow('metadata', line)
continue
}
if (line.startsWith('@@') || line.startsWith('Binary ')) {
hunkId = null
addRow('metadata', line)
continue
}
if (hunkId && line.startsWith('+')) {
addRow('addition', line.slice(1), '+', {
oldLine: null,
newLine,
side: 'new',
selectable: true,
})
newLine += 1
continue
}
if (hunkId && line.startsWith('-')) {
addRow('deletion', line.slice(1), '-', {
oldLine,
newLine: null,
side: 'old',
selectable: true,
})
oldLine += 1
continue
}
if (hunkId && line.startsWith(' ')) {
addRow('context', line.slice(1), ' ', {
oldLine,
newLine,
side: 'new',
selectable: true,
})
oldLine += 1
newLine += 1
continue
}
addRow('metadata', line)
}
return files
}
export function getCompatibleDiffRange(
rows: WorkspaceDiffRow[],
anchorId: string,
targetId: string,
): WorkspaceDiffSelection | null {
const anchorIndex = rows.findIndex((row) => row.id === anchorId)
const targetIndex = rows.findIndex((row) => row.id === targetId)
if (anchorIndex < 0 || targetIndex < 0) return null
const anchor = rows[anchorIndex]
const target = rows[targetIndex]
if (!anchor || !target) return null
if (
!anchor.selectable
|| !target.selectable
|| !anchor.hunkId
|| anchor.hunkId !== target.hunkId
|| !anchor.side
|| anchor.side !== target.side
) return null
const startIndex = Math.min(anchorIndex, targetIndex)
const endIndex = Math.max(anchorIndex, targetIndex)
const selectedRows = rows.slice(startIndex, endIndex + 1).filter((row) => (
row.selectable && row.hunkId === anchor.hunkId && row.side === anchor.side
))
const lineNumbers = selectedRows.map((row) => (
anchor.side === 'old' ? row.oldLine : row.newLine
)).filter((line): line is number => line !== null)
if (selectedRows.length === 0 || lineNumbers.length !== selectedRows.length) return null
const firstRow = selectedRows[0]!
const lastRow = selectedRows[selectedRows.length - 1]!
return {
hunkId: anchor.hunkId,
side: anchor.side,
startId: firstRow.id,
endId: lastRow.id,
rowIds: selectedRows.map((row) => row.id),
lineStart: lineNumbers[0]!,
lineEnd: lineNumbers[lineNumbers.length - 1]!,
quote: selectedRows.map((row) => row.text).join('\n'),
}
}

View File

@ -146,6 +146,7 @@ export const en = {
'workbench.expand': 'Expand panel',
'workbench.close': 'Close',
'workbench.tabTitle': 'Workbench',
'workbench.backToConversation': 'Back to conversation',
'workspace.closeTab': 'Close tab',
'workspace.preview': 'Preview',
'workspace.previewEmpty': 'Select a file to preview.',
@ -157,6 +158,14 @@ export const en = {
'workspace.noMatchingFiles': 'No matching files',
'workspace.previewKind.diff': 'Diff',
'workspace.previewKind.file': 'File',
'workspace.status.modified': 'Modified',
'workspace.status.added': 'Added',
'workspace.status.deleted': 'Deleted',
'workspace.status.renamed': 'Renamed',
'workspace.status.untracked': 'Untracked',
'workspace.status.copied': 'Copied',
'workspace.status.typeChanged': 'Type changed',
'workspace.status.unknown': 'Unknown status',
'workspace.previewState.loading': 'Loading preview...',
'workspace.previewState.binary': 'Binary file preview is unavailable.',
'workspace.previewState.tooLarge': 'File is too large to preview.',
@ -176,6 +185,16 @@ export const en = {
'workspace.commentLineTarget': 'Line {line}',
'workspace.commentPlaceholder': 'Describe what should change here...',
'workspace.addCommentToChat': 'Add comment',
'workspace.diffReview.commentLineAria': 'Comment on {path} {side} line {line}',
'workspace.diffReview.editorLabel': 'Review comment',
'workspace.diffReview.submit': 'Submit',
'workspace.diffReview.submitAria': 'Submit review comment',
'workspace.diffReview.side.old': 'old',
'workspace.diffReview.side.new': 'new',
'workspace.diffReview.selectionReset': 'Selection reset: choose lines from the same side and hunk.',
'workspace.diffReview.diffChanged': 'Diff changed. Select lines again to submit this comment.',
'workspace.diffReview.collapsedSelection': 'Selection is outside the collapsed preview. Select visible lines again to submit this comment.',
'attachments.remove': 'Remove {name}',
// ─── Status Bar ──────────────────────────────────────
'status.connected': 'Connected',
@ -1411,6 +1430,7 @@ export const en = {
'chat.turnChangesHistoricalCardLabel': 'Turn changed files',
'chat.turnChangesLatestSubtitle': 'Current turn checkpoint',
'chat.turnChangesHistoricalSubtitle': 'Saved turn checkpoint',
'chat.turnChangesCurrentWorkspaceDiff': 'Current workspace diff',
'chat.turnChangesLatestUndo': 'Undo current turn',
'chat.turnChangesHistoricalUndo': 'Rewind to before this turn',
'chat.turnChangesUndoing': 'Undoing...',

View File

@ -148,6 +148,7 @@ export const jp: Record<TranslationKey, string> = {
'workbench.expand': 'パネルを展開',
'workbench.close': '閉じる',
'workbench.tabTitle': 'ワークベンチ',
'workbench.backToConversation': '会話に戻る',
'workspace.closeTab': 'タブを閉じる',
'workspace.preview': 'プレビュー',
'workspace.previewEmpty': 'プレビューするファイルを選択してください。',
@ -159,6 +160,14 @@ export const jp: Record<TranslationKey, string> = {
'workspace.noMatchingFiles': '一致するファイルなし',
'workspace.previewKind.diff': '差分',
'workspace.previewKind.file': 'ファイル',
'workspace.status.modified': '変更済み',
'workspace.status.added': '追加済み',
'workspace.status.deleted': '削除済み',
'workspace.status.renamed': '名前変更済み',
'workspace.status.untracked': '未追跡',
'workspace.status.copied': 'コピー済み',
'workspace.status.typeChanged': '種類変更済み',
'workspace.status.unknown': '不明な状態',
'workspace.previewState.loading': 'プレビューを読み込み中...',
'workspace.previewState.binary': 'バイナリファイルはプレビューできません。',
'workspace.previewState.tooLarge': 'ファイルが大きすぎてプレビューできません。',
@ -178,6 +187,16 @@ export const jp: Record<TranslationKey, string> = {
'workspace.commentLineTarget': '{line} 行目',
'workspace.commentPlaceholder': 'ここで何を変更すべきか説明してください...',
'workspace.addCommentToChat': 'コメントを追加',
'workspace.diffReview.commentLineAria': '{path} の{side}側 {line} 行目にコメント',
'workspace.diffReview.editorLabel': 'レビューコメント',
'workspace.diffReview.submit': '送信',
'workspace.diffReview.submitAria': 'レビューコメントを送信',
'workspace.diffReview.side.old': '旧',
'workspace.diffReview.side.new': '新',
'workspace.diffReview.selectionReset': '選択をリセットしました。同じ側の同じハンク内の行を選択してください。',
'workspace.diffReview.diffChanged': '差分が更新されました。このコメントを送信するには行を選択し直してください。',
'workspace.diffReview.collapsedSelection': '選択した行は折りたたまれたプレビューの範囲外です。表示中の行を選択し直してください。',
'attachments.remove': '{name} を削除',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '接続済み',
@ -1413,6 +1432,7 @@ export const jp: Record<TranslationKey, string> = {
'chat.turnChangesHistoricalCardLabel': 'ターンで変更されたファイル',
'chat.turnChangesLatestSubtitle': '現在のターンのチェックポイント',
'chat.turnChangesHistoricalSubtitle': '保存済みターンのチェックポイント',
'chat.turnChangesCurrentWorkspaceDiff': '現在のワークスペース差分',
'chat.turnChangesLatestUndo': '現在のターンを元に戻す',
'chat.turnChangesHistoricalUndo': 'このターンの前まで巻き戻す',
'chat.turnChangesUndoing': '元に戻しています...',

View File

@ -148,6 +148,7 @@ export const kr: Record<TranslationKey, string> = {
'workbench.expand': '패널 펼치기',
'workbench.close': '닫기',
'workbench.tabTitle': '워크벤치',
'workbench.backToConversation': '대화로 돌아가기',
'workspace.closeTab': '탭 닫기',
'workspace.preview': '미리 보기',
'workspace.previewEmpty': '미리 볼 파일을 선택하세요.',
@ -159,6 +160,14 @@ export const kr: Record<TranslationKey, string> = {
'workspace.noMatchingFiles': '일치하는 파일 없음',
'workspace.previewKind.diff': '변경 내용',
'workspace.previewKind.file': '파일',
'workspace.status.modified': '수정됨',
'workspace.status.added': '추가됨',
'workspace.status.deleted': '삭제됨',
'workspace.status.renamed': '이름 변경됨',
'workspace.status.untracked': '추적되지 않음',
'workspace.status.copied': '복사됨',
'workspace.status.typeChanged': '유형 변경됨',
'workspace.status.unknown': '알 수 없는 상태',
'workspace.previewState.loading': '미리 보기 불러오는 중...',
'workspace.previewState.binary': '바이너리 파일은 미리 볼 수 없습니다.',
'workspace.previewState.tooLarge': '파일이 너무 커서 미리 볼 수 없습니다.',
@ -178,6 +187,16 @@ export const kr: Record<TranslationKey, string> = {
'workspace.commentLineTarget': '{line}번째 줄',
'workspace.commentPlaceholder': '여기서 무엇을 변경해야 하는지 설명하세요...',
'workspace.addCommentToChat': '댓글 추가',
'workspace.diffReview.commentLineAria': '{path}의 {side} 쪽 {line}번 줄에 댓글 달기',
'workspace.diffReview.editorLabel': '리뷰 댓글',
'workspace.diffReview.submit': '제출',
'workspace.diffReview.submitAria': '리뷰 댓글 제출',
'workspace.diffReview.side.old': '이전',
'workspace.diffReview.side.new': '새',
'workspace.diffReview.selectionReset': '선택이 재설정되었습니다. 같은 쪽과 같은 헝크의 줄을 선택하세요.',
'workspace.diffReview.diffChanged': 'Diff가 변경되었습니다. 이 댓글을 제출하려면 줄을 다시 선택하세요.',
'workspace.diffReview.collapsedSelection': '선택한 줄이 접힌 미리보기 범위 밖에 있습니다. 표시된 줄을 다시 선택하세요.',
'attachments.remove': '{name} 제거',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '연결됨',
@ -1413,6 +1432,7 @@ export const kr: Record<TranslationKey, string> = {
'chat.turnChangesHistoricalCardLabel': '턴에서 변경된 파일',
'chat.turnChangesLatestSubtitle': '현재 턴 체크포인트',
'chat.turnChangesHistoricalSubtitle': '저장된 턴 체크포인트',
'chat.turnChangesCurrentWorkspaceDiff': '현재 워크스페이스 변경 사항',
'chat.turnChangesLatestUndo': '현재 턴 실행 취소',
'chat.turnChangesHistoricalUndo': '이 턴 이전으로 되감기',
'chat.turnChangesUndoing': '실행 취소 중...',

View File

@ -148,6 +148,7 @@ export const zh: Record<TranslationKey, string> = {
'workbench.expand': '展開面板',
'workbench.close': '關閉',
'workbench.tabTitle': '工作臺',
'workbench.backToConversation': '返回對話',
'workspace.closeTab': '關閉標籤',
'workspace.preview': '預覽',
'workspace.previewEmpty': '選擇一個檔案進行預覽。',
@ -159,6 +160,14 @@ export const zh: Record<TranslationKey, string> = {
'workspace.noMatchingFiles': '沒有匹配的檔案',
'workspace.previewKind.diff': 'Diff',
'workspace.previewKind.file': '檔案',
'workspace.status.modified': '已修改',
'workspace.status.added': '已新增',
'workspace.status.deleted': '已刪除',
'workspace.status.renamed': '已重新命名',
'workspace.status.untracked': '未追蹤',
'workspace.status.copied': '已複製',
'workspace.status.typeChanged': '類型已變更',
'workspace.status.unknown': '未知狀態',
'workspace.previewState.loading': '正在載入預覽...',
'workspace.previewState.binary': '二進位制檔案暫不支援預覽。',
'workspace.previewState.tooLarge': '檔案過大,無法預覽。',
@ -178,6 +187,16 @@ export const zh: Record<TranslationKey, string> = {
'workspace.commentLineTarget': '第 {line} 行',
'workspace.commentPlaceholder': '描述你希望這裡怎麼改...',
'workspace.addCommentToChat': '新增評論',
'workspace.diffReview.commentLineAria': '評論 {path} 的{side}側第 {line} 行',
'workspace.diffReview.editorLabel': '審查評論',
'workspace.diffReview.submit': '提交',
'workspace.diffReview.submitAria': '提交審查評論',
'workspace.diffReview.side.old': '舊',
'workspace.diffReview.side.new': '新',
'workspace.diffReview.selectionReset': '選取已重設:只能選擇同一側、同一變更區塊中的行。',
'workspace.diffReview.diffChanged': '差異已更新。請重新選擇行以提交此評論。',
'workspace.diffReview.collapsedSelection': '所選行不在收合後的預覽中。請重新選擇可見行以提交此評論。',
'attachments.remove': '移除 {name}',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已連線',
@ -1413,6 +1432,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.turnChangesHistoricalCardLabel': '輪次已更改檔案',
'chat.turnChangesLatestSubtitle': '當前輪次檢查點',
'chat.turnChangesHistoricalSubtitle': '歷史輪次檢查點',
'chat.turnChangesCurrentWorkspaceDiff': '目前工作區差異',
'chat.turnChangesLatestUndo': '撤銷當前輪次',
'chat.turnChangesHistoricalUndo': '回滾到這一輪之前',
'chat.turnChangesUndoing': '正在撤銷...',

View File

@ -148,6 +148,7 @@ export const zh: Record<TranslationKey, string> = {
'workbench.expand': '展开面板',
'workbench.close': '关闭',
'workbench.tabTitle': '工作台',
'workbench.backToConversation': '返回对话',
'workspace.closeTab': '关闭标签',
'workspace.preview': '预览',
'workspace.previewEmpty': '选择一个文件进行预览。',
@ -159,6 +160,14 @@ export const zh: Record<TranslationKey, string> = {
'workspace.noMatchingFiles': '没有匹配的文件',
'workspace.previewKind.diff': 'Diff',
'workspace.previewKind.file': '文件',
'workspace.status.modified': '已修改',
'workspace.status.added': '已新增',
'workspace.status.deleted': '已删除',
'workspace.status.renamed': '已重命名',
'workspace.status.untracked': '未跟踪',
'workspace.status.copied': '已复制',
'workspace.status.typeChanged': '类型已变更',
'workspace.status.unknown': '未知状态',
'workspace.previewState.loading': '正在加载预览...',
'workspace.previewState.binary': '二进制文件暂不支持预览。',
'workspace.previewState.tooLarge': '文件过大,无法预览。',
@ -178,6 +187,16 @@ export const zh: Record<TranslationKey, string> = {
'workspace.commentLineTarget': '第 {line} 行',
'workspace.commentPlaceholder': '描述你希望这里怎么改...',
'workspace.addCommentToChat': '添加评论',
'workspace.diffReview.commentLineAria': '评论 {path} 的{side}侧第 {line} 行',
'workspace.diffReview.editorLabel': '评审评论',
'workspace.diffReview.submit': '提交',
'workspace.diffReview.submitAria': '提交评审评论',
'workspace.diffReview.side.old': '旧',
'workspace.diffReview.side.new': '新',
'workspace.diffReview.selectionReset': '选择已重置:只能选择同一侧、同一变更块中的行。',
'workspace.diffReview.diffChanged': '差异已更新。请重新选择行以提交此评论。',
'workspace.diffReview.collapsedSelection': '所选行不在收起后的预览中。请重新选择可见行以提交此评论。',
'attachments.remove': '移除 {name}',
// ─── Status Bar ──────────────────────────────────────
'status.connected': '已连接',
@ -1413,6 +1432,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.turnChangesHistoricalCardLabel': '轮次已更改文件',
'chat.turnChangesLatestSubtitle': '当前轮次检查点',
'chat.turnChangesHistoricalSubtitle': '历史轮次检查点',
'chat.turnChangesCurrentWorkspaceDiff': '当前工作区差异',
'chat.turnChangesLatestUndo': '撤销当前轮次',
'chat.turnChangesHistoricalUndo': '回滚到这一轮之前',
'chat.turnChangesUndoing': '正在撤销...',

View File

@ -12,6 +12,8 @@ export type ComposerAttachment = {
isDirectory?: boolean
lineStart?: number
lineEnd?: number
diffSide?: 'old' | 'new'
hunkId?: string
note?: string
quote?: string
}

View File

@ -76,6 +76,7 @@ describe('tabStore', () => {
type: 'workbench',
status: 'idle',
workbenchSessionId: 'session-1',
sourceSessionId: 'session-1',
},
])
expect(useTabStore.getState().activeTabId).toBe('__workbench__session-1')
@ -85,6 +86,48 @@ describe('tabStore', () => {
}))
})
it('returns an ephemeral workbench tab to its source session before closing it', () => {
useTabStore.getState().openTab('session-a', 'Session A')
useTabStore.getState().openTab('session-b', 'Session B')
const tabId = useTabStore.getState().openWorkbenchTab('session-b', 'Workbench', {
sourceSessionId: 'session-b',
sourceTurnKey: 'assistant:turn-2',
sourceElementId: 'turn-change-session-b-main-ts',
})
useTabStore.getState().returnFromWorkbench(tabId)
expect(useTabStore.getState().activeTabId).toBe('session-b')
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['session-a', 'session-b'])
})
it('defaults a workbench origin to its source session and keeps it ephemeral', () => {
useTabStore.getState().openTab('session-a', 'Session A')
const tabId = useTabStore.getState().openWorkbenchTab('session-a', 'Workbench')
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === tabId)).toMatchObject({
sourceSessionId: 'session-a',
})
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
openTabs: [{ sessionId: 'session-a', title: 'Session A', type: 'session' }],
activeTabId: 'session-a',
}))
})
it('persists the source session as active while its ephemeral workbench is active', () => {
useTabStore.getState().openTab('session-a', 'Session A')
useTabStore.getState().openTab('session-b', 'Session B')
useTabStore.getState().openWorkbenchTab('session-b', 'Workbench')
expect(localStorage.getItem('cc-haha-open-tabs')).toBe(JSON.stringify({
openTabs: [
{ sessionId: 'session-a', title: 'Session A', type: 'session' },
{ sessionId: 'session-b', title: 'Session B', type: 'session' },
],
activeTabId: 'session-b',
}))
})
it('opens one ephemeral SubAgent tab per source session and tool use', () => {
const tabId = useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn')
const sameTabId = useTabStore.getState().openSubagentTab('session-1', 'tool-1', 'Kuhn updated')

View File

@ -27,9 +27,17 @@ export type Tab = {
traceSessionId?: string
workbenchSessionId?: string
sourceSessionId?: string
sourceTurnKey?: string
sourceElementId?: string
subagentToolUseId?: string
}
export type WorkbenchTabOrigin = {
sourceSessionId?: string
sourceTurnKey?: string
sourceElementId?: string
}
type TabPersistence = {
openTabs: Array<{ sessionId: string; title: string; type?: TabType; traceSessionId?: string }>
activeTabId: string | null
@ -43,7 +51,8 @@ type TabStore = {
openTracesTab: (title?: string) => string
openTraceTab: (sessionId: string, title?: string) => string
openTerminalTab: (cwd?: string, terminalRuntimeId?: string) => string
openWorkbenchTab: (sessionId: string, title?: string) => string
openWorkbenchTab: (sessionId: string, title?: string, origin?: WorkbenchTabOrigin) => string
returnFromWorkbench: (tabId: string) => void
openSubagentTab: (sourceSessionId: string, toolUseId: string, title?: string) => string
closeTab: (sessionId: string) => void
setActiveTab: (sessionId: string) => void
@ -168,7 +177,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
return sessionId
},
openWorkbenchTab: (sessionId, title = 'Workbench') => {
openWorkbenchTab: (sessionId, title = 'Workbench', origin) => {
const tabId = `${WORKBENCH_TAB_PREFIX}${sessionId}`
const { tabs } = get()
const existing = tabs.find((tab) => tab.sessionId === tabId)
@ -178,6 +187,9 @@ export const useTabStore = create<TabStore>((set, get) => ({
type: 'workbench',
status: 'idle',
workbenchSessionId: sessionId,
sourceSessionId: origin?.sourceSessionId ?? sessionId,
...(origin?.sourceTurnKey ? { sourceTurnKey: origin.sourceTurnKey } : {}),
...(origin?.sourceElementId ? { sourceElementId: origin.sourceElementId } : {}),
}
if (existing) {
@ -195,6 +207,16 @@ export const useTabStore = create<TabStore>((set, get) => ({
return tabId
},
returnFromWorkbench: (tabId) => {
const tab = get().tabs.find((current) => current.sessionId === tabId)
if (tab?.type !== 'workbench') return
if (tab.sourceSessionId && get().tabs.some((current) => current.sessionId === tab.sourceSessionId)) {
get().setActiveTab(tab.sourceSessionId)
}
get().closeTab(tabId)
},
openSubagentTab: (sourceSessionId, toolUseId, title = 'SubAgent') => {
const tabId = `${SUBAGENT_TAB_PREFIX}${sourceSessionId}__${toolUseId}`
const { tabs } = get()
@ -288,6 +310,12 @@ export const useTabStore = create<TabStore>((set, get) => ({
saveTabs: () => {
const { tabs, activeTabId } = get()
const persistableTabs = tabs.filter((tab) => tab.type !== 'terminal' && tab.type !== 'workbench' && tab.type !== 'subagent')
const activeTab = tabs.find((tab) => tab.sessionId === activeTabId)
const persistedActiveTabId = activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
? activeTabId
: activeTab?.type === 'workbench' && activeTab.sourceSessionId && persistableTabs.some((tab) => tab.sessionId === activeTab.sourceSessionId)
? activeTab.sourceSessionId
: (persistableTabs[0]?.sessionId ?? null)
const data: TabPersistence = {
openTabs: persistableTabs.map((t) => ({
sessionId: t.sessionId,
@ -295,9 +323,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
type: t.type,
...(t.traceSessionId ? { traceSessionId: t.traceSessionId } : {}),
})),
activeTabId: activeTabId && persistableTabs.some((tab) => tab.sessionId === activeTabId)
? activeTabId
: (persistableTabs[0]?.sessionId ?? null),
activeTabId: persistedActiveTabId,
}
try {
localStorage.setItem(TAB_STORAGE_KEY, JSON.stringify(data))

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
formatWorkspaceReferenceLocation,
formatWorkspaceReferencePrompt,
useWorkspaceChatContextStore,
} from './workspaceChatContextStore'
@ -53,6 +54,54 @@ describe('workspaceChatContextStore', () => {
expect(prompt).not.toContain('Path: /repo/src/App.tsx')
})
it('formats diff comments with their source side while preserving ordinary locations', () => {
const diffReference = {
id: 'diff-ref-1',
kind: 'code-comment' as const,
path: 'src/a.ts',
name: 'a.ts',
lineStart: 11,
lineEnd: 12,
diffSide: 'new' as const,
hunkId: 'hunk-1',
note: 'Use a shared helper',
quote: 'const result = buildResult()\nreturn result',
}
const ordinaryReference = {
...diffReference,
id: 'ordinary-ref-1',
diffSide: undefined,
hunkId: undefined,
}
expect(formatWorkspaceReferenceLocation(diffReference)).toBe('src/a.ts:new:L11-L12')
expect(formatWorkspaceReferencePrompt([diffReference])).toContain('src/a.ts:new:L11-L12')
expect(formatWorkspaceReferencePrompt([diffReference])).toContain('Comment: Use a shared helper')
expect(formatWorkspaceReferenceLocation(ordinaryReference)).toBe('src/a.ts:L11-L12')
})
it('deduplicates diff comments only when side and hunk also match', () => {
const store = useWorkspaceChatContextStore.getState()
const baseReference = {
kind: 'code-comment' as const,
path: 'src/a.ts',
name: 'a.ts',
lineStart: 11,
lineEnd: 12,
note: 'Use a shared helper',
quote: 'return result',
}
store.addReference('session-diff', { ...baseReference, diffSide: 'new', hunkId: 'hunk-1' })
store.addReference('session-diff', { ...baseReference, diffSide: 'new', hunkId: 'hunk-1' })
store.addReference('session-diff', { ...baseReference, diffSide: 'old', hunkId: 'hunk-1' })
store.addReference('session-diff', { ...baseReference, diffSide: 'new', hunkId: 'hunk-2' })
const references = useWorkspaceChatContextStore.getState().referencesBySession['session-diff'] ?? []
expect(references).toHaveLength(3)
expect(new Set(references.map((reference) => reference.id))).toHaveLength(3)
})
it('formats selected code without requiring a comment', () => {
const prompt = formatWorkspaceReferencePrompt([
{

View File

@ -11,6 +11,8 @@ export type WorkspaceChatReference = {
isDirectory?: boolean
lineStart?: number
lineEnd?: number
diffSide?: 'old' | 'new'
hunkId?: string
note?: string
quote?: string
sourceRole?: 'user' | 'assistant'
@ -33,7 +35,10 @@ function makeReferenceId(reference: Omit<WorkspaceChatReference, 'id'>) {
? `${reference.lineStart}-${reference.lineEnd ?? reference.lineStart}`
: reference.messageId ?? 'file'
const notePart = (reference.note?.trim() || reference.quote?.trim() || '').slice(0, 48)
return `${reference.kind}:${reference.path}:${linePart}:${notePart}`
const diffPart = reference.kind === 'code-comment'
? `${reference.diffSide ?? ''}:${reference.hunkId ?? ''}:`
: ''
return `${reference.kind}:${reference.path}:${diffPart}${linePart}:${notePart}`
}
function getReferenceDedupKey(reference: WorkspaceChatReference) {
@ -45,6 +50,8 @@ function getReferenceDedupKey(reference: WorkspaceChatReference) {
reference.sourceRole ?? '',
reference.lineStart ?? '',
reference.lineEnd ?? '',
reference.kind === 'code-comment' ? reference.diffSide ?? '' : '',
reference.kind === 'code-comment' ? reference.hunkId ?? '' : '',
reference.note?.trim() ?? '',
reference.quote?.trim() ?? '',
].join(':')
@ -58,7 +65,8 @@ export function formatWorkspaceReferenceLocation(reference: WorkspaceChatReferen
const lineEnd = reference.lineEnd && reference.lineEnd !== reference.lineStart
? `-L${reference.lineEnd}`
: ''
return `${reference.path}:L${reference.lineStart}${lineEnd}`
const side = reference.diffSide ? `:${reference.diffSide}` : ''
return `${reference.path}${side}:L${reference.lineStart}${lineEnd}`
}
function getFenceForQuote(quote: string) {

View File

@ -92,6 +92,29 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().width).toBe(WORKSPACE_PANEL_MAX_WIDTH)
})
it('keeps a preview opener origin at session scope after the originating card unmounts', async () => {
mocks.getWorkspaceDiffMock.mockResolvedValue({ state: 'ok', diff: '' })
await useWorkspacePanelStore.getState().openPreview('session-origin', 'src/a.ts', 'diff', {
sourceTurnKey: 'assistant-message-4',
sourceElementId: 'turn-change-opener-message-4-a',
})
expect(useWorkspacePanelStore.getState().getOrigin('session-origin')).toEqual({
sourceTurnKey: 'assistant-message-4',
sourceElementId: 'turn-change-opener-message-4-a',
})
useWorkspacePanelStore.getState().closePanel('session-origin')
expect(useWorkspacePanelStore.getState().getOrigin('session-origin')).toEqual({
sourceTurnKey: 'assistant-message-4',
sourceElementId: 'turn-change-opener-message-4-a',
})
useWorkspacePanelStore.getState().clearOrigin('session-origin')
expect(useWorkspacePanelStore.getState().getOrigin('session-origin')).toBeNull()
})
it('loads workspace status successfully', async () => {
mocks.getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
@ -419,6 +442,102 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-refresh']).toBe('diff:src/a.ts')
})
it('keeps the last successful preview payload while a refresh is pending and after it fails', async () => {
const refresh = deferred<{ state: 'ok'; path: string; diff: string }>()
mocks.getWorkspaceDiffMock
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+cached',
})
.mockReturnValueOnce(refresh.promise)
await useWorkspacePanelStore.getState().openPreview('session-stale-refresh', 'src/a.ts', 'diff')
const refreshPromise = useWorkspacePanelStore.getState().openPreview('session-stale-refresh', 'src/a.ts', 'diff')
const previewKey = 'session-stale-refresh::diff:src/a.ts'
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-stale-refresh']).toEqual([
expect.objectContaining({ state: 'ok', diff: '@@ -1 +1 @@\n-old\n+cached' }),
])
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBe(true)
refresh.reject(new Error('refresh failed'))
await refreshPromise
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-stale-refresh']).toEqual([
expect.objectContaining({ state: 'ok', diff: '@@ -1 +1 @@\n-old\n+cached' }),
])
expect(useWorkspacePanelStore.getState().loading.previewByTabId[previewKey]).toBe(false)
expect(useWorkspacePanelStore.getState().errors.previewByTabId[previewKey]).toBe('refresh failed')
})
it('keeps the last successful file payload and records structured state for a non-ok refresh result', async () => {
mocks.getWorkspaceFileMock
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
content: 'cached file',
language: 'typescript',
size: 11,
})
.mockResolvedValueOnce({
state: 'error',
path: 'src/a.ts',
})
await useWorkspacePanelStore.getState().openPreview('session-file-refresh-error', 'src/a.ts', 'file')
await useWorkspacePanelStore.getState().openPreview('session-file-refresh-error', 'src/a.ts', 'file')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-file-refresh-error']).toEqual([
expect.objectContaining({ state: 'ok', content: 'cached file', language: 'typescript' }),
])
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-file-refresh-error::file:src/a.ts'])
.toBeNull()
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-file-refresh-error::file:src/a.ts'])
.toBe('error')
})
it('keeps the last successful diff payload and records structured state for a non-ok refresh result', async () => {
mocks.getWorkspaceDiffMock
.mockResolvedValueOnce({
state: 'ok',
path: 'src/a.ts',
diff: '@@ -1 +1 @@\n-old\n+cached',
})
.mockResolvedValueOnce({
state: 'missing',
path: 'src/a.ts',
})
await useWorkspacePanelStore.getState().openPreview('session-diff-refresh-missing', 'src/a.ts', 'diff')
await useWorkspacePanelStore.getState().openPreview('session-diff-refresh-missing', 'src/a.ts', 'diff')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-diff-refresh-missing']).toEqual([
expect.objectContaining({ state: 'ok', diff: '@@ -1 +1 @@\n-old\n+cached' }),
])
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-diff-refresh-missing::diff:src/a.ts'])
.toBeNull()
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-diff-refresh-missing::diff:src/a.ts'])
.toBe('missing')
})
it('keeps first-load non-ok state on the tab without marking it as a refresh failure', async () => {
mocks.getWorkspaceDiffMock.mockResolvedValueOnce({
state: 'missing',
path: 'src/missing.ts',
})
await useWorkspacePanelStore.getState().openPreview('session-initial-missing', 'src/missing.ts', 'diff')
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-initial-missing']).toEqual([
expect.objectContaining({ state: 'missing' }),
])
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-initial-missing::diff:src/missing.ts'])
.toBeNull()
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-initial-missing::diff:src/missing.ts'])
.toBeNull()
})
it('closes exact tab id and preserves sibling preview for the same path', async () => {
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
@ -515,6 +634,11 @@ describe('workspacePanelStore', () => {
'session-preview-scope::file:c.ts': 'loading',
'session-preview-scope::file:d.ts': 'loading',
},
previewRefreshStateByTabId: {
...state.errors.previewRefreshStateByTabId,
'session-preview-scope::file:c.ts': 'missing',
'session-preview-scope::file:d.ts': 'error',
},
},
}))
@ -527,6 +651,8 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-scope']).toBe('file:b.ts')
expect(useWorkspacePanelStore.getState().loading.previewByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
expect(useWorkspacePanelStore.getState().errors.previewByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-preview-scope::file:c.ts']).toBeUndefined()
expect(useWorkspacePanelStore.getState().errors.previewRefreshStateByTabId['session-preview-scope::file:d.ts']).toBeUndefined()
useWorkspacePanelStore.getState().closePreviewTabs('session-preview-scope', 'file:b.ts', 'others')
@ -729,6 +855,10 @@ describe('workspacePanelStore', () => {
'session-clear::file:src/a.ts': 'bad',
'session-reset::file:src/b.ts': 'bad',
},
previewRefreshStateByTabId: {
'session-clear::file:src/a.ts': 'missing',
'session-reset::file:src/b.ts': 'error',
},
},
}))
@ -762,5 +892,7 @@ describe('workspacePanelStore', () => {
expect(state.errors.treeBySessionPath['session-reset::src']).toBeUndefined()
expect(state.errors.previewByTabId['session-clear::file:src/a.ts']).toBeUndefined()
expect(state.errors.previewByTabId['session-reset::file:src/b.ts']).toBeUndefined()
expect(state.errors.previewRefreshStateByTabId['session-clear::file:src/a.ts']).toBeUndefined()
expect(state.errors.previewRefreshStateByTabId['session-reset::file:src/b.ts']).toBeUndefined()
})
})

View File

@ -15,6 +15,10 @@ export type WorkspacePanelView = 'changed' | 'all'
export type WorkbenchMode = 'workspace' | 'browser'
export type WorkspacePreviewKind = 'file' | 'diff'
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
export type WorkspacePanelOrigin = {
sourceTurnKey: string
sourceElementId: string
}
export type WorkspacePreviewState =
| 'loading'
| WorkspaceReadFileResult['state']
@ -52,6 +56,7 @@ type WorkspacePanelErrorState = {
statusBySession: Record<string, string | null | undefined>
treeBySessionPath: Record<string, string | null | undefined>
previewByTabId: Record<string, string | null | undefined>
previewRefreshStateByTabId: Record<string, WorkspacePreviewState | null | undefined>
}
type WorkspacePanelStore = {
@ -63,12 +68,15 @@ type WorkspacePanelStore = {
treeBySessionPath: Record<string, Record<string, WorkspaceTreeResult | undefined> | undefined>
previewTabsBySession: Record<string, WorkspacePreviewTab[] | undefined>
activePreviewTabIdBySession: Record<string, string | null | undefined>
originBySession: Record<string, WorkspacePanelOrigin | undefined>
loading: WorkspacePanelLoadingState
errors: WorkspacePanelErrorState
isPanelOpen: (sessionId: string) => boolean
getActiveView: (sessionId: string) => WorkspacePanelView
getMode: (sessionId: string) => WorkbenchMode
getOrigin: (sessionId: string) => WorkspacePanelOrigin | null
clearOrigin: (sessionId: string) => void
setMode: (sessionId: string, mode: WorkbenchMode) => void
openPanel: (sessionId: string) => void
closePanel: (sessionId: string) => void
@ -78,7 +86,7 @@ type WorkspacePanelStore = {
loadStatus: (sessionId: string) => Promise<void>
loadTree: (sessionId: string, path?: string) => Promise<void>
toggleTreeNode: (sessionId: string, path: string) => Promise<void>
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind) => Promise<void>
openPreview: (sessionId: string, path: string, kind: WorkspacePreviewKind, origin?: WorkspacePanelOrigin) => Promise<void>
closePreview: (sessionId: string, tabId: string) => void
closePreviewTabs: (sessionId: string, tabId: string, scope: WorkspacePreviewCloseScope) => void
clearSession: (sessionId: string) => void
@ -195,6 +203,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
treeBySessionPath: {},
previewTabsBySession: {},
activePreviewTabIdBySession: {},
originBySession: {},
loading: {
statusBySession: {},
treeBySessionPath: {},
@ -204,11 +213,16 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
statusBySession: {},
treeBySessionPath: {},
previewByTabId: {},
previewRefreshStateByTabId: {},
},
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView,
getMode: (sessionId) => get().modeBySession[sessionId] ?? DEFAULT_WORKBENCH_MODE,
getOrigin: (sessionId) => get().originBySession[sessionId] ?? null,
clearOrigin: (sessionId) => set((state) => ({
originBySession: removeRecordKey(state.originBySession, sessionId),
})),
setMode: (sessionId, mode) =>
set((state) => ({
@ -446,13 +460,21 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
}
},
openPreview: async (sessionId, path, kind) => {
openPreview: async (sessionId, path, kind, origin) => {
// Ensure the workspace panel is visible — openPreview is now triggered from places
// where the panel may be closed (e.g. the chat "打开方式" menu / turn-changes card),
// not only from inside the already-open file tree. Opening a file always switches the
// unified workbench into file ("workspace") mode.
get().openPanel(sessionId)
get().setMode(sessionId, 'workspace')
if (origin) {
set((state) => ({
originBySession: {
...state.originBySession,
[sessionId]: origin,
},
}))
}
const tabId = getWorkspacePreviewTabId(path, kind)
const requestKey = makePreviewKey(sessionId, tabId)
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
@ -478,6 +500,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
...state.errors.previewByTabId,
[requestKey]: null,
},
previewRefreshStateByTabId: {
...state.errors.previewRefreshStateByTabId,
[requestKey]: null,
},
},
}))
} else {
@ -511,6 +537,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
...state.errors.previewByTabId,
[requestKey]: null,
},
previewRefreshStateByTabId: {
...state.errors.previewRefreshStateByTabId,
[requestKey]: null,
},
},
}))
}
@ -523,18 +553,22 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const current = tabs.find((tab) => tab.id === tabId)
const preserveSuccessfulPayload = current?.state === 'ok' && result.state !== 'ok'
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
diff: result.diff ?? '',
content: undefined,
language: undefined,
size: undefined,
state: result.state,
error: result.error,
})),
[sessionId]: preserveSuccessfulPayload
? tabs
: upsertPreviewTab(tabs, tabId, (tab) => ({
...tab,
diff: result.diff ?? '',
content: undefined,
language: undefined,
size: undefined,
state: result.state,
error: result.error,
})),
},
loading: {
...state.loading,
@ -549,6 +583,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
...state.errors.previewByTabId,
[requestKey]: result.error ?? null,
},
previewRefreshStateByTabId: {
...state.errors.previewRefreshStateByTabId,
[requestKey]: preserveSuccessfulPayload ? result.state : null,
},
},
}
})
@ -561,21 +599,25 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const current = tabs.find((tab) => tab.id === tabId)
const preserveSuccessfulPayload = current?.state === 'ok' && result.state !== 'ok'
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
content: result.content,
dataUrl: result.dataUrl,
mimeType: result.mimeType,
previewType: result.previewType ?? 'text',
diff: undefined,
language: result.language,
size: result.size,
state: result.state,
error: result.error,
})),
[sessionId]: preserveSuccessfulPayload
? tabs
: upsertPreviewTab(tabs, tabId, (tab) => ({
...tab,
content: result.content,
dataUrl: result.dataUrl,
mimeType: result.mimeType,
previewType: result.previewType ?? 'text',
diff: undefined,
language: result.language,
size: result.size,
state: result.state,
error: result.error,
})),
},
loading: {
...state.loading,
@ -590,6 +632,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
...state.errors.previewByTabId,
[requestKey]: result.error ?? null,
},
previewRefreshStateByTabId: {
...state.errors.previewRefreshStateByTabId,
[requestKey]: preserveSuccessfulPayload ? result.state : null,
},
},
}
})
@ -600,15 +646,19 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
set((state) => {
const tabs = state.previewTabsBySession[sessionId] ?? []
const message = error instanceof Error ? error.message : 'Failed to load workspace preview'
const current = tabs.find((tab) => tab.id === tabId)
const preserveSuccessfulPayload = current?.state === 'ok'
return {
previewTabsBySession: {
...state.previewTabsBySession,
[sessionId]: upsertPreviewTab(tabs, tabId, (current) => ({
...current,
state: 'error',
error: message,
})),
[sessionId]: preserveSuccessfulPayload
? tabs
: upsertPreviewTab(tabs, tabId, (tab) => ({
...tab,
state: 'error',
error: message,
})),
},
loading: {
...state.loading,
@ -623,6 +673,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
...state.errors.previewByTabId,
[requestKey]: message,
},
previewRefreshStateByTabId: {
...state.errors.previewRefreshStateByTabId,
[requestKey]: null,
},
},
}
})
@ -648,6 +702,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
errors: {
...state.errors,
previewByTabId: removeRecordKey(state.errors.previewByTabId, requestKey),
previewRefreshStateByTabId: removeRecordKey(state.errors.previewRefreshStateByTabId, requestKey),
},
}
}
@ -715,6 +770,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
errors: {
...state.errors,
previewByTabId: removeRecordKeys(state.errors.previewByTabId, requestKeys),
previewRefreshStateByTabId: removeRecordKeys(state.errors.previewRefreshStateByTabId, requestKeys),
},
}
})
@ -733,6 +789,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),
previewTabsBySession: removeRecordKey(state.previewTabsBySession, sessionId),
activePreviewTabIdBySession: removeRecordKey(state.activePreviewTabIdBySession, sessionId),
originBySession: removeRecordKey(state.originBySession, sessionId),
loading: {
statusBySession: removeRecordKey(state.loading.statusBySession, sessionId),
treeBySessionPath: stripSessionKeys(state.loading.treeBySessionPath, sessionId),
@ -742,6 +799,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
statusBySession: removeRecordKey(state.errors.statusBySession, sessionId),
treeBySessionPath: stripSessionKeys(state.errors.treeBySessionPath, sessionId),
previewByTabId: stripSessionKeys(state.errors.previewByTabId, sessionId),
previewRefreshStateByTabId: stripSessionKeys(state.errors.previewRefreshStateByTabId, sessionId),
},
}))
},