// @vitest-environment jsdom
// @ts-expect-error jsdom is installed in this workspace without local type declarations
import { JSDOM } from 'jsdom'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
if (typeof document === 'undefined') {
const dom = new JSDOM('
', {
url: 'http://localhost/',
})
const { window } = dom
Object.assign(globalThis, {
window,
document: window.document,
navigator: window.navigator,
localStorage: window.localStorage,
HTMLElement: window.HTMLElement,
HTMLButtonElement: window.HTMLButtonElement,
MutationObserver: window.MutationObserver,
Node: window.Node,
Event: window.Event,
MouseEvent: window.MouseEvent,
KeyboardEvent: window.KeyboardEvent,
getComputedStyle: window.getComputedStyle.bind(window),
IS_REACT_ACT_ENVIRONMENT: true,
})
}
type WorkspaceApiMocks = {
getWorkspaceStatusMock: ReturnType
getWorkspaceTreeMock: ReturnType
getWorkspaceFileMock: ReturnType
getWorkspaceDiffMock: ReturnType
}
var mocks: WorkspaceApiMocks | undefined
function getMocks() {
if (!mocks) {
throw new Error('Workspace API mocks were not initialized')
}
return mocks
}
function deferred() {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
async function setWorkspaceState(
updater:
| ReturnType
| Parameters[0],
) {
await act(() => {
useWorkspacePanelStore.setState(updater as Parameters[0], true)
})
}
async function setSettingsState(
updater:
| ReturnType
| Parameters[0],
) {
await act(() => {
useSettingsStore.setState(updater as Parameters[0], true)
})
}
async function flushReactWork() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
async function renderPanel(sessionId: string) {
let view!: ReturnType
await act(async () => {
view = render()
await Promise.resolve()
})
return view
}
async function clickElement(element: Element) {
await act(async () => {
fireEvent.click(element)
await Promise.resolve()
})
await flushReactWork()
}
function findTextNodeContaining(container: Element, text: string) {
const walker = document.createTreeWalker(container, 4)
let current = walker.nextNode()
while (current) {
if (current.textContent?.includes(text)) return current
current = walker.nextNode()
}
throw new Error(`Unable to find text node containing ${text}`)
}
async function selectWorkspaceCodeText(
view: Awaited>,
startLine: number,
startText: string,
endLine: number,
endText: string,
) {
const code = view.getByTestId('workspace-code')
const startRow = code.querySelector(`[data-workspace-line-number="${startLine}"]`)
const endRow = code.querySelector(`[data-workspace-line-number="${endLine}"]`)
if (!startRow || !endRow) throw new Error('Selection rows were not rendered')
Object.assign(code.parentElement?.parentElement ?? code, {
getBoundingClientRect: () => ({
left: 100,
top: 24,
right: 520,
bottom: 420,
width: 420,
height: 396,
x: 100,
y: 24,
toJSON: () => ({}),
}),
})
const startNode = findTextNodeContaining(startRow, startText)
const endNode = findTextNodeContaining(endRow, endText)
const startOffset = startNode.textContent?.indexOf(startText) ?? -1
const endOffset = (endNode.textContent?.indexOf(endText) ?? -1) + endText.length
const range = document.createRange()
range.setStart(startNode, startOffset)
range.setEnd(endNode, endOffset)
Object.assign(range, {
getBoundingClientRect: () => ({
left: 120,
top: 40,
right: 240,
bottom: 58,
width: 120,
height: 18,
x: 120,
y: 40,
toJSON: () => ({}),
}),
})
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
await act(async () => {
fireEvent.mouseUp(code, { clientX: 180, clientY: 72 })
await Promise.resolve()
})
await flushReactWork()
}
async function selectRenderedText(container: Element, text: string, target?: Element) {
const textNode = findTextNodeContaining(container, text)
const startOffset = textNode.textContent?.indexOf(text) ?? -1
const range = document.createRange()
range.setStart(textNode, startOffset)
range.setEnd(textNode, startOffset + text.length)
Object.assign(range, {
getBoundingClientRect: () => ({
left: 130,
top: 60,
right: 260,
bottom: 78,
width: 130,
height: 18,
x: 130,
y: 60,
toJSON: () => ({}),
}),
})
Object.assign(target ?? container, {
getBoundingClientRect: () => ({
left: 100,
top: 24,
right: 520,
bottom: 420,
width: 420,
height: 396,
x: 100,
y: 24,
toJSON: () => ({}),
}),
})
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
await act(async () => {
fireEvent.mouseUp(target ?? container, { clientX: 190, clientY: 80 })
await Promise.resolve()
})
await flushReactWork()
}
function classNameContains(element: Element | null, needle: string) {
let current = element
while (current) {
if (typeof current.className === 'string' && current.className.includes(needle)) {
return true
}
current = current.parentElement
}
return false
}
vi.mock('../../api/sessions', () => ({
sessionsApi: (() => {
if (!mocks) {
mocks = {
getWorkspaceStatusMock: vi.fn(),
getWorkspaceTreeMock: vi.fn(),
getWorkspaceFileMock: vi.fn(),
getWorkspaceDiffMock: vi.fn(),
}
}
return {
getWorkspaceStatus: mocks.getWorkspaceStatusMock,
getWorkspaceTree: mocks.getWorkspaceTreeMock,
getWorkspaceFile: mocks.getWorkspaceFileMock,
getWorkspaceDiff: mocks.getWorkspaceDiffMock,
}
})(),
}))
import { useSettingsStore } from '../../stores/settingsStore'
import { useChatStore } from '../../stores/chatStore'
import { useWorkspaceChatContextStore } from '../../stores/workspaceChatContextStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { WorkspacePanel } from './WorkspacePanel'
describe('WorkspacePanel', () => {
const workspaceInitialState = useWorkspacePanelStore.getInitialState()
const workspaceChatInitialState = useWorkspaceChatContextStore.getInitialState()
const settingsInitialState = useSettingsStore.getInitialState()
const chatInitialState = useChatStore.getInitialState()
beforeEach(async () => {
vi.clearAllMocks()
await setWorkspaceState(workspaceInitialState)
useChatStore.setState(chatInitialState, true)
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
await setSettingsState({ ...settingsInitialState, locale: 'en' })
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) =>
useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
)
getMocks().getWorkspaceTreeMock.mockImplementation(async (sessionId: string, path = '') =>
useWorkspacePanelStore.getState().treeBySessionPath[sessionId]?.[path] ?? {
state: 'ok',
path,
entries: [],
},
)
})
afterEach(async () => {
cleanup()
await setWorkspaceState(workspaceInitialState)
useChatStore.setState(chatInitialState, true)
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
await setSettingsState(settingsInitialState)
vi.restoreAllMocks()
})
it('stays hidden when the panel is closed', async () => {
const view = await renderPanel('session-hidden')
expect(view.queryByTestId('workspace-panel')).toBeNull()
})
it('loads changed status on open and opens a diff preview from the changed view', async () => {
const statusRequest = deferred<{
state: 'ok'
workDir: string
repoName: string
branch: string
isGitRepo: true
changedFiles: Array<{
path: string
status: 'modified'
additions: number
deletions: number
}>
}>()
const diffRequest = deferred<{
state: 'ok'
path: string
diff: string
}>()
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
getMocks().getWorkspaceDiffMock.mockReturnValue(diffRequest.promise)
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-changed')
})
const view = await renderPanel('session-changed')
const compactPanel = view.getByTestId('workspace-panel')
expect(compactPanel.style.width).toBe('520px')
expect(compactPanel.style.maxWidth).toBe('36%')
expect(compactPanel.style.minWidth).toBe('min(340px, 40%)')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-changed')
})
await act(async () => {
statusRequest.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 4,
deletions: 1,
},
],
})
await statusRequest.promise
})
expect(view.getByPlaceholderText('Filter files...')).toBeTruthy()
await clickElement(await view.findByText('src/app.ts'))
await waitFor(() => {
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-changed', 'src/app.ts')
})
await act(async () => {
diffRequest.resolve({
state: 'ok',
path: 'src/app.ts',
diff: '@@ -1 +1 @@\n-console.log("old")\n+console.log("new")',
})
await diffRequest.promise
})
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('console.log("new")')
})
const expandedPanel = view.getByTestId('workspace-panel')
expect(expandedPanel.style.width).toBe('860px')
expect(expandedPanel.style.maxWidth).toBe('min(62%, calc(100% - 328px))')
expect(expandedPanel.style.minWidth).toBe('min(420px, 54%)')
expect(view.getAllByText('Diff').length).toBeGreaterThan(0)
})
it('refreshes status on open and switches back to changed files when new changes exist', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/Fresh.ts',
status: 'modified',
additions: 4,
deletions: 1,
},
],
})
getMocks().getWorkspaceTreeMock.mockResolvedValue({
state: 'ok',
path: '',
entries: [{ name: 'src', path: 'src', isDirectory: true }],
})
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-stale-all': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-stale-all': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-stale-all')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-stale-all')
})
await waitFor(() => {
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
})
expect(view.getByText('src/Fresh.ts')).toBeTruthy()
})
it('loads workspace status when opened while the chat is running', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [
{
path: 'src/running.ts',
status: 'modified',
additions: 1,
deletions: 0,
},
],
})
useChatStore.setState({
sessions: {
'session-running-open': {
chatState: 'thinking',
} as never,
},
})
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-running-open')
})
const view = await renderPanel('session-running-open')
await waitFor(() => {
expect(getMocks().getWorkspaceStatusMock).toHaveBeenCalledWith('session-running-open')
})
await waitFor(() => {
expect(view.getByText('src/running.ts')).toBeTruthy()
})
expect(view.queryByText('Loading...')).toBeNull()
})
it('renders transcript-derived changed files for non-git sessions', async () => {
getMocks().getWorkspaceStatusMock.mockResolvedValue({
state: 'ok',
workDir: '/tmp/non-git-session',
repoName: 'non-git-session',
branch: null,
isGitRepo: false,
changedFiles: [
{
path: 'src/app.ts',
status: 'modified',
additions: 1,
deletions: 1,
},
],
})
getMocks().getWorkspaceDiffMock.mockResolvedValue({
state: 'ok',
path: 'src/app.ts',
diff: 'diff --session a/src/app.ts b/src/app.ts\n-export const answer = 1\n+export const answer = 2',
})
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-non-git')
})
const view = await renderPanel('session-non-git')
await waitFor(() => {
expect(view.getByText('src/app.ts')).toBeTruthy()
})
expect(view.queryByText('No matching files')).toBeNull()
await clickElement(view.getByText('src/app.ts'))
await waitFor(() => {
expect(getMocks().getWorkspaceDiffMock).toHaveBeenCalledWith('session-non-git', 'src/app.ts')
})
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('export const answer = 2')
})
})
it('opens to all files when the current turn has no changed files', async () => {
const statusRequest = deferred<{
state: 'ok'
workDir: string
repoName: string
branch: string
isGitRepo: true
changedFiles: []
}>()
const rootTreeRequest = deferred<{
state: 'ok'
path: ''
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}>()
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
getMocks().getWorkspaceTreeMock.mockReturnValue(rootTreeRequest.promise)
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-empty-tree')
})
const view = await renderPanel('session-empty-tree')
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
await act(async () => {
statusRequest.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
await statusRequest.promise
})
await waitFor(() => {
expect(useWorkspacePanelStore.getState().getActiveView('session-empty-tree')).toBe('all')
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-empty-tree', '')
})
await act(async () => {
rootTreeRequest.resolve({
state: 'ok',
path: '',
entries: [
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
],
})
await rootTreeRequest.promise
})
expect(view.getByRole('button', { name: 'All files' })).toBeTruthy()
expect(await view.findByText('src')).toBeTruthy()
expect(await view.findByText('README.md')).toBeTruthy()
expect(view.queryByText('No changes')).toBeNull()
})
it('lazy loads the root tree, expands directories, and opens file previews from the all-files view', async () => {
const statusRequest = deferred<{
state: 'ok'
workDir: string
repoName: string
branch: string
isGitRepo: true
changedFiles: []
}>()
const rootTreeRequest = deferred<{
state: 'ok'
path: ''
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}>()
const childTreeRequest = deferred<{
state: 'ok'
path: 'src'
entries: Array<{ name: string; path: string; isDirectory: boolean }>
}>()
const fileRequest = deferred<{
state: 'ok'
path: string
content: string
language: string
size: number
}>()
getMocks().getWorkspaceStatusMock.mockReturnValue(statusRequest.promise)
getMocks().getWorkspaceTreeMock
.mockReturnValueOnce(rootTreeRequest.promise)
.mockReturnValueOnce(childTreeRequest.promise)
getMocks().getWorkspaceFileMock.mockReturnValue(fileRequest.promise)
await act(() => {
useWorkspacePanelStore.getState().openPanel('session-tree')
})
const view = await renderPanel('session-tree')
expect(view.getByRole('button', { name: 'Changed files' })).toBeTruthy()
await clickElement(view.getByRole('button', { name: 'Changed files' }))
await clickElement(view.getByRole('menuitem', { name: 'All files' }))
await waitFor(() => {
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', '')
})
await act(async () => {
statusRequest.resolve({
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
})
rootTreeRequest.resolve({
state: 'ok',
path: '',
entries: [
{ name: 'src', path: 'src', isDirectory: true },
{ name: 'README.md', path: 'README.md', isDirectory: false },
],
})
await Promise.all([statusRequest.promise, rootTreeRequest.promise])
})
const folderLabel = await view.findByText('src')
const folderButton = folderLabel.closest('button')
if (!folderButton) {
throw new Error('Expected src label to be rendered inside a folder button')
}
expect(folderButton.getAttribute('aria-expanded')).toBe('false')
await clickElement(folderButton)
await waitFor(() => {
expect(getMocks().getWorkspaceTreeMock).toHaveBeenCalledWith('session-tree', 'src')
})
await act(async () => {
childTreeRequest.resolve({
state: 'ok',
path: 'src',
entries: [{ name: 'index.ts', path: 'src/index.ts', isDirectory: false }],
})
await childTreeRequest.promise
})
await waitFor(() => {
expect(folderButton.getAttribute('aria-expanded')).toBe('true')
})
await clickElement(await view.findByText('index.ts'))
await waitFor(() => {
expect(getMocks().getWorkspaceFileMock).toHaveBeenCalledWith('session-tree', 'src/index.ts')
})
await act(async () => {
fileRequest.resolve({
state: 'ok',
path: 'src/index.ts',
content: 'export const ready = true',
language: 'typescript',
size: 25,
})
await fileRequest.promise
})
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('export const ready = true')
})
expect(view.getAllByText('File').length).toBeGreaterThan(0)
})
it('renders multiple preview tabs and closes only the exact requested tab', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-tabs': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-tabs': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-tabs': [
{
id: 'file:src/a.ts',
path: 'src/a.ts',
kind: 'file',
title: 'a.ts',
language: 'typescript',
content: 'export const a = 1',
state: 'ok',
size: 18,
},
{
id: 'diff:src/a.ts',
path: 'src/a.ts',
kind: 'diff',
title: 'a.ts',
diff: '@@ -1 +1 @@',
state: 'ok',
},
{
id: 'file:src/b.ts',
path: 'src/b.ts',
kind: 'file',
title: 'b.ts',
language: 'typescript',
content: 'export const b = 1',
state: 'ok',
size: 18,
},
],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-tabs': 'diff:src/a.ts',
},
}))
const view = await renderPanel('session-tabs')
expect(view.getByRole('tablist', { name: 'Preview tabs' })).toBeTruthy()
expect(view.getAllByRole('tab', { name: /a\.ts/ })).toHaveLength(2)
expect(view.getAllByText('a.ts').length).toBeGreaterThanOrEqual(2)
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
await clickElement(view.getByLabelText('Close tab a.ts Diff'))
expect(view.queryByLabelText('Close tab a.ts Diff')).toBeNull()
expect(view.getByLabelText('Close tab a.ts File')).toBeTruthy()
expect(view.getAllByText('b.ts').length).toBeGreaterThanOrEqual(1)
})
it('uses theme tokens for the panel, preview tabs, and code surface in dark mode', async () => {
await setSettingsState({ ...settingsInitialState, locale: 'en', theme: 'dark' })
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-dark-theme': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-dark-theme': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-dark-theme': [{
id: 'file:src/theme.ts',
path: 'src/theme.ts',
kind: 'file',
title: 'theme.ts',
language: 'typescript',
content: 'export const theme = "dark"',
state: 'ok',
size: 27,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-dark-theme': 'file:src/theme.ts',
},
}))
const view = await renderPanel('session-dark-theme')
const panel = view.getByTestId('workspace-panel')
const tabList = view.getByRole('tablist', { name: 'Preview tabs' })
const codeSurface = view.getByTestId('workspace-code')
expect(panel.className).toContain('bg-[var(--color-surface)]')
expect(panel.className).not.toContain('bg-white')
expect(tabList.className).toContain('bg-[var(--color-surface-container-lowest)]')
expect(tabList.className).not.toContain('bg-white')
expect(classNameContains(codeSurface, 'bg-[var(--color-code-bg)]')).toBe(true)
expect(classNameContains(codeSurface, 'bg-white')).toBe(false)
})
it('can expand long diff previews beyond the default rendered line cap', async () => {
const longDiff = Array.from({ length: 2300 }, (_, index) => `+line ${index + 1}`).join('\n')
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-large-preview': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-large-preview': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-large-preview': [{
id: 'diff:large.ts',
path: 'large.ts',
kind: 'diff',
title: 'large.ts',
diff: longDiff,
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-large-preview': 'diff:large.ts',
},
}))
const view = await renderPanel('session-large-preview')
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('+line 1')
expect(highlightedCode).toContain('+line 2000')
expect(highlightedCode).not.toContain('+line 2001')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('+line 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
})
it('keeps diff rows intrinsically wide so H5 users can scroll sideways', async () => {
const longDiffLine = '+const label = "this is a very long generated line that should not be compressed into the phone viewport";'
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-wide-diff': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-wide-diff': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-wide-diff': [{
id: 'diff:wide.ts',
path: 'wide.ts',
kind: 'diff',
title: 'wide.ts',
diff: longDiffLine,
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-wide-diff': 'diff:wide.ts',
},
}))
const view = await renderPanel('session-wide-diff')
const diffSurface = view.getByTestId('workspace-code')
const firstRow = diffSurface.querySelector('div')
expect(firstRow?.className).toContain('w-max')
expect(firstRow?.className).toContain('min-w-full')
expect(firstRow?.className).toContain('grid-cols-[48px_18px_max-content]')
expect(diffSurface.textContent).toContain(longDiffLine)
})
it('can expand long file previews beyond the default rendered line cap', async () => {
const longFile = Array.from({ length: 2300 }, (_, index) => `const line${index + 1} = ${index + 1}`).join('\n')
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-large-file-preview': {
isOpen: true,
activeView: 'all',
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-large-file-preview': [{
id: 'file:large-file.ts',
path: 'large-file.ts',
kind: 'file',
title: 'large-file.ts',
content: longFile,
language: 'typescript',
previewType: 'text',
state: 'ok',
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-large-file-preview': 'file:large-file.ts',
},
}))
const view = await renderPanel('session-large-file-preview')
const highlightedCode = view.getByTestId('workspace-code').textContent ?? ''
expect(highlightedCode).toContain('const line1 = 1')
expect(highlightedCode).toContain('const line2000 = 2000')
expect(highlightedCode).not.toContain('const line2001 = 2001')
await clickElement(view.getByRole('button', { name: 'Show all loaded lines' }))
await waitFor(() => {
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
})
it('renders image previews from workspace files', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-image-preview': {
isOpen: true,
activeView: 'all',
},
},
treeBySessionPath: {
...state.treeBySessionPath,
'session-image-preview': {
'': {
state: 'ok',
path: '',
entries: [{ name: 'logo.png', path: 'logo.png', isDirectory: false }],
},
},
},
}))
getMocks().getWorkspaceFileMock.mockResolvedValue({
state: 'ok',
path: 'logo.png',
previewType: 'image',
dataUrl: 'data:image/png;base64,iVBORw0KGgo=',
mimeType: 'image/png',
language: 'image',
size: 8,
})
const view = await renderPanel('session-image-preview')
await clickElement(await view.findByText('logo.png'))
const image = await view.findByRole('img', { name: 'logo.png' })
expect(image.getAttribute('src')).toBe('data:image/png;base64,iVBORw0KGgo=')
})
it('renders markdown file previews as formatted documents', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-markdown-preview': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-markdown-preview': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-markdown-preview': [{
id: 'file:README.md',
path: 'README.md',
kind: 'file',
title: 'README.md',
language: 'markdown',
content: '# Project Notes\n\n- **Done** item\n\n```ts\nexport const ok = true\n```',
state: 'ok',
size: 70,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-markdown-preview': 'file:README.md',
},
}))
const view = await renderPanel('session-markdown-preview')
expect(view.getByRole('heading', { name: 'Project Notes', level: 1 })).toBeTruthy()
expect(view.getByText('Done')).toBeTruthy()
expect(view.container.textContent).toContain('export const ok = true')
expect(view.queryByTestId('workspace-code')).toBeNull()
})
it('opens a context menu for preview tabs and closes tabs to the right', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-preview-menu': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-preview-menu': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-preview-menu': [
{
id: 'file:src/App.jsx',
path: 'src/App.jsx',
kind: 'file',
title: 'App.jsx',
language: 'jsx',
content: 'app',
state: 'ok',
size: 3,
},
{
id: 'diff:vite.config.js',
path: 'vite.config.js',
kind: 'diff',
title: 'vite.config.js',
diff: '@@ -1 +1 @@',
state: 'ok',
size: 12,
},
{
id: 'file:src/index.css',
path: 'src/index.css',
kind: 'file',
title: 'index.css',
language: 'css',
content: 'body{}',
state: 'ok',
size: 6,
},
],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-preview-menu': 'diff:vite.config.js',
},
}))
const view = await renderPanel('session-preview-menu')
await act(() => {
fireEvent.contextMenu(view.getByRole('tab', { name: /vite\.config\.js/i }), {
clientX: 320,
clientY: 42,
})
})
await clickElement(view.getByRole('menuitem', { name: 'Close to the Right' }))
expect(useWorkspacePanelStore.getState().previewTabsBySession['session-preview-menu']).toMatchObject([
{ id: 'file:src/App.jsx' },
{ id: 'diff:vite.config.js' },
])
expect(useWorkspacePanelStore.getState().activePreviewTabIdBySession['session-preview-menu']).toBe('diff:vite.config.js')
expect(view.queryByRole('tab', { name: /index\.css/i })).toBeNull()
})
it('adds a workspace file to the chat context from the file tree menu', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-add-file': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-add-file': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
treeBySessionPath: {
...state.treeBySessionPath,
'session-add-file': {
'': {
state: 'ok',
path: '',
entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }],
},
},
},
}))
const view = await renderPanel('session-add-file')
await act(() => {
fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), {
clientX: 260,
clientY: 80,
})
})
await clickElement(view.getByRole('menuitem', { name: 'Add to chat' }))
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-add-file']).toMatchObject([
{
kind: 'file',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
},
])
})
it('copies file paths from the file tree menu with the legacy clipboard fallback', async () => {
const originalClipboard = navigator.clipboard
const originalExecCommand = document.execCommand
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: vi.fn().mockReturnValue(true),
})
const execCommand = vi.mocked(document.execCommand)
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: {
writeText: vi.fn().mockRejectedValue(new Error('clipboard blocked')),
},
})
const writeText = vi.mocked(navigator.clipboard.writeText)
try {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-copy-file': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-copy-file': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
treeBySessionPath: {
...state.treeBySessionPath,
'session-copy-file': {
'': {
state: 'ok',
path: '',
entries: [{ name: 'App.tsx', path: 'src/App.tsx', isDirectory: false }],
},
},
},
}))
const view = await renderPanel('session-copy-file')
await act(() => {
fireEvent.contextMenu(view.getByRole('button', { name: /App\.tsx/i }), {
clientX: 260,
clientY: 80,
})
})
await clickElement(view.getByRole('menuitem', { name: 'Copy path' }))
await waitFor(() => {
expect(execCommand).toHaveBeenCalledWith('copy')
})
expect(writeText).toHaveBeenCalledWith('/repo/src/App.tsx')
} finally {
Object.defineProperty(document, 'execCommand', {
configurable: true,
value: originalExecCommand,
})
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
})
}
})
it('adds a line comment from a code preview to the chat context', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-line-comment': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-line-comment': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-line-comment': [{
id: 'file:src/App.tsx',
path: 'src/App.tsx',
kind: 'file',
title: 'App.tsx',
language: 'tsx',
content: 'const title = "Todo"\nexport default title',
state: 'ok',
size: 42,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-line-comment': 'file:src/App.tsx',
},
}))
const view = await renderPanel('session-line-comment')
await clickElement(view.getByRole('button', { name: 'Comment line 1' }))
const textarea = view.getByPlaceholderText('Describe what should change here...')
await act(() => {
fireEvent.change(textarea, { target: { value: 'Rename this title' } })
})
await clickElement(view.getByRole('button', { name: 'Add comment' }))
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-line-comment']).toMatchObject([
{
kind: 'code-comment',
path: 'src/App.tsx',
absolutePath: '/repo/src/App.tsx',
name: 'App.tsx',
lineStart: 1,
lineEnd: 1,
note: 'Rename this title',
quote: 'const title = "Todo"',
},
])
})
it('adds selected code from a preview to the chat context without requiring a note', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-code-selection': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-code-selection': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-code-selection': [{
id: 'file:src/App.ts',
path: 'src/App.ts',
kind: 'file',
title: 'App.ts',
language: 'text',
content: 'const title = "Todo"\nexport default title',
state: 'ok',
size: 42,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-code-selection': 'file:src/App.ts',
},
}))
const view = await renderPanel('session-code-selection')
await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 2, 'export default title')
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
await clickElement(addButtons[addButtons.length - 1]!)
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']).toMatchObject([
{
kind: 'code-selection',
path: 'src/App.ts',
absolutePath: '/repo/src/App.ts',
name: 'App.ts',
lineStart: 1,
lineEnd: 2,
quote: 'const title = "Todo"\nexport default title',
},
])
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-code-selection']?.[0]?.note).toBeUndefined()
})
it('keeps the selected-code action near the preview instead of the file tree', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-selection-position': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-selection-position': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-selection-position': [{
id: 'file:src/App.ts',
path: 'src/App.ts',
kind: 'file',
title: 'App.ts',
language: 'text',
content: 'const title = "Todo"\nexport default title',
state: 'ok',
size: 42,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-selection-position': 'file:src/App.ts',
},
}))
const view = await renderPanel('session-selection-position')
await selectWorkspaceCodeText(view, 1, 'const title = "Todo"', 1, 'const title = "Todo"')
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
const floatingAddButton = addButtons[addButtons.length - 1]!
expect(floatingAddButton.style.left).toBe('180px')
expect(floatingAddButton.style.top).toBe('80px')
fireEvent.keyDown(view.getByTestId('workspace-code').parentElement?.parentElement ?? view.getByTestId('workspace-code'), {
key: 'Escape',
})
await flushReactWork()
expect(view.queryAllByRole('button', { name: 'Add to chat' })).toHaveLength(1)
})
it('adds selected markdown text from a preview to the chat context', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-markdown-selection': {
isOpen: true,
activeView: 'all',
},
},
statusBySession: {
...state.statusBySession,
'session-markdown-selection': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
previewTabsBySession: {
...state.previewTabsBySession,
'session-markdown-selection': [{
id: 'file:docs/guide.md',
path: 'docs/guide.md',
kind: 'file',
title: 'guide.md',
language: 'markdown',
content: '# Guide\nAlpha note and Beta note',
state: 'ok',
size: 33,
}],
},
activePreviewTabIdBySession: {
...state.activePreviewTabIdBySession,
'session-markdown-selection': 'file:docs/guide.md',
},
}))
const view = await renderPanel('session-markdown-selection')
const paragraph = view.getByText('Alpha note and Beta note')
const markdownSurface = paragraph.closest('.min-h-0') ?? paragraph
await selectRenderedText(paragraph, 'Alpha note and Beta note', markdownSurface)
const addButtons = view.getAllByRole('button', { name: 'Add to chat' })
const floatingAddButton = addButtons[addButtons.length - 1]!
await clickElement(floatingAddButton)
expect(useWorkspaceChatContextStore.getState().referencesBySession['session-markdown-selection']).toMatchObject([
{
kind: 'code-selection',
path: 'docs/guide.md',
absolutePath: '/repo/docs/guide.md',
name: 'guide.md',
lineStart: 2,
lineEnd: 2,
quote: 'Alpha note and Beta note',
},
])
})
it('uses the localized view menu label', async () => {
await setSettingsState({ ...settingsInitialState, locale: 'zh' })
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-zh': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-zh': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-zh')
expect(view.getByRole('button', { name: '已更改文件' })).toBeTruthy()
})
it('keeps the workspace header controls compact', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-compact-header': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-compact-header': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-compact-header')
const viewMenuButton = view.getByRole('button', { name: 'Changed files' })
const refreshButton = view.getByRole('button', { name: 'Refresh workspace' })
const closeButton = view.getByRole('button', { name: 'Close workspace panel' })
expect(viewMenuButton.className).toContain('text-[14px]')
expect(viewMenuButton.className).not.toContain('text-[18px]')
expect(viewMenuButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[15px]')
expect(refreshButton.className).toContain('h-7 w-7')
expect(closeButton.className).toContain('h-7 w-7')
expect(refreshButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
expect(closeButton.querySelector('.material-symbols-outlined')?.className).toContain('text-[16px]')
})
it('shows explicit empty and error states in the changed view', async () => {
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-empty': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
statusBySession: {
...state.statusBySession,
'session-empty': {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
},
},
}))
const view = await renderPanel('session-empty')
expect(view.getByText('No changes')).toBeTruthy()
getMocks().getWorkspaceStatusMock.mockImplementation(async (sessionId: string) => {
if (sessionId === 'session-error') {
throw new Error('status failed')
}
return useWorkspacePanelStore.getState().statusBySession[sessionId] ?? {
state: 'ok',
workDir: '/repo',
repoName: 'repo',
branch: 'main',
isGitRepo: true,
changedFiles: [],
}
})
await setWorkspaceState((state) => ({
...state,
panelBySession: {
...state.panelBySession,
'session-error': {
isOpen: true,
activeView: 'changed',
hasUserSelectedView: true,
},
},
errors: {
...state.errors,
statusBySession: {
...state.errors.statusBySession,
'session-error': 'status failed',
},
},
}))
await act(async () => {
view.rerender()
await Promise.resolve()
await Promise.resolve()
})
await waitFor(() => {
expect(view.getByText('status failed')).toBeTruthy()
})
})
})