Repository navigation
Foldable, resizable desktop sidebar - #279
Conversation
A toggle in the sidebar's header folds it to an icon-only rail and back. Folded, every entry keeps its name for assistive technology and shows it as a tooltip on hover and keyboard focus (with its count or status, if it has one); counts become a bubble on the icon, section headings a short rule, and the footer shows the avatar, settings, the project's monogram and a logout icon. Open, its right edge can be dragged between 240 and 400px, or moved with the arrow keys, Home and End as a focusable separator; a double click resets it to 286px, and dragging well past the minimum folds it. Width and folded state are kept in localStorage per browser. The mobile drawer is unchanged.
…on for the toggle The resize edge now stays on the folded rail: dragging it out past 160px opens the sidebar at the width under the pointer, and dragging an open one back past that point folds it. Both happen as the pointer crosses the threshold rather than on release, so the rail snaps open or shut under the mouse. Folded, the right arrow and End open it from the keyboard, Enter toggles, and a double click opens it at the default width. The toggle button shows a single double chevron that turns round as the sidebar folds and leans the way it would move on hover, instead of the two panel icons.
… on the edge Folded, the footer is a single avatar button instead of the card squeezed into the rail. Hovering it, or clicking or pressing it, slides the full footer card (profile, settings, project switcher, logout) out beside the rail; it closes when the pointer leaves, on Escape, on a click elsewhere and on navigating. The card stays mounted while hidden, so the project switcher's Cmd/Ctrl+K and modal keep working. The fold toggle moves out of the header onto the sidebar's edge at the logo's height, so it no longer jumps between two places. Its icon is now a drawn window with a rail that is wide while open and a sliver while folded, and leans towards the other state on hover.
A click on the logo in the desktop sidebar folds or unfolds it, and hovering it shows "Collapse sidebar" or "Expand sidebar" (beside the badge on the folded rail, below it while open). The edge button stays, as the keyboard's way to the same thing. A click within 600ms of the previous one only counts towards the five-click gravity egg, so the egg drops the badge instead of flapping the sidebar while it falls. The mobile header's logo is unchanged.
The round button on the sidebar's edge doubled the logo, which folds and unfolds the sidebar with its own tooltip, and read as a stray glitch beside it; it and its drawn panel icon go. From the keyboard, Enter on the resize edge still folds and unfolds. The tests fold through the logo instead of the button.
…se-resize # Conflicts: # src/components/layout/SideBar.tsx # src/features/projects/components/ProjectSwitcher.tsx # tests/unit/components/layout/SideBar.test.tsx
Hovering the avatar opened the card and the click that followed toggled it shut again, so the natural mouse gesture closed what it had just opened. The card now tells a hover-open (follows the pointer) from a click-open (stays until Escape, a click elsewhere or a second click), and a click on a hover-open card pins it. The test looks the Settings link up by prefix, since dev now puts its shortcut in the name.
Alt+S now toggles whichever sidebar is on screen: the drawer below lg, as before, and the fold to icons from lg up (#244), which the chord had been left free for. The drawer's state still never flips on a wide screen. The shortcuts list drops its "small screens" note, and the logo's tooltip shows the chord beside "Collapse/Expand sidebar".
DavidLeuter
left a comment
There was a problem hiding this comment.
Lovely piece of work — the fold/resize/drag-past-the-threshold model is coherent, the CSS-variable-during-drag trick is the right call, and the comments explain the why everywhere I looked. Verified on bf6c2338 in a clean worktree: tsc -b, eslint src tests, prettier --check src tests clean, and the full suite green (394 files / 3366 tests).
Two things to fix before this goes in, both in SidebarNavLink; the rest are notes.
1. The shortcut chip is still rendered in the folded rail
src/components/layout/SidebarNavLink.tsx
{shortcut && (
<ShortcutHint keys={shortcut} className="border-app-border text-app-text-muted" />
)}The label, the count and the attention dot all learned about collapsed; this one did not. ShortcutHint is sm:inline-block and only opacity-0, so in the rail it still takes layout width — and it is revealed by group-hover and group-focus-visible, i.e. at exactly the moment the new tooltip appears.
Measured with the real classes at a 76px rail (nav px-[16px], link px-[12px], so 20px of content box): the icon is shrink-0 and stays put, and the chip is laid out at x 86 → 108 inside a nav that clips at 99. It gets squeezed to 22px, wraps to three lines (Al / + / H), and is cut in half by the nav's overflow-x-hidden against the sidebar border. Four to six rows do this, on hover and on keyboard focus.
{shortcut && !collapsed && (
<ShortcutHint keys={shortcut} className="border-app-border text-app-text-muted" />
)}The chord is not lost: it stays in the link's own title ("Dashboard (Alt + H)") and in the shortcuts modal.
2. The count's new title is applied in both states
Same file:
<span
className={collapsed ? "absolute -top-2 -right-2.5 flex items-center" : "ml-auto flex items-center"}
title={countLabel?.(count)}
>The className is conditional, the title is not. In the open sidebar this puts a native tooltip on the number that shadows the link's own title whenever the pointer is over it — "3 open escalations" instead of "Escalation Inbox (Alt + …)". The comment above it says this is for the folded rail, so:
title={collapsed ? countLabel?.(count) : undefined}Notes, not blocking
data-sidebar-resizingcan be left behind.SidebarResizeHandlesets it onpointerdownand clears it only inendDrag. If the handle unmounts mid-drag (focus mode swapping the wrapper, a route change that tears the shell down, HMR), the attribute stays on<html>and.app-sidebar-easesis transition-less for the rest of the session. A cleanup effect (useEffect(() => () => setDragging(false), [])) or anonLostPointerCapturecovers it.- The logo's tooltip is only cleared on click.
handleLogoClicknullshintAtbecause the badge moves when it folds — but Alt+S folds it too. With the pointer resting on the badge, the hint re-renders with the new text at the old position (the badge goes frompx-[24px]to centred). Clearing it whencollapsedchanges would do it. - A pinned flyout survives focus mode sliding the sidebar away. The card is portalled to
document.body, outside the wrapperApptranslates by-100%, so folded + pinned + entering focus mode leaves the card floating atleft: rail + 10pxwith no sidebar behind it. Narrow, just noting it. - Only the mouse is told that Enter folds. Dropping the edge's fold button is the right call visually, but it leaves the logo as the sole pointer control, and it is (deliberately) unfocusable and unnamed. The separator's
aria-labelsays "Resize sidebar" and itstitlesays "Drag to resize, double-click to reset" — neither mentions the fold, while the mouse tooltip does. Alt+S in the shortcuts modal carries it, so this is discoverability rather than a gap; a few words in thetitlewould close it. - The egg guard has a band.
TOGGLE_REPEAT_GUARD_MSis 600ms,useRepeatClicks's window is5 × 1000ms. Five clicks about 700ms apart therefore both flap the sidebar five times and drop the badge. Tiny, and the fast case the comment describes works.
One thing I liked: writing the width straight into the CSS variable during the drag and only handing it back on release, with the state update reserved for the threshold crossing. That is the distinction most implementations of this get wrong.
Happy to approve as soon as 1 and 2 are in.
… loose ends - The shortcut chip is no longer rendered on the folded rail, where it took layout width and was revealed (squeezed and clipped) on hover and focus; the chord stays in the link's title. - The count's words are its title only on the folded rail; open, it no longer shadows the link's own title. - The resize handle clears its drag mark on unmount, so a drag cut short cannot leave the sidebar's easing off. - The logo's tooltip clears whenever the sidebar folds or unfolds, Alt+S included, not only on its own click. - The folded footer card closes when focus mode slides the sidebar away. - The resize edge's title says Enter folds or opens it.
|
Thanks for the thorough review! Addressed in add2060:
From the notes:
Layout, a11y and shortcuts tests: 178/178, |
DavidLeuter
left a comment
There was a problem hiding this comment.
All six addressed in add20608, and the two blocking ones exactly as suggested. Approving.
- Shortcut chip — gated on
!collapsed, with a test that checks the chip both before and after folding and confirms the chord survives in the link's owntitle. That is the assertion I would have written. - Count
title—collapsed ? countLabel?.(count) : undefined; the link's title is no longer shadowed in the open sidebar. data-sidebar-resizing— cleared on unmount.- Logo tooltip — the render-phase reset on
collapsedcovers all three ways the sidebar folds, not just the badge's own click. The explicitsetHintAt(null)inhandleLogoClickis still doing work on the egg's quick-repeat path, wherecollapseddoes not change, so it is right that it stayed. - Flyout in focus mode — keying the reset on
`${pathname}|${isFocused}`is neater than the separate piece of state I had in mind. - Enter on the edge — now in the
title.
Nothing new opened up: useFocusMode returns an inert context outside its provider, which the sidebar tests rely on since they render SideBar without FocusModeProvider, and the render-phase state adjustments follow the pattern CODING_STANDARDS.md §3 names.
Verified on add20608 in a clean worktree: tsc -b, eslint src tests and prettier --check src tests clean. The full suite came back 3366 passed / 1 failed, but the failure is tests/unit/features/chatbot/useChat.test.tsx:490 — a waitFor on a streamed SSE chunk, in a run that took 1346s against 591s for my earlier one because I had a lint job competing with it for CPU. On its own the file is 25/25 in 10s, the branch touches no chat code, and CI's npm run unit is green on this commit. Not yours.
One residual on the flyout, for a follow-up at most: what is fixed is entering and leaving focus mode. Already inside it, hovering the sidebar out, clicking the card to pin it and then moving away still leaves it floating for a moment — closeSoon() is deliberately a no-op while pinned, and Escape or a click elsewhere clears it. Focus mode plus folded plus pinned is narrow enough that I would leave it.
Nice feature. The drag model in particular — width straight into the CSS variable, state reserved for the threshold crossing — is the part most implementations of this get wrong.
What
The desktop sidebar can fold to an icon-only rail and be resized, and remembers both per browser.
localStorage, per browser.Refs #244, the desktop collapse the
SIDEBAR_TOGGLE_SHORTCUTcomment pointed to. Alt+S now toggles whichever sidebar is on screen: the drawer belowlg, the fold to icons fromlgup. The shortcuts list drops its "small screens" note. Trade-off: on a wide screen the app now takes Alt+S, which is Firefox's History menu on Windows.Notes
dev(keyboard shortcuts, feat(shortcuts): app-wide keyboard shortcuts and the help registry (#243) #276). The conflicts were inSideBar.tsx,ProjectSwitcher.tsxandSideBar.test.tsx. The Settings link keeps dev's shortcut label, andProjectSwitcheris dev's version (itscompactmode is no longer needed).src/components/layout/*,src/App.tsx,src/styles/index.css, theProjectSwitcher(via the merge) and tests.Testing
vitest run: full suite green, apart from one layout test that failed on the first run and was fixed in the last commit (the footer card closed on hover-then-click). The layout and a11y SideBar tests pass after the fix (68/68).tsc -b,eslint src tests,prettier --check src testsandvite buildall pass.