From 2d2e8534a54213d5dd614b53ca1eeab35e19fdd4 Mon Sep 17 00:00:00 2001 From: Justin Skowyra <289723040+Jstn-1g@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:17:10 -0400 Subject: [PATCH] fix: keep restored mobile drawer closed --- docs/plans/2026-08-12-mobile-layout-design.md | 4 +- src/client/state.ts | 17 +++- tests/prefs.spec.ts | 4 +- tests/state.spec.ts | 99 +++++++++++++++++++ 4 files changed, 115 insertions(+), 9 deletions(-) diff --git a/docs/plans/2026-08-12-mobile-layout-design.md b/docs/plans/2026-08-12-mobile-layout-design.md index 92d975d..6fa181f 100644 --- a/docs/plans/2026-08-12-mobile-layout-design.md +++ b/docs/plans/2026-08-12-mobile-layout-design.md @@ -51,7 +51,7 @@ ### 4.4 行为修正 -- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。 +- `state.ts` `loadState`:窄屏加载一律以 `panelOpen=false` 开始(包括持久化布局恢复);仅覆盖抽屉可见性,右侧/底部 pane、tab 与自由窗口状态原样恢复。宽屏仍按持久化值或新会话偏好决定展开状态。 - `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。 ### 4.5 样式(`sidebar.module.css`) @@ -61,7 +61,7 @@ ### 4.6 测试 - `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。 -- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。 +- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起;`tests/state.spec.ts` 守护窄屏恢复强制收起且 pane / free-window 状态不丢,768px 宽屏仍恢复持久化展开值。 - `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。 ## 5. 边界情况 diff --git a/src/client/state.ts b/src/client/state.ts index abdd510..172415c 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -1142,6 +1142,7 @@ function resetRequested(): boolean { function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { const reset = resetRequested() + const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined if (reset) { try { localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`) @@ -1164,7 +1165,15 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { nextIdCounter = maxCounterId(parsed) const sanitized = sanitizeState(parsed) if (sanitized !== undefined) { - return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth } + const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth } + // A narrow panel is a full-screen drawer, so every page/session load + // starts it closed even when the prior desktop/mobile state persisted + // `panelOpen: true`. Preserve the entire restored workbench (panes, + // tabs, bottom panel, and free windows); visibility is the sole + // viewport-derived override. + return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen + ? { ...restored, panelOpen: false } + : restored } } } catch { @@ -1179,10 +1188,8 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState { // disabled editor type seeds nothing. On a NARROW viewport a brand-new // session starts collapsed instead — the panel is a full-screen drawer // there, and auto-opening it on first paint would cover the conversation - // before the user asked. Only the first seeding is affected: once the - // user expands the drawer, `panelOpen: true` persists like any other - // state. - const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined + // before the user asked. Persisted layouts follow the same narrow-load + // visibility rule above, while their workbench contents remain intact. const width = globalWidth ?? (viewport === undefined ? PANEL_DEFAULT : defaultWidthFor(viewport, prefs.defaultWidthPercent)) diff --git a/tests/prefs.spec.ts b/tests/prefs.spec.ts index fe48adb..1415c01 100644 --- a/tests/prefs.spec.ts +++ b/tests/prefs.spec.ts @@ -306,8 +306,8 @@ describe('side card preferences', () => { } try { const store = createSidebarStore() - // The narrow viewport keeps a fresh session collapsed for the FIRST - // seeding only (a later user expansion persists). + // The narrow viewport keeps a fresh session collapsed. Persisted + // sessions follow the same load-time visibility rule in state.spec.ts. store.setSession('narrow-fresh') expect(store.getSnapshot().state?.panelOpen).toBe(false) // The width seeding still follows the window (clamped to the floor). diff --git a/tests/state.spec.ts b/tests/state.spec.ts index 6167938..55d659d 100644 --- a/tests/state.spec.ts +++ b/tests/state.spec.ts @@ -1328,6 +1328,105 @@ describe('pinned terminals (v0.17.0)', () => { }) }) +describe('narrow persisted-layout visibility (issue #162)', () => { + const persistedLayout = JSON.stringify({ + panelOpen: true, + width: 420, + nextTerminal: 2, + nextBrowser: 2, + activePane: 'pane:2', + expanded: ['/workspace/src'], + splits: { + kind: 'split', + id: 'split:1', + dir: 'row', + sizes: [0.5, 0.5], + children: [ + { + kind: 'leaf', + id: 'pane:1', + active: 'editor:readme', + tabs: [{ id: 'editor:readme', type: 'editor', title: 'README.md', path: '/workspace/README.md' }], + }, + { + kind: 'leaf', + id: 'pane:2', + active: 'terminal:1', + tabs: [{ id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }], + }, + ], + }, + bottomOpen: true, + bottomHeight: 300, + bottomOpenedOnce: true, + bottomSplits: { + kind: 'leaf', + id: 'pane:3', + active: 'browser:1', + tabs: [{ id: 'browser:1', type: 'browser', title: 'Docs', path: 'https://example.com' }], + }, + floats: [{ + id: 'float:4', + tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } }, + x: 16, + y: 24, + w: 320, + h: 240, + }], + }) + + afterEach(() => { + const g = globalThis as Record + delete g.window + delete g.localStorage + }) + + it.each([ + { width: 390, expectedOpen: false }, + { width: 768, expectedOpen: true }, + ])('restores panelOpen=$expectedOpen at $width px without changing pane or free-window state', ({ width, expectedOpen }) => { + const g = globalThis as Record + g.window = { + clearTimeout: () => {}, + setTimeout: () => 0, + innerWidth: width, + innerHeight: 800, + location: { search: '' }, + } + g.localStorage = { + getItem: (key: string) => key === 'dsh-sidebar:v1:restored' ? persistedLayout : null, + setItem: () => {}, + removeItem: () => {}, + } + + const store = createSidebarStore() + store.setSession('restored') + const state = store.getSnapshot().state! + + expect(state.panelOpen).toBe(expectedOpen) + expect(state.activePane).toBe('pane:2') + expect(allLeaves(state.splits).map(leaf => ({ + id: leaf.id, + active: leaf.active, + tabs: leaf.tabs.map(tab => tab.id), + }))).toEqual([ + { id: 'pane:1', active: 'editor:readme', tabs: ['editor:readme'] }, + { id: 'pane:2', active: 'terminal:1', tabs: ['terminal:1'] }, + ]) + expect(state.bottomOpen).toBe(true) + expect(allLeaves(state.bottomSplits).map(leaf => ({ id: leaf.id, tabs: leaf.tabs.map(tab => tab.id) }))) + .toEqual([{ id: 'pane:3', tabs: ['browser:1'] }]) + expect(state.floats).toEqual([{ + id: 'float:4', + tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } }, + x: 16, + y: 24, + w: 320, + h: 240, + }]) + }) +}) + describe('URL reset escape hatch (issue #369)', () => { // Same browser-global stubs as the v0.12.0 block above; loadState reads // window.location.search (reset param) and localStorage (persisted state).