Skip to content

Commit 3e7edc7

Browse files
refactor(studio): reuse the shared UI primitives instead of duplicating them
This branch was cut before 0989f9f, which reworked the same Live page and added hooks/useUiState.ts and ui/ResizeHandle. The rebase kept this branch's structure — four zones, an extracted PhaseRail and RunSetup — but there is no reason for it to carry its own copies of infrastructure that now exists: - useMediaQuery is deleted; useUiState already exports one. - usePersistentState now backs the rail tab, the rail width and the RunSetup disclosure, so a layout the user arranged survives a reload. - useStickToBottom replaces an unconditional scrollIntoView on every event, which yanked the log back down mid-flush and made reading anything during a run impossible. It follows the stream only while the user is already at the bottom. - ResizeHandle restores the draggable, persisted side-panel width that the superseded version had. The stored width is clamped twice — by the handle and again in CSS — so a window narrowed after the fact can never leave the stream with no room. RunSetup's tests clear localStorage between cases: jsdom shares it across a file, so the tests that click the toggle otherwise leak an expanded panel into every test declared after them.
1 parent 41f8544 commit 3e7edc7

5 files changed

Lines changed: 61 additions & 68 deletions

File tree

‎web/src/components/Live/LiveView.tsx‎

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,10 @@ import TokenStream from './TokenStream';
4040
import PhaseRail from './PhaseRail';
4141
import type { PhaseState, RailGroup } from './PhaseRail';
4242
import RunSetup from './RunSetup';
43+
import ResizeHandle from '@/components/ui/ResizeHandle';
4344
import { useToast } from '@/components/ui/Toast';
4445
import { FOCUS_PROMPT_EVENT } from '@/hooks/useKeyboard';
45-
import { useIsDesktop } from '@/hooks/useMediaQuery';
46+
import { usePersistentState, useMediaQuery, useStickToBottom } from '@/hooks/useUiState';
4647
import clsx from 'clsx';
4748

4849
/**
@@ -77,6 +78,14 @@ const PIPELINE_GROUPS: RailGroup[] = [
7778

7879
type 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+
8089
export 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">

‎web/src/components/Live/RunSetup.test.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { beforeEach, describe, expect, it } from 'vitest';
22
import { render, screen } from '@testing-library/react';
33
import userEvent from '@testing-library/user-event';
44
import RunSetup from './RunSetup';
@@ -40,6 +40,12 @@ function setup(props: Partial<React.ComponentProps<typeof RunSetup>> = {}) {
4040
}
4141

4242
describe('RunSetup', () => {
43+
// The disclosure state is persisted, and jsdom shares localStorage across
44+
// tests in a file — so without this the tests that CLICK the toggle leak an
45+
// expanded panel into every test after them, and the suite passes or fails on
46+
// declaration order.
47+
beforeEach(() => localStorage.clear());
48+
4349
it('renders nothing without a composition or an error', () => {
4450
const { container } = setup({ composition: null });
4551
expect(container).toBeEmptyDOMElement();

‎web/src/components/Live/RunSetup.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { useState } from 'react';
21
import type { ReactNode } from 'react';
32
import { ChevronRight, Bot, Layers, AlertTriangle, Cpu } from 'lucide-react';
43
import type { AgentSpec, DynamicComposition } from '@/types';
4+
import { usePersistentState } from '@/hooks/useUiState';
55
import clsx from 'clsx';
66

77
/**
@@ -81,7 +81,9 @@ export default function RunSetup({
8181
// reading.
8282
//
8383
// The user can override either way and their choice sticks for the session.
84-
const [open, setOpen] = useState(mode === 'runtime' && !running);
84+
// Persisted, so a reader who opened this panel does not have to reopen it
85+
// after every reload. The mode-derived value is only the FIRST-mount default.
86+
const [open, setOpen] = usePersistentState('live.setup.open', mode === 'runtime' && !running);
8587

8688
if (!composition && !compositionError) return null;
8789

‎web/src/components/Sidebar.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
FileDiff,
2020
} from 'lucide-react';
2121
import { AppContext } from '@/App';
22-
import { useIsDesktop } from '@/hooks/useMediaQuery';
22+
import { useMediaQuery } from '@/hooks/useUiState';
2323
import { getHealth } from '@/api/client';
2424
import type { Health } from '@/types';
2525
import clsx from 'clsx';
@@ -55,7 +55,7 @@ export default function Sidebar() {
5555
const ctx = useContext(AppContext);
5656
const [liveHealth, setLiveHealth] = useState<Health | null>(null);
5757

58-
const isDesktop = useIsDesktop();
58+
const isDesktop = useMediaQuery('(min-width: 1024px)');
5959

6060
const [isCollapsed, setIsCollapsed] = useState(() => {
6161
try {

‎web/src/hooks/useMediaQuery.ts‎

Lines changed: 0 additions & 43 deletions
This file was deleted.

0 commit comments

Comments
 (0)