feat(desktop): unify right panel into Workbench with file/browser mode switch

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-05-30 23:36:21 +08:00
parent 41d1cb5c00
commit 1d9f4c9cb7
15 changed files with 415 additions and 44 deletions

View File

@ -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', () => {

View File

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

View File

@ -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<HTMLDivElement>(null)
@ -388,7 +393,15 @@ export function TabBar() {
<ToolbarIconButton
icon={isWorkspacePanelOpen ? <FolderOpen size={18} strokeWidth={1.9} /> : <Folder size={18} strokeWidth={1.9} />}
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={<Globe size={17} strokeWidth={1.9} />}
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/')
}

View File

@ -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 }) => (
<div data-testid="workspace-panel" data-embedded={embedded ? 'true' : 'false'}>
workspace:{sessionId}
</div>
),
}))
vi.mock('../browser/BrowserSurface', () => ({
BrowserSurface: ({ sessionId }: { sessionId: string }) => (
<div data-testid="browser-surface">browser:{sessionId}</div>
),
}))
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(<WorkbenchPanel sessionId={SESSION_ID} />)
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(<WorkbenchPanel sessionId={SESSION_ID} />)
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(<WorkbenchPanel sessionId={SESSION_ID} />)
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(<WorkbenchPanel sessionId={SESSION_ID} />)
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(<WorkbenchPanel sessionId={SESSION_ID} />)
fireEvent.click(screen.getByRole('tab', { name: 'Files' }))
expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('workspace')
})
it('the close button closes the unified panel', () => {
render(<WorkbenchPanel sessionId={SESSION_ID} />)
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Close' }))
expect(useWorkspacePanelStore.getState().isPanelOpen(SESSION_ID)).toBe(false)
})
})

View File

@ -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 (
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
<div className="flex h-10 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] px-2.5">
<div
role="tablist"
aria-label={t('workbench.modeSwitch')}
className="inline-flex items-center gap-0.5 rounded-[8px] border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5"
>
{MODE_ITEMS.map(({ mode: itemMode, labelKey, Icon }) => {
const isActive = mode === itemMode
return (
<button
key={itemMode}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setMode(sessionId, itemMode)}
className={`inline-flex h-7 items-center gap-1.5 rounded-[6px] px-2.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 ${
isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] shadow-[inset_0_0_0_1px_var(--color-border-focus)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]'
}`}
>
<Icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
<span>{t(labelKey)}</span>
</button>
)
})}
</div>
<button
type="button"
aria-label={t('workbench.close')}
onClick={() => closePanel(sessionId)}
className="ml-auto inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[7px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35"
>
<X size={16} strokeWidth={2} aria-hidden="true" />
</button>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{mode === 'browser' ? (
<BrowserSurface sessionId={sessionId} />
) : (
<WorkspacePanel sessionId={sessionId} embedded />
)}
</div>
</div>
)
}

View File

@ -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 }) => (
<div data-testid="workspace-panel">workspace:{sessionId}</div>
),
}))
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(<WorkbenchPanel sessionId={SESSION_ID} />)
// 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)
})
})

View File

@ -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 (
<aside
data-testid="workspace-panel"
className="flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
className={
embedded
? 'flex h-full min-h-0 w-full min-w-0 bg-[var(--color-surface)]'
: 'flex h-full shrink-0 border-l border-[var(--color-border)] bg-[var(--color-surface)]'
}
style={embedded ? undefined : { width: panelWidth, maxWidth: panelMaxWidth, minWidth: panelMinWidth }}
>
{hasPreviewTabs && (
<div className="flex min-w-0 flex-1 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface)]">
@ -1450,11 +1460,13 @@ export function WorkspacePanel({ sessionId }: WorkspacePanelProps) {
label={t('workspace.refresh')}
onClick={handleRefresh}
/>
<ToolbarIconButton
icon="close"
label={t('workspace.closePanel')}
onClick={() => closePanel(sessionId)}
/>
{!embedded && (
<ToolbarIconButton
icon="close"
label={t('workspace.closePanel')}
onClick={() => closePanel(sessionId)}
/>
)}
</div>
</div>

View File

@ -124,6 +124,10 @@ export const en = {
'workspace.refresh': 'Refresh workspace',
'workspace.closePanel': 'Close workspace panel',
'workspace.resizePanel': 'Resize workspace panel',
'workbench.modeSwitch': 'Workbench mode',
'workbench.modeWorkspace': 'Files',
'workbench.modeBrowser': 'Browser',
'workbench.close': 'Close',
'workspace.closeTab': 'Close tab',
'workspace.preview': 'Preview',
'workspace.previewEmpty': 'Select a file to preview.',

View File

@ -126,6 +126,10 @@ export const zh: Record<TranslationKey, string> = {
'workspace.refresh': '刷新工作区',
'workspace.closePanel': '关闭工作区面板',
'workspace.resizePanel': '调整工作区宽度',
'workbench.modeSwitch': '工作台模式',
'workbench.modeWorkspace': '文件',
'workbench.modeBrowser': '浏览器',
'workbench.close': '关闭',
'workspace.closeTab': '关闭标签',
'workspace.preview': '预览',
'workspace.previewEmpty': '选择一个文件进行预览。',

View File

@ -31,8 +31,8 @@ vi.mock('../components/chat/SessionTaskBar', () => ({
SessionTaskBar: () => <div data-testid="session-task-bar" />,
}))
vi.mock('../components/workspace/WorkspacePanel', () => ({
WorkspacePanel: ({ sessionId }: { sessionId: string }) => (
vi.mock('../components/workbench/WorkbenchPanel', () => ({
WorkbenchPanel: ({ sessionId }: { sessionId: string }) => (
<div data-testid="workspace-panel">workspace:{sessionId}</div>
),
}))
@ -613,16 +613,18 @@ describe('ActiveSession task polling', () => {
const chatColumn = screen.getByTestId('active-session-chat-column')
const resizeHandle = screen.getByTestId('workspace-resize-handle')
const workbenchPanel = screen.getByTestId('workbench-panel')
expect(within(contentRow).getByTestId('message-list')).toBeInTheDocument()
expect(within(contentRow).getByTestId('message-list')).toHaveAttribute('data-compact', 'true')
expect(within(contentRow).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
expect(within(workbenchPanel).getByTestId('workspace-panel')).toHaveTextContent(`workspace:${sessionId}`)
expect(within(chatColumn).getByTestId('chat-input')).toBeInTheDocument()
expect(within(chatColumn).getByTestId('chat-input')).toHaveAttribute('data-compact', 'true')
expect(chatColumn).toHaveClass('flex-1')
expect(chatColumn).not.toHaveClass('shrink-0')
expect(contentRow.children[0]).toBe(chatColumn)
expect(contentRow.children[1]).toBe(resizeHandle)
expect(contentRow.children[2]).toBe(screen.getByTestId('workspace-panel'))
expect(contentRow.children[2]).toBe(workbenchPanel)
act(() => {
fireEvent.keyDown(resizeHandle, { key: 'ArrowLeft' })

View File

@ -12,7 +12,6 @@ import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useTeamStore } from '../stores/teamStore'
import { useWorkspacePanelStore } from '../stores/workspacePanelStore'
import { useBrowserPanelStore } from '../stores/browserPanelStore'
import {
TERMINAL_PANEL_DEFAULT_HEIGHT,
TERMINAL_PANEL_MAX_HEIGHT,
@ -24,8 +23,7 @@ import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
import { WorkspacePanel } from '../components/workspace/WorkspacePanel'
import { BrowserSurface } from '../components/browser/BrowserSurface'
import { WorkbenchPanel } from '../components/workbench/WorkbenchPanel'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { TerminalSettings } from './TerminalSettings'
import type { SessionListItem } from '../types/session'
@ -278,17 +276,12 @@ export function ActiveSession() {
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const activeTeam = useTeamStore((s) => s.activeTeam)
const isMemberSession = !!memberInfo
const showWorkspacePanel = useWorkspacePanelStore((state) =>
const showWorkbench = useWorkspacePanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? state.isPanelOpen(activeTabId)
: false,
)
const showBrowserPanel = useBrowserPanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
? Boolean(state.bySession[activeTabId]?.isOpen)
: false,
)
const showRightPanel = showWorkspacePanel || showBrowserPanel
const showRightPanel = showWorkbench
const rightPanelWidth = useWorkspacePanelStore((state) => state.width)
const showTerminalPanel = useTerminalPanelStore((state) =>
activeTabId && isSessionTabState(activeTabId, activeTabType) && !isMemberSession && !isMobileLayout
@ -543,22 +536,17 @@ export function ActiveSession() {
) : null}
</div>
{showBrowserPanel ? (
{showWorkbench ? (
<>
<WorkspaceResizeHandle />
<aside
data-testid="browser-panel"
data-testid="workbench-panel"
className="flex h-full shrink-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ width: rightPanelWidth, maxWidth: '62%', minWidth: 'min(420px, 54%)' }}
>
<BrowserSurface sessionId={activeTabId} />
<WorkbenchPanel sessionId={activeTabId} />
</aside>
</>
) : showWorkspacePanel ? (
<>
<WorkspaceResizeHandle />
<WorkspacePanel sessionId={activeTabId} />
</>
) : null}
</div>

View File

@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { useBrowserPanelStore } from './browserPanelStore'
import { useWorkspacePanelStore } from './workspacePanelStore'
const reset = () => useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
const reset = () => {
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
}
describe('browserPanelStore', () => {
beforeEach(reset)
@ -82,4 +86,16 @@ describe('browserPanelStore', () => {
st.setReady('s1')
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(false)
})
it('open surfaces the unified workbench in browser mode', () => {
// Panel starts closed and defaults to the workspace (file) mode.
expect(useWorkspacePanelStore.getState().isPanelOpen('s1')).toBe(false)
expect(useWorkspacePanelStore.getState().getMode('s1')).toBe('workspace')
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
// Opening a browser target opens the shared workbench in browser mode.
expect(useWorkspacePanelStore.getState().isPanelOpen('s1')).toBe(true)
expect(useWorkspacePanelStore.getState().getMode('s1')).toBe('browser')
})
})

View File

@ -1,4 +1,5 @@
import { create } from 'zustand'
import { useWorkspacePanelStore } from './workspacePanelStore'
export type BrowserSessionState = {
isOpen: boolean
@ -39,9 +40,17 @@ const withNav = (s: BrowserSessionState): BrowserSessionState => ({
export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
bySession: {},
open: (sessionId, url) => set((st) => ({
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
})),
open: (sessionId, url) => {
set((st) => ({
bySession: { ...st.bySession, [sessionId]: { ...empty(url), loading: true } },
}))
// The browser is one mode of the unified workbench: opening a previewable
// link / localhost url surfaces the workbench in BROWSER mode. Import is
// one-directional (workspacePanelStore never imports this store), so no cycle.
const workspacePanel = useWorkspacePanelStore.getState()
workspacePanel.openPanel(sessionId)
workspacePanel.setMode(sessionId, 'browser')
},
navigate: (sessionId, url) => set((st) => {
const cur = st.bySession[sessionId] ?? empty(url)
const history = [...cur.history.slice(0, cur.historyIndex + 1), url]

View File

@ -314,6 +314,34 @@ describe('workspacePanelStore', () => {
expect(useWorkspacePanelStore.getState().isPanelOpen('session-closed-preview')).toBe(true)
})
it('defaults the workbench mode to "workspace"', () => {
expect(useWorkspacePanelStore.getState().getMode('session-no-mode')).toBe('workspace')
})
it('setMode stores the workbench mode per session', () => {
const store = useWorkspacePanelStore.getState()
store.setMode('session-a', 'browser')
store.setMode('session-b', 'workspace')
expect(useWorkspacePanelStore.getState().getMode('session-a')).toBe('browser')
expect(useWorkspacePanelStore.getState().getMode('session-b')).toBe('workspace')
// Unrelated sessions still fall back to the default.
expect(useWorkspacePanelStore.getState().getMode('session-c')).toBe('workspace')
})
it('openPreview opens the panel and forces "workspace" mode', async () => {
mocks.getWorkspaceFileMock.mockResolvedValue({
state: 'ok', path: 'src/a.ts', content: 'export const a = 1', language: 'typescript', size: 18,
})
// Start in browser mode (panel could already be showing the browser).
useWorkspacePanelStore.getState().setMode('session-preview-mode', 'browser')
expect(useWorkspacePanelStore.getState().getMode('session-preview-mode')).toBe('browser')
await useWorkspacePanelStore.getState().openPreview('session-preview-mode', 'src/a.ts', 'file')
expect(useWorkspacePanelStore.getState().isPanelOpen('session-preview-mode')).toBe(true)
expect(useWorkspacePanelStore.getState().getMode('session-preview-mode')).toBe('workspace')
})
it('opens preview tabs, supports multiple kinds, and reuses duplicates without persistence', async () => {
const storage = typeof globalThis.localStorage === 'undefined' ? null : globalThis.localStorage
const setItemSpy = storage ? vi.spyOn(storage, 'setItem') : null
@ -600,6 +628,10 @@ describe('workspacePanelStore', () => {
'session-clear': { isOpen: true, activeView: 'all' },
'session-reset': { isOpen: true, activeView: 'all' },
},
modeBySession: {
'session-clear': 'browser',
'session-reset': 'browser',
},
statusBySession: {
'session-clear': {
state: 'ok',
@ -678,6 +710,8 @@ describe('workspacePanelStore', () => {
const state = useWorkspacePanelStore.getState()
expect(state.panelBySession['session-clear']).toBeUndefined()
expect(state.panelBySession['session-reset']).toBeUndefined()
expect(state.modeBySession['session-clear']).toBeUndefined()
expect(state.modeBySession['session-reset']).toBeUndefined()
expect(state.statusBySession['session-clear']).toBeUndefined()
expect(state.statusBySession['session-reset']).toBeUndefined()
expect(state.expandedPathsBySession['session-clear']).toBeUndefined()

View File

@ -12,6 +12,7 @@ export const WORKSPACE_PANEL_MIN_WIDTH = 640
export const WORKSPACE_PANEL_MAX_WIDTH = 1120
export type WorkspacePanelView = 'changed' | 'all'
export type WorkbenchMode = 'workspace' | 'browser'
export type WorkspacePreviewKind = 'file' | 'diff'
export type WorkspacePreviewCloseScope = 'current' | 'others' | 'left' | 'right' | 'all'
export type WorkspacePreviewState =
@ -55,6 +56,7 @@ type WorkspacePanelErrorState = {
type WorkspacePanelStore = {
panelBySession: Record<string, WorkspacePanelSessionState | undefined>
modeBySession: Record<string, WorkbenchMode | undefined>
width: number
statusBySession: Record<string, WorkspaceStatusResult | undefined>
expandedPathsBySession: Record<string, string[] | undefined>
@ -66,6 +68,8 @@ type WorkspacePanelStore = {
isPanelOpen: (sessionId: string) => boolean
getActiveView: (sessionId: string) => WorkspacePanelView
getMode: (sessionId: string) => WorkbenchMode
setMode: (sessionId: string, mode: WorkbenchMode) => void
openPanel: (sessionId: string) => void
closePanel: (sessionId: string) => void
togglePanel: (sessionId: string) => void
@ -86,6 +90,8 @@ const DEFAULT_PANEL_STATE: WorkspacePanelSessionState = {
activeView: 'changed',
}
const DEFAULT_WORKBENCH_MODE: WorkbenchMode = 'workspace'
const statusRequestIds = new Map<string, number>()
const treeRequestIds = new Map<string, number>()
const previewRequestIds = new Map<string, number>()
@ -182,6 +188,7 @@ function upsertPreviewTab(
export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) => ({
panelBySession: {},
modeBySession: {},
width: WORKSPACE_PANEL_DEFAULT_WIDTH,
statusBySession: {},
expandedPathsBySession: {},
@ -201,6 +208,15 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
isPanelOpen: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).isOpen,
getActiveView: (sessionId) => getSessionPanelState(get().panelBySession, sessionId).activeView,
getMode: (sessionId) => get().modeBySession[sessionId] ?? DEFAULT_WORKBENCH_MODE,
setMode: (sessionId, mode) =>
set((state) => ({
modeBySession: {
...state.modeBySession,
[sessionId]: mode,
},
})),
openPanel: (sessionId) =>
set((state) => ({
@ -433,8 +449,10 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
openPreview: async (sessionId, path, kind) => {
// Ensure the workspace panel is visible — openPreview is now triggered from places
// where the panel may be closed (e.g. the chat "打开方式" menu / turn-changes card),
// not only from inside the already-open file tree.
// not only from inside the already-open file tree. Opening a file always switches the
// unified workbench into file ("workspace") mode.
get().openPanel(sessionId)
get().setMode(sessionId, 'workspace')
const tabId = getWorkspacePreviewTabId(path, kind)
const requestKey = makePreviewKey(sessionId, tabId)
const existing = get().previewTabsBySession[sessionId]?.find((tab) => tab.id === tabId)
@ -695,6 +713,7 @@ export const useWorkspacePanelStore = create<WorkspacePanelStore>((set, get) =>
set((state) => ({
panelBySession: removeRecordKey(state.panelBySession, sessionId),
modeBySession: removeRecordKey(state.modeBySession, sessionId),
statusBySession: removeRecordKey(state.statusBySession, sessionId),
expandedPathsBySession: removeRecordKey(state.expandedPathsBySession, sessionId),
treeBySessionPath: removeRecordKey(state.treeBySessionPath, sessionId),