feat: add hook event history with badge counts in SessionLifecycleStatus

Server persists hookHistory[] per agent (cap 500, resets on SessionStart),
synced realtime via session-state-patch. Frontend computes event counts
by macro type and renders color-coded badges at the ribbon start.
Mock mode also accumulates badges during demo sequence.
This commit is contained in:
2026-02-21 04:29:02 -06:00
parent 638b449f08
commit b9eec1013b
4 changed files with 200 additions and 4 deletions

View File

@@ -89,6 +89,12 @@ const lifecycleDetail = computed(() => {
return sessionStore.agents[agent]?.lastHookDetail ?? ''
})
const hookHistory = computed(() => {
const agent = props.selectedAgent
if (!agent) return []
return sessionStore.agents[agent]?.hookHistory ?? []
})
// ── Derived display values ──
const permissionMode = computed(() => props.hookPermissionMode || '')
const fullCwd = computed(() => props.conversation.metadata.cwd || '')
@@ -664,6 +670,7 @@ function formatDuration(start: string, end: string): string {
<SessionLifecycleStatus
:current-event="lifecycleEvent"
:event-detail="lifecycleDetail"
:hook-history="hookHistory"
/>
<UserInput

View File

@@ -35,6 +35,10 @@ const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' },
}
const CATEGORY_ORDER: Record<Category, number> = {
session: 0, user: 1, tool: 2, agent: 3, system: 4
}
interface MockStep {
event: LifecycleEvent
duration: number
@@ -69,14 +73,28 @@ const MOCK_SEQUENCE: MockStep[] = [
{ event: 'SessionEnd', duration: 4000 },
]
interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
interface EventCountEntry {
event: LifecycleEvent
count: number
color: string
}
const props = defineProps<{
currentEvent?: string | null
eventDetail?: string
hookHistory?: HookHistoryEntry[]
}>()
const isMock = computed(() => !props.currentEvent || !(props.currentEvent in LIFECYCLE_DISPLAY))
const mockIndex = ref(0)
const mockHistory = ref<{ event: string }[]>([])
let mockTimer: ReturnType<typeof setTimeout> | null = null
const activeEvent = computed<LifecycleEvent>(() => {
@@ -92,8 +110,53 @@ const activeDetail = computed(() => {
const displayInfo = computed(() => LIFECYCLE_DISPLAY[activeEvent.value])
const isProcessing = computed(() => !!displayInfo.value.processing)
// ── Badge counts ──
function countEvents(entries: { event: string }[]): EventCountEntry[] {
const counts = new Map<string, number>()
for (const entry of entries) {
counts.set(entry.event, (counts.get(entry.event) || 0) + 1)
}
const result: EventCountEntry[] = []
for (const [event, count] of counts) {
if (event in LIFECYCLE_DISPLAY) {
result.push({
event: event as LifecycleEvent,
count,
color: LIFECYCLE_DISPLAY[event as LifecycleEvent].color,
})
}
}
result.sort((a, b) => {
const ca = LIFECYCLE_DISPLAY[a.event].category
const cb = LIFECYCLE_DISPLAY[b.event].category
if (CATEGORY_ORDER[ca] !== CATEGORY_ORDER[cb]) {
return CATEGORY_ORDER[ca] - CATEGORY_ORDER[cb]
}
return a.event.localeCompare(b.event)
})
return result
}
const realCounts = computed(() => countEvents(props.hookHistory || []))
const mockCounts = computed(() => countEvents(mockHistory.value))
const displayCounts = computed(() => isMock.value ? mockCounts.value : realCounts.value)
// ── Mock mode ──
function advanceMock() {
mockIndex.value = (mockIndex.value + 1) % MOCK_SEQUENCE.length
const nextIndex = (mockIndex.value + 1) % MOCK_SEQUENCE.length
const nextEvent = MOCK_SEQUENCE[nextIndex].event
if (nextEvent === 'SessionStart') {
mockHistory.value = []
}
mockHistory.value = [...mockHistory.value, { event: nextEvent }]
mockIndex.value = nextIndex
scheduleMock()
}
@@ -104,6 +167,7 @@ function scheduleMock() {
watch(isMock, (mock) => {
if (mock) {
mockHistory.value = [{ event: MOCK_SEQUENCE[mockIndex.value].event }]
scheduleMock()
} else if (mockTimer) {
clearTimeout(mockTimer)
@@ -112,7 +176,10 @@ watch(isMock, (mock) => {
})
onMounted(() => {
if (isMock.value) scheduleMock()
if (isMock.value) {
mockHistory.value = [{ event: MOCK_SEQUENCE[mockIndex.value].event }]
scheduleMock()
}
})
onBeforeUnmount(() => {
@@ -125,6 +192,24 @@ onBeforeUnmount(() => {
<template>
<div class="lifecycle-ribbon" :class="['category-' + displayInfo.category]">
<!-- Badge counts -->
<div class="lc-badges" v-if="displayCounts.length > 0">
<TransitionGroup name="badge">
<span
v-for="entry in displayCounts"
:key="entry.event"
class="lc-badge"
:style="{
background: entry.color + '1a',
color: entry.color,
borderColor: entry.color + '33',
}"
:title="entry.event + ': ' + entry.count"
>{{ entry.count }}</span>
</TransitionGroup>
</div>
<!-- Current event display -->
<Transition name="lc" mode="out-in">
<div class="lc-content" :key="activeEvent + activeDetail">
<span
@@ -155,11 +240,62 @@ onBeforeUnmount(() => {
.lifecycle-ribbon.category-agent { border-top-color: rgba(192, 132, 252, 0.15); }
.lifecycle-ribbon.category-system { border-top-color: rgba(56, 189, 248, 0.15); }
/* ── Badges ── */
.lc-badges {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
margin-right: 4px;
overflow: hidden;
}
.lc-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 12px;
height: 12px;
padding: 0 2px;
font-size: 8px;
font-weight: 700;
font-family: 'Courier New', monospace;
border: 1px solid;
border-radius: 2px;
line-height: 1;
white-space: nowrap;
}
.badge-enter-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.badge-leave-active {
transition: opacity 0.15s ease;
}
.badge-enter-from {
opacity: 0;
transform: scale(0.7);
}
.badge-leave-to {
opacity: 0;
}
.badge-move {
transition: transform 0.2s ease;
}
/* ── Current event ── */
.lc-content {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
flex: 1;
min-width: 0;
}
.lc-dot {

View File

@@ -31,6 +31,12 @@ export interface PendingApproval {
timestamp: number
}
export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
export interface SessionNotification {
id: string
event: string
@@ -71,6 +77,7 @@ export interface AgentSessionState {
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
}