diff --git a/desktop/src/components/workbench/WorkspaceFileTab.test.tsx b/desktop/src/components/workbench/WorkspaceFileTab.test.tsx index 03ffe961..19cc717e 100644 --- a/desktop/src/components/workbench/WorkspaceFileTab.test.tsx +++ b/desktop/src/components/workbench/WorkspaceFileTab.test.tsx @@ -139,10 +139,10 @@ afterEach(() => { }) describe('content states', () => { - it('gives the file picker a centered invitation while the tree remains available', () => { + it('gives the empty file picker all the space instead of reserving a blank preview', () => { renderTab('') - expect(screen.getByRole('heading', { name: 'Open file' })).toBeVisible() - expect(screen.getByText('Choose a file from the tree')).toBeVisible() + expect(screen.queryByTestId('workspace-file-preview')).toBeNull() + expect(screen.getByTestId('workspace-tree-sidebar')).toHaveStyle({ width: '100%', maxWidth: '100%' }) expect(screen.getByTestId('file-tree-pane')).toBeVisible() }) @@ -237,7 +237,7 @@ describe('content states', () => { it('invites the user to pick a file when the tab has no path yet', () => { renderTab('') - expect(screen.getByText('Choose a file from the tree')).toBeInTheDocument() + expect(screen.getByTestId('file-tree-pane')).toBeVisible() expect(loadFile).not.toHaveBeenCalled() }) }) @@ -279,6 +279,45 @@ describe('refresh', () => { }) describe('file tree', () => { + it.each([undefined, { line: 4 }])('restores a hidden preview when a chat link reopens the same file (%j)', (reveal) => { + const store = useWorkspaceStore.getState() + const id = store.openTarget(SESSION, { kind: 'file', path: 'src/a.ts' }) + const getTab = () => useWorkspaceStore.getState().bySession[SESSION]?.tabs.find((tab) => tab.id === id) as WorkspaceFileTabModel + seedEntry('src/a.ts', { content: 'first', previewType: 'text' }) + const view = render() + fireEvent.click(screen.getByRole('button', { name: 'Hide preview' })) + expect(screen.getByTestId('code-surface')).not.toBeVisible() + store.openTarget(SESSION, { kind: 'file', path: 'src/a.ts', reveal }) + view.rerender() + expect(screen.getByTestId('code-surface')).toBeVisible() + }) + + it('hides and restores the preview without losing the tree or selected file', () => { + seedEntry('src/a.ts', { content: 'const x = 1', previewType: 'text' }) + renderTab('src/a.ts') + fireEvent.click(screen.getByTestId('workspace-file-tree-toggle')) + fireEvent.click(screen.getByRole('button', { name: 'Hide preview' })) + expect(screen.getByTestId('workspace-file-preview')).not.toBeVisible() + expect(screen.getByTestId('file-tree-pane')).toBeVisible() + expect(screen.getByTestId('file-tree-pane')).toHaveAttribute('data-selected', 'src/a.ts') + expect(screen.getByTestId('workspace-tree-sidebar')).toHaveStyle({ width: '100%', maxWidth: '100%' }) + expect(screen.queryByTestId('workspace-file-tree-toggle')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Show preview' })) + expect(screen.getByTestId('code-surface')).toBeVisible() + expect(screen.getByTestId('file-tree-pane')).toBeVisible() + }) + + it('shows the preview when another file is selected from tree-only mode', () => { + seedEntry('src/a.ts', { content: 'first', previewType: 'text' }) + seedEntry('src/other.ts', { content: 'second', previewType: 'text' }) + const view = renderTab('src/a.ts') + fireEvent.click(screen.getByRole('button', { name: 'Hide preview' })) + fireEvent.click(screen.getByRole('button', { name: 'tree row' })) + view.rerender() + expect(screen.getByTestId('code-surface')).toBeVisible() + expect(screen.getByTestId('code-surface')).toHaveTextContent('second') + }) + it('keeps the tree beside the content instead of replacing one with the other', () => { // The previous panel hid its navigation the moment a file opened, so // browsing a repository made the structure appear and disappear. diff --git a/desktop/src/components/workbench/WorkspaceFileTab.tsx b/desktop/src/components/workbench/WorkspaceFileTab.tsx index 3ffcc2f0..ad8afe52 100644 --- a/desktop/src/components/workbench/WorkspaceFileTab.tsx +++ b/desktop/src/components/workbench/WorkspaceFileTab.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react' -import { ChevronDown, ChevronRight, ExternalLink, FolderClosed, FolderOpen } from 'lucide-react' +import { ChevronDown, ChevronRight, ExternalLink, FolderClosed, FolderOpen, PanelLeftClose, PanelLeftOpen } from 'lucide-react' import { IconButton } from '@/components/ui/IconButton' import { TargetIcon } from '@/components/composite/TargetIcon' import { useWorkspaceFileOpenTargets } from '@/components/workspace/workspaceFileOpenTargets' @@ -73,6 +73,10 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) { const restoredSurface = useRef<{ node: HTMLElement; revealNonce: number | undefined }>() const [consumedReveal, setConsumedReveal] = useState(null) const viewKey = `${sessionId}::${path}` + // Explicit file opens replace the tab model, including same-path chat links. + // Keep collapse local to this activation so a later open can reveal content. + const [hiddenPreviewTab, setHiddenPreviewTab] = useState(null) + const previewVisible = !!path && hiddenPreviewTab !== tab const revealKey = `${viewKey}::${tab.reveal?.nonce}` const restoredView = useRef<{ key: string; view: WorkspaceFileView | undefined }>() if (restoredView.current?.key !== viewKey) { @@ -202,7 +206,7 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) { )) )} - @@ -213,9 +217,20 @@ export function WorkspaceFileTab({ sessionId, tab }: WorkspaceFileTabProps) { pressed={treeOpen} data-testid="workspace-file-tree-toggle" onClick={() => setTreeOpen((open) => !open)} - /> + /> : null} {path ? ( <> + : } + label={t(previewVisible ? 'workspace.files.hidePreview' : 'workspace.files.showPreview')} + size="sm" + tone="muted" + pressed={previewVisible} + onClick={() => { + setHiddenPreviewTab(previewVisible ? tab : null) + if (previewVisible) setTreeOpen(true) + }} + /> + {workDir ? ( + onOpen(menu.row.path)} + onAfterSelect={closeMenu} + /> + ) : null} ) : null} diff --git a/desktop/src/components/workbench/WorkspaceTreeSidebar.test.tsx b/desktop/src/components/workbench/WorkspaceTreeSidebar.test.tsx index 5277dfab..c5269e28 100644 --- a/desktop/src/components/workbench/WorkspaceTreeSidebar.test.tsx +++ b/desktop/src/components/workbench/WorkspaceTreeSidebar.test.tsx @@ -12,6 +12,22 @@ function Fixture() { afterEach(() => vi.unstubAllGlobals()) describe('WorkspaceTreeSidebar', () => { + it('keeps tree-only mode full width and visible even in a narrow panel', () => { + let resize: ResizeObserverCallback = () => {} + vi.stubGlobal('ResizeObserver', class { + constructor(callback: ResizeObserverCallback) { resize = callback } + observe() {} + disconnect() {} + }) + const onOpenChange = vi.fn() + render(
file rows
) + act(() => resize([{ contentRect: { width: 400 } } as ResizeObserverEntry], {} as ResizeObserver)) + expect(onOpenChange).not.toHaveBeenCalled() + expect(screen.getByTestId('workspace-tree-sidebar')).toHaveAttribute('data-overlay', 'false') + expect(screen.getByTestId('workspace-tree-sidebar')).toHaveStyle({ width: '100%', maxWidth: '100%' }) + expect(screen.queryByRole('separator')).toBeNull() + }) + it('collapses at narrow content widths and can explicitly reopen as an overlay', () => { let resize: ResizeObserverCallback = () => {} const disconnect = vi.fn() diff --git a/desktop/src/components/workbench/WorkspaceTreeSidebar.tsx b/desktop/src/components/workbench/WorkspaceTreeSidebar.tsx index 78c4abb2..08660434 100644 --- a/desktop/src/components/workbench/WorkspaceTreeSidebar.tsx +++ b/desktop/src/components/workbench/WorkspaceTreeSidebar.tsx @@ -4,11 +4,12 @@ import { X } from 'lucide-react' import { IconButton } from '@/components/ui/IconButton' /** The tree collapses before the main split does, but its toolbar can always reopen it. */ -export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnNarrow = true }: { +export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnNarrow = true, fullWidth = false }: { open: boolean onOpenChange: (open: boolean) => void children: ReactNode collapseOnNarrow?: boolean + fullWidth?: boolean }) { const t = useTranslation() const ref = useRef(null) @@ -24,12 +25,12 @@ export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnN if (!entry || entry.contentRect.width === 0) return const next = entry.contentRect.width < 660 setNarrow(next) - if (next && !wasNarrow && collapseOnNarrow) onOpenChange(false) + if (next && !wasNarrow && collapseOnNarrow && !fullWidth) onOpenChange(false) wasNarrow = next }) observer.observe(parent) return () => observer.disconnect() - }, [collapseOnNarrow, onOpenChange]) + }, [collapseOnNarrow, fullWidth, onOpenChange]) useEffect(() => { const move = (event: PointerEvent) => { @@ -46,26 +47,28 @@ export function WorkspaceTreeSidebar({ open, onOpenChange, children, collapseOnN } }, []) + const overlay = narrow && !fullWidth + return (