Add sssf skill, installable via the skills CLI

Port the sssf skill from ~/.agents/skills/sssf into this repo so it can be
distributed and installed with the skills CLI (skills add INDigitalStudio/skills
--skill sssf).

- Copy the skill (SKILL.md, cookbooks, references, scripts, templates, and the
  visualizer app source) into sssf/.
- Gitignore build/runtime artifacts: the visualizer's node_modules/ and dist/,
  Python bytecode, and the machine-specific repos.json.
- Make the skill location-independent: install.py now stamps the skill's real
  path into the stamped justfile's skill_dir (replacing the hardcoded
  ~/.agents/skills/sssf), so 'just obs' finds the visualizer wherever the CLI
  installed the skill.
- Update cookbooks to use <skill>/scripts/... instead of the hardcoded path,
  and document the skills CLI install command.
- Update the repo README with install instructions.
This commit is contained in:
INDigitalStudio 2026-08-09 21:00:28 +00:00
parent a42608f602
commit 2cc766aabe
98 changed files with 12508 additions and 0 deletions

View file

@ -0,0 +1,264 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRoute, hrefFor, phaseCrumb, navigate } from './lib/router'
import type { RepoInfo } from './lib/types'
import { fetchRepos, reloadRepos } from './lib/api'
import SessionsList from './components/SessionsList.vue'
import SessionTrace from './components/SessionTrace.vue'
const route = useRoute()
const repos = ref<RepoInfo[]>([])
const repoName = ref<string | null>(null)
const reloading = ref(false)
const reloadError = ref<string | null>(null)
onMounted(async () => {
try {
repos.value = await fetchRepos()
// The URL must always carry the repo slug — parse() reads parts[0] as the
// repo, so a bare #/<adw_id> would be misread as repo=adwId. If we loaded
// with no repo segment, redirect to the first repo.
if (!route.value.repo && repos.value.length > 0) {
navigate(repos.value[0].slug)
return
}
const current = repos.value.find((r) => r.slug === route.value.repo)
repoName.value = current?.name ?? current?.slug ?? null
} catch {
// Repo list is a nicety; the default repo still works without it.
}
})
function onRepoChange(event: Event) {
const slug = (event.target as HTMLSelectElement).value
navigate(slug || null)
}
/** Re-read the server's repos.json so newly added repos show up without a restart. */
async function reload() {
reloading.value = true
reloadError.value = null
try {
repos.value = await reloadRepos()
// If the current repo was removed, fall back to the first one.
if (route.value.repo && !repos.value.some((r) => r.slug === route.value.repo)) {
navigate(repos.value[0]?.slug ?? null)
}
const current = repos.value.find((r) => r.slug === route.value.repo)
repoName.value = current?.name ?? current?.slug ?? null
} catch (error) {
reloadError.value = (error as Error).message
} finally {
reloading.value = false
}
}
</script>
<template>
<div class="app">
<header class="topbar">
<nav class="crumbs">
<!-- Inline copy of public/logo.svg (the favicon) so the mark renders
crisply with no fetch; keep the two in sync. -->
<svg class="logo" viewBox="0 0 32 32" aria-hidden="true">
<rect x="4" y="6" width="17" height="5" rx="2.5" fill="#e8b64a" />
<rect x="8" y="13.5" width="20" height="5" rx="2.5" fill="#c89bff" />
<rect x="4" y="21" width="13" height="5" rx="2.5" fill="#5ad2dd" />
</svg>
<span class="brand">Super Simple Software Factory</span>
<span class="sep">›</span>
<a :href="hrefFor(route.repo)" :class="{ current: !route.adwId }">sessions</a>
<template v-if="route.adwId">
<span class="sep">›</span>
<a :href="hrefFor(route.repo, route.adwId)" :class="{ current: !route.phaseId }">{{
route.adwId
}}</a>
</template>
<template v-if="route.adwId && route.phaseId">
<span class="sep">›</span>
<span class="current">{{ phaseCrumb ?? route.phaseId }}</span>
</template>
</nav>
<div class="topbar-right">
<select
v-if="repos.length > 1"
class="repo-switcher"
:value="route.repo ?? repos[0]?.slug ?? ''"
@change="onRepoChange"
:title="repoName ?? undefined"
>
<option v-for="r in repos" :key="r.slug" :value="r.slug">{{ r.name || r.slug }}</option>
</select>
<button
class="reload-btn"
type="button"
:disabled="reloading"
:title="reloadError ?? 'Re-read repos.json to pick up newly added repos'"
@click="reload"
>
{{ reloading ? '…' : '↻' }}
</button>
<span class="live-hint"><span class="live-dot" /> live</span>
</div>
<div v-if="reloadError" class="reload-error">{{ reloadError }}</div>
</header>
<main>
<SessionsList v-if="!route.adwId" />
<SessionTrace v-else :key="route.adwId" :adw-id="route.adwId" :phase-id="route.phaseId" />
</main>
</div>
</template>
<style scoped>
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 15px 28px;
background: rgba(11, 15, 24, 0.72);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
position: sticky;
top: 0;
z-index: 10;
}
/* Gradient hairline instead of a hard border — the brand colors, whispered. */
.topbar::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background: linear-gradient(
90deg,
rgba(200, 155, 255, 0.45),
rgba(90, 210, 221, 0.35) 40%,
rgba(90, 210, 221, 0.06)
);
}
.crumbs {
display: flex;
align-items: center;
gap: 10px;
font-size: 17px;
min-width: 0;
}
.logo {
width: 28px;
height: 28px;
flex: none;
filter: drop-shadow(0 0 8px rgba(200, 155, 255, 0.35));
}
.brand {
background: linear-gradient(90deg, var(--purple), var(--cyan));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-weight: 700;
letter-spacing: 0.05em;
white-space: nowrap;
}
.sep {
color: var(--faint);
}
.crumbs a {
color: var(--dim);
}
.crumbs a:hover {
color: var(--text);
}
.crumbs .current {
color: var(--text);
}
.live-hint {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--dim);
font-size: 16px;
white-space: nowrap;
}
.topbar-right {
display: flex;
align-items: center;
gap: 16px;
}
.repo-switcher {
background: rgba(11, 15, 24, 0.72);
color: var(--text);
border: 1px solid rgba(200, 155, 255, 0.25);
border-radius: 8px;
padding: 5px 10px;
font-size: 14px;
cursor: pointer;
}
.repo-switcher:hover {
border-color: rgba(200, 155, 255, 0.5);
}
.repo-switcher option {
background: #0b0f18;
color: var(--text);
}
.reload-btn {
background: rgba(11, 15, 24, 0.72);
color: var(--dim);
border: 1px solid rgba(200, 155, 255, 0.25);
border-radius: 8px;
width: 30px;
height: 30px;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.reload-btn:hover:not(:disabled) {
color: var(--text);
border-color: rgba(200, 155, 255, 0.5);
}
.reload-btn:disabled {
opacity: 0.5;
cursor: default;
}
.reload-error {
position: absolute;
right: 28px;
top: 100%;
margin-top: 6px;
padding: 6px 10px;
font-size: 13px;
color: #ff8a8a;
background: rgba(11, 15, 24, 0.9);
border: 1px solid rgba(255, 138, 138, 0.4);
border-radius: 8px;
z-index: 11;
}
.live-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--green);
box-shadow: 0 0 10px rgba(74, 222, 128, 0.7);
animation: pulse 1.6s ease-in-out infinite;
}
</style>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
title: string
/** Lucide icon component rendered before the title. */
icon?: Component
/** Shown after the title; omit for sections without a natural count. */
count?: number | null
open: boolean
}>()
defineEmits<{ toggle: [] }>()
</script>
<template>
<section class="dsec">
<button class="dsec-head" @click="$emit('toggle')">
<span class="chev">{{ open ? '▾' : '▸' }}</span>
<component :is="icon" v-if="icon" class="dsec-icon" :size="19" :stroke-width="2" />
<span class="dsec-title">{{ title }}</span>
<span v-if="count != null" class="dsec-count dim">({{ count }})</span>
</button>
<div v-if="open" class="dsec-body">
<slot />
</div>
</section>
</template>
<style scoped>
.dsec {
margin-bottom: 14px;
}
.dsec-head {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 6px 8px;
background: none;
border: none;
border-bottom: 1px solid var(--border-soft);
border-radius: 6px 6px 0 0;
color: var(--dim);
font-size: 16px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: lowercase;
cursor: pointer;
text-align: left;
}
.dsec-icon {
flex: none;
color: var(--faint);
}
.dsec-head:hover {
background: var(--panel-2);
color: var(--text);
}
.chev {
color: var(--faint);
flex: none;
}
.dsec-count {
font-weight: 500;
}
.dsec-body {
padding-top: 10px;
}
</style>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Phase } from '../lib/types'
const props = defineProps<{ phases: Phase[] }>()
const ordered = computed(() => props.phases.toSorted((a, b) => (a.seq ?? 0) - (b.seq ?? 0)))
const glyph: Record<string, string> = {
success: '●',
running: '◐',
queued: '○',
fail: '✗',
}
</script>
<template>
<span class="dots">
<span
v-for="p in ordered"
:key="p.phase_id"
class="d"
:class="p.status"
:title="`${p.name} — ${p.status}`"
>{{ glyph[p.status ?? ''] ?? '○' }}</span
>
<span v-if="!ordered.length" class="faint">—</span>
</span>
</template>
<style scoped>
.dots {
display: inline-flex;
gap: 5px;
font-size: 16px;
letter-spacing: 0;
}
.d.success {
color: var(--green);
}
.d.fail {
color: var(--red);
}
.d.running {
color: var(--blue);
animation: pulse 1.2s ease-in-out infinite;
}
.d.queued {
color: var(--faint);
}
</style>

View file

@ -0,0 +1,473 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, shallowRef, watch } from 'vue'
import type { EventRow, SessionSummary } from '../lib/types'
import { archiveSession, fetchEvents } from '../lib/api'
import { axisTicks, fmtDate, fmtOffset, ts } from '../lib/format'
import { agentColor, dotColor, eventLabel } from '../lib/events'
import { hrefFor, useRoute } from '../lib/router'
import StatusChip from './StatusChip.vue'
import StatChip from './StatChip.vue'
import PhaseDots from './PhaseDots.vue'
const props = defineProps<{ session: SessionSummary; nowMs: number }>()
const emit = defineEmits<{ archived: [adwId: string] }>()
const route = useRoute()
// The card is an <a>; the button lives inside it, so the click must not
// navigate. Told the parent optimistically — the poll would take up to half a
// second to drop the card, and a triage click should feel instant.
async function archive(event: MouseEvent) {
event.preventDefault()
event.stopPropagation()
emit('archived', props.session.adw_id)
try {
await archiveSession(route.value.repo, props.session.adw_id)
} catch {
emit('archived', '') // signals the parent to re-sync from the server
}
}
// Each card tails its own event stream: one full fetch on mount, then the
// same rowid-cursor poll as the trace view — but only while the run is live.
const events = shallowRef<EventRow[]>([])
let cursor = 0
let inflight = false
let timer: ReturnType<typeof setInterval> | undefined
function stopPolling() {
clearInterval(timer)
timer = undefined
}
async function pull() {
if (inflight) return
inflight = true
try {
const fresh: EventRow[] = []
let page
do {
// Cursor pagination is inherently sequential: each request needs the previous cursor.
// oxlint-disable-next-line no-await-in-loop
page = await fetchEvents(route.value.repo, props.session.adw_id, cursor, 1000)
cursor = Math.max(cursor, page.cursor)
fresh.push(...page.events)
} while (page.has_more)
if (fresh.length) events.value = [...events.value, ...fresh]
if (props.session.status !== 'running') stopPolling()
} catch {
/* the list view surfaces api errors; a card just retries next poll */
} finally {
inflight = false
}
}
onMounted(() => {
void pull()
if (props.session.status === 'running') timer = setInterval(() => void pull(), 500)
})
onUnmounted(stopPolling)
watch(
() => props.session.status,
(status) => {
if (status === 'running' && !timer) timer = setInterval(() => void pull(), 500)
// On the transition out of running, one last pull drains the tail and stops the timer.
else if (status !== 'running') void pull()
},
)
const running = computed(() => props.session.status === 'running')
const range = computed(() => {
const s = props.session
let t0 = ts(s.started_at)
if (!Number.isFinite(t0)) {
t0 = Math.min(...events.value.map((e) => ts(e.started_at)).filter(Number.isFinite))
}
if (!Number.isFinite(t0)) t0 = props.nowMs
let t1 = running.value ? props.nowMs : ts(s.ended_at)
if (!Number.isFinite(t1)) {
t1 = Math.max(...events.value.map((e) => ts(e.started_at)).filter(Number.isFinite))
}
if (!Number.isFinite(t1)) t1 = t0 + 1000
return { t0, span: Math.max(t1 - t0, 1000) }
})
const ticks = computed(() => axisTicks(range.value.span, 5))
interface TimelineDot {
id: string
xPct: number
color: string
title: string
latest: boolean
}
interface TimelineRow {
owner: string
color: string
title: string
dots: TimelineDot[]
}
// Per-agent rows: events attribute to an agent through their phase's owner.
const rows = computed<TimelineRow[]>(() => {
const owners: string[] = []
const ownerByPhase = new Map<string, string>()
for (const p of props.session.phases ?? []) {
if (p.kind !== 'agent' || !p.owner) continue
ownerByPhase.set(p.phase_id, p.owner)
if (!owners.includes(p.owner)) owners.push(p.owner)
}
if (!owners.length) return []
const { t0, span } = range.value
const byOwner = new Map<string, TimelineDot[]>(owners.map((o) => [o, []]))
let latest: TimelineDot | null = null
let latestT = -Infinity
for (const e of events.value) {
const owner = e.phase_id ? ownerByPhase.get(e.phase_id) : undefined
const color = dotColor(e.type)
if (!owner || !color) continue
const t = ts(e.started_at)
if (!Number.isFinite(t)) continue
const dot: TimelineDot = {
id: e.event_id,
xPct: Math.min(Math.max(((t - t0) / span) * 100, 0), 100),
color,
title: `${e.type} ${eventLabel(e)} at ${fmtOffset(t - t0)}`,
latest: false,
}
byOwner.get(owner)?.push(dot)
if (t >= latestT) {
latestT = t
latest = dot
}
}
if (running.value && latest) latest.latest = true
// /api/sessions embeds agents so the labels can use config colors with no
// extra request; historical sessions return color null → fallback palette.
return owners.map((owner, i) => {
const info = (props.session.agents ?? []).find((a) => a.agent === owner)
return {
owner,
color: agentColor(info?.color, null, i),
title: info?.model ? `${owner} ${info.model}` : owner,
dots: byOwner.get(owner) ?? [],
}
})
})
const durationMs = computed(() => {
const s = props.session
const start = ts(s.started_at)
if (!Number.isFinite(start)) return NaN
const end = running.value ? props.nowMs : ts(s.ended_at)
return (Number.isFinite(end) ? end : props.nowMs) - start
})
// Cards are a fixed size, so the timeline region fits exactly MAX_VISIBLE_ROWS
// row slots. A roster that overflows spends one slot on the "+N more" line and
// shows MIN_VISIBLE_ROWS agents in the rest — never fewer than three, so a
// five-agent chain still reads as a chain rather than as a pair and a count.
const MAX_VISIBLE_ROWS = 4
const MIN_VISIBLE_ROWS = 3
const overflowing = computed(() => rows.value.length > MAX_VISIBLE_ROWS)
const visibleRows = computed(() =>
overflowing.value ? rows.value.slice(0, MIN_VISIBLE_ROWS) : rows.value,
)
const hiddenRowCount = computed(() =>
overflowing.value ? rows.value.length - MIN_VISIBLE_ROWS : 0,
)
</script>
<template>
<a class="card" :class="session.status" :href="hrefFor(route.repo, session.adw_id)">
<button
class="card-archive"
type="button"
title="Archive — remove this run from review"
aria-label="Archive run"
@click="archive"
>
×
</button>
<span class="card-id">{{ session.adw_id }}</span>
<span class="card-adw" :title="session.adw_name ?? ''">{{ session.adw_name ?? '—' }}</span>
<span class="card-req" :title="session.request ?? ''">{{ session.request }}</span>
<div v-if="rows.length" class="tl">
<div class="tl-axis">
<span class="tl-gutter" />
<span class="tl-scale">
<span
v-for="(t, i) in ticks"
:key="i"
class="tl-tick"
:class="{ edge: t.pct === 0 }"
:style="{ left: `${t.pct}%` }"
>{{ t.label }}</span
>
</span>
</div>
<div v-for="row in visibleRows" :key="row.owner" class="tl-row">
<span class="tl-agent" :style="{ color: row.color }" :title="row.title">{{
row.owner
}}</span>
<span class="tl-track">
<span
v-for="dot in row.dots"
:key="dot.id"
class="tl-dot"
:class="{ latest: dot.latest }"
:style="{ left: `${dot.xPct}%`, background: dot.color }"
:title="dot.title"
/>
</span>
</div>
<div v-if="hiddenRowCount" class="tl-more dim">+{{ hiddenRowCount }} more agents</div>
</div>
<div v-else class="tl tl-empty faint">no agent activity yet</div>
<div class="card-foot">
<span class="foot-status">
<StatusChip :status="session.status ?? 'fail'" />
<PhaseDots :phases="session.phases ?? []" />
</span>
<span class="dim">{{ fmtDate(session.started_at) }}</span>
</div>
<div class="card-stats">
<StatChip kind="cost" :value="session.total_cost" />
<StatChip kind="runtime" :value="durationMs" />
<StatChip kind="tokens" :value="session.total_tokens" />
</div>
</a>
</template>
<style scoped>
.card {
/* Uniform size: the grid fixes the width, this fixes the height — content
clamps and truncates rather than resizing the card. Grew by one 40px row
slot when the timeline went from three to four. */
height: 420px;
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 22px;
position: relative; /* anchors the archive button */
border: 1px solid var(--border-soft);
border-radius: 16px;
background: var(--surface);
color: var(--text);
cursor: pointer;
overflow: hidden;
transition:
border-color 0.18s ease,
box-shadow 0.18s ease,
transform 0.18s ease;
}
.card-archive {
/* Top-right of the card, out of the text flow so nothing reflows around it. */
position: absolute;
top: 10px;
right: 12px;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--dim);
font-family: inherit;
font-size: 20px;
line-height: 1;
cursor: pointer;
opacity: 0;
transition:
opacity 0.15s ease,
background 0.15s ease,
color 0.15s ease;
}
/* Hidden until the card is hovered — 50 cards should read as runs, not as a
wall of close buttons. Focus reveals it too, so keyboards are not excluded. */
.card:hover .card-archive,
.card-archive:focus-visible {
opacity: 1;
}
.card-archive:hover {
background: rgba(255, 111, 103, 0.16);
color: #ff6f67;
}
.card:hover {
border-color: rgba(148, 163, 255, 0.45);
box-shadow: 0 10px 34px rgba(148, 163, 255, 0.12);
transform: translateY(-2px);
}
.card.running {
border-color: rgba(108, 182, 255, 0.6);
box-shadow: 0 0 22px rgba(108, 182, 255, 0.16);
}
.card.fail {
border-color: rgba(255, 111, 103, 0.6);
}
/* Text rows must never absorb flex shrink — the fixed-height card squeezes
overflow into .tl (which clips), not into the text. */
.card-id {
flex: none;
font-family: var(--mono);
font-size: 18px;
font-weight: 700;
color: var(--purple);
}
.card-adw {
flex: none;
font-family: var(--mono);
font-size: 16px;
color: var(--cyan);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-req {
flex: none;
font-size: 16px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tl {
display: flex;
flex-direction: column;
margin-top: 4px;
/* Fixed region: axis (28 + 6) + four 40px row slots, roster size or not.
Four slots is what lets three agents show alongside a "+N more" line. */
height: 194px;
flex: none;
overflow: hidden;
}
.tl-more {
display: flex;
align-items: center;
height: 40px;
padding-left: 96px;
font-size: 16px;
}
.tl-axis {
display: flex;
align-items: flex-end;
height: 28px;
margin-bottom: 6px;
}
.tl-gutter,
.tl-agent {
flex: none;
/* Wide enough for full agent names (planner, builder, documenter). */
width: 96px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding-right: 8px;
}
.tl-scale {
position: relative;
flex: 1;
height: 100%;
border-bottom: 1px solid var(--border);
}
.tl-tick {
position: absolute;
bottom: 4px;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 16px;
color: var(--faint);
white-space: nowrap;
}
.tl-tick.edge {
transform: none;
}
.tl-row {
display: flex;
align-items: center;
height: 40px;
}
.tl-agent {
font-size: 16px;
color: var(--dim);
}
.tl-track {
position: relative;
flex: 1;
height: 100%;
border-bottom: 1px solid var(--border-soft);
}
.tl-dot {
position: absolute;
top: 50%;
width: 9px;
height: 9px;
border-radius: 50%;
transform: translate(-50%, -50%);
}
.tl-dot.latest {
width: 13px;
height: 13px;
box-shadow: 0 0 10px currentColor;
animation: pulse 1.4s ease-in-out infinite;
}
.tl-empty {
align-items: center;
justify-content: center;
font-size: 16px;
}
.card-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: auto;
font-size: 16px;
}
.foot-status {
display: inline-flex;
align-items: center;
gap: 14px;
}
.card-stats {
display: flex;
align-items: center;
gap: 12px;
}
</style>

View file

@ -0,0 +1,862 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import type {
AgentSession,
AgentStartPayload,
Envelope,
EventRow,
GateResult,
Phase,
PhaseKind,
Session,
SessionUsage,
} from '../lib/types'
import { Bot, SquareTerminal, UserRound } from 'lucide-vue-next'
import { fetchEnvelopes, fetchEvents, fetchGates, fetchSession } from '../lib/api'
import { axisTicks, fmtDate, payloadOk, ts } from '../lib/format'
import { modelIcon, modelName } from '../lib/models'
import { agentColor, hexAlpha, parseAgentStart } from '../lib/events'
import { navigate, phaseCrumb, useRoute } from '../lib/router'
import StatusChip from './StatusChip.vue'
import StatChip from './StatChip.vue'
import PhaseDetail from './PhaseDetail.vue'
const props = defineProps<{ adwId: string; phaseId: string | null }>()
const route = useRoute()
const session = ref<Session | null>(null)
const phases = ref<Phase[]>([])
const agents = ref<AgentSession[]>([])
const usage = ref<SessionUsage>({ read: 0, written: 0 })
const events = ref<EventRow[]>([])
const envelopes = ref<Envelope[]>([])
const gates = ref<GateResult[]>([])
const apiError = ref<string | null>(null)
const loaded = ref(false)
const nowMs = ref(Date.now())
let cursor = 0
let inflight = false
let timer: ReturnType<typeof setInterval> | undefined
const SIDE_TABLE_TYPES = new Set(['gate_pass', 'gate_fail', 'handoff', 'agent_end', 'phase_end', 'error'])
async function tick() {
if (inflight) return
inflight = true
try {
const detail = await fetchSession(route.value.repo, props.adwId)
session.value = detail.session
phases.value = detail.phases.toSorted((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
agents.value = detail.agents
usage.value = detail.usage
const fresh: EventRow[] = []
let page
do {
// Cursor pagination is inherently sequential: each request needs the previous cursor.
// oxlint-disable-next-line no-await-in-loop
page = await fetchEvents(route.value.repo, props.adwId, cursor, 1000)
cursor = Math.max(cursor, page.cursor)
fresh.push(...page.events)
} while (page.has_more)
if (fresh.length) events.value = [...events.value, ...fresh]
// Envelopes and gates only gain rows around phase/agent boundaries — refetch
// on those events instead of every tick.
if (!loaded.value || fresh.some((e) => e.type !== null && SIDE_TABLE_TYPES.has(e.type))) {
const [env, g] = await Promise.all([
fetchEnvelopes(route.value.repo, props.adwId),
fetchGates(route.value.repo, props.adwId),
])
envelopes.value = env
gates.value = g
}
nowMs.value = Date.now()
apiError.value = null
loaded.value = true
} catch (err) {
apiError.value = err instanceof Error ? err.message : String(err)
} finally {
inflight = false
}
}
onMounted(() => {
void tick()
timer = setInterval(() => void tick(), 500)
})
onUnmounted(() => {
clearInterval(timer)
phaseCrumb.value = null
})
const selectedPhase = computed(
() => phases.value.find((p) => p.phase_id === props.phaseId) ?? null,
)
watchEffect(() => {
phaseCrumb.value = selectedPhase.value?.name ?? null
})
// ── Lanes ────────────────────────────────────────────────────────────────────
const ENGINEER_COLOR = '#e8b64a'
const CODE_COLOR = '#5ad2dd'
const KIND_ICONS = { engineer: UserRound, code: SquareTerminal, agent: Bot }
interface Lane {
id: string
label: string
/** Model driving this lane's agent — rendered with its provider icon. */
model: string | null
/** Context-window occupancy, or null while unknown (running / old db). */
context: LaneContext | null
metaLines: string[]
color: string
kind: PhaseKind
phases: Phase[]
}
interface LaneContext {
used: number
window: number
/** 0–100, uncapped by the floor applied to the bar's width. */
pct: number
}
/** Occupancy for an agent lane. Null unless BOTH numbers are real — a bar
* against an unknown ceiling would be decoration, not data. */
function laneContext(info: AgentSession | undefined): LaneContext | null {
const used = info?.context_tokens ?? 0
const window = info?.context_window ?? 0
if (!used || !window) return null
return { used, window, pct: Math.min(100, (used / window) * 100) }
}
/** Sub-1% occupancy is common and real; round it away and the bar reads empty. */
function contextLabel(ctx: LaneContext): string {
return ctx.pct < 1 ? `${ctx.pct.toFixed(1)}%` : `${Math.round(ctx.pct)}%`
}
/** Keep a non-zero fill visible — the exact numbers ride in the label and title. */
function contextFill(ctx: LaneContext): string {
return `${Math.max(ctx.pct, 2)}%`
}
const NUM = new Intl.NumberFormat('en-US')
// A live agent's model/thinking/color arrive on its agent_start event before
// any agent_sessions row exists; attribute each start to its phase's owner.
const ownerStart = computed<Record<string, AgentStartPayload>>(() => {
const ownerByPhase = new Map<string, string | null>(
phases.value.map((p) => [p.phase_id, p.owner]),
)
const meta: Record<string, AgentStartPayload> = {}
for (const e of events.value) {
if (e.type !== 'agent_start') continue
const owner = (e.phase_id ? ownerByPhase.get(e.phase_id) : null) ?? e.name
if (!owner || meta[owner]) continue
const payload = parseAgentStart(e)
if (payload) meta[owner] = payload
}
return meta
})
const lanes = computed<Lane[]>(() => {
const ph = phases.value
const agentOwners: string[] = []
for (const p of ph) {
if (p.kind === 'agent' && p.owner && !agentOwners.includes(p.owner)) agentOwners.push(p.owner)
}
const codePhases = ph.filter((p) => p.kind === 'code')
const out: Lane[] = [
{
id: 'engineer',
label: session.value?.engineer ?? 'engineer',
model: null,
context: null,
metaLines: ['engineer'],
color: ENGINEER_COLOR,
kind: 'engineer' as const,
phases: ph.filter((p) => p.kind === 'engineer'),
},
]
if (codePhases.length) {
out.push({
id: 'code',
label: 'code',
model: null,
context: null,
metaLines: ['workspace'],
color: CODE_COLOR,
kind: 'code' as const,
phases: codePhases,
})
}
for (const [i, owner] of agentOwners.entries()) {
const info = agents.value.find((a) => a.agent === owner)
const start = ownerStart.value[owner]
out.push({
id: `agent:${owner}`,
label: owner,
// The model is the lane's whole story; thinking level lives in the
// phase detail's agent config section.
model: info?.model ?? start?.model ?? null,
context: laneContext(info),
metaLines: [],
color: agentColor(info?.color, start?.color, i),
kind: 'agent' as const,
phases: ph.filter((p) => p.kind === 'agent' && p.owner === owner),
})
}
return out
})
// ── Timeline geometry ────────────────────────────────────────────────────────
const range = computed(() => {
let t0 = Infinity
let t1 = -Infinity
const s = session.value
const sStart = ts(s?.started_at)
const sEnd = ts(s?.ended_at)
if (Number.isFinite(sStart)) t0 = Math.min(t0, sStart)
if (Number.isFinite(sEnd)) t1 = Math.max(t1, sEnd)
for (const p of phases.value) {
const a = ts(p.started_at)
const b = ts(p.ended_at)
if (Number.isFinite(a)) {
t0 = Math.min(t0, a)
t1 = Math.max(t1, a)
}
if (Number.isFinite(b)) t1 = Math.max(t1, b)
}
if (s?.status === 'running') t1 = Math.max(t1, nowMs.value)
if (!Number.isFinite(t0)) {
t0 = nowMs.value
t1 = t0 + 1000
}
if (t1 - t0 < 1000) t1 = t0 + 1000
return { t0, t1, span: t1 - t0 }
})
// The engineer's request opens the run and owns the start of the timeline: it
// gets an exclusive leading zone, and every later phase maps into the rest —
// nothing can render on top of it.
const REQ_ZONE_PCT = 16
const requestPhase = computed(
() => phases.value.find((p) => p.kind === 'engineer' && p.started_at) ?? null,
)
const zonePct = computed(() => (requestPhase.value ? REQ_ZONE_PCT : 0))
/**
* Where the post-request timeline begins, in ms.
*
* The earliest non-engineer phase start, not the request phase's end: a later
* ADW joining the session pushes the request row's ended_at forward, which
* would otherwise throw every already-finished phase behind the origin.
*/
const originMs = computed(() => {
const { t0 } = range.value
const req = requestPhase.value
if (!req) return t0
let earliest = Infinity
for (const p of phases.value) {
if (p.kind === 'engineer') continue
const s = ts(p.started_at)
if (Number.isFinite(s)) earliest = Math.min(earliest, s)
}
if (Number.isFinite(earliest)) return Math.max(earliest, t0)
const end = ts(req.ended_at ?? req.started_at)
return Number.isFinite(end) ? Math.max(end, t0) : t0
})
const postSpan = computed(() => Math.max(range.value.t1 - originMs.value, 1000))
const ticks = computed(() => {
const zone = zonePct.value
return axisTicks(postSpan.value, 7).map((t) => ({
pct: zone + (t.pct * (100 - zone)) / 100,
label: t.label,
}))
})
/**
* Adjusted layout for every timed phase, in track-%.
*
* Phases are sequential by doctrine, and the render must say so: when a
* near-zero phase (a git commit) is widened to a readable floor, every later
* block shifts right by the same amount instead of being overlapped, and the
* whole layout is normalized back into the track. Blocks may squeeze a hair;
* they never stack.
*/
const MIN_BLOCK_PCT = 3.5
const blockLayout = computed<Record<string, { left: number; width: number }>>(() => {
const zone = zonePct.value
const avail = 100 - zone - 0.4 // hair of right margin
const t0 = originMs.value
const span = postSpan.value
const reqId = requestPhase.value?.phase_id
const timed = phases.value
.filter((p) => p.phase_id !== reqId && Number.isFinite(ts(p.started_at)))
.map((p) => {
const start = ts(p.started_at)
let end = ts(p.ended_at)
if (!Number.isFinite(end)) end = p.status === 'running' ? nowMs.value : start
return {
id: p.phase_id,
start,
left: ((start - t0) / span) * avail,
width: ((Math.max(end, start) - start) / span) * avail,
}
})
.toSorted((a, b) => a.start - b.start)
let shift = 0
let prevEdge = 0
const rows: { id: string; left: number; width: number }[] = []
for (const b of timed) {
let left = b.left + shift
if (left < prevEdge) {
shift += prevEdge - left
left = prevEdge
}
const width = Math.max(b.width, MIN_BLOCK_PCT)
shift += width - b.width
prevEdge = left + width
rows.push({ id: b.id, left, width })
}
const scale = avail / Math.max(prevEdge, avail)
const out: Record<string, { left: number; width: number }> = {}
for (const r of rows) out[r.id] = { left: zone + r.left * scale, width: r.width * scale }
return out
})
function blockGeom(p: Phase): { left: string; width: string } | null {
// The request block fills its reserved zone, nothing else ever enters it.
if (p.phase_id === requestPhase.value?.phase_id && zonePct.value > 0) {
return { left: '0.4%', width: `${zonePct.value - 0.8}%` }
}
const geom = blockLayout.value[p.phase_id]
if (!geom) return null
return { left: `${geom.left}%`, width: `${geom.width}%` }
}
function blockStyle(p: Phase, lane: Lane): Record<string, string> | undefined {
const geom = blockGeom(p)
if (!geom) return undefined
return {
left: geom.left,
width: geom.width,
background: `linear-gradient(180deg, ${hexAlpha(lane.color, 0.2)}, ${hexAlpha(lane.color, 0.05)})`,
borderColor: p.status === 'fail' ? 'rgba(255, 111, 103, 0.8)' : hexAlpha(lane.color, 0.55),
'--lane-glow': hexAlpha(lane.color, 0.28),
}
}
function blockDurationMs(p: Phase): number {
const start = ts(p.started_at)
if (!Number.isFinite(start)) return NaN
const end = p.status === 'running' ? nowMs.value : ts(p.ended_at)
if (!Number.isFinite(end)) return NaN
return end - start
}
const STATUS_GLYPH: Record<string, string> = {
success: '✓',
fail: '✗',
running: '●',
queued: '○',
}
// Tool-call tick marks inside a phase block, positioned within the block's own span.
interface ToolTick {
t: number
ok: boolean
}
const toolTicks = computed(() => {
const map: Record<string, ToolTick[]> = {}
for (const e of events.value) {
if (e.type !== 'tool_call' || !e.phase_id) continue
map[e.phase_id] ??= []
map[e.phase_id]?.push({ t: ts(e.started_at), ok: payloadOk(e.payload_json) })
}
return map
})
function ticksFor(p: Phase): { x: number; ok: boolean }[] {
const start = ts(p.started_at)
if (!Number.isFinite(start)) return []
let end = ts(p.ended_at)
if (!Number.isFinite(end)) end = p.status === 'running' ? nowMs.value : start
const width = Math.max(end - start, 1)
return (toolTicks.value[p.phase_id] ?? [])
.filter((mark) => Number.isFinite(mark.t))
.map((mark) => ({
x: Math.min(Math.max(((mark.t - start) / width) * 100, 1), 99),
ok: mark.ok,
}))
}
const queuedByLane = computed(() => {
const map: Record<string, Phase[]> = {}
for (const lane of lanes.value) {
map[lane.id] = lane.phases.filter((p) => !p.started_at)
}
return map
})
const sessionDurationMs = computed(() => {
const s = session.value
if (!s) return NaN
const start = ts(s.started_at)
if (!Number.isFinite(start)) return NaN
const end = s.status === 'running' ? nowMs.value : ts(s.ended_at)
return (Number.isFinite(end) ? end : nowMs.value) - start
})
function selectPhase(p: Phase) {
navigate(route.value.repo, props.adwId, p.phase_id === props.phaseId ? null : p.phase_id)
}
</script>
<template>
<div class="trace">
<div v-if="apiError" class="error-bar">api unreachable — retrying {{ apiError }}</div>
<div v-if="session" class="run-strip">
<span class="request" :title="session.request ?? ''">{{ session.request }}</span>
<StatusChip :status="session.status ?? 'fail'" />
<span class="dim">started {{ fmtDate(session.started_at) }}</span>
<span class="run-stats">
<StatChip kind="cost" :value="session.total_cost" />
<StatChip kind="runtime" :value="sessionDurationMs" />
<StatChip kind="tokens" :value="session.total_tokens" />
<StatChip kind="read" :value="usage.read" />
<StatChip kind="written" :value="usage.written" />
</span>
</div>
<div v-if="phases.length" class="waterfall">
<div class="row axis-row">
<div class="label" />
<div class="track">
<span v-if="zonePct" class="zone-head" :style="{ width: `${zonePct}%` }">request</span>
<span
v-for="(t, i) in ticks"
:key="i"
class="axis-label"
:style="{ left: `${t.pct}%` }"
>{{ t.label }}</span
>
</div>
</div>
<div v-for="lane in lanes" :key="lane.id" class="row lane" :class="`kind-${lane.kind}`">
<div class="label">
<span class="lane-name" :style="{ color: lane.color }">
<component :is="KIND_ICONS[lane.kind]" class="lane-icon" :size="22" :stroke-width="2" />
{{ lane.label }}
</span>
<span v-if="lane.model" class="lane-meta lane-model" :title="lane.model">
<img v-if="modelIcon(lane.model)" class="model-icon" :src="modelIcon(lane.model)!" alt="" />
{{ modelName(lane.model) }}
</span>
<span
v-if="lane.context"
class="lane-ctx"
:title="`${NUM.format(lane.context.used)} / ${NUM.format(lane.context.window)} tokens used · ${NUM.format(lane.context.window - lane.context.used)} remaining`"
>
<span class="ctx-head">
<span class="ctx-label">Context</span>
<span class="ctx-pct">{{ contextLabel(lane.context) }}</span>
</span>
<span class="ctx-bar">
<span
class="ctx-fill"
:style="{
width: contextFill(lane.context),
background: `linear-gradient(90deg, ${hexAlpha(lane.color, 0.55)}, ${lane.color})`,
boxShadow: `0 0 10px ${hexAlpha(lane.color, 0.45)}`,
}"
/>
</span>
</span>
<span v-for="(line, i) in lane.metaLines" :key="i" class="lane-meta">{{ line }}</span>
</div>
<div class="track">
<span v-if="zonePct" class="zone-divider" :style="{ left: `${zonePct}%` }" />
<span v-for="(t, i) in ticks" :key="i" class="gridline" :style="{ left: `${t.pct}%` }" />
<template v-for="p in lane.phases" :key="p.phase_id">
<button
v-if="blockGeom(p)"
class="block"
:class="[p.status, { selected: p.phase_id === phaseId }]"
:style="blockStyle(p, lane)"
:title="`${p.name} — ${p.status}${p.description ? `\n${p.description}` : ''}`"
@click="selectPhase(p)"
>
<span class="b-top">
<span class="b-status" :class="p.status">{{
STATUS_GLYPH[p.status ?? ''] ?? '○'
}}</span>
<span class="b-name">{{ p.name }}</span>
<StatChip
v-if="Number.isFinite(blockDurationMs(p))"
class="b-dur"
kind="runtime"
compact
:value="blockDurationMs(p)"
/>
</span>
<span class="b-desc">{{ p.description }}</span>
<span
v-for="(tick, i) in ticksFor(p)"
:key="i"
class="tool-tick"
:class="{ err: !tick.ok }"
:style="{ left: `${tick.x}%` }"
/>
</button>
</template>
<button
v-for="(p, i) in queuedByLane[lane.id]"
:key="p.phase_id"
class="block queued"
:class="{ selected: p.phase_id === phaseId }"
:style="{ right: `${10 + i * 5}px`, width: '170px' }"
:title="`${p.name} — queued`"
@click="selectPhase(p)"
>
<span class="b-top">
<span class="b-status queued">○</span>
<span class="b-name">{{ p.name }}</span>
</span>
<span class="b-desc">queued</span>
</button>
</div>
</div>
</div>
<div v-else-if="loaded" class="empty-state">no phases recorded for this session</div>
<div v-else-if="!apiError" class="empty-state">loading trace…</div>
<PhaseDetail
v-if="selectedPhase"
:phase="selectedPhase"
:events="events"
:envelopes="envelopes"
:gates="gates"
@close="navigate(route.repo, props.adwId)"
/>
</div>
</template>
<style scoped>
.trace {
padding: 0 0 40px;
}
.run-strip {
display: flex;
align-items: center;
gap: 18px;
padding: 14px 24px;
border-bottom: 1px solid var(--border-soft);
flex-wrap: wrap;
}
.run-strip .request {
font-size: 17px;
color: var(--text);
max-width: 52ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.run-stats {
display: inline-flex;
gap: 12px;
flex-wrap: wrap;
}
.waterfall {
margin: 20px 28px;
border: 1px solid var(--border-soft);
border-radius: 16px;
background: var(--surface);
overflow: hidden;
}
.row {
display: grid;
grid-template-columns: 280px 1fr;
}
.axis-row {
border-bottom: 1px solid var(--border);
background: var(--panel-2);
}
.axis-row .track {
height: 40px;
overflow: hidden;
}
.zone-head {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: var(--amber);
border-right: 1px solid var(--border);
}
.axis-label {
position: absolute;
bottom: 7px;
transform: translateX(-50%);
font-family: var(--mono);
font-size: 16px;
color: var(--dim);
white-space: nowrap;
}
.label {
padding: 12px 16px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 2px;
border-right: 1px solid var(--border);
overflow: hidden;
white-space: nowrap;
}
.lane-name {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.lane-icon {
flex: none;
opacity: 0.85;
}
.lane-meta {
font-family: var(--mono);
font-size: 16px;
color: var(--dim);
overflow: hidden;
text-overflow: ellipsis;
}
.lane-model {
display: inline-flex;
align-items: center;
gap: 7px;
}
.model-icon {
width: 17px;
height: 17px;
flex: none;
object-fit: contain;
}
/* Context occupancy — label row over a thin track, under the model. */
.lane-ctx {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 2px;
max-width: 190px;
}
.ctx-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.ctx-label {
font-size: 14px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--faint);
}
.ctx-pct {
font-family: var(--mono);
font-size: 14px;
color: var(--dim);
}
.ctx-bar {
height: 6px;
border-radius: 999px;
background: rgba(6, 8, 15, 0.75);
border: 1px solid var(--border-soft);
overflow: hidden;
}
.ctx-fill {
display: block;
height: 100%;
border-radius: 999px;
transition: width 300ms ease;
}
.lane {
border-bottom: 1px solid var(--border-soft);
}
.lane:last-child {
border-bottom: none;
}
.track {
position: relative;
height: 118px;
overflow: hidden;
}
.zone-divider {
position: absolute;
top: 0;
bottom: 0;
border-left: 1px solid var(--border);
}
.gridline {
position: absolute;
top: 0;
bottom: 0;
border-left: 1px dashed rgba(174, 191, 212, 0.14);
}
.block {
position: absolute;
top: 13px;
height: 92px;
display: flex;
flex-direction: column;
justify-content: flex-start;
gap: 4px;
padding: 10px 12px 16px;
border-radius: 10px;
border: 1px solid;
font-size: 16px;
color: var(--text);
cursor: pointer;
overflow: hidden;
white-space: nowrap;
text-align: left;
transition: box-shadow 0.16s ease;
}
.block:hover {
box-shadow: 0 0 18px var(--lane-glow, rgba(108, 182, 255, 0.2));
}
.b-top {
display: flex;
align-items: baseline;
gap: 10px;
min-width: 0;
}
.b-status {
flex: none;
font-size: 16px;
}
.b-status.success {
color: var(--green);
}
.b-status.fail {
color: var(--red);
}
.b-status.running {
color: var(--blue);
animation: pulse 1.2s ease-in-out infinite;
}
.b-status.queued {
color: var(--faint);
}
.block .b-name {
font-size: 17px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.block .b-dur {
margin-left: auto;
flex: none;
}
.block .b-desc {
color: var(--dim);
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.block.running {
animation: pulse 1.6s ease-in-out infinite;
}
.block.queued {
background: transparent;
border-style: dashed;
border-color: var(--faint);
color: var(--dim);
}
.block.selected {
outline: 2px solid var(--blue);
outline-offset: 2px;
box-shadow: 0 0 22px var(--lane-glow, rgba(108, 182, 255, 0.25));
}
.tool-tick {
position: absolute;
bottom: 4px;
width: 3px;
height: 9px;
background: currentColor;
opacity: 0.55;
border-radius: 1px;
}
.tool-tick.err {
background: var(--red);
opacity: 1;
}
</style>

View file

@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { SessionSummary } from '../lib/types'
import { fetchSessions } from '../lib/api'
import { ts } from '../lib/format'
import { useRoute } from '../lib/router'
import SessionCard from './SessionCard.vue'
const route = useRoute()
const sessions = shallowRef<SessionSummary[]>([])
const apiError = ref<string | null>(null)
const loaded = ref(false)
const nowMs = ref(Date.now())
let timer: ReturnType<typeof setInterval> | undefined
let inflight = false
async function tick() {
if (inflight) return
inflight = true
try {
sessions.value = await fetchSessions(route.value.repo)
nowMs.value = Date.now()
apiError.value = null
loaded.value = true
} catch (err) {
apiError.value = err instanceof Error ? err.message : String(err)
} finally {
inflight = false
}
}
onMounted(() => {
void tick()
timer = setInterval(() => void tick(), 500)
})
// Switching repo in the topbar changes the route but not the mounted view
// (adwId stays null), so refetch and reset the list.
watch(
() => route.value.repo,
() => {
sessions.value = []
loaded.value = false
void tick()
},
)
onUnmounted(() => clearInterval(timer))
/** Optimistic removal; an empty id means the write failed, so re-sync instead. */
function onArchived(adwId: string) {
if (!adwId) {
void tick()
return
}
sessions.value = sessions.value.filter((s) => s.adw_id !== adwId)
}
const ordered = computed(() =>
sessions.value.toSorted((a, b) => (ts(b.started_at) || 0) - (ts(a.started_at) || 0)),
)
</script>
<template>
<div class="sessions">
<div v-if="apiError" class="error-bar">api unreachable — retrying {{ apiError }}</div>
<div v-if="ordered.length" class="list-head dim">{{ ordered.length }} runs</div>
<div v-if="ordered.length" class="cards">
<SessionCard
v-for="s in ordered"
:key="s.adw_id"
:session="s"
:now-ms="nowMs"
@archived="onArchived"
/>
</div>
<div v-else-if="loaded" class="empty-state">no sessions yet — run an ADW to see it here</div>
<div v-else-if="!apiError" class="empty-state">loading sessions…</div>
</div>
</template>
<style scoped>
.sessions {
display: flex;
flex-direction: column;
}
.list-head {
padding: 16px 24px 0;
font-size: 16px;
}
.cards {
/* Uniform grid: every card the same width and (fixed in SessionCard) height,
independent of content. */
display: grid;
grid-template-columns: repeat(auto-fill, minmax(460px, 1fr));
gap: 18px;
padding: 16px 24px 28px;
}
</style>

View file

@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed } from 'vue'
import { BookOpen, CircleDollarSign, Coins, PenLine, Timer } from 'lucide-vue-next'
import { fmtCost, fmtDuration, fmtTokens } from '../lib/format'
const props = defineProps<{
kind: 'cost' | 'tokens' | 'runtime' | 'read' | 'written'
/** Raw value — cost in dollars, tokens as a count, runtime in milliseconds. */
value: number | null | undefined
/** Bare value, no pill chrome — for tight spots like waterfall blocks. */
compact?: boolean
}>()
const ICONS = {
cost: CircleDollarSign,
tokens: Coins,
runtime: Timer,
read: BookOpen,
written: PenLine,
}
// Every chip explains itself on hover. The token numbers in particular are read
// wrong without one — the headline is billed volume, not distinct tokens.
const TITLES = {
cost: 'Cost — dollars billed for this run, all agents combined.',
tokens:
'Tokens exchanged (billed) — everything sent or generated, counted once per turn. ' +
'Each turn re-sends the whole conversation, so this is far larger than the ' +
'conversation itself: it is spend, not size. The gap between it and read + ' +
'written is cached context re-read on later turns.',
runtime: 'Duration — wall-clock from the first phase starting to the last one ending.',
read:
'Read — raw tokens the models took in: prompts, file contents and tool results, ' +
'counted the first time they enter the context. Excludes cached re-reads of ' +
'material already counted here.',
written:
'Written — tokens the models actually generated. Each one produced exactly ' +
'once, so this is a true count of output.',
}
const text = computed(() => {
if (props.kind === 'cost') return fmtCost(props.value)
if (props.kind === 'runtime') return fmtDuration(props.value ?? NaN)
return fmtTokens(props.value)
})
</script>
<template>
<span class="stat" :class="{ compact }" :title="TITLES[kind]">
<component :is="ICONS[kind]" class="stat-icon" :size="compact ? 17 : 19" :stroke-width="2" />
<span class="stat-value">{{ text }}</span>
</span>
</template>
<style scoped>
.stat {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 3px 12px;
border: 1px solid var(--border-soft);
border-radius: 999px;
background: rgba(19, 26, 38, 0.6);
font-size: 16px;
white-space: nowrap;
}
.stat-icon {
color: var(--faint);
flex: none;
}
.stat-value {
color: var(--text);
font-family: var(--mono);
font-variant-numeric: tabular-nums;
}
.stat.compact {
padding: 0;
border: none;
background: transparent;
}
.stat.compact .stat-value {
color: var(--dim);
}
</style>

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import { Check, Circle, LoaderCircle, X } from 'lucide-vue-next'
defineProps<{ status: string }>()
const ICONS: Record<string, unknown> = {
success: Check,
fail: X,
running: LoaderCircle,
queued: Circle,
}
</script>
<template>
<span class="chip" :class="status">
<component :is="ICONS[status] ?? Circle" class="chip-icon" :size="18" :stroke-width="2.5" />
{{ status }}
</span>
</template>
<style scoped>
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 3px 13px 3px 10px;
border-radius: 999px;
border: 1px solid var(--border);
font-size: 16px;
color: var(--dim);
white-space: nowrap;
}
.chip-icon {
flex: none;
}
.chip.success {
color: var(--green);
border-color: rgba(74, 222, 128, 0.45);
background: rgba(74, 222, 128, 0.09);
box-shadow: 0 0 12px rgba(74, 222, 128, 0.12);
}
.chip.fail {
color: var(--red);
border-color: rgba(255, 111, 103, 0.45);
background: rgba(255, 111, 103, 0.09);
box-shadow: 0 0 12px rgba(255, 111, 103, 0.12);
}
.chip.running {
color: var(--blue);
border-color: rgba(108, 182, 255, 0.45);
background: rgba(108, 182, 255, 0.09);
box-shadow: 0 0 12px rgba(108, 182, 255, 0.18);
}
.chip.running .chip-icon {
animation: spin 1.1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.chip.queued {
color: var(--dim);
border-style: dashed;
}
</style>

View file

@ -0,0 +1,111 @@
import type {
Envelope,
EventRow,
EventsPage,
GateResult,
HealthResponse,
PromptsResponse,
RepoInfo,
SessionDetail,
SessionSummary,
} from './types'
async function getJson(url: string): Promise<unknown> {
const res = await fetch(url)
if (!res.ok) throw new Error(`GET ${url} → ${res.status}`)
return res.json()
}
/** Prefix an API path with the repo slug; null/empty → the server's default repo. */
function repoPath(repo: string | null, path: string): string {
return repo ? `/api/${encodeURIComponent(repo)}${path}` : `/api${path}`
}
export function fetchRepos(): Promise<RepoInfo[]> {
return getJson('/api/repos') as Promise<RepoInfo[]>
}
/** Re-read the server's repos.json and return the refreshed repo list. */
export async function reloadRepos(): Promise<RepoInfo[]> {
const res = await fetch('/api/reload', { method: 'POST' })
if (!res.ok) throw new Error(`POST /api/reload → ${res.status}`)
const data = (await res.json()) as { repos?: RepoInfo[] }
return data.repos ?? []
}
export function fetchSessions(repo: string | null): Promise<SessionSummary[]> {
return getJson(repoPath(repo, '/sessions')) as Promise<SessionSummary[]>
}
export async function fetchSession(repo: string | null, adwId: string): Promise<SessionDetail> {
const detail = (await getJson(
repoPath(repo, `/sessions/${encodeURIComponent(adwId)}`),
)) as SessionDetail
return {
session: detail.session,
usage: detail.usage ?? { read: 0, written: 0 },
phases: detail.phases ?? [],
agents: detail.agents ?? [],
}
}
export async function fetchEvents(
repo: string | null,
adwId: string,
after: number,
limit = 500,
): Promise<EventsPage> {
const page = (await getJson(
repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/events?after=${after}&limit=${limit}`),
)) as EventsPage | EventRow[]
if (Array.isArray(page)) {
const cursor = page.reduce((max, e) => Math.max(max, e.rowid), after)
return { events: page, cursor, has_more: page.length === limit }
}
return { events: page.events ?? [], cursor: page.cursor ?? after, has_more: page.has_more ?? false }
}
/** Archive a run out of the review list (or restore it with archived=false). */
export async function archiveSession(
repo: string | null,
adwId: string,
archived = true,
): Promise<void> {
const url = repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/archive`)
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ archived }),
})
if (!res.ok) throw new Error(`POST ${url} → ${res.status}`)
}
export function fetchHealth(repo: string | null): Promise<HealthResponse> {
return getJson(repoPath(repo, '/health')) as Promise<HealthResponse>
}
// PhaseDetail imports the prompts type from here alongside fetchPrompts.
export type { PromptsResponse }
export async function fetchPrompts(
repo: string | null,
adwId: string,
agent: string,
): Promise<PromptsResponse> {
const res = await fetch(
repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/agents/${encodeURIComponent(agent)}/prompts`),
)
// Not recorded (or endpoint not deployed yet) renders as "no prompts", not an error.
if (res.status === 404) return { system: null, user: null }
if (!res.ok) throw new Error(`GET prompts → ${res.status}`)
const data = (await res.json()) as Partial<PromptsResponse>
return { system: data.system ?? null, user: data.user ?? null }
}
export function fetchEnvelopes(repo: string | null, adwId: string): Promise<Envelope[]> {
return getJson(repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/envelopes`)) as Promise<Envelope[]>
}
export function fetchGates(repo: string | null, adwId: string): Promise<GateResult[]> {
return getJson(repoPath(repo, `/sessions/${encodeURIComponent(adwId)}/gates`)) as Promise<GateResult[]>
}

View file

@ -0,0 +1,131 @@
import type { AgentStartPayload, EventRow, ToolCallPayload } from './types'
// ── Event dot colors ─────────────────────────────────────────────────────────
// One color per event type, shared by the session-card timelines and the phase
// detail list. gate_fail reads as an error signal on purpose.
export const EVENT_DOT_COLORS: Record<string, string> = {
agent_start: '#c89bff',
tool_call: '#5ad2dd',
handoff: '#94a3ff',
agent_end: '#4ade80',
error: '#ff6f67',
gate_fail: '#ff6f67',
}
export function dotColor(type: string | null): string | null {
if (!type) return null
return EVENT_DOT_COLORS[type] ?? null
}
// ── Agent lane colors ────────────────────────────────────────────────────────
// Config color wins (agents[].color from the API, or the agent_start payload
// for in-flight agents); the palette below covers dbs written before the
// color column existed.
export const AGENT_FALLBACK_COLORS = ['#c89bff', '#5ad2dd', '#94a3ff', '#e8b64a', '#f2a2c4']
export function agentColor(
configColor: string | null | undefined,
payloadColor: string | null | undefined,
index: number,
): string {
return (
configColor ??
payloadColor ??
AGENT_FALLBACK_COLORS[index % AGENT_FALLBACK_COLORS.length] ??
'#c89bff'
)
}
/** "#c89bff" + alpha → rgba() usable in inline styles. Invalid input → transparent. */
export function hexAlpha(hex: string, alpha: number): string {
const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim())
if (!m || !m[1]) return 'transparent'
const n = Number.parseInt(m[1], 16)
return `rgba(${(n >> 16) & 0xff}, ${(n >> 8) & 0xff}, ${n & 0xff}, ${alpha})`
}
// ── Payload parsing ──────────────────────────────────────────────────────────
export function parsePayload(raw: string | null | undefined): Record<string, unknown> | null {
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>
}
} catch {
/* legacy or truncated payloads render raw */
}
return null
}
export function parseToolCall(e: EventRow): ToolCallPayload | null {
const payload = parsePayload(e.payload_json)
if (!payload || typeof payload.tool !== 'string') return null
return payload as ToolCallPayload
}
export function parseAgentStart(e: EventRow): AgentStartPayload | null {
return parsePayload(e.payload_json) as AgentStartPayload | null
}
// Args keys most likely to BE the call, in priority order — a bash command, a
// file path, a search pattern. Used to build the one-line label.
const ARG_LABEL_KEYS = [
'command',
'cmd',
'file_path',
'path',
'pattern',
'query',
'url',
'prompt',
'description',
]
const LABEL_MAX = 160
function oneLine(value: string): string {
const flat = value.replaceAll(/\s+/g, ' ').trim()
return flat.length > LABEL_MAX ? `${flat.slice(0, LABEL_MAX)}…` : flat
}
/** One-line summary of a tool's args: the call itself, not a JSON dump. */
export function argsSummary(args: Record<string, unknown> | undefined): string {
if (!args) return ''
for (const key of ARG_LABEL_KEYS) {
const v = args[key]
if (typeof v === 'string' && v.trim() !== '') return oneLine(v)
}
const parts: string[] = []
for (const [key, v] of Object.entries(args)) {
if (v == null) continue
parts.push(`${key}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
}
return oneLine(parts.join(' '))
}
/**
* Exact-call row label for an event.
* Rich tool_call → "bash: bun test src". Legacy payloads fall back to
* the event name plus whatever hint the payload carries.
*/
export function eventLabel(e: EventRow): string {
if (e.type === 'tool_call') {
const call = parseToolCall(e)
if (call?.tool) {
// New-tracer rows already carry the human label in events.name
// ("bash: ls -la src") — prefer it over re-deriving.
if (e.name?.startsWith(call.tool)) return oneLine(e.name)
const summary = argsSummary(call.args)
return summary ? `${call.tool}: ${summary}` : call.tool
}
const legacy = parsePayload(e.payload_json)
if (legacy && typeof legacy.pi_event === 'string') {
return `${e.name ?? 'tool'} ${legacy.pi_event}`
}
}
return e.name ?? e.type ?? ''
}

View file

@ -0,0 +1,92 @@
export function ts(iso: string | null | undefined): number {
if (!iso) return NaN
return new Date(iso).getTime()
}
export function fmtDuration(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '—'
if (ms < 1000) return `${(ms / 1000).toFixed(2)}s`
const s = ms / 1000
if (s < 60) return `${s.toFixed(1)}s`
const m = Math.floor(s / 60)
const rem = Math.round(s % 60)
if (m < 60) return `${m}m ${String(rem).padStart(2, '0')}s`
const h = Math.floor(m / 60)
return `${h}h ${String(m % 60).padStart(2, '0')}m`
}
export function fmtClock(iso: string | null | undefined): string {
const t = ts(iso)
if (!Number.isFinite(t)) return '—'
return new Date(t).toLocaleTimeString([], { hour12: false })
}
export function fmtDate(iso: string | null | undefined): string {
const t = ts(iso)
if (!Number.isFinite(t)) return '—'
const d = new Date(t)
return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${fmtClock(iso)}`
}
export function fmtTokens(n: number | null | undefined): string {
if (n == null) return '—'
if (n < 1000) return String(n)
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
return `${(n / 1_000_000).toFixed(2)}M`
}
export function fmtCost(n: number | null | undefined): string {
if (n == null) return '—'
return n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(4)}`
}
// Compact offset label for time axes: 0s, 30s, 1m, 1m30s, 2m, 1h05m.
export function fmtOffset(ms: number): string {
const s = Math.round(ms / 1000)
if (s < 60) return `${s}s`
const m = Math.floor(s / 60)
if (m < 60) {
const rem = s % 60
return rem ? `${m}m${String(rem).padStart(2, '0')}s` : `${m}m`
}
const h = Math.floor(m / 60)
const mrem = m % 60
return mrem ? `${h}h${String(mrem).padStart(2, '0')}m` : `${h}h`
}
const TICK_STEPS_MS = [1, 2, 5, 10, 15, 30, 60, 120, 300, 600, 1200, 1800, 3600].map(
(s) => s * 1000,
)
/** Evenly-stepped time-axis ticks over a span, ≤ maxTicks of them. */
export function axisTicks(spanMs: number, maxTicks = 8): { pct: number; label: string }[] {
const span = Math.max(spanMs, 1)
const step = TICK_STEPS_MS.find((s) => span / s <= maxTicks) ?? 3_600_000
const out: { pct: number; label: string }[] = []
for (let t = 0; t <= span; t += step) {
out.push({ pct: (t / span) * 100, label: fmtOffset(t) })
}
return out
}
// New-tracer tool_call payloads carry ok:false on tool errors; older payloads
// have no ok key and count as ok.
export function payloadOk(raw: string | null | undefined): boolean {
if (!raw) return true
try {
const p: unknown = JSON.parse(raw)
if (p && typeof p === 'object' && 'ok' in p) return (p as { ok?: unknown }).ok !== false
} catch {
/* not JSON — treat as ok */
}
return true
}
export function prettyJson(raw: string | null | undefined): string {
if (!raw) return ''
try {
return JSON.stringify(JSON.parse(raw), null, 2)
} catch {
return raw
}
}

View file

@ -0,0 +1,54 @@
/**
* Dependency-free JSON syntax highlighting.
*
* Same safety model as markdown.ts: every character of input is HTML-escaped;
* the only tags in the output are the <span>s this module writes. Token
* colors live in style.css under the .j-* classes.
*/
export function escapeHtml(s: string): string {
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
// Strings first so digits/keywords inside them are consumed as part of the
// string token. A string followed by a colon is an object key.
const TOKEN =
/("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false)\b|\b(null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g
/** Highlight text that is already (or claims to be) JSON. Escapes everything. */
export function highlightJsonText(text: string): string {
let out = ''
let last = 0
for (const m of text.matchAll(TOKEN)) {
out += escapeHtml(text.slice(last, m.index))
const [full, str, colon, bool, nil, num] = m
if (str !== undefined) {
const cls = colon !== undefined ? 'j-key' : 'j-str'
out += `<span class="${cls}">${escapeHtml(str)}</span>${escapeHtml(colon ?? '')}`
} else if (bool !== undefined) {
out += `<span class="j-bool">${bool}</span>`
} else if (nil !== undefined) {
out += `<span class="j-null">null</span>`
} else {
out += `<span class="j-num">${escapeHtml(num ?? full)}</span>`
}
last = (m.index ?? 0) + full.length
}
out += escapeHtml(text.slice(last))
return out
}
/** Pretty-print raw JSON and highlight it; non-JSON falls back to escaped raw. */
export function highlightJson(raw: string | null | undefined): string {
if (!raw) return ''
try {
return highlightJsonText(JSON.stringify(JSON.parse(raw), null, 2))
} catch {
return escapeHtml(raw)
}
}

View file

@ -0,0 +1,120 @@
/**
* Minimal, dependency-free markdown → HTML for the compiled prompt panels.
*
* Safety model: ALL input is HTML-escaped before any tags are produced, so
* the only HTML in the output is what this module writes. No raw-HTML
* passthrough, links restricted to http(s).
*
* Supported: #–#### headings, fenced code blocks, inline code, bold, links,
* unordered/ordered lists, blockquotes, horizontal rules, paragraphs
* (pre-wrap preserves intra-paragraph line breaks).
*/
import { escapeHtml, highlightJsonText } from './highlight'
/** Bold + links, applied only OUTSIDE inline-code spans. */
function inline(escaped: string): string {
const parts = escaped.split(/(`[^`\n]+`)/g)
return parts
.map((part, i) => {
if (i % 2 === 1) return `<code>${part.slice(1, -1)}</code>`
return part
.replaceAll(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
.replaceAll(
/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
)
})
.join('')
}
export function renderMarkdown(src: string): string {
const lines = src.replaceAll('\r\n', '\n').split('\n')
const out: string[] = []
let i = 0
const paragraph: string[] = []
function flushParagraph() {
if (paragraph.length) {
out.push(`<p>${paragraph.map(inline).join('\n')}</p>`)
paragraph.length = 0
}
}
while (i < lines.length) {
const raw = lines[i] ?? ''
const line = escapeHtml(raw)
// Fenced code block — verbatim until the closing fence. json fences get
// syntax highlighting (the Report contract in every user.md is one).
const fence = /^\s*```(\w*)/.exec(raw)
if (fence) {
flushParagraph()
const code: string[] = []
i += 1
while (i < lines.length && !/^\s*```/.test(lines[i] ?? '')) {
code.push(lines[i] ?? '')
i += 1
}
i += 1
const text = code.join('\n')
const body =
(fence[1] ?? '').toLowerCase() === 'json' ? highlightJsonText(text) : escapeHtml(text)
out.push(`<pre class="md-code"><code>${body}</code></pre>`)
continue
}
const heading = /^(#{1,4})\s+(.*)$/.exec(raw)
if (heading?.[1] && heading[2] !== undefined) {
flushParagraph()
const level = heading[1].length
out.push(`<h${level}>${inline(escapeHtml(heading[2]))}</h${level}>`)
i += 1
continue
}
if (/^\s*(---+|\*\*\*+)\s*$/.test(raw)) {
flushParagraph()
out.push('<hr>')
i += 1
continue
}
if (/^\s*&gt;\s?/.test(line)) {
flushParagraph()
const quote: string[] = []
while (i < lines.length && /^\s*>\s?/.test(lines[i] ?? '')) {
quote.push(inline(escapeHtml((lines[i] ?? '').replace(/^\s*>\s?/, ''))))
i += 1
}
out.push(`<blockquote>${quote.join('\n')}</blockquote>`)
continue
}
const ulItem = /^\s*[-*]\s+/.test(raw)
const olItem = /^\s*\d+\.\s+/.test(raw)
if (ulItem || olItem) {
flushParagraph()
const tag = ulItem ? 'ul' : 'ol'
const marker = ulItem ? /^\s*[-*]\s+/ : /^\s*\d+\.\s+/
const items: string[] = []
while (i < lines.length && marker.test(lines[i] ?? '')) {
items.push(`<li>${inline(escapeHtml((lines[i] ?? '').replace(marker, '')))}</li>`)
i += 1
}
out.push(`<${tag}>${items.join('')}</${tag}>`)
continue
}
if (raw.trim() === '') {
flushParagraph()
i += 1
continue
}
paragraph.push(line)
i += 1
}
flushParagraph()
return out.join('\n')
}

View file

@ -0,0 +1,29 @@
/**
* Model → provider icon, by contains-check on the model name.
*
* Icons live in public/models/ (served from the site root). First matching
* needle wins; unknown models render no icon.
*/
const MODEL_ICONS: [needles: string[], icon: string][] = [
[['claude', 'opus', 'sonnet', 'haiku'], '/models/claude.png'],
[['gemini'], '/models/gemini.png'],
[['kimi', 'moonshot'], '/models/kimi.png'],
[['gpt', 'openai', 'codex', 'o3', 'o4'], '/models/openai.png'],
[['glm', 'zai', 'z.ai'], '/models/zai.png'],
]
export function modelIcon(model: string | null | undefined): string | null {
if (!model) return null
const m = model.toLowerCase()
for (const [needles, icon] of MODEL_ICONS) {
if (needles.some((n) => m.includes(n))) return icon
}
return null
}
/** Keep provider-qualified IDs compact while preserving the full ID in titles. */
export function modelName(model: string | null | undefined): string {
if (!model) return ''
return model.split('/').filter(Boolean).at(-1) ?? model
}

View file

@ -0,0 +1,44 @@
import { ref } from 'vue'
// Hash routes: #/ → sessions · #/<repo>/<adw_id> → waterfall · #/<repo>/<adw_id>/<phase_id> → phase panel open
// The repo segment is optional and defaults to the first repo the server serves.
export interface Route {
repo: string | null
adwId: string | null
phaseId: string | null
}
function parse(): Route {
const parts = window.location.hash
.replace(/^#\/?/, '')
.split('/')
.filter(Boolean)
.map(decodeURIComponent)
return { repo: parts[0] ?? null, adwId: parts[1] ?? null, phaseId: parts[2] ?? null }
}
const route = ref<Route>(parse())
window.addEventListener('hashchange', () => {
route.value = parse()
})
export function useRoute() {
return route
}
// Display name for the phase crumb — set by the trace view once phases load,
// since the phase_id in the URL is not the display name.
export const phaseCrumb = ref<string | null>(null)
export function hrefFor(repo?: string | null, adwId?: string | null, phaseId?: string | null): string {
const parts: string[] = []
if (repo) parts.push(encodeURIComponent(repo))
if (adwId) parts.push(encodeURIComponent(adwId))
if (phaseId) parts.push(encodeURIComponent(phaseId))
return '#/' + parts.join('/')
}
export function navigate(repo?: string | null, adwId?: string | null, phaseId?: string | null): void {
window.location.hash = hrefFor(repo, adwId, phaseId)
}

View file

@ -0,0 +1,27 @@
// Single switch point onto the server's contract: everything UI-side imports
// table shapes from here, which re-exports shared/types.ts.
export type {
Session,
SessionSummary,
SessionUsage,
SessionDetail,
Phase,
Event as EventRow,
EventsPage,
Envelope,
GateResult,
GateCheck,
AgentSession,
AgentStartPayload,
AgentEndPayload,
UsageBreakdown,
ToolCallPayload,
AgentPrompts,
PromptsResponse,
HealthResponse,
RepoInfo,
SessionStatus,
PhaseStatus,
PhaseKind,
EventType,
} from '@shared/types'

View file

@ -0,0 +1,7 @@
import { createApp } from 'vue'
import '@fontsource/play/400.css'
import '@fontsource/play/700.css'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')

View file

@ -0,0 +1,206 @@
:root {
--bg: #06080f;
--panel: #0d1119;
--panel-2: #131a26;
--panel-3: #0a0e16;
--border: #232c3d;
--border-soft: #222b3d;
--text: #f2f5fa;
--dim: #aabdd5;
--faint: #8b9cb6;
--green: #4ade80;
--red: #ff6f67;
--blue: #6cb6ff;
--amber: #e8b64a;
--purple: #c89bff;
--cyan: #5ad2dd;
--violet: #94a3ff;
/* Play carries the UI voice; mono is reserved for data (ids, times, code). */
--sans: 'Play', 'Helvetica Neue', system-ui, sans-serif;
--mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, Monaco, 'Cascadia Mono',
'Roboto Mono', monospace;
/* Surface gradient shared by cards and panels — one recipe, everywhere. */
--surface: linear-gradient(180deg, #10141f 0%, #0b0f18 100%);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
/* Deep space with a faint violet/cyan aurora — fixed so scrolling glides over it. */
background:
radial-gradient(1100px 700px at 8% -10%, rgba(148, 163, 255, 0.09), transparent 62%),
radial-gradient(1000px 720px at 102% 108%, rgba(90, 210, 221, 0.07), transparent 60%),
linear-gradient(180deg, #06080f 0%, #090d17 100%);
background-attachment: fixed;
color: var(--text);
font-family: var(--sans);
/* Readability floor: nothing in the app renders below 16px. */
font-size: 16px;
line-height: 1.5;
}
#app {
min-height: 100vh;
}
a {
color: var(--blue);
text-decoration: none;
}
pre {
margin: 0;
padding: 12px 14px;
background: var(--panel-3);
border: 1px solid var(--border-soft);
border-radius: 8px;
overflow-x: auto;
font-family: var(--mono);
font-size: 16px;
line-height: 1.55;
color: #cfdded;
white-space: pre-wrap;
word-break: break-word;
}
/* JSON token colors — spans emitted by lib/highlight.ts */
.j-key {
color: var(--purple);
}
.j-str {
color: var(--green);
}
.j-num {
color: var(--amber);
}
.j-bool {
color: var(--cyan);
}
.j-null {
color: var(--red);
}
.dim {
color: var(--dim);
}
.faint {
color: var(--faint);
}
.error-bar {
margin: 12px 24px;
padding: 10px 14px;
border: 1px solid rgba(255, 111, 103, 0.5);
background: rgba(255, 111, 103, 0.1);
color: var(--red);
border-radius: 8px;
font-size: 16px;
}
.empty-state {
padding: 56px 24px;
text-align: center;
color: var(--dim);
font-size: 16px;
}
/* Rendered-markdown blocks (compiled prompts) — global because the content is
injected via v-html and scoped styles can't reach it. */
.md {
font-size: 16px;
line-height: 1.6;
color: var(--text);
}
.md h1 {
font-size: 20px;
margin: 14px 0 8px;
}
.md h2 {
font-size: 18px;
margin: 14px 0 8px;
}
.md h3,
.md h4 {
font-size: 17px;
margin: 12px 0 6px;
}
.md h1:first-child,
.md h2:first-child,
.md h3:first-child {
margin-top: 0;
}
.md p {
margin: 8px 0;
white-space: pre-wrap;
}
.md code {
background: var(--panel-2);
border: 1px solid var(--border-soft);
border-radius: 4px;
padding: 1px 7px;
font-size: 16px;
}
.md pre.md-code {
margin: 10px 0;
white-space: pre;
}
.md pre.md-code code {
background: transparent;
border: none;
padding: 0;
}
.md ul,
.md ol {
margin: 8px 0;
padding-left: 28px;
}
.md li {
margin: 3px 0;
}
.md blockquote {
margin: 10px 0;
padding: 4px 0 4px 14px;
border-left: 3px solid var(--border);
color: var(--dim);
white-space: pre-wrap;
}
.md hr {
border: none;
border-top: 1px solid var(--border);
margin: 14px 0;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}