Files
agent-ui/frontend/src/stores/session-state.ts
josedario87 b9eec1013b 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.
2026-02-21 04:29:02 -06:00

237 lines
5.7 KiB
TypeScript

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// ── Types (mirror server/services/session-state.ts) ──
export type AgentStatus =
| 'idle'
| 'thinking'
| 'reading'
| 'writing'
| 'toolUse'
| 'permissionRequest'
| 'interrupted'
| 'error'
| 'sessionStart'
| 'sessionEnd'
export interface ActiveTool {
name: string
input: unknown
startedAt: number
}
export interface PendingApproval {
requestId: string
type: 'permission' | 'plan'
toolName?: string
toolInput?: unknown
cwd?: string
lastAssistantText?: string
timestamp: number
}
export interface HookHistoryEntry {
event: string
timestamp: number
detail?: string
}
export interface SessionNotification {
id: string
event: string
title: string
detail: string
type: 'info' | 'success' | 'warning' | 'error'
timestamp: number
persistent: boolean
expiresAt: number | null
}
export interface AgentTerminalInfo {
ptySessionId: string | null
alive: boolean
bufferSize: number
connectedClients: number
}
export interface LastError {
tool: string
message: string
interrupted: boolean
timestamp: number
}
export interface AgentSessionState {
agent: string
sessionId: string | null
model: string | null
cwd: string | null
transcriptPath: string | null
permissionMode: string | null
status: AgentStatus
currentTool: ActiveTool | null
lastActivity: number
lastStopResponse: string | null
lastError: LastError | null
pendingApprovals: PendingApproval[]
terminal: AgentTerminalInfo
notifications: SessionNotification[]
hookHistory: HookHistoryEntry[]
lastHookEvent: string | null
lastHookDetail: string | null
}
// WS message types
interface SessionStateSnapshot {
type: 'session-state-snapshot'
agents: Record<string, AgentSessionState>
}
interface SessionStatePatch {
type: 'session-state-patch'
agent: string
patch: Partial<AgentSessionState>
event: string
timestamp: number
}
interface TerminalRegistryChange {
type: 'terminal-registry-change'
registry: TerminalRegistryEntry[]
timestamp: number
}
export interface TerminalRegistryEntry {
ephemeralSessionId: string
transcriptSessionId: string
agent: string
label: string
command: string
createdAt: string
alive: boolean
clients: number
bufferSize: number
}
type SessionStateMessage = SessionStateSnapshot | SessionStatePatch | TerminalRegistryChange
// ── Store ──
export const useSessionState = defineStore('session-state', () => {
const agents = ref<Record<string, AgentSessionState>>({})
const terminalRegistry = ref<TerminalRegistryEntry[]>([])
const connected = ref(false)
const lastUpdate = ref(0)
// ── Computed helpers ──
const agentList = computed(() => Object.values(agents.value))
const agentNames = computed(() => Object.keys(agents.value))
function getAgent(name: string) {
return computed(() => agents.value[name] ?? null)
}
const anyPendingApprovals = computed(() =>
agentList.value.some(a => a.pendingApprovals.length > 0)
)
const totalPendingApprovals = computed(() =>
agentList.value.reduce((sum, a) => sum + a.pendingApprovals.length, 0)
)
const allPendingApprovals = computed(() =>
agentList.value.flatMap(a =>
a.pendingApprovals.map(p => ({ ...p, agent: a.agent }))
)
)
const isProcessing = computed(() =>
agentList.value.some(a => !['idle', 'sessionStart', 'sessionEnd'].includes(a.status))
)
const hasErrors = computed(() =>
agentList.value.some(a => a.status === 'error' || a.status === 'interrupted')
)
const visibleNotifications = computed(() => {
const now = Date.now()
return agentList.value
.flatMap(a => a.notifications.map(n => ({ ...n, agent: a.agent })))
.filter(n => n.persistent || !n.expiresAt || n.expiresAt > now)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, 10)
})
// ── Message handler ──
function handleMessage(msg: SessionStateMessage) {
if (msg.type === 'session-state-snapshot') {
agents.value = { ...msg.agents }
lastUpdate.value = Date.now()
} else if (msg.type === 'session-state-patch') {
const current = agents.value[msg.agent]
if (current) {
agents.value = {
...agents.value,
[msg.agent]: { ...current, ...msg.patch }
}
} else {
// New agent appeared — treat patch as full state if it has required fields
agents.value = {
...agents.value,
[msg.agent]: msg.patch as AgentSessionState
}
}
lastUpdate.value = msg.timestamp
} else if (msg.type === 'terminal-registry-change') {
terminalRegistry.value = msg.registry
lastUpdate.value = msg.timestamp
}
}
// ── Actions ──
async function respondApproval(requestId: string, decision: string, reason?: string) {
await fetch('/api/hooks-approval/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
}
async function respondPlanApproval(requestId: string, decision: string, reason?: string) {
await fetch('/api/hooks-approval/respond-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
})
}
function setConnected(value: boolean) {
connected.value = value
}
return {
agents,
terminalRegistry,
connected,
lastUpdate,
agentList,
agentNames,
getAgent,
anyPendingApprovals,
totalPendingApprovals,
allPendingApprovals,
isProcessing,
hasErrors,
visibleNotifications,
handleMessage,
respondApproval,
respondPlanApproval,
setConnected,
}
})