Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions web/src/components/Cron/CronSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="w-[220px] border-r border-border-subtle flex flex-col shrink-0 overflow-y-auto">
<div className={inDrawer
// In drawer mode the panel supplies the width and the edge, so the
// fixed 220px column and its own border would fight them.
? 'w-full flex-1 min-h-0 flex flex-col overflow-y-auto'
: 'w-[220px] border-r border-border-subtle flex flex-col shrink-0 overflow-y-auto'}>
<div className="p-2 space-y-1">
<button onClick={() => selectJob(null)}
<button onClick={() => { selectJob(null); onSelect?.(); }}
className={`w-full flex items-center justify-between px-2 py-1.5 rounded text-[13px] transition-colors cursor-pointer
${selectedJobId === null ? 'bg-accent/15 text-accent' : 'text-text-muted hover:text-text-secondary hover:bg-surface-raised'}`}>
<span className="flex items-center gap-2"><Timer size={14} /> All Jobs</span>
Expand All @@ -27,6 +41,7 @@ export function CronSidebar() {
return;
}
selectJob(job.id);
onSelect?.();
}}
onAuxClick={(e) => {
// Middle-click → new tab, like a link.
Expand Down
20 changes: 16 additions & 4 deletions web/src/components/ui/PageHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ import type { ReactNode } from 'react';
* would move them visually while leaving the keyboard to step through every
* filter and the search box first.
*/
export function PageHeader({ icon, title, filters, search, actions }: {
export function PageHeader({ leading, icon, title, filters, search, actions }: {
/**
* Sits ahead of the icon. For pages whose side pane collapses into a
* drawer on mobile, this is where its toggle goes — mirroring the chat
* header, so the control is in the same place on every page that has one.
*/
leading?: ReactNode;
icon?: ReactNode;
title: ReactNode;
/** Filter pills. Laid out by the caller; scrolled horizontally here. */
Expand All @@ -34,10 +40,14 @@ export function PageHeader({ icon, title, filters, search, actions }: {
<div className="border-b border-border-subtle bg-bg shrink-0 px-4 lg:px-6 py-2.5 lg:py-3
flex flex-wrap lg:flex-nowrap items-center gap-x-4 gap-y-2">
{/* Row one below `lg`: the title, with the page's primary buttons pinned
opposite it. With an icon, desktop keeps the 16px icon-to-title gap
and the 24px run-out to the filters the per-page headers used. */}
opposite it. `w-full` is what guarantees the filters and search a line
of their own — leaving that to the other items overflowing the line is
how the break silently collapsed on the page with no visible actions.
With an icon, desktop keeps the 16px icon-to-title gap and the 24px
run-out to the filters that the per-page headers already had. */}
<div className={`flex items-center gap-2 min-w-0 w-full lg:w-auto lg:flex-none
${icon ? 'lg:gap-4 lg:mr-2' : ''}`}>
{leading}
{icon}
<h1 className="text-lg font-semibold truncate">{title}</h1>
{actions && (
Expand All @@ -50,7 +60,9 @@ export function PageHeader({ icon, title, filters, search, actions }: {
// half-visible pill signals "there is more this way" instead of looking
// like a clipped layout. The width has to grow by the same 2rem the
// margins take back — `w-full` alone would only shift the strip left
// and leave it stopping 32px short of the right edge.
// and leave it stopping 32px short of the right edge. (Which is also
// why this cannot use `basis-full`: a non-auto flex-basis would
// override the width and take the bleed with it.)
<div className="w-[calc(100%+2rem)] lg:w-auto min-w-0
-mx-4 px-4 lg:mx-0 lg:px-0
overflow-x-auto
Expand Down
28 changes: 28 additions & 0 deletions web/src/components/ui/PaneToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PanelLeftOpen, PanelLeftClose } from 'lucide-react';

/**
* Opens/closes a page's side pane once it has collapsed into a drawer.
*
* Same icon pair and position as the chat header's sidebar toggle, so the
* control for "show me the list" is in the same corner on every page that
* has a list.
*/
export function PaneToggle({ open, onToggle, label }: {
open: boolean;
onToggle: () => void;
/** Names the pane, e.g. "job list" — used for the title/aria-label. */
label: string;
}) {
const action = `${open ? 'Hide' : 'Show'} ${label}`;
return (
<button
onClick={onToggle}
title={action}
aria-label={action}
aria-expanded={open}
className="w-8 h-8 -ml-1 shrink-0 flex items-center justify-center rounded text-text-faint hover:text-text-muted hover:bg-surface-raised cursor-pointer transition-colors"
>
{open ? <PanelLeftClose size={16} /> : <PanelLeftOpen size={16} />}
</button>
);
}
56 changes: 46 additions & 10 deletions web/src/pages/CronPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -25,19 +51,29 @@ export function CronPage() {

return (
<div className="h-full flex flex-col">
{/* Header */}
<div className="border-b border-border-subtle px-4 py-2.5 flex items-center justify-between bg-bg shrink-0">
<h1 className="text-lg font-semibold">Cron Jobs</h1>
<button onClick={handleRefresh} disabled={refreshing}
className="text-text-dim hover:text-text-muted cursor-pointer p-1.5 hover:bg-surface-raised rounded"
title="Refresh">
{refreshing ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
</button>
</div>
<PageHeader
leading={isMobile
? <PaneToggle open={listOpen} onToggle={() => setListOpen(o => !o)} label="job list" />
: undefined}
title="Cron Jobs"
actions={
<button onClick={handleRefresh} disabled={refreshing}
className="text-text-dim hover:text-text-muted cursor-pointer p-1.5 hover:bg-surface-raised rounded"
title="Refresh" aria-label="Refresh">
{refreshing ? <Loader2 size={16} className="animate-spin" /> : <RefreshCw size={16} />}
</button>
}
/>

{/* Body */}
<div className="flex-1 flex min-h-0">
<CronSidebar />
{isMobile ? (
<Drawer open={listOpen} onClose={() => setListOpen(false)} side="left" label="Cron jobs">
<CronSidebar inDrawer onSelect={() => setListOpen(false)} />
</Drawer>
) : (
<CronSidebar />
)}

<div className="flex-1 flex flex-col min-w-0">
{selectedJob && <JobInfoCard job={selectedJob} />}
Expand Down
87 changes: 69 additions & 18 deletions web/src/pages/MemuPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
profile: 'var(--theme-accent)',
Expand Down Expand Up @@ -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);

Expand All @@ -646,7 +658,11 @@ function Sidebar() {
}, [categoryItems]);

return (
<div className="w-52 shrink-0 border-r border-border-subtle flex flex-col overflow-hidden">
<div className={inDrawer
// The drawer supplies the width and the edge; the fixed column and its
// own border would fight them.
? 'w-full flex-1 min-h-0 flex flex-col overflow-hidden'
: 'w-52 shrink-0 border-r border-border-subtle flex flex-col overflow-hidden'}>
<div className="p-3 border-b border-border-subtle">
<div className="text-[11px] text-text-dim uppercase tracking-wider mb-2">Types</div>
<div className="space-y-1">
Expand All @@ -662,15 +678,15 @@ function Sidebar() {
<div className="flex-1 overflow-y-auto p-3">
<div className="text-[11px] text-text-dim uppercase tracking-wider mb-2">Categories</div>
{selectedCategory && (
<button onClick={() => setSelectedCategory(null)} className="w-full text-left px-2 py-1 mb-1 text-[11px] text-accent hover:bg-surface-raised rounded cursor-pointer transition-colors flex items-center gap-1">
<button onClick={() => { setSelectedCategory(null); onSelect?.(); }} className="w-full text-left px-2 py-1 mb-1 text-[11px] text-accent hover:bg-surface-raised rounded cursor-pointer transition-colors flex items-center gap-1">
<X size={10} /> Clear filter
</button>
)}
<div className="space-y-0.5">
{categories.map(cat => {
const isActive = selectedCategory === cat.id;
return (
<button key={cat.id} onClick={() => setSelectedCategory(isActive ? null : cat.id)} className={`w-full text-left px-2 py-1.5 rounded text-[12px] cursor-pointer transition-colors flex items-center gap-2 ${isActive ? 'bg-accent/15 text-accent' : 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'}`}>
<button key={cat.id} onClick={() => { setSelectedCategory(isActive ? null : cat.id); onSelect?.(); }} className={`w-full text-left px-2 py-1.5 rounded text-[12px] cursor-pointer transition-colors flex items-center gap-2 ${isActive ? 'bg-accent/15 text-accent' : 'text-text-muted hover:bg-surface-raised hover:text-text-secondary'}`}>
<span className="flex-1 truncate">{cat.name.replace(/_/g, ' ')}</span>
<span className="text-[10px] text-text-dim">{catCounts[cat.id] || 0}</span>
</button>
Expand Down Expand Up @@ -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 <div className="flex-1 flex items-center justify-center text-text-faint">Loading...</div>;

if (!available) {
Expand All @@ -717,22 +757,33 @@ export function MemuPage() {

return (
<div className="h-full flex flex-col">
<div className="border-b border-border-subtle px-5 py-2.5 flex items-center justify-between bg-bg shrink-0">
<div className="flex items-center gap-3">
<span className="font-medium text-[15px]">Semantic Memory</span>
<span className="text-xs text-text-faint">{items.length} items · {categories.length} categories · {resources.length} sources</span>
</div>
<div className="flex gap-1 items-center">
{TABS.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} className={`px-3 py-1 rounded text-xs cursor-pointer transition-colors ${activeTab === tab.key ? 'bg-accent/20 text-accent' : 'text-text-dim hover:text-text-muted'}`}>
{tab.key === 'log' && <History size={11} className="inline mr-1 -mt-0.5" />}{tab.label}
</button>
))}
</div>
</div>
<PageHeader
leading={isMobile
? <PaneToggle open={paneOpen} onToggle={() => setPaneOpen(o => !o)} label="types and categories" />
: undefined}
title="Semantic Memory"
filters={TABS.map(tab => (
<button key={tab.key} onClick={() => setActiveTab(tab.key)} className={`px-3 py-1 rounded text-xs cursor-pointer transition-colors whitespace-nowrap ${activeTab === tab.key ? 'bg-accent/20 text-accent' : 'text-text-dim hover:text-text-muted'}`}>
{tab.key === 'log' && <History size={11} className="inline mr-1 -mt-0.5" />}{tab.label}
</button>
))}
actions={
// The counts are context, not a control: below `lg` the tabs and
// the pane toggle are the better use of the row.
<span className="hidden lg:inline text-xs text-text-faint whitespace-nowrap">
{items.length} items · {categories.length} categories · {resources.length} sources
</span>
}
/>

<div className="flex-1 flex overflow-hidden">
<Sidebar />
{isMobile ? (
<Drawer open={paneOpen} onClose={() => setPaneOpen(false)} side="left" label="Types and categories">
<Sidebar inDrawer onSelect={() => setPaneOpen(false)} />
</Drawer>
) : (
<Sidebar />
)}
<div className="flex-1 flex flex-col overflow-hidden">
{showSearch && (
<div className="px-3 py-2 border-b border-border-subtle shrink-0">
Expand Down
Loading