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:
parent
a42608f602
commit
2cc766aabe
98 changed files with 12508 additions and 0 deletions
76
sssf/apps/visualizer/src/components/DetailSection.vue
Normal file
76
sssf/apps/visualizer/src/components/DetailSection.vue
Normal 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>
|
||||
1267
sssf/apps/visualizer/src/components/PhaseDetail.vue
Normal file
1267
sssf/apps/visualizer/src/components/PhaseDetail.vue
Normal file
File diff suppressed because it is too large
Load diff
55
sssf/apps/visualizer/src/components/PhaseDots.vue
Normal file
55
sssf/apps/visualizer/src/components/PhaseDots.vue
Normal 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>
|
||||
473
sssf/apps/visualizer/src/components/SessionCard.vue
Normal file
473
sssf/apps/visualizer/src/components/SessionCard.vue
Normal 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>
|
||||
862
sssf/apps/visualizer/src/components/SessionTrace.vue
Normal file
862
sssf/apps/visualizer/src/components/SessionTrace.vue
Normal 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>
|
||||
109
sssf/apps/visualizer/src/components/SessionsList.vue
Normal file
109
sssf/apps/visualizer/src/components/SessionsList.vue
Normal 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>
|
||||
88
sssf/apps/visualizer/src/components/StatChip.vue
Normal file
88
sssf/apps/visualizer/src/components/StatChip.vue
Normal 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>
|
||||
73
sssf/apps/visualizer/src/components/StatusChip.vue
Normal file
73
sssf/apps/visualizer/src/components/StatusChip.vue
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue