From f6a32b2b305607657e02922018a4341b857ffa55 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 19:00:40 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- tickets/010-resizable-sections.md | 8 --- webapp/src/App.tsx | 104 +++++++++++++++++++++++++++++- 2 files changed, 101 insertions(+), 11 deletions(-) delete mode 100644 tickets/010-resizable-sections.md diff --git a/tickets/010-resizable-sections.md b/tickets/010-resizable-sections.md deleted file mode 100644 index 6031766..0000000 --- a/tickets/010-resizable-sections.md +++ /dev/null @@ -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. diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f055571..3f09b6a 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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 ( +
+
+
+ ) +} + /// 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(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. */} -