Make the log viewer resizable via a drag handle (ticket 010)

Adds a drag-to-resize handle between the log viewer and the sections
below it in the main layout, addressing the log view being too small
with no way to enlarge it. Height is tracked in state, clamped to a
sane range, and persisted to localStorage (ottyl.logViewerHeightPx)
following the app's existing per-viewer UI-state persistence pattern
(see CollapsibleSection.tsx, LogPreview.tsx).

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:40 +01:00
co-authored by Claude Sonnet 5
parent ed1524139a
commit f6a32b2b30
2 changed files with 101 additions and 11 deletions
+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} />