diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index 9d829608..828a548b 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -14,10 +14,13 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {} import { BrowserSurface } from './BrowserSurface' import { useBrowserPanelStore } from '../../stores/browserPanelStore' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' afterEach(() => { Object.values(bridge).forEach((f) => f.mockReset()) useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) + // browserPanelStore.open() now also opens the unified workbench; keep it isolated. + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) }) describe('BrowserSurface', () => { diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index d735517c..6ada6b08 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -784,11 +784,12 @@ describe('TabBar', () => { expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false) }) - it('toggles the browser panel for the active session from the toolbar', async () => { + it('toggles the unified workbench in browser mode for the active session from the toolbar', async () => { const { TabBar } = await import('./TabBar') const { useTabStore } = await import('../../stores/tabStore') const { useChatStore } = await import('../../stores/chatStore') const { useBrowserPanelStore } = await import('../../stores/browserPanelStore') + const { useWorkspacePanelStore } = await import('../../stores/workspacePanelStore') useTabStore.setState({ tabs: [ @@ -805,11 +806,16 @@ describe('TabBar', () => { render() }) + // Opening the browser surfaces the shared workbench in browser mode. fireEvent.click(screen.getByRole('button', { name: 'Show Browser' })) expect(useBrowserPanelStore.getState().bySession['tab-1']?.isOpen).toBe(true) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(true) + expect(useWorkspacePanelStore.getState().getMode('tab-1')).toBe('browser') + // Toggling it off closes the unified panel (browser state is retained in its store). fireEvent.click(screen.getByRole('button', { name: 'Hide Browser' })) - expect(useBrowserPanelStore.getState().bySession['tab-1']?.isOpen).toBe(false) + expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false) + expect(screen.getByRole('button', { name: 'Show Browser' })).toBeInTheDocument() }) it('hides the browser toolbar button for non-session tabs', async () => { diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 2fdfc547..cebcaaa3 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -62,15 +62,20 @@ export function TabBar() { const openProjectPath = isActiveSessionTab && activeSession?.workDirExists !== false ? activeSession?.workDir ?? null : null - const isWorkspacePanelOpen = useWorkspacePanelStore((state) => + // The right-side panel is now a single unified "workbench" with a per-session + // mode (file ↔ browser). The folder/browser toolbar buttons reflect whether + // the panel is open in their respective mode. + const isWorkbenchOpen = useWorkspacePanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) + const workbenchMode = useWorkspacePanelStore((state) => + activeTabId && isActiveSessionTab ? state.getMode(activeTabId) : 'workspace', + ) + const isWorkspacePanelOpen = isWorkbenchOpen && workbenchMode === 'workspace' + const isBrowserPanelOpen = isWorkbenchOpen && workbenchMode === 'browser' const isTerminalPanelOpen = useTerminalPanelStore((state) => activeTabId && isActiveSessionTab ? state.isPanelOpen(activeTabId) : false, ) - const isBrowserPanelOpen = useBrowserPanelStore((state) => - activeTabId && isActiveSessionTab ? Boolean(state.bySession[activeTabId]?.isOpen) : false, - ) const moveTab = useTabStore((s) => s.moveTab) const scrollRef = useRef(null) @@ -388,7 +393,15 @@ export function TabBar() { : } label={t(isWorkspacePanelOpen ? 'tabs.hideWorkspace' : 'tabs.showWorkspace')} - onClick={() => useWorkspacePanelStore.getState().togglePanel(activeTabId)} + onClick={() => { + const workbench = useWorkspacePanelStore.getState() + if (workbench.isPanelOpen(activeTabId) && workbench.getMode(activeTabId) === 'workspace') { + workbench.closePanel(activeTabId) + } else { + workbench.setMode(activeTabId, 'workspace') + workbench.openPanel(activeTabId) + } + }} active={isWorkspacePanelOpen} /> )} @@ -397,9 +410,18 @@ export function TabBar() { icon={} label={t(isBrowserPanelOpen ? 'tabs.hideBrowser' : 'tabs.showBrowser')} onClick={() => { + const workbench = useWorkspacePanelStore.getState() + if (workbench.isPanelOpen(activeTabId) && workbench.getMode(activeTabId) === 'browser') { + workbench.closePanel(activeTabId) + return + } const browser = useBrowserPanelStore.getState() - if (browser.bySession[activeTabId]?.isOpen) { - browser.close(activeTabId) + const existing = browser.bySession[activeTabId] + if (existing) { + // Re-surface the workbench in browser mode at the last url + // without resetting history. (browser.open resets history.) + workbench.setMode(activeTabId, 'browser') + workbench.openPanel(activeTabId) } else { browser.open(activeTabId, 'http://localhost:5173/') } diff --git a/desktop/src/components/workbench/WorkbenchPanel.test.tsx b/desktop/src/components/workbench/WorkbenchPanel.test.tsx new file mode 100644 index 00000000..d61f02ba --- /dev/null +++ b/desktop/src/components/workbench/WorkbenchPanel.test.tsx @@ -0,0 +1,88 @@ +import '@testing-library/jest-dom' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../workspace/WorkspacePanel', () => ({ + WorkspacePanel: ({ sessionId, embedded }: { sessionId: string; embedded?: boolean }) => ( +
+ workspace:{sessionId} +
+ ), +})) + +vi.mock('../browser/BrowserSurface', () => ({ + BrowserSurface: ({ sessionId }: { sessionId: string }) => ( +
browser:{sessionId}
+ ), +})) + +import { WorkbenchPanel } from './WorkbenchPanel' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { useSettingsStore } from '../../stores/settingsStore' + +const SESSION_ID = 'workbench-session' + +beforeEach(() => { + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useSettingsStore.setState({ locale: 'en' }) + useWorkspacePanelStore.getState().openPanel(SESSION_ID) +}) + +afterEach(() => { + cleanup() + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) +}) + +describe('WorkbenchPanel', () => { + it('renders the file workspace (embedded) in the default workspace mode', () => { + render() + + const workspace = screen.getByTestId('workspace-panel') + expect(workspace).toHaveTextContent(`workspace:${SESSION_ID}`) + expect(workspace).toHaveAttribute('data-embedded', 'true') + expect(screen.queryByTestId('browser-surface')).not.toBeInTheDocument() + }) + + it('renders the native BrowserSurface in browser mode', () => { + useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser') + render() + + expect(screen.getByTestId('browser-surface')).toHaveTextContent(`browser:${SESSION_ID}`) + expect(screen.queryByTestId('workspace-panel')).not.toBeInTheDocument() + }) + + it('reflects the active mode on the segmented control tabs', () => { + useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser') + render() + + expect(screen.getByRole('tab', { name: 'Browser' })).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('tab', { name: 'Files' })).toHaveAttribute('aria-selected', 'false') + }) + + it('switching to the browser tab calls setMode("browser")', () => { + render() + expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('workspace') + + fireEvent.click(screen.getByRole('tab', { name: 'Browser' })) + + expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('browser') + }) + + it('switching to the files tab calls setMode("workspace")', () => { + useWorkspacePanelStore.getState().setMode(SESSION_ID, 'browser') + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Files' })) + + expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('workspace') + }) + + it('the close button closes the unified panel', () => { + render() + expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true) + + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + + expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false) + }) +}) diff --git a/desktop/src/components/workbench/WorkbenchPanel.tsx b/desktop/src/components/workbench/WorkbenchPanel.tsx new file mode 100644 index 00000000..ef6b6d4a --- /dev/null +++ b/desktop/src/components/workbench/WorkbenchPanel.tsx @@ -0,0 +1,83 @@ +import { FolderOpen, Globe, X } from 'lucide-react' +import { useTranslation } from '../../i18n' +import { + useWorkspacePanelStore, + type WorkbenchMode, +} from '../../stores/workspacePanelStore' +import { WorkspacePanel } from '../workspace/WorkspacePanel' +import { BrowserSurface } from '../browser/BrowserSurface' + +type WorkbenchPanelProps = { + sessionId: string +} + +const MODE_ITEMS: ReadonlyArray<{ + mode: WorkbenchMode + labelKey: 'workbench.modeWorkspace' | 'workbench.modeBrowser' + Icon: typeof FolderOpen +}> = [ + { mode: 'workspace', labelKey: 'workbench.modeWorkspace', Icon: FolderOpen }, + { mode: 'browser', labelKey: 'workbench.modeBrowser', Icon: Globe }, +] + +/** + * Unified right-side "Workbench" panel. Hosts the file workspace and the native + * browser surface behind a single per-session mode switch (file ↔ browser), + * sharing the panel's open state and width via {@link useWorkspacePanelStore}. + */ +export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) { + const t = useTranslation() + const mode = useWorkspacePanelStore((state) => state.getMode(sessionId)) + const setMode = useWorkspacePanelStore((state) => state.setMode) + const closePanel = useWorkspacePanelStore((state) => state.closePanel) + + return ( +
+
+
+ {MODE_ITEMS.map(({ mode: itemMode, labelKey, Icon }) => { + const isActive = mode === itemMode + return ( + + ) + })} +
+ + +
+ +
+ {mode === 'browser' ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx b/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx new file mode 100644 index 00000000..4c3eab57 --- /dev/null +++ b/desktop/src/components/workbench/WorkbenchPanel.webview.test.tsx @@ -0,0 +1,81 @@ +// Integration test: proves the native child webview is torn down (hidden) when +// the unified workbench switches from browser mode to file mode. Uses the REAL +// BrowserSurface so the unmount-cleanup path that hides the webview is exercised. +import '@testing-library/jest-dom' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +beforeAll(() => { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver +}) + +const { bridge } = vi.hoisted(() => ({ + bridge: { + open: vi.fn(), + navigate: vi.fn(), + setBounds: vi.fn(), + setVisible: vi.fn(), + close: vi.fn(), + eval: vi.fn(), + }, +})) +vi.mock('../../lib/previewBridge', () => ({ previewBridge: bridge })) +vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {}) })) + +// Keep the file workspace a lightweight stub — this test is about the webview. +vi.mock('../workspace/WorkspacePanel', () => ({ + WorkspacePanel: ({ sessionId }: { sessionId: string }) => ( +
workspace:{sessionId}
+ ), +})) + +import { WorkbenchPanel } from './WorkbenchPanel' +import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { useBrowserPanelStore } from '../../stores/browserPanelStore' +import { useSettingsStore } from '../../stores/settingsStore' + +const SESSION_ID = 'webview-session' + +beforeEach(() => { + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) + useSettingsStore.setState({ locale: 'en' }) + // open() opens the workbench in browser mode (and records the url for BrowserSurface). + useBrowserPanelStore.getState().open(SESSION_ID, 'http://localhost:5173/') +}) + +afterEach(() => { + cleanup() + Object.values(bridge).forEach((f) => f.mockReset()) + useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) +}) + +describe('WorkbenchPanel native webview lifecycle', () => { + it('shows the webview in browser mode and hides it when switching to file mode', () => { + render() + + // Browser mode mounts BrowserSurface, which opens + shows the native webview. + expect(screen.getByTestId('preview-host')).toBeInTheDocument() + expect(bridge.open).toHaveBeenCalledWith( + 'http://localhost:5173/', + expect.objectContaining({ width: expect.any(Number) }), + ) + expect(bridge.setVisible).toHaveBeenCalledWith(true) + + bridge.setVisible.mockClear() + + // Switch to the file tab: BrowserSurface unmounts, hiding the webview overlay. + act(() => { + fireEvent.click(screen.getByRole('tab', { name: 'Files' })) + }) + + expect(screen.queryByTestId('preview-host')).not.toBeInTheDocument() + expect(screen.getByTestId('workspace-panel')).toBeInTheDocument() + expect(bridge.setVisible).toHaveBeenCalledWith(false) + }) +}) diff --git a/desktop/src/components/workspace/WorkspacePanel.tsx b/desktop/src/components/workspace/WorkspacePanel.tsx index ac3603ce..7939528f 100644 --- a/desktop/src/components/workspace/WorkspacePanel.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.tsx @@ -32,6 +32,12 @@ import { WorkspaceFileOpenWith } from './WorkspaceFileOpenWith' type WorkspacePanelProps = { sessionId: string + /** + * When hosted inside the unified WorkbenchPanel, the close action lives in the + * shared workbench mode strip. Set this to drop WorkspacePanel's own close + * button so the panel header doesn't render a duplicate close control. + */ + embedded?: boolean } type TreeNodeProps = { @@ -906,7 +912,7 @@ function TreeNode({ ) } -export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { +export function WorkspacePanel({ sessionId, embedded = false }: WorkspacePanelProps) { const t = useTranslation() const addToast = useUIStore((state) => state.addToast) const [filterQuery, setFilterQuery] = useState('') @@ -1386,8 +1392,12 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) { return (