Merge ticket/010-resizable-sections

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 19:00:54 +01:00
co-authored by Claude Sonnet 5
2 changed files with 101 additions and 11 deletions
-8
View File
@@ -1,8 +0,0 @@
# Make layout sections resizable
The sections should be resizable. Currently the log view is too small and can't be resized.
## Acceptance criteria
- Main layout sections (including the log view) can be resized by the user, e.g. via a drag handle between panes.
- Resized proportions are reasonably persisted (e.g. in local storage) across reloads, consistent with other UI state persistence in the app.
- Log view in particular can be enlarged.
+101 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react'
import { NavMenu } from './components/NavMenu'
import { FilterBar } from './components/FilterBar'
import { LogTimeline, computeBucketMs, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
@@ -22,6 +22,85 @@ import { DocsNavProvider } from './lib/docsNav'
const POLL_MS = 2000
// Height (px) the log viewer had before it became resizable — kept as the default so existing
// layouts don't jump on first load after this change.
const DEFAULT_LOG_VIEWER_HEIGHT_PX = 352
const MIN_LOG_VIEWER_HEIGHT_PX = 200
// Leaves room below the log viewer for the drag handle and at least a sliver of the next
// section, so a drag can never grow it to swallow the whole viewport.
const LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX = 160
// Namespaced under `ottyl.` per the app's existing localStorage convention (see
// CollapsibleSection.tsx's COLLAPSED_SECTIONS_STORAGE_KEY, LogPreview.tsx's
// VISIBLE_PARAM_COLUMNS_STORAGE_KEY).
const LOG_VIEWER_HEIGHT_STORAGE_KEY = 'ottyl.logViewerHeightPx'
function clampLogViewerHeight(px: number): number {
const max = Math.max(MIN_LOG_VIEWER_HEIGHT_PX, window.innerHeight - LOG_VIEWER_HEIGHT_VIEWPORT_MARGIN_PX)
return Math.min(max, Math.max(MIN_LOG_VIEWER_HEIGHT_PX, px))
}
function loadLogViewerHeight(): number {
try {
const raw = localStorage.getItem(LOG_VIEWER_HEIGHT_STORAGE_KEY)
if (!raw) return DEFAULT_LOG_VIEWER_HEIGHT_PX
const parsed = Number(raw)
return Number.isFinite(parsed) ? clampLogViewerHeight(parsed) : DEFAULT_LOG_VIEWER_HEIGHT_PX
} catch {
return DEFAULT_LOG_VIEWER_HEIGHT_PX
}
}
function saveLogViewerHeight(px: number) {
try {
localStorage.setItem(LOG_VIEWER_HEIGHT_STORAGE_KEY, String(px))
} catch {
// best-effort — private browsing or full storage just means the size won't persist across reloads
}
}
/// Thin drag handle between the log viewer and the sections below it. Reports each pointer-move
/// delta via `onResize` (letting the caller clamp/apply it) and fires `onResizeEnd` on release so
/// the final size can be persisted once, not on every pixel of movement.
function ResizeHandle({ onResize, onResizeEnd }: { onResize: (deltaY: number) => void; onResizeEnd: () => void }) {
const draggingRef = useRef(false)
function handlePointerDown(e: ReactPointerEvent) {
e.preventDefault()
draggingRef.current = true
let lastY = e.clientY
function handlePointerMove(ev: PointerEvent) {
if (!draggingRef.current) return
onResize(ev.clientY - lastY)
lastY = ev.clientY
}
function handlePointerUp() {
draggingRef.current = false
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
onResizeEnd()
}
window.addEventListener('pointermove', handlePointerMove)
window.addEventListener('pointerup', handlePointerUp)
}
return (
<div
role="separator"
aria-orientation="horizontal"
aria-label="Resize log viewer"
title="Drag to resize"
onPointerDown={handlePointerDown}
className="group relative h-1.5 shrink-0 cursor-ns-resize touch-none"
>
<div className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-border-soft group-hover:bg-border-strong" />
</div>
)
}
/// Reads the routing params `PopOutButton` writes: `section` picks which primary section to render full-page, and `startMs`/`endMs` carry over the range for sections with no `LogTimeline` of their own to pick one.
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
const query = new URLSearchParams(window.location.search)
@@ -190,6 +269,18 @@ function FullApp() {
const [filter, setFilter] = useState('')
const [filterError, setFilterError] = useState<string | null>(null)
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
const [logViewerHeight, setLogViewerHeight] = useState(() => loadLogViewerHeight())
function handleLogViewerResize(deltaY: number) {
setLogViewerHeight((current) => clampLogViewerHeight(current + deltaY))
}
function handleLogViewerResizeEnd() {
setLogViewerHeight((current) => {
saveLogViewerHeight(current)
return current
})
}
// Read via ref inside the poll loop instead of depending on `range` directly, which would restart the interval every rolling-preset tick.
const rangeRef = useRef(range)
@@ -289,8 +380,14 @@ function FullApp() {
</>
}
/>
{/* min-h-[...] keeps this from being squeezed to 0 by the sections below it in the shared scroll container — flex-1 alone lets a flex item shrink past its content, which let this section's absolutely-unclipped content spill over Params et al instead of the column just scrolling. */}
<div className="flex min-h-[22rem] flex-1 flex-col" hidden={logViewerCollapsed}>
{/* An explicit height (user-resizable via the handle below, persisted to localStorage) keeps this
from being squeezed by the sections below it in the shared scroll container, and bounds
LogPreview's own internal `flex-1 overflow-auto` scroll area. */}
<div
className="flex shrink-0 flex-col overflow-hidden"
style={{ height: logViewerHeight }}
hidden={logViewerCollapsed}
>
<LogTimeline
buckets={buckets}
range={range}
@@ -300,6 +397,7 @@ function FullApp() {
/>
<LogPreview logs={logs} params={params} />
</div>
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
<Params params={params} onDeclared={loadParams} />
<Metrics range={range} />
<Dashboards range={range} />