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

View File

@@ -35,6 +35,10 @@ const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' }, 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 { interface MockStep {
event: LifecycleEvent event: LifecycleEvent
duration: number duration: number
@@ -69,14 +73,28 @@ const MOCK_SEQUENCE: MockStep[] = [
{ event: 'SessionEnd', duration: 4000 }, { event: 'SessionEnd', duration: 4000 },
] ]
interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
interface EventCountEntry {
event: LifecycleEvent
count: number
color: string
}
const props = defineProps<{ const props = defineProps<{
currentEvent?: string | null currentEvent?: string | null
eventDetail?: string eventDetail?: string
hookHistory?: HookHistoryEntry[]
}>() }>()
const isMock = computed(() => !props.currentEvent || !(props.currentEvent in LIFECYCLE_DISPLAY)) const isMock = computed(() => !props.currentEvent || !(props.currentEvent in LIFECYCLE_DISPLAY))
const mockIndex = ref(0) const mockIndex = ref(0)
const mockHistory = ref<{ event: string }[]>([])
let mockTimer: ReturnType<typeof setTimeout> | null = null let mockTimer: ReturnType<typeof setTimeout> | null = null
const activeEvent = computed<LifecycleEvent>(() => { const activeEvent = computed<LifecycleEvent>(() => {
@@ -92,8 +110,53 @@ const activeDetail = computed(() => {
const displayInfo = computed(() => LIFECYCLE_DISPLAY[activeEvent.value]) const displayInfo = computed(() => LIFECYCLE_DISPLAY[activeEvent.value])
const isProcessing = computed(() => !!displayInfo.value.processing) 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() { 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() scheduleMock()
} }
@@ -104,6 +167,7 @@ function scheduleMock() {
watch(isMock, (mock) => { watch(isMock, (mock) => {
if (mock) { if (mock) {
mockHistory.value = [{ event: MOCK_SEQUENCE[mockIndex.value].event }]
scheduleMock() scheduleMock()
} else if (mockTimer) { } else if (mockTimer) {
clearTimeout(mockTimer) clearTimeout(mockTimer)
@@ -112,7 +176,10 @@ watch(isMock, (mock) => {
}) })
onMounted(() => { onMounted(() => {
if (isMock.value) scheduleMock() if (isMock.value) {
mockHistory.value = [{ event: MOCK_SEQUENCE[mockIndex.value].event }]
scheduleMock()
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -125,6 +192,24 @@ onBeforeUnmount(() => {
<template> <template>
<div class="lifecycle-ribbon" :class="['category-' + displayInfo.category]"> <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"> <Transition name="lc" mode="out-in">
<div class="lc-content" :key="activeEvent + activeDetail"> <div class="lc-content" :key="activeEvent + activeDetail">
<span <span
@@ -155,11 +240,62 @@ onBeforeUnmount(() => {
.lifecycle-ribbon.category-agent { border-top-color: rgba(192, 132, 252, 0.15); } .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); } .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 { .lc-content {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
width: 100%; flex: 1;
min-width: 0;
} }
.lc-dot { .lc-dot {

View File

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

View File

@@ -32,6 +32,12 @@ export interface PendingApproval {
timestamp: number timestamp: number
} }
export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
export interface SessionNotification { export interface SessionNotification {
id: string id: string
event: string event: string
@@ -72,6 +78,9 @@ export interface AgentSessionState {
pendingApprovals: PendingApproval[] pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo terminal: AgentTerminalInfo
notifications: SessionNotification[] notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
} }
export interface HookPayload { export interface HookPayload {
@@ -114,6 +123,7 @@ export interface SessionStatePatch {
// ── Notification builders ── // ── Notification builders ──
const MAX_NOTIFICATIONS = 30 const MAX_NOTIFICATIONS = 30
const MAX_HOOK_HISTORY = 500
const TTL_INFO = 3500 const TTL_INFO = 3500
const TTL_WARNING = 5000 const TTL_WARNING = 5000
@@ -219,6 +229,9 @@ function createDefaultState(agent: string): AgentSessionState {
connectedClients: 0, connectedClients: 0,
}, },
notifications: [], notifications: [],
hookHistory: [],
lastHookEvent: null,
lastHookDetail: null,
} }
} }
@@ -236,7 +249,7 @@ class SessionStateManager {
/** Process a raw hook event and return the patch to broadcast */ /** Process a raw hook event and return the patch to broadcast */
processHookEvent(payload: HookPayload): SessionStatePatch { processHookEvent(payload: HookPayload): SessionStatePatch {
const agentName = payload.agent_name || 'main' const agentName = payload.agent_name || 'claude'
const state = this.getOrCreateAgent(agentName) const state = this.getOrCreateAgent(agentName)
const now = Date.now() const now = Date.now()
@@ -246,6 +259,8 @@ class SessionStateManager {
// Build patch // Build patch
const patch: Partial<AgentSessionState> = { const patch: Partial<AgentSessionState> = {
lastActivity: now, lastActivity: now,
lastHookEvent: payload.hook_event_name || null,
lastHookDetail: payload.tool_name || payload.message || null,
} }
// Only update status/tool if deriveStatus returned a result // Only update status/tool if deriveStatus returned a result
@@ -310,6 +325,20 @@ class SessionStateManager {
const updatedNotifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS) const updatedNotifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
patch.notifications = updatedNotifications patch.notifications = updatedNotifications
// Build hook history entry
const historyEntry: HookHistoryEntry = {
event: payload.hook_event_name || 'unknown',
timestamp: now,
}
const historyDetail = payload.tool_name || payload.message
if (historyDetail) historyEntry.detail = historyDetail as string
if (payload.hook_event_name === 'SessionStart') {
patch.hookHistory = [historyEntry]
} else {
patch.hookHistory = [...state.hookHistory, historyEntry].slice(-MAX_HOOK_HISTORY)
}
// Apply patch to state // Apply patch to state
Object.assign(state, patch) Object.assign(state, patch)
@@ -372,6 +401,23 @@ class SessionStateManager {
return this.agents.get(agent) || null return this.agents.get(agent) || null
} }
/** Find agent name by session_id */
findAgentBySessionId(sessionId: string): string | null {
for (const [name, state] of this.agents) {
if (state.sessionId === sessionId) return name
}
return null
}
/** Find all agents matching a transcript_path */
findAgentsByTranscript(transcriptPath: string): string[] {
const matches: string[] = []
for (const [name, state] of this.agents) {
if (state.transcriptPath === transcriptPath) matches.push(name)
}
return matches
}
/** Clean up expired notifications (call periodically) */ /** Clean up expired notifications (call periodically) */
cleanExpiredNotifications(): void { cleanExpiredNotifications(): void {
const now = Date.now() const now = Date.now()