| 157 | type RunTab = "session" | "browser" | "terminal" | "source"; |
| 158 | |
| 159 | const RunView = ({ target, slug }: { target: string; slug: string }) => { |
| 160 | const base = `${target}/${slug}`; |
| 161 | const [result, setResult] = useState<RunResult | null>(null); |
| 162 | const [error, setError] = useState<string | null>(null); |
| 163 | const [tab, setTab] = useState<RunTab | null>(null); |
| 164 | const [traces, setTraces] = useState<RunTraceRef[]>([]); |
| 165 | const [timeline, setTimeline] = useState<SessionTimeline | null>(null); |
| 166 | |
| 167 | useEffect(() => { |
| 168 | fetch(`${base}/result.json`) |
| 169 | .then((r) => r.json()) |
| 170 | .then(setResult) |
| 171 | .catch((e) => setError(String(e))); |
| 172 | fetch(`${base}/traces.json`) |
| 173 | .then((r) => (r.ok ? r.json() : [])) |
| 174 | .then(setTraces) |
| 175 | .catch(() => setTraces([])); |
| 176 | fetch(`${base}/timeline.json`) |
| 177 | .then((r) => (r.ok ? r.json() : null)) |
| 178 | .then(setTimeline) |
| 179 | .catch(() => setTimeline(null)); |
| 180 | }, [base]); |
| 181 | |
| 182 | if (error) return <div className="page error-text">failed to load run: {error}</div>; |
| 183 | if (!result) return <div className="page dim">loading…</div>; |
| 184 | |
| 185 | const has = (name: string) => result.artifacts.includes(name); |
| 186 | const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); |
| 187 | const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null; |
| 188 | const film = has("film.mp4") ? "film.mp4" : null; |
| 189 | const cast = has("terminal.cast") ? "terminal.cast" : null; |
| 190 | const traceUrl = has("trace.zip") |
| 191 | ? new URL( |
| 192 | `trace-viewer/index.html?trace=${encodeURIComponent( |
| 193 | new URL(`${base}/trace.zip`, window.location.href).toString(), |
| 194 | )}`, |
| 195 | window.location.href, |
| 196 | ).toString() |
| 197 | : null; |
| 198 | // The live two-recording player needs both recordings AND a focus |
| 199 | // timeline whose clocks are anchored. Anything less falls back to |
| 200 | // film.mp4 (pre-rendered cuts) or the single recording. |
| 201 | const playable = Boolean( |
| 202 | cast && |
| 203 | video && |
| 204 | timeline && |
| 205 | timeline.focus.length >= 2 && |
| 206 | timeline.anchors.terminal !== undefined && |
| 207 | timeline.anchors.browser !== undefined, |
| 208 | ); |
| 209 | |
| 210 | const tabs: Array<{ id: RunTab; label: string; available: boolean }> = [ |
| 211 | { id: "session", label: "▶ session", available: playable || Boolean(film) }, |
| 212 | { id: "browser", label: "browser", available: Boolean(video) }, |
| 213 | { id: "terminal", label: "terminal", available: Boolean(cast) }, |
| 214 | { id: "source", label: "</> test source", available: has("test.ts") }, |
| 215 | ]; |
| 216 | const available = tabs.filter((entry) => entry.available); |