feat: narrow-window behavior — sidebar auto-close, chrome wrap, 640px floor

The sidebar no longer squeezes the page when the window narrows; the
page itself never goes below a comfortable floor (owner decisions,
2026-09-17: 640px min, widen-window on toggle, auto-restore):

- minWidth 640 / minHeight 600 on the BrowserWindow (macOS enforces)
- Below SIDEBAR_AUTOCLOSE (928 = 640 + 288 sidebar) the sidebar
  auto-closes, and auto-RESTORES when the window widens past it again —
  both fall out of one expression: rendered = pref && !narrow, where
  pref is the persisted user choice. Manual closes persist and stay
  closed; auto-close never touches the preference.
- Opening the sidebar while narrow asks the shell to widen the window
  to 928 first (new lh:expand-window IPC, preload.cjs contextBridge —
  the renderer's first and only Electron bridge): the toggle is never a
  dead button, and the layout never has to survive narrow + sidebar.
  In the plain browser there is no suppression and no widening — a
  narrow tab opens the sidebar if the user says so.
- Graph chrome row wraps: max-w-[calc(100%-9rem)] + flex-wrap keeps the
  row clear of the header toggle cluster and stacks it onto extra lines
  below ~640px instead of overflowing left.

State machine verified in the Electron dev shell across all six
transitions (auto-close, auto-restore, manual-close-persists,
reopen-widens, browser fallback, wrap at 640/500/360).
This commit is contained in:
amosblomqvist
2026-09-17 18:57:02 +03:00
parent a20e1f1fb5
commit d46c3dae93
4 changed files with 105 additions and 6 deletions
+6 -2
View File
@@ -973,8 +973,12 @@ export function GraphView() {
lh-no-drag (2026-09-17): home.tsx's floating drag strip
underlays this row — Electron's drag mask ignores z-index,
so without an explicit carve-out the controls were
unclickable in the desktop app (owner report). */}
<div className="lh-no-drag absolute right-3 top-3 z-10 flex items-start gap-2">
unclickable in the desktop app (owner report). max-w + flex-wrap
(narrow-window wave, 2026-09-17): below the sidebar auto-close
threshold the row wraps to a second line instead of overflowing
left over the header toggle cluster (92px gutter + 36px button);
9rem keeps one clear column of air alongside it. */}
<div className="lh-no-drag absolute right-3 top-3 z-10 flex max-w-[calc(100%-9rem)] flex-wrap items-start gap-2">
{!isEmpty && (
<>
{/* Search — its own container (icon + input + no-match note). */}
+55 -3
View File
@@ -21,6 +21,34 @@ import type { ChatListItem } from "@/src/core";
const STORAGE_KEY = "lh:sidebar-open";
/*
* Narrow-window behavior (owner, 2026-09-17): below SIDEBAR_AUTOCLOSE the
* sidebar would squeeze the page below the 640px window floor (the two
* numbers compose: 640 + 288 sidebar = 928), so it auto-closes — and
* auto-RESTORES when the window widens again (owner choice: the sidebar
* pops back, not a manual re-open). The whole behavior is one expression:
* rendered = pref && !narrow, where pref is the persisted user choice.
* Manual closes persist and stay closed; auto-close does not touch the
* preference. Opening the sidebar while narrow asks the Electron shell to
* widen the window to the threshold (window.lh.expandWindow, IPC in
* electron/main.cjs) — the resize listener flips `narrow` and the sidebar
* appears. In the plain browser there is no window to widen and no
* suppression — a narrow tab opens the sidebar if the user says so.
*
* Mirrors SIDEBAR_AUTOCLOSE in electron/main.cjs — keep in sync.
*/
const SIDEBAR_AUTOCLOSE = 928;
// Module scope, guarded for SSR (the server has no navigator).
const IS_ELECTRON = typeof navigator !== "undefined" && navigator.userAgent.includes("Electron");
declare global {
interface Window {
/** Preload bridge (electron/preload.cjs) — Electron only, absent in the browser. */
lh?: { expandWindow: () => void };
}
}
const SidebarContext = createContext<{ open: boolean; toggle: () => void }>({
open: false,
toggle: () => {},
@@ -34,6 +62,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
// Start closed so server and client first-render agree (no hydration
// mismatch); the stored value is applied after mount.
const [open, setOpen] = useState(false);
const [narrow, setNarrow] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
@@ -42,7 +71,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
// into the window's drag surface. Post-mount only — server HTML
// and first client render must agree (no hydration mismatch),
// and in the plain browser the class never appears.
if (typeof navigator !== "undefined" && navigator.userAgent.includes("Electron")) {
if (IS_ELECTRON) {
document.documentElement.classList.add("lh-electron");
}
@@ -52,15 +81,38 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
setMounted(true);
}, []);
// Narrow-window auto-close/auto-restore (see SIDEBAR_AUTOCLOSE note):
// fires only on actual resizes — a boolean compare, no layout work.
// Both this and the mount effect land before the first paint, so a
// narrow window never flashes the open sidebar on load.
useEffect(() => {
if (!IS_ELECTRON) return; // plain-browser tabs open freely
const onResize = () => setNarrow(window.innerWidth < SIDEBAR_AUTOCLOSE);
onResize();
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
const toggle = useCallback(() => {
// Opening while narrow (auto-closed, or the user's own closed-at-
// narrow): ask the shell to widen to the threshold — the resize
// listener flips `narrow` and the sidebar appears. The preference
// persists like any other open, so a restart at a narrow width
// still auto-restores when widened.
if (narrow && !open) {
localStorage.setItem(STORAGE_KEY, "0");
setOpen(true);
window.lh?.expandWindow();
return;
}
setOpen((v) => {
localStorage.setItem(STORAGE_KEY, v ? "0" : "1");
return !v;
});
}, []);
}, [narrow, open]);
return (
<SidebarContext.Provider value={{ open, toggle }}>
<SidebarContext.Provider value={{ open: open && !narrow, toggle }}>
{/* transitions gated on mount: no slide-in flash on page load */}
<div className={cn("flex h-dvh", mounted && "[&_.sidebar-motion]:transition-[width,margin,opacity]")}>{children}</div>
</SidebarContext.Provider>