<script lang="ts">
import { onMount } from "svelte";
import {
Activity, Brain, Check, ChevronDown, CircleSlash, Layers, Megaphone, MessageSquare, ShieldAlert,
Terminal, User,
} from "lucide-svelte";
import type { StreamingClientV2, TimelineItem } from "../lib/streaming-v2";
import { everything, filterItems, sameSelection, TRANSCRIPT_PRESET, type LaneSelection } from "../lib/turns";
export let client: StreamingClientV2;
export let selection: LaneSelection;
export let panelOpen = false;
export let onTogglePanel: () => void = () => {};
const timeline = client.timelineStore;
const conn = client.connectionStore;
const status = client.sessionStatusStore;
let open = false;
let menuEl: HTMLDivElement;
onMount(() => {
const close = (e: MouseEvent) => { if (open && menuEl && !menuEl.contains(e.target as Node)) open = false; };
window.addEventListener("mousedown", close);
return () => window.removeEventListener("mousedown", close);
});
// distinct glyphs: a row of near-identical chat bubbles says nothing
const LANES = [
{ id: "conversation", label: "conversation", hint: "your messages and what was delivered", icon: MessageSquare },
{ id: "speech", label: "speech", hint: "everything the agent said, including held and rejected", icon: Megaphone },
{ id: "cell", label: "cells", hint: "python executions and their output", icon: Terminal },
{ id: "scratchpad", label: "scratchpad", hint: "private reasoning, never delivered", icon: Brain },
{ id: "lifecycle", label: "lifecycle", hint: "hook decisions, waits, activations", icon: Activity },
{ id: "input", label: "input", hint: "messages from you and other sources", icon: User },
] as const;
$: items = $timeline as TimelineItem[];
$: shown = filterItems(items, selection).length;
$: held = items.filter((it) => it.lane === "speech" && (it.data as { receipt?: { status?: string } }).receipt?.status === "held").length;
$: rejected = items.filter((it) => it.lane === "speech" && ["rejected", "failed"].includes(String((it.data as { receipt?: { status?: string } }).receipt?.status))).length;
function toggle(id: string) {
selection = selection.includes(id) ? selection.filter((s) => s !== id) : [...selection, id];
}
// A view is named when it has a name. Spelling out which lanes are on is a sentence, not a label,
// and past one lane it stops being readable at a glance.
const presets = () => [
{ label: "transcript", hint: "what you said, what the agent said, and what it ran", lanes: TRANSCRIPT_PRESET },
{ label: "everything", hint: "every lane at once", lanes: everything() },
];
$: preset = presets().find((p) => sameSelection(selection, p.lanes));
$: label = selection.length === 0
? "nothing selected"
: preset
? preset.label
: selection.length === 1
? LANES.find((l) => l.id === selection[0])?.label ?? "lanes"
: `${selection.length} lanes`;
</script>
<div class="top">
<span class="id">
{#if $conn.state === "connected"}
<span class="live" title="connected"></span>
{:else}
<span class="dead" title={$conn.state === "connecting" ? "connecting" : "no daemon"}></span>
{/if}
<span class="name">{$conn.sessionId}</span>
{#if typeof ($status as Record<string, unknown> | null)?.generation === "string"}
<span class="mute num">gen {String(($status as Record<string, unknown>).generation)}</span>
{/if}
</span>
{#if held > 0}
<button class="chip chip-agent chip-action" type="button" on:click={onTogglePanel}
title="a delivery was held for review — open diagnostics">
<ShieldAlert size={12} stroke-width={1.5} /><span class="num">{held} held</span>
</button>
{/if}
{#if rejected > 0}
<button class="chip chip-err chip-action" type="button" on:click={onTogglePanel}
title="a delivery was rejected — open diagnostics">
<CircleSlash size={12} stroke-width={1.5} /><span class="num">{rejected} rejected</span>
</button>
{/if}
<span class="spacer"></span>
<div class="picker" bind:this={menuEl}>
<button
class="btn"
class:on={open}
type="button"
aria-expanded={open}
aria-haspopup="listbox"
title="choose which lanes this view shows"
on:click={() => (open = !open)}
>
<Layers size={14} stroke-width={1.5} />
<span>{label}</span>
<span class="picker-count mute num">{shown}</span>
<ChevronDown size={13} stroke-width={1.5} />
</button>
{#if open}
<div class="menu" role="listbox" aria-multiselectable="true" aria-label="lanes">
{#each LANES as lane (lane.id)}
{@const Icon = lane.icon}
<button
class="item"
type="button"
role="option"
aria-selected={selection.includes(lane.id)}
title={lane.hint}
on:click={() => toggle(lane.id)}
>
<span class="box" class:checked={selection.includes(lane.id)}>
{#if selection.includes(lane.id)}<Check size={12} stroke-width={2.5} />{/if}
</span>
<Icon size={14} stroke-width={1.5} class="lane-{lane.id}" />
<span class="item-label">{lane.label}</span>
<span class="mute num">{items.filter((it) => it.lane === lane.id || (lane.id === "conversation" && (it.lane === "input" || (it.lane === "speech" && it.data.receipt?.status === "confirmed")))).length}</span>
</button>
{/each}
<div class="foot">
{#each presets() as p (p.label)}
<button class="btn" type="button" title={p.hint} on:click={() => (selection = p.lanes)}>{p.label}</button>
{/each}
</div>
</div>
{/if}
</div>
<button
class="btn"
class:on={panelOpen}
type="button"
aria-pressed={panelOpen}
title="diagnostics — capability health, work items, candidate revision"
on:click={onTogglePanel}
>
<Activity size={14} stroke-width={1.5} />
<span class="btn-shed">diagnostics</span>
</button>
</div>
<style>
.top {
display: flex;
align-items: center;
gap: var(--s-4);
/* chrome never shrinks. the bar's min-content height is its buttons, so a long transcript used
to squeeze 9px out of it and the controls ended up sitting on the bottom edge. */
flex: 0 0 auto;
height: 36px;
/* the bar's own width is the constraint, not the viewport: the column is 780px inside a
wider window, so viewport media queries were shedding figures that still fit. */
container-type: inline-size;
width: 100%;
max-width: var(--col);
margin: 0 auto;
padding: 0 var(--s-4) ;
border-bottom: 1px solid var(--line-2);
}
.id { display: inline-flex; align-items: center; gap: var(--s-4); flex: 0 0 auto; white-space: nowrap; }
.name { font-family: var(--sans); font-weight: 600; font-size: var(--fs-2); }
.live { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.dead { width: 7px; height: 7px; border-radius: 50%; background: var(--warn); }
.spacer { flex: 1 1 auto; }
/* shed words before shedding shape: below this the diagnostics button keeps its icon and title,
and the picker keeps its lane name but drops the count. */
@container (max-width: 820px) {
.btn-shed { display: none; }
.picker-count { display: none; }
}
/* the metrics cluster is the thing that gives ground when the bar is tight */
.top :global(.metrics) { flex: 0 0 auto; } /* a clipped readout is worse than a shorter one */
.picker { position: relative; }
.picker .btn.on { border-color: var(--accent); }
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 30;
min-width: 268px;
padding: var(--s-3);
background: var(--bg-3);
border: 1px solid var(--line-2);
border-radius: var(--r);
box-shadow: 0 12px 28px rgb(0 0 0 / 0.5);
}
.item {
display: flex;
align-items: center;
gap: var(--s-4);
width: 100%;
padding: var(--s-3) var(--s-4);
font-family: var(--sans); font-size: var(--fs-2); font-weight: 500; color: var(--fg);
background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--r);
cursor: pointer; text-align: left;
}
.item + .item { margin-top: var(--s-2); }
.item:hover { background: var(--raise); border-color: var(--line-2); }
.item-label { flex: 1 1 auto; }
.box {
display: inline-flex; align-items: center; justify-content: center;
width: 15px; height: 15px; flex: 0 0 15px;
border: 1px solid var(--line-2); border-radius: 3px; color: var(--bg);
}
.box.checked { background: var(--accent); border-color: var(--accent); }
.foot { display: flex; gap: var(--s-3); padding: var(--s-3) var(--s-4) var(--s-2); border-top: 1px solid var(--line); margin-top: var(--s-3); }
</style>