1- import { useCallback , useEffect , useState } from "react" ;
1+ import { useCallback , useEffect , useMemo , useState } from "react" ;
22import { Icons } from "./components/Icons" ;
3+ import { OverflowMenu } from "./components/OverflowMenu" ;
34import { ThemeCtx , type Theme } from "./components/theme" ;
45import { LeftPane } from "./components/LeftPane" ;
56import { CenterPane } from "./components/CenterPane" ;
67import { RightPane } from "./components/RightPane" ;
78import { RunsDrawer } from "./components/RunsDrawer" ;
89import { type Activation , type DryLabView , type ExperimentRequest , emptyView } from "./lib/contract" ;
910import { isLive , loadView } from "./lib/loadView" ;
10- import { recordedRunLabel } from "./lib/spine" ;
1111import { continueLive , hasLiveSession , postExperiment } from "./lib/sse" ;
1212import { copyToClipboard , createShare } from "./lib/share" ;
1313import { navigate } from "./lib/router" ;
1414import { cleanGoal } from "./lib/goal" ;
1515
16- // Capture month of the committed replay fixture (frontend/public/replay/replay.json) — see PLAN.md run log.
17- // Honest: the default /app is a RECORDED run, not a live one. Bump this when the fixture is recaptured.
18- const FIXTURE_DATE = "Jun 2026" ;
16+ function truncateTitle ( text : string , max = 56 ) : string {
17+ if ( text . length <= max ) return text ;
18+ return text . slice ( 0 , max - 1 ) . trimEnd ( ) + "…" ;
19+ }
1920
2021function useTheme ( ) : [ Theme , ( t : Theme | ( ( p : Theme ) => Theme ) ) => void ] {
2122 const [ theme , setTheme ] = useState < Theme > ( ( ) => ( localStorage . getItem ( "drylab-theme" ) as Theme ) || "light" ) ;
@@ -143,64 +144,67 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
143144 }
144145 } , [ view ] ) ;
145146
147+ const runTitle = useMemo ( ( ) => {
148+ const goal = cleanGoal ( view . research_goal ) ;
149+ if ( goal ) return truncateTitle ( goal ) ;
150+ return "Dry Lab" ;
151+ } , [ view . research_goal ] ) ;
152+
153+ const modeLabel = isPastRun ? "Past run" : sharedId ? "Shared" : isLive ( ) ? "Live" : "Recorded" ;
154+
155+ const overflowItems = useMemo ( ( ) => {
156+ const items : { label : string ; onClick : ( ) => void ; disabled ?: boolean } [ ] = [ ] ;
157+ if ( ! sharedId ) {
158+ items . push ( {
159+ label : shareState === "copied" ? "Link copied" : shareState === "busy" ? "Sharing…" : "Copy share link" ,
160+ onClick : onShare ,
161+ disabled : shareState === "busy" ,
162+ } ) ;
163+ }
164+ items . push ( { label : "Print report" , onClick : ( ) => window . print ( ) } ) ;
165+ return items ;
166+ } , [ sharedId , shareState , onShare ] ) ;
167+
146168 return (
147169 < ThemeCtx . Provider value = { theme } >
148170 < div className = "app" >
149171 < header className = "topbar" >
150- < button className = "brand brand-home" onClick = { ( ) => navigate ( "/" ) } title = "Dry Lab home" >
151- < span className = "brand-mark" > < Icons . dna /> </ span >
152- < div className = "brand-text" >
153- < span className = "brand-name" > Dry Lab</ span >
154- < span className = "brand-project mono" >
155- { isPastRun
156- ? "Past run · read-only (from the runs index)"
157- : sharedId
158- ? "Shared run · read-only"
159- : isLive ( )
160- ? "Live run · /run_sse"
161- : `Recorded run · ${ recordedRunLabel (
162- new URLSearchParams ( location . search ) . get ( "fixture" ) || "replay" ,
163- view . research_goal ,
164- ) } · ${ FIXTURE_DATE } — watch it replay`}
165- </ span >
166- </ div >
167- </ button >
168- { /* Layout toggles (VS Code / Linear grammar): collapse either side pane to focus the center. The
169- buttons stay in the bar whatever the state, so a hidden pane is always one click from returning. */ }
170- < div className = "topbar-toggles" >
171- < button className = { "btn btn-ghost btn-icon" + ( leftOpen ? " on" : "" ) } onClick = { toggleLeft } aria-pressed = { leftOpen } title = { leftOpen ? "Hide the conversation" : "Show the conversation" } >
172- < Icons . panelLeft cls = "icon-sm" />
172+ < div className = "topbar-left" >
173+ < button
174+ className = { "btn btn-ghost btn-icon" + ( runsOpen ? " on" : "" ) }
175+ onClick = { ( ) => setRunsOpen ( ( o ) => ! o ) }
176+ aria-pressed = { runsOpen }
177+ title = "History"
178+ aria-label = "Run history"
179+ >
180+ < Icons . list cls = "icon-sm" />
173181 </ button >
174- < button className = { "btn btn-ghost btn-icon" + ( rightOpen ? " on" : "" ) } onClick = { toggleRight } aria-pressed = { rightOpen } title = { rightOpen ? "Hide the run details" : "Show the run details" } >
175- < Icons . panelRight cls = "icon-sm" />
182+ < button className = "brand brand-home" onClick = { ( ) => navigate ( "/" ) } title = "Dry Lab home" >
183+ < span className = "brand-mark" > < Icons . dna /> </ span >
184+ < div className = "brand-text" >
185+ < span className = "brand-title serif" > { runTitle } </ span >
186+ < span className = "mode-chip mono" > { modeLabel } </ span >
187+ </ div >
176188 </ button >
177189 </ div >
178- < div className = "topbar-right" >
179- { readOnly && < span className = "view-only-badge mono" > < Icons . shield cls = "icon-sm" /> View only</ span > }
180- < button className = { "btn btn-ghost" + ( runsOpen ? " on" : "" ) } onClick = { ( ) => setRunsOpen ( true ) } title = "Run history" >
181- < Icons . list cls = "icon-sm" /> Runs
182- </ button >
183- < button className = "btn btn-ghost" onClick = { ( ) => setTheme ( ( t ) => ( t === "light" ? "dark" : "light" ) ) } title = "Toggle theme" >
190+ < div className = "topbar-actions" >
191+ < button className = "btn btn-ghost btn-icon" onClick = { ( ) => setTheme ( ( t ) => ( t === "light" ? "dark" : "light" ) ) } title = "Toggle theme" >
184192 { theme === "light" ? < Icons . moon cls = "icon-sm" /> : < Icons . sun cls = "icon-sm" /> }
185193 </ button >
186- { /* A reconstructed past run can be re-run live (a NEW run with the same goal) — never a faked resume. */ }
194+ < OverflowMenu items = { overflowItems } />
187195 { isPastRun && view . research_goal && (
188196 < button className = "btn btn-accent" onClick = { ( ) => navigate ( `/app?live=1&goal=${ encodeURIComponent ( cleanGoal ( view . research_goal ) ) } ` ) } title = "Run this goal again, live" >
189- < Icons . flask cls = "icon-sm" /> Re-run live
197+ < Icons . flask cls = "icon-sm" /> Re-run
190198 </ button >
191199 ) }
192- { /* Share is available for live/replay/past-run views, but NOT for an already-shared /r/:id snapshot. */ }
193- { ! sharedId && (
194- < button className = "btn btn-ghost mobile-hide" onClick = { onShare } disabled = { shareState === "busy" } title = "Create a read-only share link" >
195- { shareState === "copied" ? < Icons . check cls = "icon-sm" /> : < Icons . share cls = "icon-sm" /> }
196- { shareState === "copied" ? "Link copied" : shareState === "busy" ? "Sharing…" : "Share" }
197- </ button >
198- ) }
199- < button className = "btn btn-ghost mobile-hide" onClick = { ( ) => window . print ( ) } > < Icons . download cls = "icon-sm" /> Export</ button >
200- { /* This default workspace is a recorded run — make "Run it live" the first-class accent CTA. */ }
201200 { ! readOnly && ! isLive ( ) && (
202201 < button className = "btn btn-accent" onClick = { ( ) => navigate ( "/app?live=1" ) } title = "Start a live run" >
203- < Icons . flask cls = "icon-sm" /> Run it live
202+ < Icons . flask cls = "icon-sm" /> Run live
203+ </ button >
204+ ) }
205+ { ! readOnly && isLive ( ) && (
206+ < button className = "btn btn-accent" onClick = { ( ) => navigate ( "/app?live=1" ) } title = "Start a new run" >
207+ < Icons . flask cls = "icon-sm" /> New run
204208 </ button >
205209 ) }
206210 </ div >
@@ -212,10 +216,27 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
212216 < div className = "load-state" > Loading run…</ div >
213217 ) : (
214218 < div className = "workspace" >
219+ { ! leftOpen && (
220+ < div className = "pane-rail pane-rail-left" >
221+ < button className = "btn btn-ghost btn-icon" onClick = { toggleLeft } title = "Show conversation" aria-label = "Show conversation" >
222+ < Icons . panelLeft cls = "icon-sm" />
223+ </ button >
224+ </ div >
225+ ) }
215226 { leftOpen && (
216227 < >
217228 < div className = "pane-host pane-host-left fade-in" style = { { width : leftW } } >
218- < LeftPane view = { view } live = { isLive ( ) && ! readOnly } onApprovePlan = { onApprovePlan } onEditPlan = { onEditPlan } onSend = { onSend } onOpenFile = { openFile } />
229+ < LeftPane
230+ view = { view }
231+ live = { isLive ( ) && ! readOnly }
232+ onApprovePlan = { onApprovePlan }
233+ onEditPlan = { onEditPlan }
234+ onApproveExperiment = { onApproveExperiment }
235+ onSend = { onSend }
236+ onOpenFile = { openFile }
237+ paneOpen = { leftOpen }
238+ onTogglePane = { toggleLeft }
239+ />
219240 </ div >
220241 < div className = "divider" onMouseDown = { ( e ) => dragLeft ( e , "left" ) } > < span /> </ div >
221242 </ >
@@ -225,10 +246,24 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
225246 < >
226247 < div className = "divider" onMouseDown = { ( e ) => dragRight ( e , "right" ) } > < span /> </ div >
227248 < div className = "pane-host pane-host-right fade-in" style = { { width : rightW } } >
228- < RightPane view = { view } onApproveExperiment = { onApproveExperiment } onOpenFile = { openFile } canUpload = { ! readOnly } />
249+ < RightPane
250+ view = { view }
251+ onOpenFile = { openFile }
252+ canUpload = { ! readOnly }
253+ live = { isLive ( ) && ! readOnly }
254+ paneOpen = { rightOpen }
255+ onTogglePane = { toggleRight }
256+ />
229257 </ div >
230258 </ >
231259 ) }
260+ { ! rightOpen && (
261+ < div className = "pane-rail pane-rail-right" >
262+ < button className = "btn btn-ghost btn-icon" onClick = { toggleRight } title = "Show run details" aria-label = "Show run details" >
263+ < Icons . panelRight cls = "icon-sm" />
264+ </ button >
265+ </ div >
266+ ) }
232267 </ div >
233268 ) }
234269
0 commit comments