fix(desktop): simplify workbench open affordances

Previewable changed-file rows no longer show a redundant workspace chevron when the row already has an Open With control. The unified Workbench now owns the Browser entry point, and opening Browser mode creates a blank address bar instead of navigating to a hard-coded localhost port.

Constraint: Keep explicit preview links and localhost output cards able to open the browser directly with their target URL.
Rejected: Keep the top toolbar browser button | it duplicated the Workbench Browser tab and silently opened localhost:5173.
Rejected: Use localhost:5173 as the blank browser default | it makes arbitrary Browser mode look tied to a dev server.
Confidence: high
Scope-risk: moderate
Tested: cd desktop && /Users/nanmi/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/typescript/bin/tsc --noEmit
Tested: cd desktop && /Users/nanmi/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node ./node_modules/vitest/vitest.mjs run src/components/chat/CurrentTurnChangeCard.test.tsx src/stores/browserPanelStore.test.ts src/components/browser/BrowserAddressBar.test.tsx src/components/workbench/WorkbenchPanel.test.tsx src/components/browser/BrowserSurface.test.tsx src/components/layout/TabBar.test.tsx --pool=forks --poolOptions.forks.singleFork=true
Tested: git diff --check -- desktop/src/components/chat/CurrentTurnChangeCard.tsx desktop/src/components/chat/CurrentTurnChangeCard.test.tsx desktop/src/components/browser/BrowserAddressBar.tsx desktop/src/components/browser/BrowserAddressBar.test.tsx desktop/src/components/browser/BrowserSurface.tsx desktop/src/components/browser/BrowserSurface.test.tsx desktop/src/components/layout/TabBar.tsx desktop/src/components/layout/TabBar.test.tsx desktop/src/components/workbench/WorkbenchPanel.tsx desktop/src/components/workbench/WorkbenchPanel.test.tsx desktop/src/stores/browserPanelStore.ts desktop/src/stores/browserPanelStore.test.ts
Not-tested: Full desktop check:desktop gate.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-31 23:18:20 +08:00
parent fac8ffb0f2
commit 8e5e86353a
12 changed files with 175 additions and 57 deletions

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { BrowserAddressBar } from './BrowserAddressBar'
@ -8,6 +8,10 @@ const baseProps = {
onNavigate: vi.fn(), onBack: vi.fn(), onForward: vi.fn(), onReload: vi.fn(),
}
afterEach(() => {
cleanup()
})
describe('BrowserAddressBar', () => {
it('shows the current url in the input', () => {
render(<BrowserAddressBar {...baseProps} />)
@ -23,6 +27,36 @@ describe('BrowserAddressBar', () => {
expect(onNavigate).toHaveBeenCalledWith('http://localhost:3000/x')
})
it('submits an empty address as empty', () => {
const onNavigate = vi.fn()
render(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
fireEvent.submit(screen.getByRole('textbox').closest('form')!)
expect(onNavigate).toHaveBeenCalledWith('')
})
it('normalizes localhost and bare domains before navigating', () => {
const onNavigate = vi.fn()
render(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'localhost:3000' } })
fireEvent.submit(input.closest('form')!)
fireEvent.change(input, { target: { value: 'example.com' } })
fireEvent.submit(input.closest('form')!)
expect(onNavigate).toHaveBeenNthCalledWith(1, 'http://localhost:3000')
expect(onNavigate).toHaveBeenNthCalledWith(2, 'https://example.com')
})
it('preserves explicit URL schemes', () => {
const onNavigate = vi.fn()
render(<BrowserAddressBar {...baseProps} url="" onNavigate={onNavigate} />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'about:blank' } })
fireEvent.submit(input.closest('form')!)
expect(onNavigate).toHaveBeenCalledWith('about:blank')
})
it('disables back when cannot go back', () => {
render(<BrowserAddressBar {...baseProps} canGoBack={false} />)
expect(screen.getByLabelText('后退')).toBeDisabled()

View File

@ -23,11 +23,12 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
<button aria-label="刷新" aria-busy={loading} onClick={onReload} className="p-1">
{loading ? <Loader2 size={16} className="animate-spin" /> : <RotateCw size={16} />}
</button>
<form className="flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(draft.trim()) }}>
<form className="flex-1" onSubmit={(e) => { e.preventDefault(); onNavigate(normalizeBrowserAddress(draft)) }}>
<input
className="w-full rounded-md bg-[var(--color-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)]"
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="输入网址..."
spellCheck={false}
/>
</form>
@ -42,3 +43,13 @@ export function BrowserAddressBar({ url, canGoBack, canGoForward, loading = fals
</div>
)
}
export function normalizeBrowserAddress(input: string): string {
const value = input.trim()
if (!value) return ''
if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value) || /^(about|data|file):/i.test(value)) return value
if (/^(localhost|127(?:\.\d{1,3}){3}|\[::1\]|::1)(?::\d+)?(?:[/?#].*)?$/i.test(value)) {
return `http://${value}`
}
return `https://${value}`
}

View File

@ -1,9 +1,12 @@
import '@testing-library/jest-dom'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
beforeAll(() => {
globalThis.ResizeObserver = class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver
Object.defineProperty(globalThis, 'ResizeObserver', {
configurable: true,
value: class { observe() {} unobserve() {} disconnect() {} },
})
})
const { bridge } = vi.hoisted(() => ({
@ -18,6 +21,7 @@ import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useOverlayStore } from '../../stores/overlayStore'
afterEach(() => {
cleanup()
Object.values(bridge).forEach((f) => f.mockReset())
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
// browserPanelStore.open() now also opens the unified workbench; keep it isolated.
@ -32,6 +36,23 @@ describe('BrowserSurface', () => {
expect(bridge.open).toHaveBeenCalledWith('http://localhost:5173/', expect.objectContaining({ width: expect.any(Number) }))
})
it('renders an empty address bar without opening a preview for a blank session', () => {
useBrowserPanelStore.getState().ensureBlank('s1')
render(<BrowserSurface sessionId="s1" />)
expect(screen.getByRole('textbox')).toHaveValue('')
expect(bridge.open).not.toHaveBeenCalled()
})
it('first navigation from a blank session opens the native preview', () => {
useBrowserPanelStore.getState().ensureBlank('s1')
render(<BrowserSurface sessionId="s1" />)
const input = screen.getByRole('textbox')
fireEvent.change(input, { target: { value: 'localhost:3000' } })
fireEvent.submit(input.closest('form')!)
expect(bridge.open).toHaveBeenCalledWith('http://localhost:3000', expect.objectContaining({ width: expect.any(Number) }))
expect(bridge.navigate).not.toHaveBeenCalled()
})
it('navigating via address bar calls store + bridge', () => {
useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/')
render(<BrowserSurface sessionId="s1" />)

View File

@ -21,7 +21,7 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
useLayoutEffect(() => {
const el = hostRef.current
if (!el || !session) return
if (!el || !session?.url) return
previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect()))
// The visibility-sync effect below owns setVisible() — including the
// initial reveal — so it always factors in overlayCount.
@ -70,6 +70,22 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
if (!session) return null
const openOrNavigate = (url: string) => {
if (!url) return
const current = useBrowserPanelStore.getState().bySession[sessionId]
store.navigate(sessionId, url)
if (current?.url) {
previewBridge.navigate(url)
return
}
const el = hostRef.current
if (el) {
previewBridge.open(url, computeWebviewBounds(el.getBoundingClientRect()))
} else {
previewBridge.navigate(url)
}
}
return (
<div className="flex h-full flex-col">
<BrowserAddressBar
@ -77,10 +93,14 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) {
canGoBack={session.canGoBack}
canGoForward={session.canGoForward}
loading={session.loading}
onNavigate={(url) => { store.navigate(sessionId, url); previewBridge.navigate(url) }}
onNavigate={openOrNavigate}
onBack={() => { store.goBack(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
onForward={() => { store.goForward(sessionId); store.setLoading(sessionId, true); previewBridge.navigate(useBrowserPanelStore.getState().bySession[sessionId]!.url) }}
onReload={() => { store.setLoading(sessionId, true); previewBridge.navigate(session.url) }}
onReload={() => {
if (!session.url) return
store.setLoading(sessionId, true)
previewBridge.navigate(session.url)
}}
/>
<div className="flex items-center gap-1 border-b px-2 py-1">
<button

View File

@ -1,6 +1,6 @@
import '@testing-library/jest-dom'
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
import { act } from 'react'
// ──────────────────────────────────────────────────────────────────────────────
@ -126,6 +126,10 @@ function renderCard(filesChanged: string[]) {
// ──────────────────────────────────────────────────────────────────────────────
// Tests
// ──────────────────────────────────────────────────────────────────────────────
afterEach(() => {
cleanup()
})
describe('CurrentTurnChangeCard rich file row (icon / name / type)', () => {
beforeEach(() => {
vi.clearAllMocks()
@ -222,6 +226,13 @@ describe('CurrentTurnChangeCard open-with buttons', () => {
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
})
it('hides the workspace chevron on rows that already show an open-with button', () => {
renderCard(['/w/proj/README.md', '/w/proj/index.html', '/w/proj/src/main.ts'])
expect(screen.getAllByRole('button', { name: 'openWith.title' })).toHaveLength(2)
expect(screen.getAllByText('chevron_right')).toHaveLength(1)
})
it('clicking README.md open-with opens menu with workspace preview item', async () => {
renderCard(['/w/proj/README.md'])
const [openWithBtn] = screen.getAllByRole('button', { name: 'openWith.title' })

View File

@ -157,7 +157,9 @@ export function CurrentTurnChangeCard({
<span className="block truncate text-sm font-medium text-[var(--color-text-primary)]">{fileName}</span>
<span className="block truncate text-xs text-[var(--color-text-tertiary)]">{`${t(typeInfo.categoryKey as Parameters<typeof t>[0])} · ${typeInfo.ext}`}</span>
</span>
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
{!previewable && (
<span className="material-symbols-outlined shrink-0 text-[18px] text-[var(--color-text-tertiary)]">chevron_right</span>
)}
</button>
{previewable && (
<button

View File

@ -784,12 +784,10 @@ describe('TabBar', () => {
expect(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
})
it('toggles the unified workbench in browser mode for the active session from the toolbar', async () => {
it('does not render a browser toolbar button for session tabs', 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: [
@ -806,16 +804,8 @@ 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(useWorkspacePanelStore.getState().isPanelOpen('tab-1')).toBe(false)
expect(screen.getByRole('button', { name: 'Show Browser' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Show Browser' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Hide Browser' })).not.toBeInTheDocument()
})
it('hides the browser toolbar button for non-session tabs', async () => {

View File

@ -11,11 +11,10 @@ import { useChatStore } from '../../stores/chatStore'
import { useSessionStore } from '../../stores/sessionStore'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useTerminalPanelStore } from '../../stores/terminalPanelStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { useTranslation } from '../../i18n'
import { WindowControls, showWindowControls } from './WindowControls'
import { OpenProjectMenu } from './OpenProjectMenu'
import { Folder, FolderOpen, Globe, SquareTerminal } from 'lucide-react'
import { Folder, FolderOpen, SquareTerminal } from 'lucide-react'
import { ActionDialog } from '../shared/ActionDialog'
const TAB_WIDTH = 180
@ -73,7 +72,6 @@ export function TabBar() {
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,
)
@ -406,30 +404,6 @@ export function TabBar() {
active={isWorkspacePanelOpen}
/>
)}
{isActiveSessionTab && activeTabId && (
<ToolbarIconButton
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()
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/')
}
}}
active={isBrowserPanelOpen}
/>
)}
</div>
{isTauri && (

View File

@ -19,11 +19,13 @@ vi.mock('../browser/BrowserSurface', () => ({
import { WorkbenchPanel } from './WorkbenchPanel'
import { useWorkspacePanelStore } from '../../stores/workspacePanelStore'
import { useSettingsStore } from '../../stores/settingsStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
const SESSION_ID = 'workbench-session'
beforeEach(() => {
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
useSettingsStore.setState({ locale: 'en' })
useWorkspacePanelStore.getState().openPanel(SESSION_ID)
})
@ -31,6 +33,7 @@ beforeEach(() => {
afterEach(() => {
cleanup()
useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true)
useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true)
})
describe('WorkbenchPanel', () => {
@ -66,6 +69,13 @@ describe('WorkbenchPanel', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Browser' }))
expect(useWorkspacePanelStore.getState().getMode(SESSION_ID)).toBe('browser')
expect(useBrowserPanelStore.getState().bySession[SESSION_ID]).toMatchObject({
isOpen: true,
url: '',
history: [],
historyIndex: -1,
loading: false,
})
})
it('switching to the files tab calls setMode("workspace")', () => {

View File

@ -4,6 +4,7 @@ import {
useWorkspacePanelStore,
type WorkbenchMode,
} from '../../stores/workspacePanelStore'
import { useBrowserPanelStore } from '../../stores/browserPanelStore'
import { WorkspacePanel } from '../workspace/WorkspacePanel'
import { BrowserSurface } from '../browser/BrowserSurface'
@ -30,6 +31,14 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
const mode = useWorkspacePanelStore((state) => state.getMode(sessionId))
const setMode = useWorkspacePanelStore((state) => state.setMode)
const closePanel = useWorkspacePanelStore((state) => state.closePanel)
const ensureBlankBrowser = useBrowserPanelStore((state) => state.ensureBlank)
const handleModeSelect = (nextMode: WorkbenchMode) => {
if (nextMode === 'browser') {
ensureBlankBrowser(sessionId)
}
setMode(sessionId, nextMode)
}
return (
<div className="flex h-full min-h-0 w-full flex-col bg-[var(--color-surface)]">
@ -47,7 +56,7 @@ export function WorkbenchPanel({ sessionId }: WorkbenchPanelProps) {
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setMode(sessionId, itemMode)}
onClick={() => handleModeSelect(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)]'

View File

@ -20,6 +20,16 @@ describe('browserPanelStore', () => {
expect(s.canGoBack).toBe(false)
})
it('creates a blank browser session without navigating', () => {
useBrowserPanelStore.getState().ensureBlank('s1')
const s = useBrowserPanelStore.getState().bySession['s1']!
expect(s.url).toBe('')
expect(s.isOpen).toBe(true)
expect(s.history).toEqual([])
expect(s.historyIndex).toBe(-1)
expect(s.loading).toBe(false)
})
it('navigate pushes history and truncates forward entries', () => {
const st = useBrowserPanelStore.getState()
st.open('s1', 'http://localhost/a')
@ -67,6 +77,17 @@ describe('browserPanelStore', () => {
expect(useBrowserPanelStore.getState().bySession['s1']!.loading).toBe(true)
})
it('navigate from a blank session records the first URL as the first history entry', () => {
const st = useBrowserPanelStore.getState()
st.ensureBlank('s1')
st.navigate('s1', 'http://localhost/a')
const s = useBrowserPanelStore.getState().bySession['s1']!
expect(s.url).toBe('http://localhost/a')
expect(s.history).toEqual(['http://localhost/a'])
expect(s.historyIndex).toBe(0)
expect(s.canGoBack).toBe(false)
})
it('setNavigated clears loading and updates url/title without growing history', () => {
const st = useBrowserPanelStore.getState()
st.open('s1', 'http://x/a')

View File

@ -16,6 +16,7 @@ export type BrowserSessionState = {
type BrowserPanelState = {
bySession: Record<string, BrowserSessionState>
open: (sessionId: string, url: string) => void
ensureBlank: (sessionId: string) => void
navigate: (sessionId: string, url: string) => void
goBack: (sessionId: string) => void
goForward: (sessionId: string) => void
@ -26,14 +27,21 @@ type BrowserPanelState = {
setReady: (sessionId: string) => void
}
const empty = (url: string): BrowserSessionState => ({
isOpen: true, url, title: '', history: [url], historyIndex: 0,
loading: false, pickerActive: false, canGoBack: false, canGoForward: false,
const empty = (url = ''): BrowserSessionState => ({
isOpen: true,
url,
title: '',
history: url ? [url] : [],
historyIndex: url ? 0 : -1,
loading: false,
pickerActive: false,
canGoBack: false,
canGoForward: false,
})
const withNav = (s: BrowserSessionState): BrowserSessionState => ({
...s,
url: s.history[s.historyIndex]!,
url: s.history[s.historyIndex] ?? s.url,
canGoBack: s.historyIndex > 0,
canGoForward: s.historyIndex < s.history.length - 1,
})
@ -51,9 +59,16 @@ export const useBrowserPanelStore = create<BrowserPanelState>((set) => ({
workspacePanel.openPanel(sessionId)
workspacePanel.setMode(sessionId, 'browser')
},
ensureBlank: (sessionId) => set((st) => {
const cur = st.bySession[sessionId]
if (cur) {
return { bySession: { ...st.bySession, [sessionId]: { ...cur, isOpen: true } } }
}
return { bySession: { ...st.bySession, [sessionId]: empty() } }
}),
navigate: (sessionId, url) => set((st) => {
const cur = st.bySession[sessionId] ?? empty(url)
const history = [...cur.history.slice(0, cur.historyIndex + 1), url]
const history = [...cur.history.slice(0, Math.max(0, cur.historyIndex + 1)), url]
return { bySession: { ...st.bySession, [sessionId]: withNav({ ...cur, isOpen: true, loading: true, history, historyIndex: history.length - 1 }) } }
}),
goBack: (sessionId) => set((st) => {