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
}

View File

@@ -32,6 +32,12 @@ export interface PendingApproval {
timestamp: number
}
export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
export interface SessionNotification {
id: string
event: string
@@ -72,6 +78,9 @@ export interface AgentSessionState {
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
}
export interface HookPayload {
@@ -114,6 +123,7 @@ export interface SessionStatePatch {
// ── Notification builders ──
const MAX_NOTIFICATIONS = 30
const MAX_HOOK_HISTORY = 500
const TTL_INFO = 3500
const TTL_WARNING = 5000
@@ -219,6 +229,9 @@ function createDefaultState(agent: string): AgentSessionState {
connectedClients: 0,
},
notifications: [],
hookHistory: [],
lastHookEvent: null,
lastHookDetail: null,
}
}
@@ -236,7 +249,7 @@ class SessionStateManager {
/** Process a raw hook event and return the patch to broadcast */
processHookEvent(payload: HookPayload): SessionStatePatch {
const agentName = payload.agent_name || 'main'
const agentName = payload.agent_name || 'claude'
const state = this.getOrCreateAgent(agentName)
const now = Date.now()
@@ -246,6 +259,8 @@ class SessionStateManager {
// Build patch
const patch: Partial<AgentSessionState> = {
lastActivity: now,
lastHookEvent: payload.hook_event_name || null,
lastHookDetail: payload.tool_name || payload.message || null,
}
// Only update status/tool if deriveStatus returned a result
@@ -310,6 +325,20 @@ class SessionStateManager {
const updatedNotifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
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
Object.assign(state, patch)
@@ -372,6 +401,23 @@ class SessionStateManager {
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) */
cleanExpiredNotifications(): void {
const now = Date.now()