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 { NavMenu } from './components/NavMenu'
|
||||||
import { FilterBar } from './components/FilterBar'
|
import { FilterBar } from './components/FilterBar'
|
||||||
import { LogTimeline, computeBucketMs, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
|
import { LogTimeline, computeBucketMs, computePresetRange, type TimeRange, type TimeRangePresetId } from './components/LogTimeline'
|
||||||
@@ -22,6 +22,85 @@ import { DocsNavProvider } from './lib/docsNav'
|
|||||||
|
|
||||||
const POLL_MS = 2000
|
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.
|
/// 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 } {
|
function parsePopOutQuery(): { section: PopOutSectionId | null; range: TimeRange | null } {
|
||||||
const query = new URLSearchParams(window.location.search)
|
const query = new URLSearchParams(window.location.search)
|
||||||
@@ -190,6 +269,18 @@ function FullApp() {
|
|||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [filterError, setFilterError] = useState<string | null>(null)
|
const [filterError, setFilterError] = useState<string | null>(null)
|
||||||
const [logViewerCollapsed, setLogViewerCollapsed] = useSectionCollapsed('log-viewer')
|
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.
|
// 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)
|
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. */}
|
{/* An explicit height (user-resizable via the handle below, persisted to localStorage) keeps this
|
||||||
<div className="flex min-h-[22rem] flex-1 flex-col" hidden={logViewerCollapsed}>
|
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
|
<LogTimeline
|
||||||
buckets={buckets}
|
buckets={buckets}
|
||||||
range={range}
|
range={range}
|
||||||
@@ -300,6 +397,7 @@ function FullApp() {
|
|||||||
/>
|
/>
|
||||||
<LogPreview logs={logs} params={params} />
|
<LogPreview logs={logs} params={params} />
|
||||||
</div>
|
</div>
|
||||||
|
{!logViewerCollapsed && <ResizeHandle onResize={handleLogViewerResize} onResizeEnd={handleLogViewerResizeEnd} />}
|
||||||
<Params params={params} onDeclared={loadParams} />
|
<Params params={params} onDeclared={loadParams} />
|
||||||
<Metrics range={range} />
|
<Metrics range={range} />
|
||||||
<Dashboards range={range} />
|
<Dashboards range={range} />
|
||||||
|
|||||||
Reference in New Issue
Block a user