<script lang="ts">
import { onMount } from "svelte";
import TopBar from "./components/TopBar.svelte";
import TurnStream from "./components/TurnStream.svelte";
import Composer from "./components/Composer.svelte";
import CapabilityHealthPanel from "./components/CapabilityHealthPanel.svelte";
import { StreamingClientV2 } from "./lib/streaming-v2";
import { everything, TRANSCRIPT_PRESET, type LaneSelection } from "./lib/turns";
const LOCAL_WS_URL = "ws://127.0.0.1:8765/ws";
function resolveWsUrl(): string {
const params = new URLSearchParams(window.location.search);
const override = params.get("ws");
if (override) return override;
const { protocol, host } = window.location;
if (protocol === "http:" || protocol === "https:") {
return `${protocol === "https:" ? "wss:" : "ws"}://${host}/ws`;
}
return LOCAL_WS_URL;
}
const client = new StreamingClientV2(resolveWsUrl());
const healthStore = client.healthStore;
const workItemsStore = client.workItemsStore;
const candidateStore = client.candidateStore;
const diagnosticsStore = client.diagnosticsStore;
const connectionStore = client.connectionStore;
let stream: TurnStream;
/**
* `?lanes=everything`, `?lanes=cells` or `?lanes=speech,cell`. A view is worth being able to link to.
* The default is the two lanes that answer "what was said and what was run"; everything else is a
* deliberate widening, and the picker remembers nothing between loads on purpose.
*/
const DEFAULT_LANES: LaneSelection = TRANSCRIPT_PRESET;
function resolveLanes(): LaneSelection {
const wanted = new URLSearchParams(window.location.search).get("lanes");
if (!wanted) return DEFAULT_LANES;
if (wanted === "everything") return everything();
const known = wanted.split(",").map((s) => s.trim()).filter(Boolean).map((s) => (s === "cells" ? "cell" : s));
return known.length ? known : DEFAULT_LANES;
}
let selection: LaneSelection = resolveLanes();
let panel = false;
let atLive = true;
let viewingAt: number | undefined = undefined;
let behind = 0;
onMount(() => {
client.connect();
return () => client.disconnect();
});
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape" && panel) {
panel = false;
}
}
</script>
<svelte:window on:keydown={handleKeydown} />
<div class="shell">
<div class="col">
<TopBar {client} bind:selection panelOpen={panel} onTogglePanel={() => (panel = !panel)} />
<TurnStream
bind:this={stream}
{client}
{selection}
bind:atLive
bind:viewingAt
bind:behind
/>
<Composer {client} {atLive} onJumpToLive={() => stream?.jumpToLive()} onSent={() => stream?.jumpToLive()} />
</div>
{#if panel}
<aside class="side">
<CapabilityHealthPanel
health={$healthStore}
workItems={$workItemsStore}
candidate={$candidateStore}
diagnostics={$diagnosticsStore}
rejectedCount={$connectionStore.rejectedCount}
lastRejection={$connectionStore.lastRejection}
onClose={() => (panel = false)}
/>
</aside>
{/if}
</div>
<style>
.shell {
display: flex;
height: 100vh;
height: 100dvh;
background: var(--bg);
}
/* one column. the page is the column; the rest of the viewport is breathing room. */
/* the column is not the viewport: the scroller spans the window so its scrollbar sits at the
window edge, and the reading column is centred *inside* it. */
.col {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
width: 100%;
}
.side {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: 320px;
z-index: 25;
overflow-y: auto;
background: var(--bg-2);
border-left: 1px solid var(--line);
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.4);
}
@media (max-width: 900px) {
.side { flex-basis: 260px; max-width: 260px; }
}
</style>