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:
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
Reference in New Issue
Block a user