From 398f506e4fe784f87a3bfd4a4765eae9eb5e8efc Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Sun, 9 Aug 2026 17:31:06 +0000 Subject: [PATCH] Collapse the cron and memory side panes into drawers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pages split the viewport into fixed columns. At 412px that left /cron's run table showing two of its six columns, and gave /memory's three panes roughly 130px each. Below `md` the list pane becomes a left drawer, matching the chat session list — same anchor, same toggle, same icons — so "show me the list" is the same gesture in the same corner everywhere. /cron's table goes from two visible columns to five; /memory's facts get the full width. Both headers move to PageHeader in the same pass. Converting the body without the header would have left them the only pages still scrolling sideways from the old header row. PageHeader gains a `leading` slot for the pane toggle, so it sits where the chat header's sidebar toggle sits. Both drawers close the moment a selection is made, driven by the pane's own handler rather than by watching the selected id. Watching the id misses the case where the tap picks what is already picked: on /cron that is "All Jobs", on /memory it is the active category (which clears the filter, so the list underneath does change). Either way the drawer would have stayed parked over the content it was asked to reveal. Crossing the breakpoint still retires the drawer, adjusted during render so the overlay is never painted over the content for a frame. MemuPage's hooks go above its `if (loading) return` — added below it they changed the hook order between renders and blanked the page as soon as loading flipped. Desktop is unchanged on both pages. Refs #271 --- web/src/components/Cron/CronSidebar.tsx | 21 +++++- web/src/components/ui/PageHeader.tsx | 20 ++++-- web/src/components/ui/PaneToggle.tsx | 28 ++++++++ web/src/pages/CronPage.tsx | 56 +++++++++++++--- web/src/pages/MemuPage.tsx | 87 ++++++++++++++++++++----- 5 files changed, 177 insertions(+), 35 deletions(-) create mode 100644 web/src/components/ui/PaneToggle.tsx diff --git a/web/src/components/Cron/CronSidebar.tsx b/web/src/components/Cron/CronSidebar.tsx index c61bb3c3..061eaca8 100644 --- a/web/src/components/Cron/CronSidebar.tsx +++ b/web/src/components/Cron/CronSidebar.tsx @@ -3,13 +3,27 @@ import { useCronStore } from '../../stores/cronStore'; import { chatPath, jobLabel } from './utils'; import { ChatLink, TriggerButton, JobTypeIcon } from './controls'; -export function CronSidebar() { +export function CronSidebar({ inDrawer = false, onSelect }: { + inDrawer?: boolean; + /** + * Fired after every plain selection, including one that re-picks the job + * already selected. Drawer mode closes on it — watching `selectedJobId` + * instead misses that case and leaves the list parked over the table. + * Modified and middle clicks open a new tab and select nothing, so they + * are exempt. + */ + onSelect?: () => void; +}) { const { jobs, selectedJobId, selectJob } = useCronStore(); return ( -
+
- + ); +} diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index ad52f6ef..ad14f476 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -4,11 +4,37 @@ import { useCronStore } from '../stores/cronStore'; import { CronSidebar } from '../components/Cron/CronSidebar'; import { JobInfoCard } from '../components/Cron/JobInfoCard'; import { LogsTable } from '../components/Cron/LogsTable'; +import { PageHeader } from '../components/ui/PageHeader'; +import { PaneToggle } from '../components/ui/PaneToggle'; +import { Drawer } from '../components/ui/Drawer'; +import { useIsMobile } from '../hooks/useMediaQuery'; export function CronPage() { const { jobs, selectedJobId, loadJobs, loadLogs, refresh } = useCronStore(); const [refreshing, setRefreshing] = useState(false); + // The job list is "which item within this section", so on a phone it + // becomes a left drawer — the same anchor and the same toggle as the chat + // session list, rather than a 220px column squeezing the run table to + // roughly two visible columns. + const isMobile = useIsMobile(); + const [listOpen, setListOpen] = useState(false); + + // Picking a job shuts the drawer, but that is driven by `CronSidebar`'s + // `onSelect` rather than by watching `selectedJobId`: tapping the job that + // is already selected — "All Jobs", most often — leaves the id unchanged, + // and the drawer would stay over the table it was asked to reveal. + // + // All that is left here is retiring the drawer when the layout leaves the + // phone breakpoint, so a later resize back down doesn't arrive with an + // overlay already open. Adjusted during render rather than in an effect: + // an effect paints the stale state for a frame first. + const [lastIsMobile, setLastIsMobile] = useState(isMobile); + if (lastIsMobile !== isMobile) { + setLastIsMobile(isMobile); + setListOpen(false); + } + useEffect(() => { loadJobs(); loadLogs(); @@ -25,19 +51,29 @@ export function CronPage() { return (
- {/* Header */} -
-

Cron Jobs

- -
+ setListOpen(o => !o)} label="job list" /> + : undefined} + title="Cron Jobs" + actions={ + + } + /> {/* Body */}
- + {isMobile ? ( + setListOpen(false)} side="left" label="Cron jobs"> + setListOpen(false)} /> + + ) : ( + + )}
{selectedJob && } diff --git a/web/src/pages/MemuPage.tsx b/web/src/pages/MemuPage.tsx index 75fe8aa9..33d8e2f6 100644 --- a/web/src/pages/MemuPage.tsx +++ b/web/src/pages/MemuPage.tsx @@ -4,6 +4,10 @@ import { FileText, Clock, Circle, History, } from 'lucide-react'; import { useMemoryStore, type Category, type MemoryItem, type Resource, type TabView } from '../stores/memoryStore'; +import { PageHeader } from '../components/ui/PageHeader'; +import { PaneToggle } from '../components/ui/PaneToggle'; +import { Drawer } from '../components/ui/Drawer'; +import { useIsMobile } from '../hooks/useMediaQuery'; const TYPE_COLORS: Record = { profile: 'var(--theme-accent)', @@ -629,7 +633,15 @@ function LogView() { // --- Sidebar --- -function Sidebar() { +function Sidebar({ inDrawer = false, onSelect }: { + inDrawer?: boolean; + /** + * Fired whenever a tap changes which facts are listed. Drawer mode closes + * on it, so the newly filtered list is actually revealed; creating a + * category happens inside this pane and deliberately does not fire it. + */ + onSelect?: () => void; +}) { const { items, categories, categoryItems, selectedCategory, setSelectedCategory } = useMemoryStore(); const [showCreateCat, setShowCreateCat] = useState(false); @@ -646,7 +658,11 @@ function Sidebar() { }, [categoryItems]); return ( -
+
Types
@@ -662,7 +678,7 @@ function Sidebar() {
Categories
{selectedCategory && ( - )} @@ -670,7 +686,7 @@ function Sidebar() { {categories.map(cat => { const isActive = selectedCategory === cat.id; return ( - @@ -699,6 +715,30 @@ export function MemuPage() { useEffect(() => { load(); }, [load]); + // Types/categories collapse into a left drawer on a phone — three panes + // sharing 412px left every one of them unreadable. + // + // Declared above the loading/unavailable early returns: hooks after a + // conditional return change the hook order between renders, which is what + // the rules of hooks forbid (React throws once `loading` flips to false). + const isMobile = useIsMobile(); + const [paneOpen, setPaneOpen] = useState(false); + + // Choosing a category shuts the drawer, driven by `Sidebar`'s `onSelect`. + // A watcher over `selectedCategory` would miss re-tapping the category that + // is already active — which clears the filter, so the list underneath does + // change — and would leave the drawer covering the result either way. + // + // All that is left here is retiring the drawer when the layout leaves the + // phone breakpoint, so a later resize back down doesn't arrive with an + // overlay already open. Adjusted during render rather than in an effect: + // an effect paints the stale state for a frame first. + const [lastIsMobile, setLastIsMobile] = useState(isMobile); + if (lastIsMobile !== isMobile) { + setLastIsMobile(isMobile); + setPaneOpen(false); + } + if (loading) return
Loading...
; if (!available) { @@ -717,22 +757,33 @@ export function MemuPage() { return (
-
-
- Semantic Memory - {items.length} items · {categories.length} categories · {resources.length} sources -
-
- {TABS.map(tab => ( - - ))} -
-
+ setPaneOpen(o => !o)} label="types and categories" /> + : undefined} + title="Semantic Memory" + filters={TABS.map(tab => ( + + ))} + actions={ + // The counts are context, not a control: below `lg` the tabs and + // the pane toggle are the better use of the row. + + {items.length} items · {categories.length} categories · {resources.length} sources + + } + />
- + {isMobile ? ( + setPaneOpen(false)} side="left" label="Types and categories"> + setPaneOpen(false)} /> + + ) : ( + + )}
{showSearch && (