Skip to content

Commit 6ab6b72

Browse files
fix(app-shell): the narrow-window sidebar rail draws no expanded first frame, folds on one Esc, and keeps the wide-window choice apart (#28141)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgDFH4S5pR1C8YvYCWWsZfXx)_ ## 维护者速读 - 改了什么:窄窗(低于 1416 px)下控制台侧栏的三处返工。① 首帧就是 48 px 图标轨,不再先画 256 px 再收;② 浮出的侧栏里,鼠标停在条目上或焦点在条目/「保持侧栏展开」上,按一次 Esc 就收起;③ 宽窗的展开/收起选择单独保存,窄窗里开关「保持侧栏展开」不再改它,刷新后也一样。 - 为什么改:PR #27893 合并后的代理预览审查(评论 6521834471)在 app-crm 上实测到这三处:首帧闪、第一次 Esc 被隐藏的条目提示吃掉、宽窗选择被窄窗开关覆盖。 - 风险与回滚:只动 app-shell 控制台外壳(`consoleSidebarRail.ts`、`useResponsiveSidebar.ts`、`ConsoleLayout.tsx` 一行)和测试;阈值 1416 不变;`components/src/ui/**` 没碰,无新 i18n 键、无发布包新导出。宽窗选择沿用原来的 `os:console-sidebar:wide-open` 键,老数据语义一致。回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/layout/consoleSidebarRail.ts`(`primeConsoleSidebarOpen` 首帧、`escapeOnlyForHiddenTooltip` Esc 判定、宽窗选择的读写);`ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts`(只有宽窗的开合写宽窗选择)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/layout/__tests__/ConsoleLayout.sidebarFirstFrame-28077.test.tsx packages/app-shell/src/layout/__tests__/UnifiedSidebar.railOverlay-27593.test.tsx`,预期全绿;或 1280 px 打开 app-crm 联系人,点轨道空白处浮出,鼠标停在「Accounts」上按一次 Esc,侧栏收回。 - 需要你做的:无,席位落地。 Fixes #28077 Fixes #28078 Fixes #28079 Clause-②: no Metadata-protocol: none ## What changes - **#28077, the first frame is the rail.** `SidebarProvider` takes its first open state from the `sidebar_state` cookie the last page wrote, so a narrow window drew the expanded sidebar once and the fold then closed it, with the primitive's 200 ms width transition in between. `ConsoleLayout` now calls `primeConsoleSidebarOpen()` once (a `useState` initializer) before `AppShell`'s provider renders: below the fold width with no "keep open" it sets the cookie to `false`, on a wide window to the reader's wide-window choice; a sidebar kept open and the phone keep what the cookie says. The viewport hook was already synchronous (`useIsViewportBelow` reads `innerWidth` in its initializer), so the flash came from the provider's cookie, not from the hook. `useConsoleSidebarRail` also reads the phone breakpoint synchronously, so a phone-width first render is never taken for a narrow one while `useIsMobile` still answers `false`. - **#28078, one Esc folds the overlay.** While the sidebar is expanded the primitive keeps an entry's Radix tooltip open but `hidden` (hover or focus opens it); its dismissable layer takes the next Esc on the document's capture phase and calls `preventDefault`, so the overlay's "a layer handled it" check skipped the fold. The overlay now also listens on the window's capture phase, ahead of every layer: when an open tooltip is hidden and no dialog, alert dialog, menu or listbox is shown, that Esc folds the overlay even though it arrives marked handled. An entry's open menu still takes the Esc first (pinned). Fixed from the sidebar side; `components/src/ui/**` untouched. - **#28079, the wide-window choice is kept apart.** `os:console-sidebar:wide-open` was a stash written when the rail took over and cleared when keep-open turned on, so keep-open on and then off re-stashed the narrow overlay's state. It is now the reader's wide-window choice: written on every open and close while the window is at or above the fold width, and only then; handed back when the window turns wide and on a wide load. The fold, the overlay and a sidebar kept open on a narrow window never write it; `console.sidebar.keepOpen` stays under its own key. When no wide-window choice is kept yet (first visit on a narrow window), the open state the fold first closes seeds it. ## Tests - New `layout/__tests__/ConsoleLayout.sidebarFirstFrame-28077.test.tsx` (5): the real `ConsoleLayout` / `AppShell` / `SidebarProvider`, a probe in the sidebar's place recording the state of every render. At 1280 px a first visit, a load after an expanded wide window, and a reload with the cookie expanded: every render, the first included, is `collapsed`. 1440: every render `expanded`. Keep-open at 1280 keeps the cookie's state. - `layout/__tests__/UnifiedSidebar.railOverlay-27593.test.tsx` (+6): one Esc folds with focus on an entry (its hidden tooltip open), with the pointer on an entry, with focus on Keep sidebar open; an entry's open menu still takes the Esc; the #28079 sequence in both directions (collapsed and expanded wide-window choice), widened and then reloaded. - `hooks/__tests__/useResponsiveSidebar.test.tsx`: follows the kept wide-window choice (it is no longer cleared when the window widens). - `chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx`: its probe already cleared the sidebar cookie between mounts; it now also clears the wide-window key, since the ⌘ run's toggle is kept as the wide-window choice the Ctrl run would otherwise start from. - Ablations (each mutation through `scripts/ablation-replace.mjs`, landed and restored by blob, `git diff HEAD` empty after each): drop the `primeConsoleSidebarOpen` call: the three 1280 first-frame tests red, 1440 and keep-open green. Answer `false` for the hidden-tooltip check: the three one-Esc tests red. Let the narrow keep-open sidebar write the wide-window choice: the expanded-direction #28079 test red. Let the fold write (not seed) the wide-window choice: both #28079 tests red. ## Acceptance notes - **Scope:** one file outside the five named: `chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx`, a test that mounts the real `ConsoleLayout`; the change is its between-mounts cleanup, one line. - The hidden-tooltip check reads the DOM (a `role=tooltip` inside a `hidden` element; a shown `dialog` / `alertdialog` / `menu` / `listbox`). A layer drawn with none of those roles that marks Esc handled while a hidden tooltip is open would see the overlay fold too; none is drawn in the console shell today. - The threshold (1416 px, `CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH`) is unchanged, as ruled. ## Verification Head `55d0fb34`. - `pnpm --dir ui exec vitest run` of the 103 app-shell test files that reference `ConsoleLayout`, `UnifiedSidebar`, `useResponsiveSidebar`, `consoleSidebarRail`, `SidebarProvider` or `sidebar_state`: Test Files 103 passed, Tests 1009 passed. - `pnpm --filter @object-ui/app-shell lint`: 0 errors. - Declared to CI: the full `@object-ui/app-shell` test (1967 files; stopped locally after an hour on the shared box) and `type-check` (needs every `@object-ui/*` package built). - Real browser (Chromium, `examples/app-crm` with `objectstack dev --seed-admin --fresh`, the console on its Vite dev server, Contacts): at 1280 px every sampled animation frame from the first one is the 48 px rail on a first visit, a load after an expanded 1440 px window, and a reload with the overlay shown; at 1440 px every frame is 256 px. In the overlay one Esc folds it with the pointer on Accounts (its tooltip open and hidden), with focus on the Accounts link, and with focus on Keep sidebar open. Collapse at 1440, 1280, keep open on and off, 1440: collapsed, and after a reload; the expanded direction comes back expanded, and after a reload. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c683cf commit 6ab6b72

8 files changed

Lines changed: 397 additions & 49 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(console): the narrow-window sidebar rail draws no expanded first frame, folds on one Esc, and keeps the wide-window choice apart
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
Three fixes to the console sidebar's icon rail below 1416 px:
11+
12+
- **No expanded first frame.** A load on a narrow window drew the expanded sidebar once and then folded it, because the sidebar started from the state the last page saved. The console now hands the sidebar the rail before it first draws, so every frame from the first one is the rail. At 1416 px and above nothing changes.
13+
- **One Esc folds the overlay.** With the pointer on an entry, or focus on an entry or on "Keep sidebar open", the first Esc went to the entry's hidden tooltip and the overlay stayed open until a second Esc. One Esc now folds it; an entry's open menu still takes the Esc first.
14+
- **The wide-window choice is kept apart.** Turning "Keep sidebar open" on and off on a narrow window no longer changes the expanded or collapsed sidebar the reader chose on a wide window, across a resize and a reload. Only an open or close on a wide window is kept as that choice.

‎ui/packages/app-shell/src/chrome/KeyboardShortcutsDialog.wiredOnly-11674.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,11 @@ function chordEvents(chord: ShortcutChord): KeyboardEventInit[] {
146146

147147
const probe = () => screen.getByTestId('probe');
148148
// `SidebarProvider` persists its state in this cookie and reads it on mount,
149-
// so one toggle would decide the next mount's starting state.
149+
// and the console keeps the wide-window choice under its own key
150+
// (objectstack#28079), so one toggle would decide the next mount's starting state.
150151
const clearSidebarCookie = () => {
151152
document.cookie = 'sidebar_state=; path=/; max-age=0';
153+
localStorage.removeItem('os:console-sidebar:wide-open');
152154
};
153155
const dialogShown = () => screen.queryByTestId('overlay:keyboard-shortcuts') !== null;
154156

‎ui/packages/app-shell/src/hooks/__tests__/useResponsiveSidebar.test.tsx‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
* narrower than `CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH` and unfolds when it widens
66
* (objectstack#27593). The wide window's open state survives the fold, a
77
* reader's "keep open" turns the fold off, and the phone drawer is left alone.
8+
* Only an open or close on a wide window is kept as the wide-window choice
9+
* (objectstack#28079).
810
*/
911

1012
import '@testing-library/jest-dom/vitest';
@@ -14,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
1416
import { useResponsiveSidebar } from '../useResponsiveSidebar';
1517
import {
1618
CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE,
17-
readStashedWideOpen,
19+
readWideWindowOpen,
1820
} from '../../layout/consoleSidebarRail';
1921
import { writeWorkspacePreference } from '../../utils/workspacePreference';
2022

@@ -100,11 +102,11 @@ describe('useResponsiveSidebar (objectstack#27593)', () => {
100102

101103
resizeTo(1280);
102104
expect(openState()).toBe('false');
103-
expect(readStashedWideOpen()).toBe(true);
105+
expect(readWideWindowOpen()).toBe(true);
104106

105107
resizeTo(1440);
106108
expect(openState()).toBe('true');
107-
expect(readStashedWideOpen()).toBeUndefined();
109+
expect(readWideWindowOpen()).toBe(true);
108110
});
109111

110112
it('hands back a collapsed wide state as collapsed', () => {
@@ -127,22 +129,22 @@ describe('useResponsiveSidebar (objectstack#27593)', () => {
127129
expect(openState()).toBe('false');
128130
cleanup();
129131

130-
// The primitive's cookie recorded the fold; the stash carries the wide state.
132+
// The primitive's cookie recorded the fold; the wide-window choice is kept apart.
131133
renderAt(1440);
132134
expect(openState()).toBe('true');
133-
expect(readStashedWideOpen()).toBeUndefined();
135+
expect(readWideWindowOpen()).toBe(true);
134136
});
135137

136138
it('"keep open" turns the fold off: a narrow window keeps the sidebar open', () => {
137139
writeWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user', 'on');
138140
renderAt(1280);
139141
expect(openState()).toBe('true');
140-
expect(readStashedWideOpen()).toBeUndefined();
142+
expect(readWideWindowOpen()).toBeUndefined();
141143
});
142144

143145
it('leaves the open state alone below the phone breakpoint, where the drawer takes over', () => {
144146
renderAt(700);
145147
expect(openState()).toBe('true');
146-
expect(readStashedWideOpen()).toBeUndefined();
148+
expect(readWideWindowOpen()).toBeUndefined();
147149
});
148150
});

‎ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts‎

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,53 @@
77
* (objectstack#27593). A reader who keeps the sidebar open is left alone.
88
* Must be called inside a SidebarProvider context.
99
*
10-
* The fold is not the reader's choice, so it does not keep one: the open
11-
* state the sidebar had on the wide window is stashed when the rail takes
12-
* over and handed back when the window widens, or on the next load that
13-
* opens wide, because the primitive's cookie records every open and close,
14-
* the fold's included.
10+
* The fold is not the reader's choice, so it does not keep one: every open
11+
* and close on a wide window is kept as the wide-window choice, and only
12+
* those (objectstack#28079). A window that turns wide, or a load that opens
13+
* wide, gets that choice back, whatever the fold, the overlay or a sidebar
14+
* kept open on a narrow window did to the primitive's cookie meanwhile.
1515
* @module
1616
*/
1717

1818
import { useLayoutEffect, useRef } from 'react';
1919
import { useSidebar } from '@object-ui/components';
2020
import {
21-
readStashedWideOpen,
21+
readWideWindowOpen,
22+
seedWideWindowOpen,
2223
useConsoleSidebarRail,
23-
writeStashedWideOpen,
24+
writeWideWindowOpen,
2425
} from '../layout/consoleSidebarRail.js';
2526

2627
export function useResponsiveSidebar() {
2728
const { open, setOpen } = useSidebar();
28-
const { narrow, keepOpen, railMode } = useConsoleSidebarRail();
29-
const openRef = useRef(open);
29+
const { wide, railMode } = useConsoleSidebarRail();
3030
const setOpenRef = useRef(setOpen);
31+
const last = useRef<{ wide: boolean; railMode: boolean } | null>(null);
3132

3233
useLayoutEffect(() => {
33-
openRef.current = open;
3434
setOpenRef.current = setOpen;
3535
});
3636

3737
// A layout effect, so a window that opens narrow paints the rail first.
3838
useLayoutEffect(() => {
39-
if (railMode) {
40-
// A stash left by an earlier narrow load is the wide state; keep it.
41-
if (readStashedWideOpen() === undefined) writeStashedWideOpen(openRef.current);
42-
setOpenRef.current(false);
39+
const previous = last.current;
40+
last.current = { wide, railMode };
41+
if (wide) {
42+
// A load that opens wide, or a window that turns wide: the reader's wide-window choice.
43+
if (!previous?.wide) {
44+
const wideOpen = readWideWindowOpen();
45+
if (wideOpen !== undefined && wideOpen !== open) {
46+
setOpenRef.current(wideOpen);
47+
return;
48+
}
49+
}
50+
writeWideWindowOpen(open);
4351
return;
4452
}
45-
const wideOpen = readStashedWideOpen();
46-
if (wideOpen === undefined) return;
47-
writeStashedWideOpen(undefined);
48-
// "Keep open" on a narrow window: the reader just chose the open sidebar.
49-
if (narrow && keepOpen) return;
50-
setOpenRef.current(wideOpen);
51-
}, [railMode, narrow, keepOpen]);
53+
// The fold takes over: a narrow load, a window that narrows, "keep open" turned off.
54+
if (railMode && !previous?.railMode) {
55+
seedWideWindowOpen(open);
56+
setOpenRef.current(false);
57+
}
58+
}, [wide, railMode, open]);
5259
}

‎ui/packages/app-shell/src/layout/ConsoleLayout.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* @module
99
*/
1010

11-
import React, { useCallback, useEffect, useRef } from 'react';
11+
import React, { useCallback, useEffect, useRef, useState } from 'react';
1212
import { useLocation, useNavigate } from 'react-router-dom';
1313
import { AppShell } from '@object-ui/layout';
1414
import { useIsMobile } from '@object-ui/components';
@@ -31,6 +31,7 @@ import { UnifiedSidebar } from './UnifiedSidebar.js';
3131
import { AppHeader } from './AppHeader.js';
3232
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext.js';
3333
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js';
34+
import { primeConsoleSidebarOpen } from './consoleSidebarRail.js';
3435
import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js';
3536
import { useNavigationContext } from '../context/NavigationContext.js';
3637
import { CommandPaletteProvider } from '../context/CommandPaletteProvider.js';
@@ -135,6 +136,9 @@ export function ConsoleLayout({
135136
userId,
136137
}: ConsoleLayoutProps) {
137138
const appLabel = resolveKeyedI18nLabel(activeApp?.label) || activeAppName;
139+
// objectstack#28077 — once, before `AppShell`'s sidebar provider reads its
140+
// cookie: a narrow window's first frame is already the rail.
141+
useState(primeConsoleSidebarOpen);
138142
// Runtime, server-pushed AI gating (shared with the `/ai` route guard and the
139143
// top-bar AI link): show the chatbot only when the server actually serves AI,
140144
// or when an explicit `VITE_AI_BASE_URL` opt-in points at an external server.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* ConsoleLayout — on a window below the fold width the console sidebar's
5+
* first frame is already the icon rail (objectstack#28077).
6+
*
7+
* The sidebar provider takes its first open state from the `sidebar_state`
8+
* cookie the last page wrote. Before, a narrow window drew that state (the
9+
* expanded sidebar) once and then folded it, with the primitive's width
10+
* transition in between. The probe stands in for the sidebar and records the
11+
* state of every render, the first one included.
12+
*
13+
* Three ways in, all at 1280 px: a first visit (no cookie), a load after the
14+
* sidebar was expanded on a wide window, and a reload while the overlay was
15+
* shown (both leave the cookie expanded). At 1440 px nothing changes.
16+
*/
17+
18+
import '@testing-library/jest-dom/vitest';
19+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
20+
import React from 'react';
21+
import { cleanup, render } from '@testing-library/react';
22+
import { MemoryRouter } from 'react-router-dom';
23+
import { createI18n, I18nProvider } from '@object-ui/i18n';
24+
import { useSidebar } from '@object-ui/components';
25+
26+
const renders: string[] = [];
27+
28+
vi.mock('../../hooks/useAiSurface', () => ({
29+
useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
30+
}));
31+
vi.mock('../UnifiedSidebar', () => ({
32+
UnifiedSidebar: function SidebarProbe() {
33+
renders.push(useSidebar().state);
34+
return null;
35+
},
36+
}));
37+
vi.mock('../AppHeader', () => ({ AppHeader: () => null }));
38+
vi.mock('../ConsoleNotificationBanners', () => ({ ConsoleNotificationBanners: () => null }));
39+
vi.mock('../../preview/DraftPreviewBar', () => ({ DraftPreviewBar: () => null }));
40+
vi.mock('../../preview/UnpublishedAppBar', () => ({ UnpublishedAppBar: () => null }));
41+
42+
import { ConsoleLayout } from '../ConsoleLayout';
43+
import { NavigationProvider } from '../../context/NavigationContext';
44+
import { CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE } from '../consoleSidebarRail';
45+
import { writeWorkspacePreference } from '../../utils/workspacePreference';
46+
47+
const APP = { name: 'crm', label: 'CRM', active: true, navigation: [] };
48+
49+
function setViewportWidth(width: number) {
50+
(window as unknown as { happyDOM: { setViewport: (v: { width: number; height: number }) => void } }).happyDOM.setViewport({
51+
width,
52+
height: 900,
53+
});
54+
}
55+
56+
function load(width: number) {
57+
setViewportWidth(width);
58+
renders.length = 0;
59+
return render(
60+
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
61+
<MemoryRouter initialEntries={['/apps/crm/contact']}>
62+
<NavigationProvider>
63+
<ConsoleLayout activeAppName="crm" activeApp={APP} onAppChange={vi.fn()} objects={[]}>
64+
<div />
65+
</ConsoleLayout>
66+
</NavigationProvider>
67+
</MemoryRouter>
68+
</I18nProvider>,
69+
);
70+
}
71+
72+
const clearCookie = () => {
73+
document.cookie = 'sidebar_state=; path=/; max-age=0';
74+
};
75+
76+
beforeEach(() => {
77+
localStorage.clear();
78+
clearCookie();
79+
});
80+
81+
afterEach(() => {
82+
cleanup();
83+
setViewportWidth(1024);
84+
localStorage.clear();
85+
clearCookie();
86+
});
87+
88+
describe('the narrow window’s first frame is the rail (objectstack#28077)', () => {
89+
it('a first visit at 1280 px: every render, the first included, is the rail', () => {
90+
load(1280);
91+
expect(renders[0]).toBe('collapsed');
92+
expect(new Set(renders)).toEqual(new Set(['collapsed']));
93+
});
94+
95+
it('a 1280 px load after the sidebar was expanded on a wide window', () => {
96+
load(1440);
97+
expect(renders.at(-1)).toBe('expanded');
98+
cleanup();
99+
100+
load(1280);
101+
expect(renders[0]).toBe('collapsed');
102+
expect(new Set(renders)).toEqual(new Set(['collapsed']));
103+
});
104+
105+
it('a 1280 px reload while the overlay was shown (the cookie says expanded)', () => {
106+
document.cookie = 'sidebar_state=true; path=/';
107+
load(1280);
108+
expect(renders[0]).toBe('collapsed');
109+
expect(new Set(renders)).toEqual(new Set(['collapsed']));
110+
});
111+
112+
it('at 1440 px nothing changes: expanded from the first render', () => {
113+
load(1440);
114+
expect(new Set(renders)).toEqual(new Set(['expanded']));
115+
});
116+
117+
it('a sidebar kept open on a narrow window keeps the state it had', () => {
118+
writeWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user', 'on');
119+
document.cookie = 'sidebar_state=true; path=/';
120+
load(1280);
121+
expect(new Set(renders)).toEqual(new Set(['expanded']));
122+
});
123+
});

0 commit comments

Comments
 (0)