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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user