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:
@@ -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
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user