Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/28077-console-sidebar-rail-rework.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
---

fix(console): the narrow-window sidebar rail draws no expanded first frame, folds on one Esc, and keeps the wide-window choice apart

Clause-②: no
Metadata-protocol: none

Three fixes to the console sidebar's icon rail below 1416 px:

- **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.
- **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.
- **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.
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,11 @@ function chordEvents(chord: ShortcutChord): KeyboardEventInit[] {

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* narrower than `CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH` and unfolds when it widens
* (objectstack#27593). The wide window's open state survives the fold, a
* reader's "keep open" turns the fold off, and the phone drawer is left alone.
* Only an open or close on a wide window is kept as the wide-window choice
* (objectstack#28079).
*/

import '@testing-library/jest-dom/vitest';
Expand All @@ -14,7 +16,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useResponsiveSidebar } from '../useResponsiveSidebar';
import {
CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE,
readStashedWideOpen,
readWideWindowOpen,
} from '../../layout/consoleSidebarRail';
import { writeWorkspacePreference } from '../../utils/workspacePreference';

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

resizeTo(1280);
expect(openState()).toBe('false');
expect(readStashedWideOpen()).toBe(true);
expect(readWideWindowOpen()).toBe(true);

resizeTo(1440);
expect(openState()).toBe('true');
expect(readStashedWideOpen()).toBeUndefined();
expect(readWideWindowOpen()).toBe(true);
});

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

// The primitive's cookie recorded the fold; the stash carries the wide state.
// The primitive's cookie recorded the fold; the wide-window choice is kept apart.
renderAt(1440);
expect(openState()).toBe('true');
expect(readStashedWideOpen()).toBeUndefined();
expect(readWideWindowOpen()).toBe(true);
});

it('"keep open" turns the fold off: a narrow window keeps the sidebar open', () => {
writeWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user', 'on');
renderAt(1280);
expect(openState()).toBe('true');
expect(readStashedWideOpen()).toBeUndefined();
expect(readWideWindowOpen()).toBeUndefined();
});

it('leaves the open state alone below the phone breakpoint, where the drawer takes over', () => {
renderAt(700);
expect(openState()).toBe('true');
expect(readStashedWideOpen()).toBeUndefined();
expect(readWideWindowOpen()).toBeUndefined();
});
});
49 changes: 28 additions & 21 deletions ui/packages/app-shell/src/hooks/useResponsiveSidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,46 +7,53 @@
* (objectstack#27593). A reader who keeps the sidebar open is left alone.
* Must be called inside a SidebarProvider context.
*
* The fold is not the reader's choice, so it does not keep one: the open
* state the sidebar had on the wide window is stashed when the rail takes
* over and handed back when the window widens, or on the next load that
* opens wide, because the primitive's cookie records every open and close,
* the fold's included.
* The fold is not the reader's choice, so it does not keep one: every open
* and close on a wide window is kept as the wide-window choice, and only
* those (objectstack#28079). A window that turns wide, or a load that opens
* wide, gets that choice back, whatever the fold, the overlay or a sidebar
* kept open on a narrow window did to the primitive's cookie meanwhile.
* @module
*/

import { useLayoutEffect, useRef } from 'react';
import { useSidebar } from '@object-ui/components';
import {
readStashedWideOpen,
readWideWindowOpen,
seedWideWindowOpen,
useConsoleSidebarRail,
writeStashedWideOpen,
writeWideWindowOpen,
} from '../layout/consoleSidebarRail.js';

export function useResponsiveSidebar() {
const { open, setOpen } = useSidebar();
const { narrow, keepOpen, railMode } = useConsoleSidebarRail();
const openRef = useRef(open);
const { wide, railMode } = useConsoleSidebarRail();
const setOpenRef = useRef(setOpen);
const last = useRef<{ wide: boolean; railMode: boolean } | null>(null);

useLayoutEffect(() => {
openRef.current = open;
setOpenRef.current = setOpen;
});

// A layout effect, so a window that opens narrow paints the rail first.
useLayoutEffect(() => {
if (railMode) {
// A stash left by an earlier narrow load is the wide state; keep it.
if (readStashedWideOpen() === undefined) writeStashedWideOpen(openRef.current);
setOpenRef.current(false);
const previous = last.current;
last.current = { wide, railMode };
if (wide) {
// A load that opens wide, or a window that turns wide: the reader's wide-window choice.
if (!previous?.wide) {
const wideOpen = readWideWindowOpen();
if (wideOpen !== undefined && wideOpen !== open) {
setOpenRef.current(wideOpen);
return;
}
}
writeWideWindowOpen(open);
return;
}
const wideOpen = readStashedWideOpen();
if (wideOpen === undefined) return;
writeStashedWideOpen(undefined);
// "Keep open" on a narrow window: the reader just chose the open sidebar.
if (narrow && keepOpen) return;
setOpenRef.current(wideOpen);
}, [railMode, narrow, keepOpen]);
// The fold takes over: a narrow load, a window that narrows, "keep open" turned off.
if (railMode && !previous?.railMode) {
seedWideWindowOpen(open);
setOpenRef.current(false);
}
}, [wide, railMode, open]);
}
6 changes: 5 additions & 1 deletion ui/packages/app-shell/src/layout/ConsoleLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* @module
*/

import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AppShell } from '@object-ui/layout';
import { useIsMobile } from '@object-ui/components';
Expand All @@ -31,6 +31,7 @@ import { UnifiedSidebar } from './UnifiedSidebar.js';
import { AppHeader } from './AppHeader.js';
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext.js';
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js';
import { primeConsoleSidebarOpen } from './consoleSidebarRail.js';
import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js';
import { useNavigationContext } from '../context/NavigationContext.js';
import { CommandPaletteProvider } from '../context/CommandPaletteProvider.js';
Expand Down Expand Up @@ -135,6 +136,9 @@ export function ConsoleLayout({
userId,
}: ConsoleLayoutProps) {
const appLabel = resolveKeyedI18nLabel(activeApp?.label) || activeAppName;
// objectstack#28077 — once, before `AppShell`'s sidebar provider reads its
// cookie: a narrow window's first frame is already the rail.
useState(primeConsoleSidebarOpen);
// Runtime, server-pushed AI gating (shared with the `/ai` route guard and the
// top-bar AI link): show the chatbot only when the server actually serves AI,
// or when an explicit `VITE_AI_BASE_URL` opt-in points at an external server.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* ConsoleLayout — on a window below the fold width the console sidebar's
* first frame is already the icon rail (objectstack#28077).
*
* The sidebar provider takes its first open state from the `sidebar_state`
* cookie the last page wrote. Before, a narrow window drew that state (the
* expanded sidebar) once and then folded it, with the primitive's width
* transition in between. The probe stands in for the sidebar and records the
* state of every render, the first one included.
*
* Three ways in, all at 1280 px: a first visit (no cookie), a load after the
* sidebar was expanded on a wide window, and a reload while the overlay was
* shown (both leave the cookie expanded). At 1440 px nothing changes.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { cleanup, render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { createI18n, I18nProvider } from '@object-ui/i18n';
import { useSidebar } from '@object-ui/components';

const renders: string[] = [];

vi.mock('../../hooks/useAiSurface', () => ({
useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }),
}));
vi.mock('../UnifiedSidebar', () => ({
UnifiedSidebar: function SidebarProbe() {
renders.push(useSidebar().state);
return null;
},
}));
vi.mock('../AppHeader', () => ({ AppHeader: () => null }));
vi.mock('../ConsoleNotificationBanners', () => ({ ConsoleNotificationBanners: () => null }));
vi.mock('../../preview/DraftPreviewBar', () => ({ DraftPreviewBar: () => null }));
vi.mock('../../preview/UnpublishedAppBar', () => ({ UnpublishedAppBar: () => null }));

import { ConsoleLayout } from '../ConsoleLayout';
import { NavigationProvider } from '../../context/NavigationContext';
import { CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE } from '../consoleSidebarRail';
import { writeWorkspacePreference } from '../../utils/workspacePreference';

const APP = { name: 'crm', label: 'CRM', active: true, navigation: [] };

function setViewportWidth(width: number) {
(window as unknown as { happyDOM: { setViewport: (v: { width: number; height: number }) => void } }).happyDOM.setViewport({
width,
height: 900,
});
}

function load(width: number) {
setViewportWidth(width);
renders.length = 0;
return render(
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
<MemoryRouter initialEntries={['/apps/crm/contact']}>
<NavigationProvider>
<ConsoleLayout activeAppName="crm" activeApp={APP} onAppChange={vi.fn()} objects={[]}>
<div />
</ConsoleLayout>
</NavigationProvider>
</MemoryRouter>
</I18nProvider>,
);
}

const clearCookie = () => {
document.cookie = 'sidebar_state=; path=/; max-age=0';
};

beforeEach(() => {
localStorage.clear();
clearCookie();
});

afterEach(() => {
cleanup();
setViewportWidth(1024);
localStorage.clear();
clearCookie();
});

describe('the narrow window’s first frame is the rail (objectstack#28077)', () => {
it('a first visit at 1280 px: every render, the first included, is the rail', () => {
load(1280);
expect(renders[0]).toBe('collapsed');
expect(new Set(renders)).toEqual(new Set(['collapsed']));
});

it('a 1280 px load after the sidebar was expanded on a wide window', () => {
load(1440);
expect(renders.at(-1)).toBe('expanded');
cleanup();

load(1280);
expect(renders[0]).toBe('collapsed');
expect(new Set(renders)).toEqual(new Set(['collapsed']));
});

it('a 1280 px reload while the overlay was shown (the cookie says expanded)', () => {
document.cookie = 'sidebar_state=true; path=/';
load(1280);
expect(renders[0]).toBe('collapsed');
expect(new Set(renders)).toEqual(new Set(['collapsed']));
});

it('at 1440 px nothing changes: expanded from the first render', () => {
load(1440);
expect(new Set(renders)).toEqual(new Set(['expanded']));
});

it('a sidebar kept open on a narrow window keeps the state it had', () => {
writeWorkspacePreference(CONSOLE_SIDEBAR_KEEP_OPEN_PREFERENCE, 'user', 'on');
document.cookie = 'sidebar_state=true; path=/';
load(1280);
expect(new Set(renders)).toEqual(new Set(['expanded']));
});
});
Loading
Loading