<script lang="ts">
import Metrics from "./Metrics.svelte";
import {
ArrowDown, ArrowUp, Ellipsis, Eraser, OctagonX, Play, RotateCcw, Zap,
} from "lucide-svelte";
import type { StreamingClientV2 } from "../lib/streaming-v2";
export let client: StreamingClientV2;
export let onSent: () => void = () => {};
/** False while the operator is reading history: the way back sits beside the readout, not over the text. */
export let atLive = true;
export let onJumpToLive: () => void = () => {};
const candidate = client.candidateStore;
const acks = client.operatorAckStore;
let draft = "";
let source = "operator";
let more = false;
let armed: string | null = null;
let inputEl: HTMLTextAreaElement;
function submit() {
const text = draft.trim();
if (!text) return;
client.sendInput(text, source.trim() || "operator");
draft = "";
onSent();
}
function onKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
submit();
}
}
/** Destructive verbs confirm on a second click of the same button. Nothing fires from a stray click. */
function destructive(id: "reset" | "stop" | "drain", run: () => void) {
if (armed !== id) { armed = id; return; }
run();
armed = null;
}
// how long a cell has been running belongs on that cell, where its output arrives. a clock in the
// composer competes with the field you are typing into and answers a question you did not ask here.
$: activateReady = Boolean($candidate?.target_revision) && typeof $candidate?.epoch === "number";
function activate() {
const rev = $candidate?.target_revision;
const epoch = $candidate?.epoch;
if (!rev || typeof epoch !== "number") return;
client.send({ type: "activate", session_id: client.sessionId, expected_epoch: epoch, release_path: rev });
}
</script>
<div class="dock">
{#if $acks && !$acks.success}
<div class="notice err-t">
refused {$acks.action}{#if $acks.message} · {$acks.message}{/if}
</div>
{:else if $acks && $acks.success}
<div class="notice ok">done {$acks.action}</div>
{/if}
{#if more}
<div class="more">
<button class="btn" type="button" on:click={() => client.send({ type: "compact", session_id: client.sessionId })}
title="ask the session to compact its context"><Eraser size={13} stroke-width={1.5} />compact</button>
<button class="btn {armed === "drain" ? "btn-danger is-armed" : "btn-danger"}" type="button"
on:click={() => destructive("drain", () => client.send({ type: "drain", session_id: client.sessionId }))}
on:blur={() => (armed = null)}
title="finish accepted work, then stop accepting input">
<OctagonX size={13} stroke-width={1.5} />{armed === "drain" ? "confirm drain" : "drain"}
</button>
<button class="btn" type="button" disabled={!activateReady} on:click={activate}
title={activateReady ? "switch the active behaviour revision" : "no candidate revision reported"}>
<Play size={13} stroke-width={1.5} />activate{#if activateReady}<span class="mute num">{$candidate?.epoch}</span>{/if}
</button>
<label class="src">
<span class="mute">from</span>
<input class="field src-field" bind:value={source} spellcheck="false" autocomplete="off" aria-label="input source" />
</label>
</div>
{/if}
<div class="row">
<button class="btn btn-icon btn-ghost" type="button" title="interrupt the running cell" aria-label="interrupt cell"
on:click={() => client.interrupt()}><Zap size={14} stroke-width={1.5} /></button>
<button class="btn btn-icon btn-danger btn-ghost {armed === "reset" ? "is-armed" : ""}" type="button"
aria-label="reset kernel" title="discard the python namespace; history is kept"
on:click={() => destructive("reset", () => client.resetKernel())} on:blur={() => (armed = null)}>
<RotateCcw size={14} stroke-width={1.5} />
</button>
<button class="btn btn-icon btn-danger btn-ghost {armed === "stop" ? "is-armed" : ""}" type="button"
aria-label="stop session" title="stop the session — in-flight work is interrupted"
on:click={() => destructive("stop", () => client.send({ type: "stop", session_id: client.sessionId }))}
on:blur={() => (armed = null)}>
<OctagonX size={14} stroke-width={1.5} />
</button>
<textarea
class="prompt"
bind:this={inputEl}
bind:value={draft}
on:keydown={onKeydown}
rows="1"
spellcheck="false"
autocomplete="off"
aria-label="message for the session"
placeholder="say something, or ask for work"
></textarea>
<button class="btn btn-icon btn-primary" type="button" disabled={!draft.trim()}
title="send" aria-label="send" on:click={submit}><ArrowUp size={14} stroke-width={2} /></button>
<button class="btn btn-icon btn-ghost" type="button" class:on={more} aria-pressed={more}
title="more operator verbs" aria-label="more operator verbs" on:click={() => (more = !more)}>
<Ellipsis size={14} stroke-width={1.5} />
</button>
</div>
{#if !atLive}
<button class="btn jump" type="button" on:click={onJumpToLive} title="newer records are below">
<ArrowDown size={13} stroke-width={1.5} />jump to live
</button>
{/if}
<div class="stats">
<Metrics items={client.timelineStore} />
</div>
</div>
<style>
/* a floating dock, not a bar across the screen: it is as wide as the reading column. */
.dock {
position: relative;
flex: 0 0 auto; /* same rule as the top bar: the transcript gives ground, not the chrome */
display: flex;
flex-direction: column;
gap: var(--s-4);
margin: 0 auto var(--s-5);
width: 100%;
max-width: var(--col);
padding: var(--s-4);
container-type: inline-size; /* the dock's own width drives what the readout sheds */
background: color-mix(in srgb, var(--bg-3) 88%, var(--bg));
border: 1px solid var(--line-2);
border-radius: var(--r-lg);
box-shadow: 0 10px 30px rgb(0 0 0 / 0.45);
}
/* every control in this row shares one baseline, and the field is exactly as tall as they are */
.row { display: flex; align-items: center; gap: var(--s-3); }
.row > .btn, .row > .btn-icon { align-self: center; }
.prompt {
flex: 1 1 auto;
min-height: 26px; max-height: 200px;
padding: 3px var(--s-4) 4px;
font-family: var(--sans); font-size: var(--fs-3); line-height: 1.45;
color: var(--fg); background: var(--bg-2);
border: 1px solid var(--line); border-radius: var(--r);
resize: none; field-sizing: content;
}
.prompt:focus { border-color: var(--accent-mute-1); outline: none; }
.more { display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
.src { display: inline-flex; align-items: center; gap: var(--s-3); margin-left: auto; }
.src-field { width: 10ch; height: 22px; }
.notice { font-size: var(--fs-1); }
/* the telemetry line: one cluster at the right end, tight, not spread edge to edge */
.stats {
display: flex;
align-items: center;
gap: var(--s-5);
width: 100%;
border-top: 1px solid var(--line);
padding-top: var(--s-3);
}
/* the horizon control floats centred above the dock: anchored to the dock's own top edge, so it can
never sit on the composer or drift with the transcript's length. */
.jump {
position: absolute;
bottom: calc(100% + var(--s-3));
left: 50%;
transform: translateX(-50%);
z-index: 5;
height: 24px;
white-space: nowrap;
background: var(--bg-3);
border-color: var(--accent-mute-1);
color: var(--accent-fade);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.5);
}
.jump:hover { background: var(--bg-2); border-color: var(--accent); }
</style>