Add collapsible primary UI sections (ticket 035)

Log viewer (FilterBar+LogTimeline+LogPreview), Params, Metrics, and
Dashboards can each be collapsed/expanded via a toggle next to their
existing help icon. Collapsed state persists per-section in
localStorage (ottyl.collapsedSections) and content is hidden with the
`hidden` attribute rather than unmounted, so in-progress form state
(a half-filled Metrics/Params form, in-progress Dashboards panel
edits) survives a collapse/expand cycle.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 16:46:22 +01:00
co-authored by Claude Sonnet 5
parent dd1a0a234d
commit 7db1e0f055
7 changed files with 96 additions and 46 deletions
@@ -0,0 +1,59 @@
import { useState } from 'react'
/// localStorage key for which of the four primary sections (log viewer,
/// params, metrics, dashboards) are collapsed (ticket 035). Mirrors
/// LogPreview's VISIBLE_PARAM_COLUMNS_STORAGE_KEY pattern — one key holding
/// a record of section id -> collapsed.
const COLLAPSED_SECTIONS_STORAGE_KEY = 'ottyl.collapsedSections'
function loadCollapsedSections(): Record<string, boolean> {
try {
const raw = localStorage.getItem(COLLAPSED_SECTIONS_STORAGE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function saveCollapsedSections(sections: Record<string, boolean>) {
try {
localStorage.setItem(COLLAPSED_SECTIONS_STORAGE_KEY, JSON.stringify(sections))
} catch {
// best-effort — a private browsing mode or full storage just means the
// collapsed state won't persist across reloads.
}
}
/// Persists a section's collapsed state across reloads. Deliberately
/// returns only the boolean + setter — callers keep rendering their
/// subtree, just hide it with CSS (`hidden`), so collapsing never unmounts
/// in-progress form state.
export function useSectionCollapsed(sectionId: string): [boolean, (next: boolean) => void] {
const [collapsed, setCollapsedState] = useState(() => loadCollapsedSections()[sectionId] ?? false)
function setCollapsed(next: boolean) {
setCollapsedState(next)
const all = loadCollapsedSections()
all[sectionId] = next
saveCollapsedSections(all)
}
return [collapsed, setCollapsed]
}
export function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
return (
<button
type="button"
onClick={onToggle}
title={collapsed ? 'Expand section' : 'Collapse section'}
aria-label={collapsed ? 'Expand section' : 'Collapse section'}
aria-expanded={!collapsed}
className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-border-soft text-[10px] leading-none text-text-faint hover:border-border-strong hover:text-text-secondary"
>
{collapsed ? '▸' : '▾'}
</button>
)
}