From d46c3dae93eb25eb8c2a654d2041cdab4eb42248 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Thu, 17 Sep 2026 18:57:02 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20narrow-window=20behavior=20=E2=80=94=20?= =?UTF-8?q?sidebar=20auto-close,=20chrome=20wrap,=20640px=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- components/graph-view.tsx | 8 ++++-- components/sidebar.tsx | 58 +++++++++++++++++++++++++++++++++++++-- electron/main.cjs | 28 ++++++++++++++++++- electron/preload.cjs | 17 ++++++++++++ 4 files changed, 105 insertions(+), 6 deletions(-) create mode 100644 electron/preload.cjs diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 2111242..aa0dcd4 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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). */} -
+ 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. */} +
{!isEmpty && ( <> {/* Search — its own container (icon + input + no-match note). */} diff --git a/components/sidebar.tsx b/components/sidebar.tsx index 2691953..a77a246 100644 --- a/components/sidebar.tsx +++ b/components/sidebar.tsx @@ -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 ( - + {/* transitions gated on mount: no slide-in flash on page load */}
{children}
diff --git a/electron/main.cjs b/electron/main.cjs index eec87d8..c1935a9 100644 --- a/electron/main.cjs +++ b/electron/main.cjs @@ -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); }); diff --git a/electron/preload.cjs b/electron/preload.cjs new file mode 100644 index 0000000..dda4ce6 --- /dev/null +++ b/electron/preload.cjs @@ -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"), +});