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