// @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 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('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('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('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() }) }) })