refactor: simplify sidebar logics

This commit is contained in:
imccyu
2026-07-29 14:52:41 +08:00
parent f280a97c56
commit 66d650e4fb
18 changed files with 113 additions and 125 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: 836100066039e3695e314a4a4bbfaba8fb20c652
README.zh.md: ffef7511b6cfdc3109203be86199766073bf5efd
README.md: 9354f4b79f7b1af7d8a20a295e77913ff443c2e4
README.zh.md: c949236557e7eb3eed0c698566fb5aa9e9cdd18a

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The first ready active Session may restore an open details width across reload; New Session and every later current-Session change close details before paint, including selection invalidation after deletion. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Details width is global, not retained per Session** — changing or losing its active Session closes the panel and forgets a dragged width; returning to that Session does not reopen it.
- **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth.
- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth.
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.

View File

@@ -4,7 +4,7 @@
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 通过 `SessionProvider` 渲染。首次就绪的活动会话可在重新加载后恢复已打开的详情宽度New Session 以及后续每次当前会话变化,都会在绘制前关闭详情栏,包括删除后选中状态失效的情况。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed``width`导航操作属于侧边栏自身注入的服务表层
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,且从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话以默认宽度打开返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作
`/client` 导出表层包含插件主体(`apply``inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
@@ -18,6 +18,6 @@ AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,
## 已知限制与暂缓事项
- **详情宽度是全局状态,不按会话保留**:切换或失去当前活动会话会关闭详情栏,并忘记拖动后的宽度;返回该会话时不会重新打开详情栏
- **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。
- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息
- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。

View File

@@ -91,33 +91,21 @@ export function AppFrame({
renderSlot,
}: AppFrameProps) {
const panels = useStore(s => s)
const sessionsPhase = useSessions(s => s.phase)
const detailsSession = useSessions((s) => {
const current = s.current
if (current === undefined) return undefined
const session = s.byId[current]
return session !== undefined && !session.blank ? current : undefined
return current !== undefined && s.byId[current]?.blank === false ? current : undefined
})
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
// The first ready active Session is baseline restoration, so its persisted
// panel may remain open. New Session has no inspectable selection, and any
// later details owner change closes the root-scoped column before paint.
const detailsBaselineReady = useRef(false)
const previousDetailsSession = useRef(detailsSession)
const lastSession = useRef(detailsSession)
useLayoutEffect(() => {
if (sessionsPhase !== 'ready') return
if (!detailsBaselineReady.current) {
detailsBaselineReady.current = true
previousDetailsSession.current = detailsSession
if (detailsSession === undefined) actions.closeDetails()
return
if (detailsSession === undefined) return
if (lastSession.current !== undefined && lastSession.current !== detailsSession) {
actions.closeDetails()
}
if (previousDetailsSession.current === detailsSession) return
previousDetailsSession.current = detailsSession
actions.closeDetails()
}, [actions, detailsSession, sessionsPhase])
lastSession.current = detailsSession
}, [actions, detailsSession])
// Track the frame's own box (not the window): rAF-throttled ResizeObserver.
useEffect(() => {
@@ -139,12 +127,12 @@ export function AppFrame({
}
}, [])
const cols = computeColumns(viewport, panels.sidebar, panels.details)
const cols = computeColumns(viewport, panels.sidebar, detailsSession === undefined ? 0 : panels.details)
const colsRef = useRef(cols)
colsRef.current = cols
// The drag base is the rendered width captured at drag start (grabbing a
// concession-clamped panel must not jump back to the persisted preference);
// concession-clamped panel must not jump back to the stored preference);
// it stays frozen for the whole gesture so dx deltas do not compound.
const sidebarBase = useRef(0)
const detailsBase = useRef(0)

View File

@@ -1,7 +1,7 @@
/**
* Pure concession-chain column solver for the three-column AppFrame.
* Chain order is fixed by contract: keep center >= CENTER_MIN by shrinking
* details, then auto-closing it (derived zero width — persisted width
* details, then auto-closing it (derived zero width — preferred width
* preferences are never rewritten, so widening the window restores them).
* The sidebar never concedes: its rendered width is always the drag
* preference (or the collapsed rail), and center absorbs any remaining
@@ -45,8 +45,8 @@ export function clampWidth(px: number, min: number, max: number): number {
/**
* Solve the three column widths for one viewport frame. Pure: no hysteresis —
* the output is a function of (viewport, preferences) only, so recovery on
* re-widening is automatic. Preferences re-clamp here because they cross a
* durable boundary (localStorage rehydration may carry stale ranges).
* re-widening is automatic. Preferences re-clamp here because they cross the
* store boundary and callers may still supply stale ranges.
* @param viewport - available frame width in px.
* @param sidebar - sidebar width preference in px (0 = closed).
* @param details - details width preference in px (0 = closed).

View File

@@ -1,7 +1,7 @@
/**
* The root entry's layout store: panel geometry as plain widths in px
* (0 = closed), persisted across reloads. Module level exports the factory
* only — a module-level handle would pin the store's identity in the module
* The root entry's transient layout store: panel geometry as plain widths in
* px (0 = closed). Module level exports the factory only — a module-level
* handle would pin the store's identity in the module
* cache (a de-facto singleton surviving plugin reloads). register() receives
* the factory (exclusive use: the framework instantiates per entry), AppFrame
* derives its PropsStore share from the return type, and the service face
@@ -29,17 +29,16 @@ type LayoutActions = {
}
/**
* Create the layout panel store handle. The persisted preference IS the
* width, so closing a panel forgets its drag width — reopening restores the
* contract default. Actions are the complete write set: drag writes clamp
* Create the layout panel store handle. The preference IS the width, so
* closing a panel forgets its drag width — reopening restores the contract
* default. Actions are the complete write set: drag writes clamp
* into the panel's contract range and never cross the open/closed line;
* open/close transitions write 0 / the default explicitly.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
const handle = defineStore({
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
persist: 'dsh.layout.panels',
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }),
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },

View File

@@ -15,8 +15,8 @@ export const name = 'client-ui-layout-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: shell viewing-state stores (zustand+persist) behind
* ctx.layout — it emits no cordis events; clamp/prune/concession-chain
* No runtime invariant: the shell viewing-state store behind ctx.layout emits
* no cordis events; clamp/prune/concession-chain
* sequencing is asserted directly by this package's columns and service specs.
*/
const install: InvariantInstaller = () => {}

View File

@@ -24,7 +24,6 @@ import type {
// Session selection controls for the SessionProvider and useSessions stubs.
const selectedSession = { current: 's-test' as SessionId | undefined }
const selectedSessionBlank = { current: false }
const sessionsPhase = { current: 'ready' as SessionListState['phase'] }
const baselinesReady = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
@@ -56,7 +55,6 @@ function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapsho
function mountFrame() {
window.innerWidth = frameWidth // first-render viewport source before the observer fires
const instance = createLayoutStore().create()
instance.actions.openDetails() // seed: sidebar at default 280, details open at default 360
const slotCalls: { key: string; props: unknown }[] = []
const renderSlot = ((key: string, owner: object) => {
slotCalls.push({ key, props: owner })
@@ -74,7 +72,7 @@ function mountFrame() {
? {}
: { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } },
current,
phase: sessionsPhase.current,
phase: 'ready',
} as SessionListState
return sel(sessionState)
}) as never
@@ -116,9 +114,7 @@ beforeEach(() => {
frameWidth = 1920
selectedSession.current = 's-test' as SessionId
selectedSessionBlank.current = false
sessionsPhase.current = 'ready'
baselinesReady.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => setTimeout(() => { cb(0) }, 16) as unknown as number)
@@ -176,7 +172,7 @@ describe('AppFrame', () => {
expect(slotCalls.map(c => c.key)).toContain('details')
})
it('closes details when the ready current Session changes, including New Session, and keeps it closed on return', () => {
it('ignores unselected states and closes only when the Session id changes', () => {
const { frame, instance, rerenderFrame } = mountFrame()
expect(tracks(frame)).toEqual([280, 360])
@@ -189,31 +185,30 @@ describe('AppFrame', () => {
selectedSessionBlank.current = true
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
expect(instance.getSnapshot().details).toBe(360)
selectedSession.current = 's-test' as SessionId
selectedSession.current = 's-next' as SessionId
selectedSessionBlank.current = false
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
expect(tracks(frame)).toEqual([280, 360])
act(() => { instance.actions.openDetails() })
selectedSession.current = undefined
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
selectedSession.current = 's-test' as SessionId
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
})
it('preserves open details across active-session baseline restore but closes it for an initial New Session view', () => {
sessionsPhase.current = 'pending'
const active = mountFrame()
expect(tracks(active.frame)).toEqual([280, 360])
sessionsPhase.current = 'ready'
act(() => { active.rerenderFrame() })
expect(tracks(active.frame)).toEqual([280, 360])
active.unmount()
it('keeps the default details width when the first Session materializes', () => {
selectedSession.current = undefined
const { frame, instance, rerenderFrame } = mountFrame()
expect(tracks(frame)).toEqual([280, 0])
expect(instance.getSnapshot().details).toBe(360)
selectedSession.current = 's-blank' as SessionId
selectedSessionBlank.current = true
const blank = mountFrame()
expect(tracks(blank.frame)).toEqual([280, 0])
selectedSession.current = 's-first' as SessionId
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 360])
})
it('sidebar slot receives live concession output as owner props', () => {

View File

@@ -1,8 +1,8 @@
// @vitest-environment jsdom
/**
* createLayoutStore unit account: init shape, the action write set (clamp
* inside actions), and the persist key round-trip over jsdom localStorage.
* Uses the test-sanctioned path: factory self-call + .create() gives the
* inside actions), and the absence of browser persistence. Uses the
* test-sanctioned path: factory self-call + .create() gives the
* real engine instance (same create path as production).
*/
import { beforeEach, describe, expect, it } from 'vitest'
@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
beforeEach(() => { localStorage.clear() })
describe('createLayoutStore', () => {
it('initializes with sidebar open at default and details closed', () => {
it('initializes both panels at their default widths', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT })
})
it('each create() is an independent instance (factory is not a singleton)', () => {
@@ -52,6 +52,7 @@ describe('createLayoutStore', () => {
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
const { store, actions } = createLayoutStore().create()
actions.closeDetails()
actions.openDetails()
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
actions.setDetails(500)
@@ -61,13 +62,16 @@ describe('createLayoutStore', () => {
expect(store.getSnapshot().details).toBe(0)
})
it('persists under dsh.layout.panels and rehydrates on the next create', () => {
it('does not persist panel geometry', () => {
const first = createLayoutStore().create()
first.actions.setSidebar(320)
first.actions.openDetails()
expect(JSON.parse(localStorage.getItem(PERSIST_KEY) ?? '{}')).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
first.actions.setSidebar(400)
first.actions.closeDetails()
expect(localStorage.getItem(PERSIST_KEY)).toBeNull()
const second = createLayoutStore().create()
expect(second.store.getSnapshot()).toEqual({ sidebar: 320, details: DETAILS_DEFAULT })
expect(second.store.getSnapshot()).toEqual({
sidebar: SIDEBAR_DEFAULT,
details: DETAILS_DEFAULT,
})
})
})