@@ -40,9 +40,10 @@ import TokenStream from './TokenStream';
4040import PhaseRail from './PhaseRail' ;
4141import type { PhaseState , RailGroup } from './PhaseRail' ;
4242import RunSetup from './RunSetup' ;
43+ import ResizeHandle from '@/components/ui/ResizeHandle' ;
4344import { useToast } from '@/components/ui/Toast' ;
4445import { FOCUS_PROMPT_EVENT } from '@/hooks/useKeyboard' ;
45- import { useIsDesktop } from '@/hooks/useMediaQuery ' ;
46+ import { usePersistentState , useMediaQuery , useStickToBottom } from '@/hooks/useUiState ' ;
4647import clsx from 'clsx' ;
4748
4849/**
@@ -77,6 +78,14 @@ const PIPELINE_GROUPS: RailGroup[] = [
7778
7879type RailTab = 'tasks' | 'files' | 'result' ;
7980
81+ /** Where the side rail becomes a column instead of an overlay. */
82+ const WIDE_VIEWPORT = '(min-width: 1024px)' ;
83+
84+ /** Side-rail width bounds, in px. The user's choice is persisted between them. */
85+ const RAIL_DEFAULT_PX = 384 ;
86+ const RAIL_MIN_PX = 280 ;
87+ const RAIL_MAX_PX = 720 ;
88+
8089export default function LiveView ( ) {
8190 const ctx = useContext ( AppContext ) ;
8291 const toast = useToast ( ) ;
@@ -93,18 +102,21 @@ export default function LiveView() {
93102 const setResult = ctx ?. setLiveResult || ( ( ) => { } ) ;
94103
95104 const [ query , setQuery ] = useState ( '' ) ;
96- const [ railTab , setRailTab ] = useState < RailTab > ( 'tasks' ) ;
97- // The rail is a column on desktop and a full-height OVERLAY below it, so its
98- // default cannot be the same on both: opening it by default on a phone means
99- // the first thing a user sees is the task drawer covering the console they
100- // came for. Seeded from the breakpoint, then owned by the user — and reset
101- // when the viewport actually crosses it, so a window dragged narrow does not
102- // strand an overlay the user never asked to open.
103- const isDesktop = useIsDesktop ( ) ;
104- const [ railOpen , setRailOpen ] = useState ( isDesktop ) ;
105+ // Persisted: a layout the user arranged has to survive a reload.
106+ const [ railTab , setRailTab ] = usePersistentState < RailTab > ( 'live.rail.tab' , 'tasks' ) ;
107+ const [ railWidth , setRailWidth ] = usePersistentState ( 'live.rail.width' , RAIL_DEFAULT_PX ) ;
108+ // The rail is a column on a wide viewport and a full-height OVERLAY below it,
109+ // so its default cannot be the same on both: opening it by default on a phone
110+ // means the first thing a user sees is the task drawer covering the console
111+ // they came for. Seeded from the breakpoint, then owned by the user — and
112+ // reset when the viewport actually CROSSES it, so a window dragged narrow does
113+ // not strand an overlay nobody asked to open. Not persisted for that reason:
114+ // a stored `true` restored on a phone is the same trap.
115+ const isWide = useMediaQuery ( WIDE_VIEWPORT ) ;
116+ const [ railOpen , setRailOpen ] = useState ( isWide ) ;
105117 useEffect ( ( ) => {
106- setRailOpen ( isDesktop ) ;
107- } , [ isDesktop ] ) ;
118+ setRailOpen ( isWide ) ;
119+ } , [ isWide ] ) ;
108120 const [ agents , setAgents ] = useState < AgentSpec [ ] > ( [ ] ) ;
109121 const [ specialist , setSpecialist ] = useState ( '' ) ;
110122 const [ pipelineView , setPipelineView ] = useState < PipelineView | null > ( null ) ;
@@ -114,12 +126,11 @@ export default function LiveView() {
114126 const [ compositionPreviewFit , setCompositionPreviewFit ] = useState < string [ ] > ( [ ] ) ;
115127 const [ interrupted , setInterrupted ] = useState < InterruptedRun [ ] > ( [ ] ) ;
116128 const [ previewLoading , setPreviewLoading ] = useState ( false ) ;
117- const logEnd = useRef < HTMLDivElement > ( null ) ;
118129 const promptRef = useRef < HTMLInputElement > ( null ) ;
119-
120- useEffect ( ( ) => {
121- logEnd . current ?. scrollIntoView ( { behavior : 'smooth' } ) ;
122- } , [ events ] ) ;
130+ // Follows the stream only while the user is already AT the bottom. An
131+ // unconditional scroll yanked the log back down every flush, which made
132+ // reading anything mid-run impossible.
133+ const logRef = useStickToBottom < HTMLDivElement > ( events , true ) ;
123134
124135 // `/` focuses the prompt from anywhere in the app.
125136 useEffect ( ( ) => {
@@ -540,7 +551,7 @@ export default function LiveView() {
540551 { /* ── 4. Stream + rail ───────────────────────────────────────── */ }
541552 < div className = "flex min-h-0 flex-1 overflow-hidden" >
542553 < main className = "flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden" >
543- < div className = "min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4" >
554+ < div ref = { logRef } className = "min-h-0 flex-1 overflow-auto px-3 py-3 sm:px-4" >
544555 { events . length === 0 ? (
545556 // Keyed on EVENTS alone, not on `result`. A finished run leaves a
546557 // result behind, so the old `!result` clause meant that reopening
@@ -558,7 +569,6 @@ export default function LiveView() {
558569 < EventLog events = { events } />
559570 </ div >
560571 ) }
561- < div ref = { logEnd } />
562572 </ div >
563573
564574 { /* Feedback docks to the bottom of the stream, where a reply belongs —
@@ -580,11 +590,29 @@ export default function LiveView() {
580590 onClick = { ( ) => setRailOpen ( false ) }
581591 className = "fixed inset-0 z-30 bg-black/30 lg:hidden"
582592 />
593+ { /* The divider exists only on a wide viewport: as an overlay the
594+ rail has no neighbour to steal width from, so a horizontal drag
595+ would mean nothing. */ }
596+ { isWide && (
597+ < ResizeHandle
598+ size = { railWidth }
599+ onResize = { setRailWidth }
600+ min = { RAIL_MIN_PX }
601+ max = { RAIL_MAX_PX }
602+ invert
603+ label = "Resize the side panel"
604+ />
605+ ) }
583606 < aside
607+ // maxWidth is a second clamp, in CSS rather than in the handle: a
608+ // window narrowed AFTER the width was stored must never leave the
609+ // stream with no room, even when the stored value is wider than
610+ // the window itself.
611+ style = { isWide ? { width : railWidth , maxWidth : '60%' , minWidth : RAIL_MIN_PX } : undefined }
584612 className = { clsx (
585613 'z-40 flex min-h-0 flex-col border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-950' ,
586614 'fixed inset-y-0 right-0 w-[min(26rem,90vw)] border-l shadow-2xl' ,
587- 'lg:static lg:w-[24rem] lg: shadow-none xl:w-[26rem] ' ,
615+ 'lg:static lg:shadow-none' ,
588616 ) }
589617 >
590618 < div className = "flex shrink-0 border-b border-gray-200 dark:border-gray-800" >
0 commit comments