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>
+27 -1
View File
@@ -21,13 +21,17 @@
* settings milestone builds the UI.
*/
const { app, BrowserWindow, Menu, session, shell } = require("electron");
const { app, BrowserWindow, Menu, ipcMain, session, shell } = require("electron");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
// Mirrors SIDEBAR_AUTOCLOSE in components/sidebar.tsx — keep in sync (the
// renderer owns the number; this is the IPC widen target).
const SIDEBAR_AUTOCLOSE = 928;
const PRODUCT_NAME = "LearnHarness";
const AUTH_FILE = () => path.join(app.getPath("userData"), "auth.json");
const DATA_DIR = () => path.join(app.getPath("userData"), "data");
@@ -199,6 +203,7 @@ function createWindow(url) {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
preload: path.join(__dirname, "preload.cjs"),
},
// Live-resize + first-paint flash (owner, 2026-09-17): during a resize
// the native window surface changes size before Chromium's compositor
@@ -209,6 +214,14 @@ function createWindow(url) {
// appears only once the page has actually rendered.
backgroundColor: "#141414",
show: false,
// Narrow-window behavior (owner, 2026-09-17): 640px is the floor —
// below it even a wrapped chrome row gets cramped and the chat header
// (lights gutter 92 + toggle + title) runs out of room. The sidebar
// auto-closes below 928 = 640 + 288 (sidebar) in the renderer
// (components/sidebar.tsx, SIDEBAR_AUTOCLOSE); its "lh:expand-window"
// IPC widens the window back to that threshold.
minWidth: 640,
minHeight: 600,
});
const allowedOrigin = () => new URL(url).origin;
@@ -293,6 +306,19 @@ async function main() {
}
installMenu();
// Sidebar-at-narrow-window (owner, 2026-09-17): the renderer auto-closes
// the sidebar below SIDEBAR_AUTOCLOSE (928px); opening it again at that
// width asks the window to widen instead — the layout never has to
// accommodate a narrow window + open sidebar. Top-left stays fixed, the
// window grows right. No-op when already wide.
ipcMain.on("lh:expand-window", (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win) return;
const { x, y, width, height } = win.getBounds();
const target = SIDEBAR_AUTOCLOSE;
if (width < target) win.setBounds({ x, y, width: target, height });
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
});
+17
View File
@@ -0,0 +1,17 @@
/*
* Preload (2026-09-17, narrow-window wave): the ONLY bridge between the
* sandboxed renderer and the main process. contextIsolation + sandbox are
* on (electron/main.cjs), so this file is the renderer's entire access to
* Electron — keep it a minimal, explicit API surface.
*
* window.lh.expandWindow() — widen the window to the sidebar auto-close
* threshold (928px) when the sidebar is opened below it. The main
* process clamps: no-op if the window is already wide enough.
*
* Channel names are mirrored in electron/main.cjs — keep them in sync.
*/
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", {
expandWindow: () => ipcRenderer.send("lh:expand-window"),
});