+ 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"),
+});