Skip to content

Commit dbb28b2

Browse files
committed
fix(frontend): live shell immediately, fresh sessions, PDF preview
Skip full-screen loading on ?live=1; paint reducer state before whoami/SSE. freshLiveUrl(?n=) + resetLiveSession abort stale streams. Route /app/runs. Collapse @pipeline thread to one readiness line; embed report.pdf from Results.
1 parent 74604e7 commit dbb28b2

10 files changed

Lines changed: 158 additions & 39 deletions

File tree

frontend/src/App.tsx

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { CenterPane } from "./components/CenterPane";
77
import { RightPane } from "./components/RightPane";
88
import { RunsDrawer } from "./components/RunsDrawer";
99
import { type Activation, type DryLabView, type ExperimentRequest, emptyView } from "./lib/contract";
10-
import { isLive, loadView } from "./lib/loadView";
11-
import { continueLive, hasLiveSession, postExperiment } from "./lib/sse";
10+
import { freshLiveUrl, isLive, loadView } from "./lib/loadView";
11+
import { continueLive, hasLiveSession, postExperiment, resetLiveSession } from "./lib/sse";
1212
import { copyToClipboard, createShare } from "./lib/share";
1313
import { navigate } from "./lib/router";
1414
import { cleanGoal } from "./lib/goal";
@@ -86,8 +86,24 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
8686

8787
useEffect(() => {
8888
let alive = true;
89-
setLoading(true);
90-
loadView({ onUpdate: (v) => alive && setView(v), sharedId, runId })
89+
setErr(null);
90+
const params = new URLSearchParams(location.search);
91+
const liveEntry = params.get("live") === "1" && !runId && !sharedId;
92+
if (liveEntry) {
93+
if (params.get("n")) resetLiveSession();
94+
setLoading(false);
95+
} else {
96+
setLoading(true);
97+
}
98+
loadView({
99+
onUpdate: (v) => {
100+
if (!alive) return;
101+
setView(v);
102+
setLoading(false);
103+
},
104+
sharedId,
105+
runId,
106+
})
91107
.then((v) => {
92108
if (alive) {
93109
setView(v);
@@ -103,7 +119,7 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
103119
return () => {
104120
alive = false;
105121
};
106-
}, []);
122+
}, [sharedId, runId, location.search]);
107123

108124
const onApproveExperiment = useCallback(async (payload: ExperimentRequest): Promise<Activation> => {
109125
const act = await postExperiment(payload);
@@ -193,17 +209,21 @@ export default function App({ sharedId, runId }: { sharedId?: string; runId?: st
193209
</button>
194210
<OverflowMenu items={overflowItems} />
195211
{isPastRun && view.research_goal && (
196-
<button className="btn btn-accent" onClick={() => navigate(`/app?live=1&goal=${encodeURIComponent(cleanGoal(view.research_goal))}`)} title="Run this goal again, live">
212+
<button className="btn btn-accent" onClick={() => navigate(freshLiveUrl(cleanGoal(view.research_goal)))} title="Run this goal again, live">
197213
<Icons.flask cls="icon-sm" />Re-run
198214
</button>
199215
)}
200216
{!readOnly && !isLive() && (
201-
<button className="btn btn-accent" onClick={() => navigate("/app?live=1")} title="Start a live run">
217+
<button className="btn btn-accent" onClick={() => navigate(freshLiveUrl())} title="Start a live run">
202218
<Icons.flask cls="icon-sm" />Run live
203219
</button>
204220
)}
205221
{!readOnly && isLive() && (
206-
<button className="btn btn-accent" onClick={() => navigate("/app?live=1")} title="Start a new run">
222+
<button
223+
className="btn btn-accent"
224+
onClick={() => navigate(freshLiveUrl())}
225+
title="Start a new run"
226+
>
207227
<Icons.flask cls="icon-sm" />New run
208228
</button>
209229
)}

frontend/src/components/CenterPane.tsx

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ function FigureView({ file }: { file: ResultFile }) {
6161
);
6262
}
6363

64+
function PdfEmbed({ uri, title }: { uri: string; title: string }) {
65+
return (
66+
<div className="pdf-embed-wrap">
67+
<embed src={uri} type="application/pdf" className="pdf-embed" title={title} />
68+
</div>
69+
);
70+
}
71+
6472
function defaultTabId(view: DryLabView, tabs: Tab[], live: boolean): string {
6573
const md = (view.report as { markdown?: string } | null)?.markdown;
6674
// Live, before the report exists: open the Computer so the user watches the agent work (split-screen).
@@ -101,8 +109,10 @@ export function CenterPane({
101109
// The report.pdf + executed notebook are reached from their owning surface, not as their own tabs.
102110
const reportPdf = view.results.find((f) => basename(f.name) === "report.pdf");
103111
const notebookFile = view.results.find((f) => f.kind === "notebook");
112+
const pdfTab = tab?.file && /\.pdf$/i.test(tab.file.name);
104113
const path =
105-
tab?.kind === "report" ? (reportSource ? "report.md · source" : "report.md")
114+
tab?.id === "report" ? (reportSource ? "report.md · source" : "report.md")
115+
: pdfTab ? basename(tab.file!.name)
106116
: tab?.kind === "computer" ? "computer · in-container Jupyter kernel"
107117
: tab?.kind === "provenance" ? "provenance · runs/evidence index"
108118
: tab?.file ? basename(tab.file.name)
@@ -139,7 +149,7 @@ export function CenterPane({
139149
<Icons.dna cls="icon-sm" />
140150
<span className="pathbar-path">{path}</span>
141151
<div className="pathbar-actions">
142-
{tab?.kind === "report" && rawMd && (
152+
{tab?.id === "report" && rawMd && (
143153
<>
144154
<button className="pathbar-btn" onClick={() => setReportSource((s) => !s)} title={reportSource ? "Show the rendered report" : "View the Markdown source"}>
145155
<Icons.code cls="icon-sm" />{reportSource ? "Rendered" : "Source"}
@@ -156,18 +166,24 @@ export function CenterPane({
156166
<Icons.nb cls="icon-sm" />{basename(notebookFile.name)}
157167
</a>
158168
)}
159-
{tab?.file && tab.closable && (
169+
{tab?.file && tab.closable && !pdfTab && (
160170
<a className="pathbar-btn" href={tab.file.uri} download={basename(tab.file.name)}>
161171
<Icons.download cls="icon-sm" />Download
162172
</a>
163173
)}
174+
{pdfTab && tab.file && (
175+
<a className="pathbar-btn" href={tab.file.uri} download={basename(tab.file.name)} title="Download the publication PDF">
176+
<Icons.download cls="icon-sm" />PDF
177+
</a>
178+
)}
164179
</div>
165180
</div>
166181

167182
<div className="pane-scroll center-scroll">
168-
{tab?.kind === "report" && (reportSource
183+
{tab?.id === "report" && (reportSource
169184
? <div className="report-source-wrap"><pre className="report-source mono">{rawMd}</pre></div>
170185
: <Report view={view} onOpenProvenance={() => open("provenance")} />)}
186+
{pdfTab && tab.file && <PdfEmbed uri={tab.file.uri} title={basename(tab.file.name)} />}
171187
{tab?.kind === "computer" && <Computer view={view} live={live} />}
172188
{tab?.kind === "provenance" && (
173189
<div className="prov-tab">

frontend/src/components/Landing.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414

1515
import { type CSSProperties, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
1616
import { Icons } from "./Icons";
17+
import { freshLiveUrl } from "../lib/loadView";
1718
import { navigate } from "../lib/router";
19+
import { liveUserId } from "../lib/sse";
1820
import type { DryLabView } from "../lib/contract";
1921
import {
2022
buildThread,
@@ -1006,7 +1008,7 @@ function GallerySection({ cards }: { cards: GalleryCard[] }) {
10061008
{/* the empty slot becomes the invitation: same loop, your skill (generalize via skills, not schema) */}
10071009
<button
10081010
className="run run-byo reveal"
1009-
onClick={() => navigate("/app?live=1")}
1011+
onClick={() => navigate(freshLiveUrl())}
10101012
aria-label="Bring your own skill — start your own use case"
10111013
>
10121014
<div className="run-viz run-byo-viz">
@@ -1042,7 +1044,7 @@ const CHIPS = [
10421044

10431045
function Composer({ goal, setGoal }: { goal: string; setGoal: (s: string) => void }) {
10441046
const taRef = useRef<HTMLTextAreaElement>(null);
1045-
const runLive = () => navigate(`/app?live=1&goal=${encodeURIComponent(goal)}`);
1047+
const runLive = () => navigate(freshLiveUrl(goal));
10461048
// grow the textarea to fit the question — it wraps to the next line like any chat input, no clipped fade
10471049
const autosize = () => {
10481050
const ta = taRef.current;
@@ -1098,6 +1100,10 @@ export function Landing() {
10981100
const [fixture, setFixture] = useState(CHIPS[0].fixture);
10991101
const [scrolled, setScrolled] = useState(false);
11001102

1103+
useEffect(() => {
1104+
void liveUserId();
1105+
}, []);
1106+
11011107
useEffect(() => {
11021108
let alive = true;
11031109
fetch("/replay/replay.json")
@@ -1160,7 +1166,7 @@ export function Landing() {
11601166
<button className="lp-btn lp-btn-ghost lp-nav-ghost" onClick={() => navigate(`/app?fixture=${encodeURIComponent(fixture)}`)}>
11611167
<Icons.play cls="icon-sm" /> Watch a run
11621168
</button>
1163-
<button className="lp-btn lp-btn-accent" onClick={() => navigate("/app?live=1&goal=" + encodeURIComponent(goal))}>
1169+
<button className="lp-btn lp-btn-accent" onClick={() => navigate(freshLiveUrl(goal))}>
11641170
Run it live <span className="arr"></span>
11651171
</button>
11661172
<button className="lp-theme" onClick={toggleTheme} aria-label="Toggle light / dark theme" title="Toggle light / dark theme">
@@ -1322,7 +1328,7 @@ export function Landing() {
13221328
]}
13231329
/>
13241330
<div className="close-cta reveal d1">
1325-
<button className="lp-btn lp-btn-accent lg" onClick={() => navigate("/app?live=1&goal=" + encodeURIComponent(goal))}>
1331+
<button className="lp-btn lp-btn-accent lg" onClick={() => navigate(freshLiveUrl(goal))}>
13261332
Run it live <span className="arr"></span>
13271333
</button>
13281334
<button className="lp-btn lp-btn-ghost lg" onClick={() => navigate(`/app?fixture=${encodeURIComponent(fixture)}`)}>

frontend/src/components/LeftPane.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,14 @@ function threadTurns(view: DryLabView): ChatTurn[] {
510510
const done = (id: string) => view.todos.find((t) => t.id === id)?.status === "done";
511511
const hasAuthor = (key: string) => turns.some((t) => t.role === "agent" && phaseKey(t.author) === key);
512512

513+
if (done("pipeline")) {
514+
turns = collapsePhaseTurns(turns, "pipeline", {
515+
id: "a-pipeline",
516+
role: "agent",
517+
author: "pipeline",
518+
text: pipelineNarration(view),
519+
});
520+
}
513521
if (done("investigator")) {
514522
turns = collapsePhaseTurns(turns, "investigator", {
515523
id: "a-investigator",

frontend/src/components/RunsDrawer.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// switches on click. A thin projection of runs/run_status — no new entity model (mirrors RunsList, compact).
44
import { useCallback, useEffect, useState } from "react";
55
import { Icons } from "./Icons";
6+
import { freshLiveUrl } from "../lib/loadView";
67
import { navigate } from "../lib/router";
78
import { cleanGoal } from "../lib/goal";
89

@@ -65,7 +66,7 @@ export function RunsDrawer({ open, onClose, currentRunId }: { open: boolean; onC
6566
<button className="runs-drawer-x" onClick={onClose} aria-label="Close run history"><Icons.x cls="icon-sm" /></button>
6667
</header>
6768
<p className="runs-drawer-sub">Every supervised investigation, kept in the BigQuery provenance index.</p>
68-
<button className="btn btn-accent runs-drawer-new" onClick={() => navigate("/app?live=1")}>
69+
<button className="btn btn-accent runs-drawer-new" onClick={() => navigate(freshLiveUrl())}>
6970
<Icons.flask cls="icon-sm" />New run
7071
</button>
7172

frontend/src/components/RunsList.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// new entity model: this is a projection of runs/run_status. (Enjamb projects-table grammar; our own identity.)
44
import { useCallback, useEffect, useState } from "react";
55
import { Icons } from "./Icons";
6+
import { freshLiveUrl } from "../lib/loadView";
67
import { navigate } from "../lib/router";
78
import { cleanGoal } from "../lib/goal";
89

@@ -51,7 +52,7 @@ function Rail() {
5152
</button>
5253
<div className="runs-rail-nav">
5354
<button className="runs-rail-item" onClick={() => navigate("/")}><Icons.home cls="icon-sm" /><span className="rail-label">Home</span></button>
54-
<button className="runs-rail-item" onClick={() => navigate("/app?live=1")}><Icons.plus cls="icon-sm" /><span className="rail-label">New run</span></button>
55+
<button className="runs-rail-item" onClick={() => navigate(freshLiveUrl())}><Icons.plus cls="icon-sm" /><span className="rail-label">New run</span></button>
5556
<button className="runs-rail-item active" aria-current="page"><Icons.list cls="icon-sm" /><span className="rail-label">Runs</span></button>
5657
<span className="runs-rail-item disabled" title="Saved goal templates — coming soon"><Icons.ground cls="icon-sm" /><span className="rail-label">Templates</span><span className="soon mono">soon</span></span>
5758
</div>
@@ -90,7 +91,7 @@ export function RunsList() {
9091
<h1 className="runs-title">My runs</h1>
9192
<p className="runs-sub">Every supervised investigation, recorded in the BigQuery provenance index. Open one read-only, or re-run its goal live.</p>
9293
</div>
93-
<button className="btn btn-accent" onClick={() => navigate("/app?live=1")} title="Start a new live run">
94+
<button className="btn btn-accent" onClick={() => navigate(freshLiveUrl())} title="Start a new live run">
9495
<Icons.flask cls="icon-sm" />Run it live
9596
</button>
9697
</header>
@@ -105,7 +106,7 @@ export function RunsList() {
105106
) : runs.length === 0 ? (
106107
<div className="runs-empty">
107108
No runs recorded yet.
108-
<button className="btn btn-accent runs-empty-cta" onClick={() => navigate("/app?live=1")}><Icons.flask cls="icon-sm" />Start your first run</button>
109+
<button className="btn btn-accent runs-empty-cta" onClick={() => navigate(freshLiveUrl())}><Icons.flask cls="icon-sm" />Start your first run</button>
109110
</div>
110111
) : (
111112
<ul className="runs-list">
@@ -130,7 +131,7 @@ export function RunsList() {
130131
<button className="btn btn-ghost" onClick={() => navigate(`/runs/${r.run_id}`)} title="Open read-only">
131132
<Icons.arrowR cls="icon-sm" />Open
132133
</button>
133-
<button className="btn btn-ghost" onClick={() => navigate(`/app?live=1&goal=${encodeURIComponent(cleanGoal(r.goal))}`)} title="Re-run this goal live (a new run)">
134+
<button className="btn btn-ghost" onClick={() => navigate(freshLiveUrl(cleanGoal(r.goal)))} title="Re-run this goal live (a new run)">
134135
<Icons.restart cls="icon-sm" />Re-run
135136
</button>
136137
</div>

frontend/src/lib/loadView.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ export function isLive(): boolean {
1515
return new URLSearchParams(location.search).get("live") === "1";
1616
}
1717

18+
/** Fresh live session URL — always carries `n=` so resetLiveSession runs and stale SSE cannot resume. */
19+
export function freshLiveUrl(goal?: string): string {
20+
const n = Date.now();
21+
if (goal?.trim()) return `/app?live=1&n=${n}&goal=${encodeURIComponent(goal.trim())}`;
22+
return `/app?live=1&n=${n}`;
23+
}
24+
1825
// A shared run persisted via POST /share, read back through GET /shared/:id and rendered read-only by the SAME
1926
// reducer/replay path. Artifact URIs are left as-is (/artifacts/... resolves on the serving instance).
2027
async function loadShared(id: string): Promise<DryLabView> {

frontend/src/lib/router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export type Route = { name: "landing" | "app" | "shared" | "runs" | "run"; id?:
88
export function parseRoute(path: string = location.pathname): Route {
99
if (path === "/" || path === "") return { name: "landing" };
1010
if (path === "/app" || path === "/app/") return { name: "app" };
11+
// Legacy / mistyped deep links under /app — same surfaces as top-level /runs routes.
12+
if (path === "/app/runs" || path === "/app/runs/") return { name: "runs" };
13+
const ar = /^\/app\/runs\/([A-Za-z0-9_-]+)\/?$/.exec(path);
14+
if (ar) return { name: "run", id: ar[1] };
1115
if (path === "/runs" || path === "/runs/") return { name: "runs" };
1216
const r = /^\/runs\/([A-Za-z0-9_-]+)\/?$/.exec(path); // a past run, reconstructed read-only from the index
1317
if (r) return { name: "run", id: r[1] };

0 commit comments

Comments
 (0)