feat: 移动端侧边栏体验 — 窄视口(<1024px)双工作台合并为单面板抽屉(可拖分隔条、高度与底部面板互通)、不提供底部面板按钮、文件/外链打开自动展开、新会话默认收起

This commit is contained in:
Menghuan1918
2026-08-12 18:20:34 +08:00
parent bab659f8bd
commit b391ebffed
12 changed files with 638 additions and 72 deletions
+23
View File
@@ -0,0 +1,23 @@
/**
* Narrow-viewport breakpoint tests: the boundary the mobile sidebar layout
* keys off (paired with the CSS @media (max-width: 1023px) gates in
* sidebar.module.css — 1023px ≡ widths strictly below NARROW_MAX_WIDTH).
*/
import { describe, expect, it } from 'vitest'
import { isNarrowWidth, NARROW_MAX_WIDTH } from '../src/client/breakpoints.ts'
describe('narrow-viewport breakpoint', () => {
it('treats widths below NARROW_MAX_WIDTH as narrow (mobile)', () => {
expect(NARROW_MAX_WIDTH).toBe(1024)
expect(isNarrowWidth(320)).toBe(true)
expect(isNarrowWidth(390)).toBe(true)
expect(isNarrowWidth(768)).toBe(true)
expect(isNarrowWidth(1023)).toBe(true)
})
it('treats NARROW_MAX_WIDTH and above as wide (desktop)', () => {
expect(isNarrowWidth(1024)).toBe(false)
expect(isNarrowWidth(1280)).toBe(false)
expect(isNarrowWidth(1920)).toBe(false)
})
})
+68
View File
@@ -0,0 +1,68 @@
/**
* MobileWorkbench tests: the narrow-viewport merged workbench — BOTH trees
* (right + bottom) stacked in one panel with a draggable divider between
* them, the bottom tree at the shared state.bottomHeight. Structure
* assertions via renderToString (the repo's component-test pattern). The
* divider drag itself is pointer-capture logic — exercised manually, like
* the desktop panel drags; the store clamp it commits through
* (setBottomHeight) is unit-tested in unit.spec.ts.
*/
import { describe, expect, it } from 'vitest'
import { renderToString } from 'react-dom/server'
import { MobileWorkbench, type WorkbenchActions } from '../src/client/split-pane.tsx'
import { makeDefaultState } from '../src/client/state.ts'
const noopActions: WorkbenchActions = {
closeTab: () => {},
activateTab: () => {},
focusPane: () => {},
moveTabToEdge: () => {},
moveTabBefore: () => {},
resizeSplit: () => {},
}
describe('MobileWorkbench (narrow merged layout)', () => {
it('renders BOTH trees stacked, with the divider and the shared bottom height', () => {
const state = makeDefaultState()
// A terminal tab in the bottom tree (a desktop bottom-panel session).
const bottomPane = state.bottomSplits as { tabs: Array<{ id: string; type: string; title: string }> }
bottomPane.tabs = [{ id: 'tab:b1', type: 'terminal', title: 'Terminal' }]
const html = renderToString((
<MobileWorkbench
state={state}
actions={noopActions}
newTabOptions={[]}
onNewTab={() => {}}
renderTab={(tab) => <div data-testid={`tab-${tab.id}`}>{tab.title}</div>}
onBottomHeightCommit={() => {}}
/>
))
// The right tree's default explorer tab renders (tab strip).
expect(html).toContain('Explorer')
// The bottom tree's terminal tab renders — the merge shows both trees.
expect(html).toContain('Terminal')
// The divider sits between the two workbenches.
expect(html).toContain('divider')
// The bottom pane carries the shared bottomHeight (the desktop bottom
// panel's height field drives the mobile bottom tree's height).
expect(html).toContain(`height:${state.bottomHeight}px`)
})
it('renders an empty bottom tree with its welcome cards (both workbenches always visible)', () => {
const state = makeDefaultState()
const html = renderToString((
<MobileWorkbench
state={state}
actions={noopActions}
newTabOptions={[{ id: 'explorer', label: 'Explorer' }, { id: 'terminal', label: 'Terminal' }]}
onNewTab={() => {}}
renderTab={() => null}
onBottomHeightCommit={() => {}}
/>
))
// The top tree's explorer TAB and the empty bottom pane's welcome cards
// both render (each option card carries its label).
expect(html).toContain('Explorer')
expect(html).toContain('Terminal')
})
})
+78
View File
@@ -465,3 +465,81 @@ describe('service.openTab across the two panels', () => {
expect(allLeaves(after.bottomSplits).flatMap(l => l.tabs).some(t => t.id === gitTab.id)).toBe(false)
})
})
describe('service.openTab narrow-viewport auto-expand', () => {
/** The window stub is a plain object (see the file header), so the width is writable. */
const setWidth = (width: number): void => {
;(g.window as { innerWidth: number }).innerWidth = width
}
it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('expands the collapsed drawer for a URL (browser) open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
it('keeps a collapsed drawer for a type-only open on a narrow viewport', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'explorer', title: 'Explorer' })
expect(store.getSnapshot().state?.panelOpen).toBe(false)
} finally {
setWidth(1024)
}
})
it('never expands on a wide viewport (path open keeps the caller’s choice)', () => {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(false)
})
it('expands on a narrow viewport even when the open focuses an existing tab (id dedupe)', () => {
setWidth(390)
try {
const store = createSidebarStore()
const service = createBetterSidebarService(store)
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
store.setSession('s1')
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
store.reduce(s => ({ ...s, panelOpen: false }))
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
expect(store.getSnapshot().state?.panelOpen).toBe(true)
} finally {
setWidth(1024)
}
})
})
+23
View File
@@ -1187,6 +1187,29 @@ describe('side card preferences', () => {
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
})
it('seeds a brand-new session COLLAPSED on narrow viewports (the panel is a full-screen drawer there)', () => {
// Stub a narrow window (the file otherwise runs without one): only
// innerWidth is read while seeding a fresh session.
const original = (globalThis as Record<string, unknown>).window
;(globalThis as Record<string, unknown>).window = {
innerWidth: 390,
clearTimeout: () => {},
setTimeout: (_fn: () => void) => 0,
}
try {
const store = createSidebarStore()
// Default prefs say openByDefault: true — the narrow viewport overrides
// it for the FIRST seeding only (a later user expansion persists).
store.setSession('narrow-fresh')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
// The width seeding still follows the window (clamped to the floor).
expect(store.getSnapshot().state?.width).toBe(280)
} finally {
if (original === undefined) delete (globalThis as Record<string, unknown>).window
else (globalThis as Record<string, unknown>).window = original
}
})
it('skips the default explorer tab when the explorer type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, bottomPanelAutoTerminal: true, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })