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