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 && (