feat: centralized PTY-scoped session state, sync engine debug panel, lifecycle states, WS monitor
- Refactor session state to use ptySessionId as primary key across all components - Add SessionStateManager with PTY-scoped hook processing, approval tracking, notifications - Add sync-engine debug panel (AgentStatesSection, HookTimelineSection, TerminalRegistrySection, WsMonitorSection) - Add useLifecycleStates composable for continuous state chips (session, responding, tool, subagent, compacting) - Add WS monitor endpoint and composable for real-time connection health - Enhance SessionLifecycleStatus with animated state chips and badge counts - Enhance SystemMessage with expanded content and better formatting - Update hooks (approval-permission, approval-plan, notify) with pty_session injection - Update approval system to derive pending lists from PTY-scoped state - Update ChatContainer with PTY-derived agent status and lifecycle events - Update AgentBadge with PTY-scoped status colors - Improve PiP window, approval window, and loading window handling
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -38,7 +38,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -50,7 +50,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -62,7 +62,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -73,7 +73,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -84,7 +84,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -96,7 +96,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -108,7 +108,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -118,7 +118,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/approval-permission.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 ejecutor",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
@@ -129,7 +129,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 ejecutor",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
@@ -138,7 +138,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/approval-plan.ps1 ejecutor",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 ejecutor",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -24,7 +24,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -36,7 +36,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -48,7 +48,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -59,7 +59,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -70,7 +70,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -82,7 +82,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -94,7 +94,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -104,7 +104,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/approval-permission.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1 nucleo000",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
@@ -115,7 +115,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1 nucleo000",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
@@ -124,7 +124,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/approval-plan.ps1 nucleo000",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1 nucleo000",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -113,7 +113,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -125,7 +125,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -137,7 +137,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -149,7 +149,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -161,7 +161,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -172,7 +172,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -184,7 +184,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 5000
|
||||
}
|
||||
]
|
||||
@@ -194,7 +194,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/approval-permission.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-permission.ps1",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
@@ -205,7 +205,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/notify.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/notify.ps1",
|
||||
"timeout": 10000
|
||||
}
|
||||
]
|
||||
@@ -214,7 +214,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "powershell -NoProfile -File hooks/approval-plan.ps1",
|
||||
"command": "powershell -NoProfile -File C:/Users/jodar/agent-ui/hooks/approval-plan.ps1",
|
||||
"timeout": 130000
|
||||
}
|
||||
]
|
||||
|
||||
@@ -35,8 +35,22 @@
|
||||
<div class="badge" id="b"></div>
|
||||
</div>
|
||||
<script>
|
||||
var badge = document.getElementById('b');
|
||||
function updateBadge(n) {
|
||||
if (n > 1) { badge.textContent = n; badge.className = 'badge show'; }
|
||||
else { badge.className = 'badge'; }
|
||||
}
|
||||
|
||||
// Initial count from query param
|
||||
var c = new URLSearchParams(location.search).get('n');
|
||||
if (c && parseInt(c) > 1) { var b = document.getElementById('b'); b.textContent = c; b.className = 'badge show'; }
|
||||
if (c) updateBadge(parseInt(c));
|
||||
|
||||
// Live updates via Tauri event (no module imports needed)
|
||||
var T = window.__TAURI_INTERNALS__;
|
||||
if (T) {
|
||||
var handler = T.transformCallback(function(ev) { updateBadge(ev.payload); });
|
||||
T.invoke('plugin:event|listen', { event: 'loading:count', target: { kind: 'Any' }, handler: handler });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -27,14 +27,23 @@ const STATUS_COLORS: Record<AgentStatus, string> = {
|
||||
sessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
// Derive PTY ID from the active terminal slot
|
||||
const activePtyId = computed(() => {
|
||||
if (!props.activeSessionId) return null
|
||||
const t = props.terminals.find(t => t.sessionId === props.activeSessionId)
|
||||
return t?.ephemeralSessionId ?? null
|
||||
})
|
||||
|
||||
const agentStatusColor = computed(() => {
|
||||
const state = sessionStore.agents[props.agent]
|
||||
const ptyId = activePtyId.value
|
||||
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
|
||||
if (!state) return null
|
||||
return STATUS_COLORS[state.status] || '#6b7280'
|
||||
})
|
||||
|
||||
const agentStatusClass = computed(() => {
|
||||
const state = sessionStore.agents[props.agent]
|
||||
const ptyId = activePtyId.value
|
||||
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
|
||||
return state?.status || 'idle'
|
||||
})
|
||||
|
||||
|
||||
@@ -64,9 +64,8 @@ const STATUS_DISPLAY: Record<AgentStatus, { color: string; label: string; icon:
|
||||
}
|
||||
|
||||
const agentStatus = computed(() => {
|
||||
const agent = props.selectedAgent
|
||||
if (!agent) return null
|
||||
const state = sessionStore.agents[agent]
|
||||
const ptyId = props.terminal?.ephemeralSessionId
|
||||
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
|
||||
if (!state) return null
|
||||
const display = STATUS_DISPLAY[state.status] || STATUS_DISPLAY.idle
|
||||
const toolSuffix = state.currentTool ? ` ${state.currentTool.name}` : ''
|
||||
@@ -81,36 +80,35 @@ const agentStatus = computed(() => {
|
||||
})
|
||||
|
||||
// ── Lifecycle hook event (for SessionLifecycleStatus) ──
|
||||
// Only show live hook data when viewing the agent's current active session
|
||||
const isLiveSession = computed(() => {
|
||||
const agent = props.selectedAgent
|
||||
if (!agent) return false
|
||||
const agentState = sessionStore.agents[agent]
|
||||
if (!agentState?.sessionId) return false
|
||||
// If no session selected, assume live
|
||||
if (!props.selectedSessionId) return true
|
||||
return props.selectedSessionId === agentState.sessionId
|
||||
// Keyed by ptySessionId (ephemeralSessionId) so each PTY terminal shows its own history
|
||||
const currentPtyId = computed(() => {
|
||||
// Use the ephemeralSessionId from the terminal prop (PTY session key)
|
||||
return props.terminal?.ephemeralSessionId ?? null
|
||||
})
|
||||
|
||||
const lifecycleEvent = computed(() => {
|
||||
if (!isLiveSession.value) return null
|
||||
const agent = props.selectedAgent
|
||||
if (!agent) return null
|
||||
return sessionStore.agents[agent]?.lastHookEvent ?? null
|
||||
const pty = currentPtyId.value
|
||||
if (!pty) return null
|
||||
return sessionStore.ptySessions[pty]?.lastHookEvent ?? null
|
||||
})
|
||||
|
||||
const lifecycleDetail = computed(() => {
|
||||
if (!isLiveSession.value) return ''
|
||||
const agent = props.selectedAgent
|
||||
if (!agent) return ''
|
||||
return sessionStore.agents[agent]?.lastHookDetail ?? ''
|
||||
const pty = currentPtyId.value
|
||||
if (!pty) return ''
|
||||
return sessionStore.ptySessions[pty]?.lastHookDetail ?? ''
|
||||
})
|
||||
|
||||
const hookHistory = computed(() => {
|
||||
if (!isLiveSession.value) return []
|
||||
const agent = props.selectedAgent
|
||||
if (!agent) return []
|
||||
return sessionStore.agents[agent]?.hookHistory ?? []
|
||||
const pty = currentPtyId.value
|
||||
if (!pty) return []
|
||||
return sessionStore.ptySessions[pty]?.hookHistory ?? []
|
||||
})
|
||||
|
||||
// Per-session state for continuous lifecycle indicators
|
||||
const sessionHookState = computed(() => {
|
||||
const pty = currentPtyId.value
|
||||
if (!pty) return null
|
||||
return sessionStore.ptySessions[pty] ?? null
|
||||
})
|
||||
|
||||
// ── Derived display values ──
|
||||
@@ -735,6 +733,19 @@ function formatDuration(start: string, end: string): string {
|
||||
</Transition>
|
||||
|
||||
<div class="bottom-overlay">
|
||||
<SessionLifecycleStatus
|
||||
:current-event="lifecycleEvent"
|
||||
:event-detail="lifecycleDetail"
|
||||
:hook-history="hookHistory"
|
||||
:session-active="sessionHookState?.sessionActive ?? false"
|
||||
:agent-responding="sessionHookState?.agentResponding ?? false"
|
||||
:subagent-active="sessionHookState?.subagentActive ?? false"
|
||||
:compacting="sessionHookState?.compacting ?? false"
|
||||
:agent-status="sessionHookState?.status ?? 'idle'"
|
||||
:current-tool="sessionHookState?.currentTool ?? null"
|
||||
:last-activity="sessionHookState?.lastActivity ?? 0"
|
||||
/>
|
||||
|
||||
<UserInput
|
||||
:terminal-ready="props.terminalReady"
|
||||
:voice-transcript="voiceTranscript"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, toRef, onMounted, onUnmounted } from 'vue'
|
||||
import type { AgentStatus, ActiveTool } from '@/stores/session-state'
|
||||
import { useLifecycleStates, type ContinuousState } from '@/composables/useLifecycleStates'
|
||||
|
||||
// ── Event display config (for badges) ──
|
||||
|
||||
type LifecycleEvent =
|
||||
| 'SessionStart' | 'UserPromptSubmit'
|
||||
@@ -13,46 +17,25 @@ type Category = 'session' | 'user' | 'tool' | 'agent' | 'system'
|
||||
interface LifecycleInfo {
|
||||
color: string
|
||||
label: string
|
||||
icon: string
|
||||
category: Category
|
||||
processing?: boolean
|
||||
}
|
||||
|
||||
// SVG path data for each icon (14x14 viewBox)
|
||||
const ICON = {
|
||||
play: 'M4 2 L12 7 L4 12Z',
|
||||
chat: 'M2 2h10v7H6l-2 2v-2H2z',
|
||||
wrench: 'M10 2 L8 4 9 5 5 9 4 8 2 10 4 12 6 10 5 9 9 5 10 6 12 4z',
|
||||
lock: 'M4 6V4a3 3 0 0 1 6 0v2h1v6H3V6zm2-2v2h2V4a1 1 0 0 0-2 0z',
|
||||
check: 'M2 7 L5 10 L12 3',
|
||||
xmark: 'M3 3 L11 11 M11 3 L3 11',
|
||||
bell: 'M7 1a1 1 0 0 0-1 1v1A4 4 0 0 0 3 7v3l-1 1v1h10v-1l-1-1V7a4 4 0 0 0-3-4V2a1 1 0 0 0-1-1zm-1 12a1 1 0 0 0 2 0z',
|
||||
fork: 'M4 2v5a3 3 0 0 0 3 3h0a3 3 0 0 0 3-3V2 M4 5h6 M7 10v2',
|
||||
merge: 'M4 12V7a3 3 0 0 1 3-3h0a3 3 0 0 1 3 3v5 M4 9h6 M7 4V2',
|
||||
moon: 'M9 2A5 5 0 1 0 9 12 4 4 0 0 1 9 2z',
|
||||
trophy: 'M4 2h6v5a3 3 0 0 1-6 0z M2 2h2 M10 2h2 M5 10h4v2H5z M2 3v2a2 2 0 0 0 2 2 M12 3v2a2 2 0 0 1-2 2',
|
||||
gear: 'M7 1l1 2h-2l1-2zM7 13l-1-2h2l-1 2zM1 7l2-1v2l-2-1zM13 7l-2 1V6l2 1zM2.5 3l2 .5-1 1.5-1-2zM11.5 11l-2-.5 1-1.5 1 2zM3 11.5l.5-2 1.5 1-2 1zM11 2.5l-.5 2-1.5-1 2-1zM7 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z',
|
||||
compress: 'M2 5h4V1 M12 9H8v4 M2 9h4v4 M12 5H8V1',
|
||||
power: 'M7 1v5 M4 3A5 5 0 1 0 10 3',
|
||||
stop: 'M3 3h8v8H3z',
|
||||
} as const
|
||||
|
||||
const LIFECYCLE_DISPLAY: Record<LifecycleEvent, LifecycleInfo> = {
|
||||
SessionStart: { color: '#60a5fa', label: 'Session started', icon: 'play', category: 'session' },
|
||||
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', icon: 'chat', category: 'user' },
|
||||
PreToolUse: { color: '#fbbf24', label: 'Tool starting', icon: 'wrench', category: 'tool', processing: true },
|
||||
PermissionRequest: { color: '#fb923c', label: 'Permission required', icon: 'lock', category: 'tool', processing: true },
|
||||
PostToolUse: { color: '#4ade80', label: 'Tool completed', icon: 'check', category: 'tool' },
|
||||
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', icon: 'xmark', category: 'tool' },
|
||||
Notification: { color: '#38bdf8', label: 'Notification', icon: 'bell', category: 'system' },
|
||||
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', icon: 'fork', category: 'agent', processing: true },
|
||||
SubagentStop: { color: '#a855f7', label: 'Subagent finished', icon: 'merge', category: 'agent' },
|
||||
Stop: { color: '#22d3ee', label: 'Response complete', icon: 'stop', category: 'session' },
|
||||
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', icon: 'moon', category: 'agent' },
|
||||
TaskCompleted: { color: '#34d399', label: 'Task completed', icon: 'trophy', category: 'system' },
|
||||
ConfigChange: { color: '#e879f9', label: 'Config changed', icon: 'gear', category: 'system' },
|
||||
PreCompact: { color: '#f59e0b', label: 'Compacting context', icon: 'compress', category: 'system', processing: true },
|
||||
SessionEnd: { color: '#6b7280', label: 'Session ended', icon: 'power', category: 'session' },
|
||||
SessionStart: { color: '#60a5fa', label: 'Session started', category: 'session' },
|
||||
UserPromptSubmit: { color: '#a78bfa', label: 'Prompt submitted', category: 'user' },
|
||||
PreToolUse: { color: '#fbbf24', label: 'Tool starting', category: 'tool' },
|
||||
PermissionRequest: { color: '#fb923c', label: 'Permission required', category: 'tool' },
|
||||
PostToolUse: { color: '#4ade80', label: 'Tool completed', category: 'tool' },
|
||||
PostToolUseFailure: { color: '#f87171', label: 'Tool failed', category: 'tool' },
|
||||
Notification: { color: '#38bdf8', label: 'Notification', category: 'system' },
|
||||
SubagentStart: { color: '#c084fc', label: 'Subagent spawned', category: 'agent' },
|
||||
SubagentStop: { color: '#a855f7', label: 'Subagent finished', category: 'agent' },
|
||||
Stop: { color: '#22d3ee', label: 'Response complete', category: 'session' },
|
||||
TeammateIdle: { color: '#94a3b8', label: 'Teammate idle', category: 'agent' },
|
||||
TaskCompleted: { color: '#34d399', label: 'Task completed', category: 'system' },
|
||||
ConfigChange: { color: '#e879f9', label: 'Config changed', category: 'system' },
|
||||
PreCompact: { color: '#f59e0b', label: 'Compacting context', category: 'system' },
|
||||
SessionEnd: { color: '#6b7280', label: 'Session ended', category: 'session' },
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: Record<Category, number> = {
|
||||
@@ -71,30 +54,71 @@ interface EventCountEntry {
|
||||
color: string
|
||||
}
|
||||
|
||||
// ── Props ──
|
||||
|
||||
const props = defineProps<{
|
||||
currentEvent?: string | null
|
||||
eventDetail?: string
|
||||
hookHistory?: HookHistoryEntry[]
|
||||
// Server-derived continuous state
|
||||
sessionActive: boolean
|
||||
agentResponding: boolean
|
||||
subagentActive: boolean
|
||||
compacting: boolean
|
||||
agentStatus: AgentStatus
|
||||
currentTool: ActiveTool | null
|
||||
lastActivity: number
|
||||
}>()
|
||||
|
||||
// ── Continuous states (from server) ──
|
||||
|
||||
const { activeStates, isActive } = useLifecycleStates({
|
||||
sessionActive: toRef(props, 'sessionActive'),
|
||||
agentResponding: toRef(props, 'agentResponding'),
|
||||
subagentActive: toRef(props, 'subagentActive'),
|
||||
compacting: toRef(props, 'compacting'),
|
||||
status: toRef(props, 'agentStatus'),
|
||||
currentTool: toRef(props, 'currentTool'),
|
||||
lastActivity: toRef(props, 'lastActivity'),
|
||||
})
|
||||
|
||||
// ── Elapsed time ──
|
||||
|
||||
const now = ref(Date.now())
|
||||
let elapsedTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(() => {
|
||||
elapsedTimer = setInterval(() => { now.value = Date.now() }, 1000)
|
||||
})
|
||||
onUnmounted(() => { if (elapsedTimer) clearInterval(elapsedTimer) })
|
||||
|
||||
function hasElapsed(state: ContinuousState): boolean {
|
||||
if (state.type !== 'responding' && state.type !== 'tool') return false
|
||||
return (now.value - state.startedAt) >= 2000
|
||||
}
|
||||
|
||||
function getElapsed(state: ContinuousState): string {
|
||||
const ms = now.value - state.startedAt
|
||||
const s = Math.floor(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
const m = Math.floor(s / 60)
|
||||
const rs = s % 60
|
||||
return rs > 0 ? `${m}m ${rs}s` : `${m}m`
|
||||
}
|
||||
|
||||
// Session chip compacts to dot-only when other states are active
|
||||
const sessionCompact = computed(() =>
|
||||
activeStates.value.length > 1 &&
|
||||
activeStates.value.some(s => s.type === 'session')
|
||||
)
|
||||
|
||||
// ── Fallback: last event label (when no continuous states) ──
|
||||
|
||||
const hasEvent = computed(() => !!props.currentEvent && props.currentEvent in LIFECYCLE_DISPLAY)
|
||||
|
||||
const activeEvent = computed<LifecycleEvent | null>(() => {
|
||||
if (!hasEvent.value) return null
|
||||
return props.currentEvent as LifecycleEvent
|
||||
})
|
||||
|
||||
const activeEvent = computed<LifecycleEvent | null>(() => hasEvent.value ? props.currentEvent as LifecycleEvent : null)
|
||||
const activeDetail = computed(() => props.eventDetail || '')
|
||||
|
||||
const displayInfo = computed(() => activeEvent.value ? LIFECYCLE_DISPLAY[activeEvent.value] : null)
|
||||
const isProcessing = computed(() => !!displayInfo.value?.processing)
|
||||
const iconPath = computed(() => displayInfo.value ? ICON[displayInfo.value.icon as keyof typeof ICON] : '')
|
||||
const isFilled = computed(() => {
|
||||
if (!displayInfo.value) return false
|
||||
const i = displayInfo.value.icon
|
||||
return i === 'play' || i === 'chat' || i === 'bell' || i === 'moon' || i === 'stop'
|
||||
})
|
||||
const tooltipText = computed(() => {
|
||||
const fallbackText = computed(() => {
|
||||
if (!displayInfo.value) return ''
|
||||
const label = displayInfo.value.label
|
||||
const detail = activeDetail.value
|
||||
@@ -133,10 +157,26 @@ function countEvents(entries: { event: string }[]): EventCountEntry[] {
|
||||
}
|
||||
|
||||
const displayCounts = computed(() => countEvents(props.hookHistory || []))
|
||||
|
||||
// Show the ribbon if there are active continuous states OR a fallback event
|
||||
const showRibbon = computed(() => isActive.value || !!displayInfo.value)
|
||||
|
||||
// Border color follows the highest-priority active state or fallback
|
||||
const ribbonBorderColor = computed(() => {
|
||||
if (activeStates.value.length > 0) {
|
||||
const last = activeStates.value[activeStates.value.length - 1]
|
||||
return last?.color || 'rgba(255, 255, 255, 0.04)'
|
||||
}
|
||||
return displayInfo.value?.color || 'rgba(255, 255, 255, 0.04)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="displayInfo" class="lifecycle-ribbon" :class="['category-' + displayInfo.category]">
|
||||
<div
|
||||
v-if="showRibbon"
|
||||
class="lifecycle-ribbon"
|
||||
:style="{ borderTopColor: ribbonBorderColor + '26' }"
|
||||
>
|
||||
<!-- Badge counts -->
|
||||
<div class="lc-badges" v-if="displayCounts.length > 0">
|
||||
<TransitionGroup name="badge">
|
||||
@@ -154,22 +194,51 @@ const displayCounts = computed(() => countEvents(props.hookHistory || []))
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Current event icon + tooltip -->
|
||||
<Transition name="lc" mode="out-in">
|
||||
<div class="lc-icon-wrap" :key="activeEvent + activeDetail" :class="{ processing: isProcessing }">
|
||||
<svg
|
||||
class="lc-icon"
|
||||
width="14" height="14" viewBox="0 0 14 14"
|
||||
:fill="isFilled ? displayInfo.color : 'none'"
|
||||
:stroke="isFilled ? 'none' : displayInfo.color"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<!-- Separator -->
|
||||
<span
|
||||
v-if="displayCounts.length > 0 && (activeStates.length > 0 || displayInfo)"
|
||||
class="lc-sep"
|
||||
>|</span>
|
||||
|
||||
<!-- Active continuous state chips -->
|
||||
<div v-if="activeStates.length > 0" class="lc-states">
|
||||
<TransitionGroup name="chip">
|
||||
<span
|
||||
v-for="state in activeStates"
|
||||
:key="state.type"
|
||||
class="lc-chip"
|
||||
:style="{ '--chip-color': state.color } as any"
|
||||
:title="state.label + (state.detail ? ' — ' + state.detail : '')"
|
||||
>
|
||||
<path :d="iconPath" />
|
||||
</svg>
|
||||
<span class="lc-tooltip">{{ tooltipText }}</span>
|
||||
</div>
|
||||
<!-- Animated indicator -->
|
||||
<span class="chip-indicator" :class="'anim-' + state.type">
|
||||
<template v-if="state.type === 'responding'">
|
||||
<span class="dot dot-1"></span>
|
||||
<span class="dot dot-2"></span>
|
||||
<span class="dot dot-3"></span>
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<!-- Label (hidden for session when compact) -->
|
||||
<span
|
||||
v-if="!(state.type === 'session' && sessionCompact)"
|
||||
class="chip-label"
|
||||
>{{ state.label }}</span>
|
||||
|
||||
<!-- Elapsed time -->
|
||||
<span v-if="hasElapsed(state)" class="chip-elapsed">{{ getElapsed(state) }}</span>
|
||||
</span>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- Fallback: show last event text when no continuous states -->
|
||||
<Transition v-else name="lc" mode="out-in">
|
||||
<span
|
||||
v-if="displayInfo"
|
||||
class="lc-label"
|
||||
:key="(activeEvent || '') + activeDetail"
|
||||
:style="{ color: displayInfo.color }"
|
||||
>{{ fallbackText }}</span>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
@@ -184,12 +253,6 @@ const displayCounts = computed(() => countEvents(props.hookHistory || []))
|
||||
transition: border-top-color 0.3s ease;
|
||||
}
|
||||
|
||||
.lifecycle-ribbon.category-session { border-top-color: rgba(96, 165, 250, 0.15); }
|
||||
.lifecycle-ribbon.category-user { border-top-color: rgba(167, 139, 250, 0.15); }
|
||||
.lifecycle-ribbon.category-tool { border-top-color: rgba(251, 191, 36, 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); }
|
||||
|
||||
/* ── Badges ── */
|
||||
|
||||
.lc-badges {
|
||||
@@ -217,92 +280,249 @@ const displayCounts = computed(() => countEvents(props.hookHistory || []))
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-enter-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
.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; }
|
||||
|
||||
/* ── Separator ── */
|
||||
|
||||
.lc-sep {
|
||||
font-size: 8px;
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
margin: 0 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
/* ── Continuous state chips ── */
|
||||
|
||||
.badge-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
.badge-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.badge-move {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Icon + tooltip ── */
|
||||
|
||||
.lc-icon-wrap {
|
||||
position: relative;
|
||||
.lc-states {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
cursor: default;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.lc-icon {
|
||||
transition: transform 0.2s ease, filter 0.2s ease;
|
||||
.lc-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
padding: 1px 5px 1px 3px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, var(--chip-color) 10%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--chip-color) 20%, transparent);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.lc-icon-wrap.processing .lc-icon {
|
||||
animation: pulse-icon 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-icon {
|
||||
0%, 100% { opacity: 0.5; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.lc-tooltip {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 4px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 3px 6px;
|
||||
background: rgba(15, 15, 25, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
.chip-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
z-index: 20;
|
||||
color: var(--chip-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lc-icon-wrap:hover .lc-tooltip {
|
||||
opacity: 1;
|
||||
.chip-elapsed {
|
||||
font-size: 8px;
|
||||
font-weight: 400;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--chip-color);
|
||||
opacity: 0.55;
|
||||
margin-left: 1px;
|
||||
}
|
||||
|
||||
/* Transition classes */
|
||||
.lc-enter-active {
|
||||
.chip-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Chip transitions ── */
|
||||
|
||||
.chip-enter-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.chip-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.lc-leave-active {
|
||||
transition: opacity 0.1s ease;
|
||||
.chip-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8) translateY(2px);
|
||||
}
|
||||
|
||||
.lc-enter-from {
|
||||
.chip-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.lc-leave-to {
|
||||
opacity: 0;
|
||||
.chip-move {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════
|
||||
Per-state animations
|
||||
══════════════════════════════════════════ */
|
||||
|
||||
/* ── Session: breathing dot ── */
|
||||
|
||||
.anim-session {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
animation: breathe 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||||
50% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── Responding: typing dots ── */
|
||||
|
||||
.anim-responding {
|
||||
display: inline-flex;
|
||||
gap: 1.5px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.anim-responding .dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
animation: typing-dot 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.anim-responding .dot-2 { animation-delay: 0.2s; }
|
||||
.anim-responding .dot-3 { animation-delay: 0.4s; }
|
||||
|
||||
@keyframes typing-dot {
|
||||
0%, 60%, 100% { opacity: 0.2; transform: scale(0.75); }
|
||||
30% { opacity: 1; transform: scale(1.15); }
|
||||
}
|
||||
|
||||
/* ── Tool: shimmer bar ── */
|
||||
|
||||
.anim-tool {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
border-radius: 1.5px;
|
||||
background: color-mix(in srgb, var(--chip-color) 25%, transparent);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.anim-tool::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--chip-color);
|
||||
border-radius: 1.5px;
|
||||
animation: shimmer 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { left: -100%; }
|
||||
50% { left: 100%; }
|
||||
100% { left: 100%; }
|
||||
}
|
||||
|
||||
/* ── Subagent: orbiting dot ── */
|
||||
|
||||
.anim-subagent {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.anim-subagent::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
opacity: 0.25;
|
||||
top: 3px;
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.anim-subagent::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
top: 0;
|
||||
left: 2.5px;
|
||||
animation: orbit 1.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes orbit {
|
||||
0% { top: 0px; left: 2.5px; }
|
||||
25% { top: 2.5px; left: 5px; }
|
||||
50% { top: 5px; left: 2.5px; }
|
||||
75% { top: 2.5px; left: 0px; }
|
||||
100% { top: 0px; left: 2.5px; }
|
||||
}
|
||||
|
||||
/* ── Permission: urgent blink ── */
|
||||
|
||||
.anim-permission {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--chip-color);
|
||||
animation: urgent-blink 0.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes urgent-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.12; }
|
||||
}
|
||||
|
||||
/* ── Compacting: horizontal squeeze ── */
|
||||
|
||||
.anim-compacting {
|
||||
width: 8px;
|
||||
height: 5px;
|
||||
background: var(--chip-color);
|
||||
border-radius: 1px;
|
||||
animation: squeeze 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes squeeze {
|
||||
0%, 100% { transform: scaleX(1); opacity: 0.6; }
|
||||
50% { transform: scaleX(0.45); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Fallback text label ── */
|
||||
|
||||
.lc-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'Courier New', monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Fallback label transitions */
|
||||
.lc-enter-active { transition: opacity 0.15s ease, transform 0.15s ease; }
|
||||
.lc-leave-active { transition: opacity 0.1s ease; }
|
||||
.lc-enter-from { opacity: 0; transform: translateY(4px); }
|
||||
.lc-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,90 +1,221 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ParsedSystemMessage } from '@/types/transcript-debug'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
message: ParsedSystemMessage
|
||||
}>()
|
||||
|
||||
const expanded = ref(false)
|
||||
const hasContent = computed(() => !!props.message.content?.trim())
|
||||
|
||||
// ── Subtype display config ──
|
||||
|
||||
interface SubtypeDisplay {
|
||||
label: string
|
||||
color: string
|
||||
icon: string // SVG path(s) for the icon
|
||||
iconFill?: boolean // Whether inner elements use fill instead of stroke
|
||||
}
|
||||
|
||||
const SUBTYPE_MAP: Record<string, SubtypeDisplay> = {
|
||||
api_error: {
|
||||
label: 'API error',
|
||||
color: '#ef4444',
|
||||
// X inside circle
|
||||
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm3.5 12.5L13 12l2.5-2.5m-7 0L11 12l-2.5 2.5',
|
||||
},
|
||||
rate_limit: {
|
||||
label: 'Rate limited',
|
||||
color: '#f59e0b',
|
||||
// Clock
|
||||
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v5l3 3',
|
||||
},
|
||||
overloaded: {
|
||||
label: 'Overloaded',
|
||||
color: '#f59e0b',
|
||||
// Warning triangle
|
||||
icon: 'M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01',
|
||||
},
|
||||
init: {
|
||||
label: 'Init',
|
||||
color: '#60a5fa',
|
||||
// Power on
|
||||
icon: 'M12 2v6m-6.36.64A9 9 0 1 0 18.36 8.64',
|
||||
},
|
||||
config: {
|
||||
label: 'Config',
|
||||
color: '#a78bfa',
|
||||
// Settings gear
|
||||
icon: 'M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z',
|
||||
},
|
||||
}
|
||||
|
||||
const DEFAULT_DISPLAY: SubtypeDisplay = {
|
||||
label: 'System',
|
||||
color: '#fbbf24',
|
||||
// Info circle
|
||||
icon: 'M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 5v1m0 4v4',
|
||||
}
|
||||
|
||||
const display = computed(() => {
|
||||
const sub = props.message.subtype
|
||||
if (sub && SUBTYPE_MAP[sub]) return SUBTYPE_MAP[sub]
|
||||
return DEFAULT_DISPLAY
|
||||
})
|
||||
|
||||
const previewText = computed(() => {
|
||||
const c = props.message.content?.trim() || ''
|
||||
if (!c) return ''
|
||||
// Single line preview
|
||||
const line = c.split('\n')[0]
|
||||
return line.length > 100 ? line.slice(0, 100) + '...' : line
|
||||
})
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return ''
|
||||
return new Date(ts).toLocaleTimeString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-message">
|
||||
<button class="system-toggle" @click="expanded = !expanded">
|
||||
<span class="system-icon">ⓘ</span>
|
||||
<span class="system-label">System</span>
|
||||
<span v-if="message.subtype" class="subtype-badge">{{ message.subtype }}</span>
|
||||
<span class="content-preview">{{ message.content.slice(0, 80) }}{{ message.content.length > 80 ? '...' : '' }}</span>
|
||||
<div class="sys-row-wrapper">
|
||||
<button
|
||||
class="sys-row"
|
||||
:class="{ expandable: hasContent, expanded }"
|
||||
@click="hasContent && (expanded = !expanded)"
|
||||
>
|
||||
<!-- Icon -->
|
||||
<span class="sys-icon" :style="{ color: display.color }">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path :d="display.icon" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="sys-label" :style="{ color: display.color }">{{ display.label }}</span>
|
||||
|
||||
<!-- Subtype badge (if different from label) -->
|
||||
<span
|
||||
v-if="message.subtype && !SUBTYPE_MAP[message.subtype]"
|
||||
class="sys-subtype"
|
||||
:style="{ color: display.color, background: display.color + '1a' }"
|
||||
>{{ message.subtype }}</span>
|
||||
|
||||
<!-- Preview -->
|
||||
<span v-if="previewText" class="sys-preview">{{ previewText }}</span>
|
||||
|
||||
<!-- Expand indicator -->
|
||||
<span v-if="hasContent" class="sys-expand-hint">
|
||||
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline :points="expanded ? '18 15 12 9 6 15' : '6 9 12 15 18 9'" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- Timestamp -->
|
||||
<span class="sys-time">{{ formatTime(message.timestamp) }}</span>
|
||||
</button>
|
||||
<pre v-if="expanded" class="system-content">{{ message.content }}</pre>
|
||||
|
||||
<!-- Expanded content -->
|
||||
<pre v-if="expanded && hasContent" class="sys-content" :style="{ borderColor: display.color + '33' }">{{ message.content }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-message {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px dashed rgba(251, 191, 36, 0.3);
|
||||
.sys-row-wrapper {
|
||||
margin: 0.1rem 0;
|
||||
}
|
||||
|
||||
.system-toggle {
|
||||
.sys-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.75rem;
|
||||
background: rgba(251, 191, 36, 0.04);
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.15s;
|
||||
cursor: default;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.system-toggle:hover {
|
||||
background: rgba(251, 191, 36, 0.08);
|
||||
.sys-row.expandable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.system-icon {
|
||||
color: #fbbf24;
|
||||
font-size: 13px;
|
||||
.sys-row:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.system-label {
|
||||
font-weight: 500;
|
||||
color: #fbbf24;
|
||||
font-size: 11px;
|
||||
.sys-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.subtype-badge {
|
||||
.sys-label {
|
||||
font-size: 10px;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
color: #fbbf24;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content-preview {
|
||||
.sys-subtype {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sys-preview {
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
opacity: 0.7;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.system-content {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
font-size: 11px;
|
||||
.sys-expand-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.sys-time {
|
||||
margin-left: auto;
|
||||
font-size: 9px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Expanded content ── */
|
||||
|
||||
.sys-content {
|
||||
margin: 0 0.5rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
line-height: 1.5;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 300px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px dashed rgba(251, 191, 36, 0.2);
|
||||
background: rgba(251, 191, 36, 0.02);
|
||||
border-left: 2px solid;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
border-radius: 0 0 4px 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -18,3 +18,4 @@ export { default as ResumeTerminalButton } from './ResumeTerminalButton.vue'
|
||||
export { default as VoiceMicButton } from './VoiceMicButton.vue'
|
||||
export { default as NewSessionModal } from './NewSessionModal.vue'
|
||||
export { AquaticBackground } from './aquaticBackground'
|
||||
export { SyncEnginePanel } from './sync-engine'
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useSessionState, type PtySessionState } from '@/stores/session-state'
|
||||
|
||||
const sessionState = useSessionState()
|
||||
const ptySessions = computed(() => sessionState.ptySessionList)
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
idle: '#6b7280',
|
||||
thinking: '#a78bfa',
|
||||
reading: '#60a5fa',
|
||||
writing: '#fbbf24',
|
||||
toolUse: '#fb923c',
|
||||
permissionRequest: '#f87171',
|
||||
interrupted: '#ef4444',
|
||||
error: '#ef4444',
|
||||
sessionStart: '#4ade80',
|
||||
sessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
function elapsed(ts: number): string {
|
||||
if (!ts) return '-'
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 1000) return '<1s'
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
|
||||
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
|
||||
}
|
||||
|
||||
function truncate(s: string | null | undefined, max = 24): string {
|
||||
if (!s) return '-'
|
||||
return s.length > max ? s.slice(0, max) + '...' : s
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="agent-states">
|
||||
<div v-if="!ptySessions.length" class="empty">No PTY sessions</div>
|
||||
|
||||
<div v-for="ps in ptySessions" :key="ps.ptySessionId" class="agent-card">
|
||||
<div class="agent-header">
|
||||
<span class="agent-name pty-id">{{ ps.ptySessionId }}</span>
|
||||
<span class="status-badge" :style="{ background: STATUS_COLORS[ps.status] || '#6b7280' }">
|
||||
{{ ps.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Agent</span>
|
||||
<span class="value">{{ ps.agent }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.currentTool" class="row">
|
||||
<span class="label">Tool</span>
|
||||
<span class="value tool-name">{{ ps.currentTool.name }}</span>
|
||||
<span class="value dim">{{ elapsed(ps.currentTool.startedAt) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.transcriptSessionId" class="row">
|
||||
<span class="label">Session</span>
|
||||
<span class="value mono">{{ truncate(ps.transcriptSessionId, 20) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.model" class="row">
|
||||
<span class="label">Model</span>
|
||||
<span class="value">{{ ps.model }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.permissionMode" class="row">
|
||||
<span class="label">Mode</span>
|
||||
<span class="value">{{ ps.permissionMode }}</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<span class="label">Activity</span>
|
||||
<span class="value">{{ elapsed(ps.lastActivity) }} ago</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.lastError" class="row error-row">
|
||||
<span class="label">Error</span>
|
||||
<span class="value error-text">{{ ps.lastError.tool }}: {{ truncate(ps.lastError.message, 40) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ps.pendingApprovals.length" class="row">
|
||||
<span class="label">Approvals</span>
|
||||
<span class="value warning-text">{{ ps.pendingApprovals.length }} pending</span>
|
||||
</div>
|
||||
|
||||
<div class="flags">
|
||||
<span v-if="ps.sessionActive" class="flag active">session</span>
|
||||
<span v-if="ps.agentResponding" class="flag responding">responding</span>
|
||||
<span v-if="ps.subagentActive" class="flag subagent">subagent</span>
|
||||
<span v-if="ps.compacting" class="flag compacting">compacting</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.agent-states {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.agent-card {
|
||||
flex: 1;
|
||||
min-width: 220px;
|
||||
max-width: 340px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(34, 211, 238, 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.agent-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #64748b;
|
||||
min-width: 55px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.value.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.value.dim {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-row {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-radius: 2px;
|
||||
padding: 2px 4px;
|
||||
margin: 1px -4px;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
color: #f87171;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flag {
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.flag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
|
||||
.flag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
|
||||
.flag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
|
||||
.flag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
|
||||
.pty-id {
|
||||
font-size: 10px;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #67e8f9;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSessionState, type HookHistoryEntry } from '@/stores/session-state'
|
||||
|
||||
const sessionState = useSessionState()
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
SessionStart: '#60a5fa',
|
||||
UserPromptSubmit: '#a78bfa',
|
||||
PreToolUse: '#fbbf24',
|
||||
PermissionRequest: '#fb923c',
|
||||
PostToolUse: '#4ade80',
|
||||
PostToolUseFailure: '#f87171',
|
||||
Notification: '#38bdf8',
|
||||
SubagentStart: '#c084fc',
|
||||
SubagentStop: '#a855f7',
|
||||
Stop: '#22d3ee',
|
||||
TeammateIdle: '#94a3b8',
|
||||
TaskCompleted: '#34d399',
|
||||
ConfigChange: '#e879f9',
|
||||
PreCompact: '#f59e0b',
|
||||
SessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
ejecutor: '#60a5fa',
|
||||
nucleo000: '#4ade80',
|
||||
claude: '#a78bfa',
|
||||
}
|
||||
|
||||
interface TimelineEntry extends HookHistoryEntry {
|
||||
agent: string
|
||||
ptySessionId: string
|
||||
}
|
||||
|
||||
// Filters
|
||||
const ptyFilter = ref<string>('all')
|
||||
const eventFilter = ref<string>('all')
|
||||
const MAX_DISPLAY = 200
|
||||
|
||||
// Merge all per-PTY sessions into one sorted timeline
|
||||
const allEntries = computed<TimelineEntry[]>(() => {
|
||||
const entries: TimelineEntry[] = []
|
||||
const registry = sessionState.terminalRegistry
|
||||
|
||||
for (const [ptyId, ptyState] of Object.entries(sessionState.ptySessions)) {
|
||||
const regEntry = registry.find(r => r.ephemeralSessionId === ptyId)
|
||||
const agent = regEntry?.agent || ptyState.agent || 'unknown'
|
||||
for (const h of ptyState.hookHistory) {
|
||||
entries.push({ ...h, agent, ptySessionId: ptyId })
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => b.timestamp - a.timestamp)
|
||||
return entries
|
||||
})
|
||||
|
||||
// Get unique PTY session IDs with their agent for display
|
||||
const ptyOptions = computed(() => {
|
||||
const map = new Map<string, string>() // ptyId → agent
|
||||
for (const e of allEntries.value) map.set(e.ptySessionId, e.agent)
|
||||
return Array.from(map.entries()).map(([ptyId, agent]) => ({ ptyId, agent }))
|
||||
})
|
||||
|
||||
// Get unique event types for filter
|
||||
const eventTypes = computed(() => {
|
||||
const set = new Set<string>()
|
||||
for (const e of allEntries.value) set.add(e.event)
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
// Apply filters
|
||||
const filtered = computed(() => {
|
||||
let list = allEntries.value
|
||||
if (ptyFilter.value !== 'all') {
|
||||
list = list.filter(e => e.ptySessionId === ptyFilter.value)
|
||||
}
|
||||
if (eventFilter.value !== 'all') {
|
||||
list = list.filter(e => e.event === eventFilter.value)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const displayed = computed(() => filtered.value.slice(0, MAX_DISPLAY))
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hook-timeline">
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="filter-group">
|
||||
<button
|
||||
:class="['filter-btn', { active: ptyFilter === 'all' }]"
|
||||
@click="ptyFilter = 'all'"
|
||||
>All</button>
|
||||
<button
|
||||
v-for="p in ptyOptions"
|
||||
:key="p.ptyId"
|
||||
:class="['filter-btn', { active: ptyFilter === p.ptyId }]"
|
||||
:style="{ '--accent': AGENT_COLORS[p.agent] || '#64748b' }"
|
||||
@click="ptyFilter = p.ptyId"
|
||||
:title="p.ptyId"
|
||||
>{{ p.agent }}:{{ p.ptyId.slice(-6) }}</button>
|
||||
</div>
|
||||
<select v-model="eventFilter" class="event-select">
|
||||
<option value="all">All events</option>
|
||||
<option v-for="ev in eventTypes" :key="ev" :value="ev">{{ ev }}</option>
|
||||
</select>
|
||||
<span class="count">{{ displayed.length }}<span v-if="filtered.length > MAX_DISPLAY"> / {{ filtered.length }}</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="timeline-list">
|
||||
<div v-if="!displayed.length" class="empty">No events</div>
|
||||
<div v-for="(entry, i) in displayed" :key="i" class="timeline-entry">
|
||||
<span class="time">{{ formatTime(entry.timestamp) }}</span>
|
||||
<span class="agent-badge" :style="{ background: AGENT_COLORS[entry.agent] || '#64748b' }" :title="entry.ptySessionId">{{ entry.agent }}:{{ entry.ptySessionId.slice(-6) }}</span>
|
||||
<span class="event-name" :style="{ color: EVENT_COLORS[entry.event] || '#94a3b8' }">{{ entry.event }}</span>
|
||||
<span v-if="entry.detail" class="detail">{{ entry.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hook-timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: var(--accent, #0ea5e9);
|
||||
color: #fff;
|
||||
border-color: var(--accent, #0ea5e9);
|
||||
}
|
||||
|
||||
.event-select {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
color: #cbd5e1;
|
||||
font-size: 10px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.count {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.timeline-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 11px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.timeline-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #475569;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
min-width: 85px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-badge {
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
min-width: 85px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.event-name {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useWsMonitor } from '@/composables/useWsMonitor'
|
||||
import AgentStatesSection from './AgentStatesSection.vue'
|
||||
import HookTimelineSection from './HookTimelineSection.vue'
|
||||
import TerminalRegistrySection from './TerminalRegistrySection.vue'
|
||||
import WsMonitorSection from './WsMonitorSection.vue'
|
||||
|
||||
const wsMonitor = useWsMonitor()
|
||||
|
||||
onMounted(() => wsMonitor.start(5000))
|
||||
onUnmounted(() => wsMonitor.stop())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sync-engine-panel">
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">Agent States</h3>
|
||||
<AgentStatesSection />
|
||||
</div>
|
||||
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">Hook Timeline</h3>
|
||||
<HookTimelineSection />
|
||||
</div>
|
||||
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">Terminal Registry</h3>
|
||||
<TerminalRegistrySection />
|
||||
</div>
|
||||
|
||||
<div class="se-section">
|
||||
<h3 class="se-section-title">WS Monitor</h3>
|
||||
<WsMonitorSection :data="wsMonitor.data.value" :error="wsMonitor.error.value" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-engine-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
font-family: 'Courier New', ui-monospace, monospace;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.se-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.se-section-title {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: #64748b;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useSessionState, type PtySessionState } from '@/stores/session-state'
|
||||
|
||||
const sessionState = useSessionState()
|
||||
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function toggle(id: string) {
|
||||
const s = new Set(expandedIds.value)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
expandedIds.value = s
|
||||
}
|
||||
|
||||
function getHooks(ephemeralSessionId: string): PtySessionState | null {
|
||||
return sessionState.ptySessions[ephemeralSessionId] ?? null
|
||||
}
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
SessionStart: '#60a5fa',
|
||||
UserPromptSubmit: '#a78bfa',
|
||||
PreToolUse: '#fbbf24',
|
||||
PermissionRequest: '#fb923c',
|
||||
PostToolUse: '#4ade80',
|
||||
PostToolUseFailure: '#f87171',
|
||||
Notification: '#38bdf8',
|
||||
SubagentStart: '#c084fc',
|
||||
SubagentStop: '#a855f7',
|
||||
Stop: '#22d3ee',
|
||||
TeammateIdle: '#94a3b8',
|
||||
TaskCompleted: '#34d399',
|
||||
ConfigChange: '#e879f9',
|
||||
PreCompact: '#f59e0b',
|
||||
SessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
idle: '#6b7280',
|
||||
thinking: '#a78bfa',
|
||||
reading: '#60a5fa',
|
||||
writing: '#fbbf24',
|
||||
toolUse: '#fb923c',
|
||||
permissionRequest: '#f87171',
|
||||
interrupted: '#ef4444',
|
||||
error: '#ef4444',
|
||||
sessionStart: '#4ade80',
|
||||
sessionEnd: '#6b7280',
|
||||
}
|
||||
|
||||
function elapsed(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
return `${Math.floor(diff / 3600_000)}h ${Math.floor((diff % 3600_000) / 60_000)}m`
|
||||
}
|
||||
|
||||
function elapsedMs(ts: number): string {
|
||||
if (!ts) return '-'
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 1000) return '<1s'
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ${Math.floor((diff % 60_000) / 1000)}s`
|
||||
return `${Math.floor(diff / 3600_000)}h`
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString('en-GB', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
||||
}
|
||||
|
||||
const COLS = 8
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="terminal-registry">
|
||||
<div v-if="!sessionState.terminalRegistry.length" class="empty">No terminals registered</div>
|
||||
|
||||
<table v-else class="reg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Label</th>
|
||||
<th>Agent</th>
|
||||
<th>Session</th>
|
||||
<th>Command</th>
|
||||
<th>Buffer</th>
|
||||
<th>Clients</th>
|
||||
<th>Age</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody v-for="entry in sessionState.terminalRegistry" :key="entry.ephemeralSessionId">
|
||||
<!-- Main row -->
|
||||
<tr
|
||||
:class="['entry-row', { expanded: expandedIds.has(entry.ephemeralSessionId) }]"
|
||||
@click="toggle(entry.ephemeralSessionId)"
|
||||
>
|
||||
<td><span :class="['dot', entry.alive ? 'alive' : 'dead']"></span></td>
|
||||
<td class="cell-wrap">{{ entry.label }}</td>
|
||||
<td class="agent">{{ entry.agent }}</td>
|
||||
<td class="mono cell-wrap">{{ entry.ephemeralSessionId }}</td>
|
||||
<td class="mono cell-wrap">{{ entry.command }}</td>
|
||||
<td class="num">{{ entry.bufferSize.toLocaleString() }}</td>
|
||||
<td class="num">{{ entry.clients }}</td>
|
||||
<td class="dim">{{ elapsed(entry.createdAt) }}</td>
|
||||
</tr>
|
||||
|
||||
<!-- Expanded hooks row -->
|
||||
<tr v-if="expandedIds.has(entry.ephemeralSessionId)" class="hooks-row">
|
||||
<td :colspan="COLS" class="hooks-cell">
|
||||
<template v-if="getHooks(entry.ephemeralSessionId)">
|
||||
<div class="hooks-panel">
|
||||
<!-- Session state summary -->
|
||||
<div class="hooks-summary">
|
||||
<span class="hooks-label">Status</span>
|
||||
<span class="hooks-status" :style="{ color: STATUS_COLORS[getHooks(entry.ephemeralSessionId)!.status] || '#6b7280' }">
|
||||
{{ getHooks(entry.ephemeralSessionId)!.status }}
|
||||
</span>
|
||||
|
||||
<template v-if="getHooks(entry.ephemeralSessionId)!.currentTool">
|
||||
<span class="hooks-label">Tool</span>
|
||||
<span class="hooks-tool">{{ getHooks(entry.ephemeralSessionId)!.currentTool!.name }}</span>
|
||||
</template>
|
||||
|
||||
<template v-if="getHooks(entry.ephemeralSessionId)!.lastHookEvent">
|
||||
<span class="hooks-label">Last event</span>
|
||||
<span class="hooks-event" :style="{ color: EVENT_COLORS[getHooks(entry.ephemeralSessionId)!.lastHookEvent!] || '#94a3b8' }">
|
||||
{{ getHooks(entry.ephemeralSessionId)!.lastHookEvent }}
|
||||
</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.lastHookDetail" class="hooks-detail">
|
||||
{{ getHooks(entry.ephemeralSessionId)!.lastHookDetail }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<span class="hooks-label">Activity</span>
|
||||
<span class="hooks-dim">{{ elapsedMs(getHooks(entry.ephemeralSessionId)!.lastActivity) }} ago</span>
|
||||
|
||||
<!-- Flags -->
|
||||
<div class="hooks-flags">
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.sessionActive" class="hflag active">session</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.agentResponding" class="hflag responding">responding</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.subagentActive" class="hflag subagent">subagent</span>
|
||||
<span v-if="getHooks(entry.ephemeralSessionId)!.compacting" class="hflag compacting">compacting</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hook history -->
|
||||
<div class="hooks-history">
|
||||
<div class="hooks-history-title">
|
||||
Hook History ({{ getHooks(entry.ephemeralSessionId)!.hookHistory.length }})
|
||||
</div>
|
||||
<div class="hooks-list">
|
||||
<div
|
||||
v-for="(h, i) in [...getHooks(entry.ephemeralSessionId)!.hookHistory].reverse().slice(0, 100)"
|
||||
:key="i"
|
||||
class="hook-entry"
|
||||
>
|
||||
<span class="hook-time">{{ formatTime(h.timestamp) }}</span>
|
||||
<span class="hook-event" :style="{ color: EVENT_COLORS[h.event] || '#94a3b8' }">{{ h.event }}</span>
|
||||
<span v-if="h.detail" class="hook-detail">{{ h.detail }}</span>
|
||||
</div>
|
||||
<div v-if="getHooks(entry.ephemeralSessionId)!.hookHistory.length > 100" class="hooks-more">
|
||||
... {{ getHooks(entry.ephemeralSessionId)!.hookHistory.length - 100 }} more
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="hooks-empty">No hook data for terminal {{ entry.ephemeralSessionId }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.terminal-registry {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #6b7280;
|
||||
font-size: 11px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.reg-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 11px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.reg-table th {
|
||||
text-align: left;
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 6px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reg-table td {
|
||||
padding: 3px 6px;
|
||||
color: #cbd5e1;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.entry-row {
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.entry-row:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.entry-row.expanded td {
|
||||
background: rgba(14, 165, 233, 0.04);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.cell-wrap {
|
||||
word-break: break-all;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.alive { background: #22c55e; }
|
||||
.dot.dead { background: #ef4444; }
|
||||
|
||||
.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.agent {
|
||||
font-weight: 600;
|
||||
color: #60a5fa;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Hooks expanded row ── */
|
||||
|
||||
.hooks-row td {
|
||||
padding: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.hooks-cell {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.hooks-panel {
|
||||
padding: 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hooks-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.hooks-label {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.hooks-status {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hooks-tool {
|
||||
color: #fbbf24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hooks-event {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hooks-detail {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hooks-dim {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hooks-flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.hflag {
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.hflag.active { background: rgba(74, 222, 128, 0.15); color: #4ade80; }
|
||||
.hflag.responding { background: rgba(167, 139, 250, 0.15); color: #a78bfa; }
|
||||
.hflag.subagent { background: rgba(192, 132, 252, 0.15); color: #c084fc; }
|
||||
.hflag.compacting { background: rgba(245, 158, 11, 0.15); color: #f59e0b; }
|
||||
|
||||
.hooks-empty {
|
||||
padding: 8px 10px;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Hook history list ── */
|
||||
|
||||
.hooks-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.hooks-history-title {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.hooks-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.hook-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 1px 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.hook-time {
|
||||
color: #475569;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 9px;
|
||||
min-width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-event {
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
min-width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-detail {
|
||||
color: #64748b;
|
||||
font-size: 9px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hooks-more {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
padding: 2px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import type { WsMonitorData } from '@/composables/useWsMonitor'
|
||||
|
||||
const props = defineProps<{
|
||||
data: WsMonitorData | null
|
||||
error: string | null
|
||||
}>()
|
||||
|
||||
function elapsed(iso: string): string {
|
||||
const diff = Date.now() - new Date(iso).getTime()
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m`
|
||||
return `${Math.floor(diff / 3600_000)}h`
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('en-GB', { hour12: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ws-monitor">
|
||||
<div v-if="error" class="error-msg">Failed: {{ error }}</div>
|
||||
<div v-else-if="!data" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="server-cards">
|
||||
<!-- Terminal Server -->
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<span class="server-name">Terminal Server</span>
|
||||
<span class="port">:4103</span>
|
||||
<span :class="['status-dot', data.terminal.status === 'ok' ? 'ok' : 'err']"></span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Broadcast clients</span>
|
||||
<span class="stat-value">{{ data.terminal.broadcastClients }}</span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">PTY sessions</span>
|
||||
<span class="stat-value">{{ data.terminal.sessions.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.terminal.sessions.length" class="pty-list">
|
||||
<div v-for="s in data.terminal.sessions" :key="s.id" class="pty-entry">
|
||||
<span class="pty-id">{{ s.id }}</span>
|
||||
<span class="pty-stat">pid:{{ s.pid }}</span>
|
||||
<span class="pty-stat">clients:{{ s.clients }}</span>
|
||||
<span class="pty-stat">buf:{{ s.bufferSize.toLocaleString() }}</span>
|
||||
<span class="pty-stat dim">{{ elapsed(s.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="data.terminal.cwd" class="stat-row">
|
||||
<span class="stat-label">CWD</span>
|
||||
<span class="stat-value mono">{{ data.terminal.cwd }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Server -->
|
||||
<div class="server-card">
|
||||
<div class="server-header">
|
||||
<span class="server-name">Sync Server</span>
|
||||
<span class="port">:4105</span>
|
||||
<span :class="['status-dot', data.sync.status === 'ok' ? 'ok' : 'err']"></span>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Connected clients</span>
|
||||
<span class="stat-value">{{ data.sync.clients }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="data.sync.torch" class="stat-row">
|
||||
<span class="stat-label">Torch</span>
|
||||
<span class="stat-value dim">active</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="refresh-info">
|
||||
Last poll: {{ formatTimestamp(data.timestamp) }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws-monitor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
color: #f87171;
|
||||
font-size: 11px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.server-cards {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.server-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-weight: 700;
|
||||
color: #e2e8f0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.port {
|
||||
color: #64748b;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: auto;
|
||||
}
|
||||
.status-dot.ok { background: #22c55e; }
|
||||
.status-dot.err { background: #ef4444; }
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #cbd5e1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-value.mono {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.stat-value.dim {
|
||||
color: #94a3b8;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.pty-list {
|
||||
margin: 4px 0;
|
||||
padding: 4px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.pty-entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.pty-id {
|
||||
color: #60a5fa;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: 600;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.pty-stat {
|
||||
color: #94a3b8;
|
||||
font-family: 'Courier New', monospace;
|
||||
}
|
||||
.pty-stat.dim {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.refresh-info {
|
||||
color: #475569;
|
||||
font-size: 9px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SyncEnginePanel } from './SyncEnginePanel.vue'
|
||||
@@ -41,11 +41,17 @@ export function useTranscriptDebug() {
|
||||
const error = ref<string | null>(null)
|
||||
const isRealtime = ref(false)
|
||||
|
||||
// ── Hook metadata (derived from centralized session state) ──
|
||||
const hookMeta = computed(() => ({
|
||||
permissionMode: sessionStore.agents[selectedAgent.value]?.permissionMode || '',
|
||||
cwd: sessionStore.agents[selectedAgent.value]?.cwd || ''
|
||||
}))
|
||||
// ── Hook metadata (derived from PTY-scoped state, agent fallback) ──
|
||||
const currentPtyId = computed(() => ephemeral.value?.ephemeralSessionId ?? null)
|
||||
|
||||
const hookMeta = computed(() => {
|
||||
const ptyId = currentPtyId.value
|
||||
if (ptyId) {
|
||||
const ps = sessionStore.ptySessions[ptyId]
|
||||
if (ps) return { permissionMode: ps.permissionMode || '', cwd: ps.cwd || '' }
|
||||
}
|
||||
return { permissionMode: '', cwd: '' }
|
||||
})
|
||||
|
||||
// ── Terminal registry (server-backed) ──
|
||||
|
||||
@@ -263,12 +269,6 @@ export function useTranscriptDebug() {
|
||||
await fetchSessions()
|
||||
}
|
||||
|
||||
// Refresh agent session state (hookHistory, status, etc.) from server
|
||||
const targetAgent = entry?.agent || selectedAgent.value
|
||||
if (targetAgent) {
|
||||
await sessionStore.refreshAgentState(targetAgent)
|
||||
}
|
||||
|
||||
// Load the target session's transcript (skip for __new__ — no transcript yet)
|
||||
selectedSessionId.value = transcriptSessionId
|
||||
if (transcriptSessionId === '__new__') {
|
||||
@@ -417,8 +417,9 @@ export function useTranscriptDebug() {
|
||||
}
|
||||
|
||||
if (optimisticProcessing.value) {
|
||||
const agentState = sessionStore.agents[selectedAgent.value]
|
||||
if (agentState && ['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)) {
|
||||
const ptyId = currentPtyId.value
|
||||
const state = ptyId ? sessionStore.ptySessions[ptyId] : null
|
||||
if (state && ['idle', 'sessionStart', 'sessionEnd'].includes(state.status)) {
|
||||
optimisticProcessing.value = false
|
||||
}
|
||||
}
|
||||
@@ -564,12 +565,15 @@ export function useTranscriptDebug() {
|
||||
const optimisticProcessing = ref(false) // Set true on sendPrompt, auto-resets when server state catches up
|
||||
const optimisticMessage = ref<ParsedUserMessage | null>(null)
|
||||
|
||||
// processing is derived from centralized state with optimistic override
|
||||
// processing is derived from PTY-scoped state with optimistic override
|
||||
const processing = computed(() => {
|
||||
if (optimisticProcessing.value) return true
|
||||
const agentState = sessionStore.agents[selectedAgent.value]
|
||||
if (!agentState) return false
|
||||
return !['idle', 'sessionStart', 'sessionEnd'].includes(agentState.status)
|
||||
const ptyId = currentPtyId.value
|
||||
if (ptyId) {
|
||||
const ps = sessionStore.ptySessions[ptyId]
|
||||
if (ps) return !['idle', 'sessionStart', 'sessionEnd'].includes(ps.status)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
async function sendPrompt(text: string) {
|
||||
|
||||
@@ -75,15 +75,27 @@ export function useApprovalWindow() {
|
||||
|
||||
approvalWindowOpen.value = true
|
||||
|
||||
// When real window is ready: show it, remove from loading tracker
|
||||
win.once('tauri://webview-created', () => {
|
||||
setTimeout(async () => {
|
||||
try { await win.show() } catch {}
|
||||
try { await win.setFocus() } catch {}
|
||||
await dismissLoading()
|
||||
}, 200)
|
||||
// Wait for the child page to signal it has mounted, then show
|
||||
let shown = false
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const unlisten = await listen<string>('pip:ready', async (event) => {
|
||||
if (event.payload !== LABEL || shown) return
|
||||
shown = true
|
||||
unlisten()
|
||||
try { await win.show() } catch {}
|
||||
try { await win.setFocus() } catch {}
|
||||
await dismissLoading()
|
||||
})
|
||||
|
||||
// Safety fallback if the child never signals (crash, JS error)
|
||||
setTimeout(() => {
|
||||
if (shown) return
|
||||
shown = true
|
||||
unlisten()
|
||||
win.show().catch(() => {})
|
||||
dismissLoading()
|
||||
}, 3000)
|
||||
|
||||
// Persist geometry on close
|
||||
win.onCloseRequested(async () => {
|
||||
try {
|
||||
|
||||
@@ -16,33 +16,35 @@ const modalVisible = ref(false)
|
||||
export function useGlobalApproval() {
|
||||
const sessionStore = useSessionState()
|
||||
|
||||
// ── Derive pending lists from centralized state ──
|
||||
// ── Derive pending lists from PTY-scoped state ──
|
||||
|
||||
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() =>
|
||||
sessionStore.allPendingApprovals
|
||||
const pendingPermissions = computed<HooksApprovalPermissionRequest[]>(() => {
|
||||
return sessionStore.ptyPendingApprovals
|
||||
.filter(a => a.type === 'permission')
|
||||
.map(a => ({
|
||||
requestId: a.requestId,
|
||||
tool_name: a.toolName,
|
||||
tool_input: a.toolInput,
|
||||
agent_name: a.agent,
|
||||
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
|
||||
session_id: a.transcriptSessionId || undefined,
|
||||
pty_session_id: a.ptySessionId,
|
||||
cwd: a.cwd,
|
||||
timestamp: a.timestamp
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const pendingPlans = computed<HooksApprovalPlanRequest[]>(() =>
|
||||
sessionStore.allPendingApprovals
|
||||
const pendingPlans = computed<HooksApprovalPlanRequest[]>(() => {
|
||||
return sessionStore.ptyPendingApprovals
|
||||
.filter(a => a.type === 'plan')
|
||||
.map(a => ({
|
||||
requestId: a.requestId,
|
||||
session_id: sessionStore.agents[a.agent]?.sessionId || undefined,
|
||||
session_id: a.transcriptSessionId || undefined,
|
||||
pty_session_id: a.ptySessionId,
|
||||
permission_mode: undefined,
|
||||
lastAssistantText: (a.lastAssistantText as string) || '',
|
||||
timestamp: a.timestamp
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
const totalPending = computed(() => pendingPermissions.value.length + pendingPlans.value.length)
|
||||
|
||||
@@ -90,7 +92,7 @@ export function useGlobalApproval() {
|
||||
let prevIds = new Set<string>()
|
||||
|
||||
watch(
|
||||
() => sessionStore.allPendingApprovals,
|
||||
() => sessionStore.ptyPendingApprovals,
|
||||
async (current) => {
|
||||
const currentIds = new Set(current.map(a => a.requestId))
|
||||
|
||||
@@ -128,7 +130,6 @@ export function useGlobalApproval() {
|
||||
} catch (e) {
|
||||
console.error('[GlobalApproval] Failed to respond permission:', e)
|
||||
}
|
||||
// No local removal needed — server broadcasts resolve patch → store updates → computed updates
|
||||
}
|
||||
|
||||
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
|
||||
@@ -158,8 +159,6 @@ export function useGlobalApproval() {
|
||||
}
|
||||
|
||||
// ── connect/disconnect/fetchPending kept as no-ops for backward compat ──
|
||||
// Session state WS (initialized in App.vue) handles everything now.
|
||||
|
||||
function connect() { /* no-op — session-state-ws handles connection */ }
|
||||
function disconnect() { /* no-op */ }
|
||||
async function fetchPending() { /* no-op — session state snapshot on WS connect covers this */ }
|
||||
|
||||
130
frontend/src/composables/useLifecycleStates.ts
Normal file
130
frontend/src/composables/useLifecycleStates.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import type { AgentStatus, ActiveTool } from '@/stores/session-state'
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export type ContinuousStateType =
|
||||
| 'session'
|
||||
| 'responding'
|
||||
| 'tool'
|
||||
| 'subagent'
|
||||
| 'permission'
|
||||
| 'compacting'
|
||||
|
||||
export interface ContinuousState {
|
||||
type: ContinuousStateType
|
||||
label: string
|
||||
color: string
|
||||
startedAt: number
|
||||
detail?: string
|
||||
}
|
||||
|
||||
// ── State display config ──
|
||||
|
||||
const STATE_CONFIG: Record<ContinuousStateType, { label: string, color: string }> = {
|
||||
session: { label: 'Session', color: '#60a5fa' },
|
||||
responding: { label: 'Responding...', color: '#a78bfa' },
|
||||
tool: { label: 'Tool', color: '#fbbf24' },
|
||||
subagent: { label: 'Subagent', color: '#c084fc' },
|
||||
permission: { label: 'Awaiting approval', color: '#fb923c' },
|
||||
compacting: { label: 'Compacting', color: '#f59e0b' },
|
||||
}
|
||||
|
||||
// ── Priority for determining the "primary" state ──
|
||||
|
||||
const PRIORITY: Record<ContinuousStateType, number> = {
|
||||
permission: 6,
|
||||
compacting: 5,
|
||||
tool: 4,
|
||||
subagent: 3,
|
||||
responding: 2,
|
||||
session: 1,
|
||||
}
|
||||
|
||||
// ── Input interface (server-provided fields) ──
|
||||
|
||||
export interface LifecycleStateInput {
|
||||
sessionActive: Ref<boolean>
|
||||
agentResponding: Ref<boolean>
|
||||
subagentActive: Ref<boolean>
|
||||
compacting: Ref<boolean>
|
||||
status: Ref<AgentStatus>
|
||||
currentTool: Ref<ActiveTool | null>
|
||||
lastActivity: Ref<number>
|
||||
}
|
||||
|
||||
// ── Composable ──
|
||||
|
||||
export function useLifecycleStates(input: LifecycleStateInput) {
|
||||
const activeStates = computed(() => {
|
||||
const states: ContinuousState[] = []
|
||||
const now = input.lastActivity.value
|
||||
|
||||
if (input.sessionActive.value) {
|
||||
states.push({
|
||||
type: 'session',
|
||||
...STATE_CONFIG.session,
|
||||
startedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.agentResponding.value) {
|
||||
states.push({
|
||||
type: 'responding',
|
||||
...STATE_CONFIG.responding,
|
||||
startedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
// Tool state: derive from server status + currentTool for richer detail
|
||||
const st = input.status.value
|
||||
if ((st === 'reading' || st === 'writing' || st === 'toolUse') && input.currentTool.value) {
|
||||
const tool = input.currentTool.value
|
||||
states.push({
|
||||
type: 'tool',
|
||||
label: tool.name,
|
||||
color: STATE_CONFIG.tool.color,
|
||||
startedAt: tool.startedAt,
|
||||
detail: tool.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.subagentActive.value) {
|
||||
states.push({
|
||||
type: 'subagent',
|
||||
...STATE_CONFIG.subagent,
|
||||
startedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
if (st === 'permissionRequest') {
|
||||
states.push({
|
||||
type: 'permission',
|
||||
...STATE_CONFIG.permission,
|
||||
startedAt: now,
|
||||
detail: input.currentTool.value?.name,
|
||||
})
|
||||
}
|
||||
|
||||
if (input.compacting.value) {
|
||||
states.push({
|
||||
type: 'compacting',
|
||||
...STATE_CONFIG.compacting,
|
||||
startedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
return states
|
||||
})
|
||||
|
||||
const primaryState = computed(() => {
|
||||
if (activeStates.value.length === 0) return null
|
||||
return [...activeStates.value].sort(
|
||||
(a, b) => PRIORITY[b.type] - PRIORITY[a.type]
|
||||
)[0]
|
||||
})
|
||||
|
||||
const isActive = computed(() => activeStates.value.length > 0)
|
||||
|
||||
return { activeStates, primaryState, isActive }
|
||||
}
|
||||
@@ -11,7 +11,10 @@ interface WindowGeometry {
|
||||
height: number
|
||||
}
|
||||
|
||||
const PIP_LABEL_RE = /^pip-terminal-(\d+)$/
|
||||
|
||||
const pipWindows = ref<Map<number, boolean>>(new Map())
|
||||
let rehydratePromise: Promise<void> | null = null
|
||||
|
||||
async function loadGeometry(idx: number): Promise<WindowGeometry | null> {
|
||||
try {
|
||||
@@ -29,10 +32,55 @@ async function saveGeometry(idx: number, geo: WindowGeometry): Promise<void> {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
/** Attach geometry-save + state-cleanup handler to a PiP window. */
|
||||
function attachCloseHandler(win: { label: string; onCloseRequested: Function; outerPosition: Function; outerSize: Function }, idx: number) {
|
||||
win.onCloseRequested(async () => {
|
||||
try {
|
||||
const pos = await win.outerPosition()
|
||||
const size = await win.outerSize()
|
||||
await saveGeometry(idx, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
})
|
||||
} catch {}
|
||||
pipWindows.value.delete(idx)
|
||||
})
|
||||
}
|
||||
|
||||
/** Scan for surviving PiP windows after a main-window reload. */
|
||||
async function rehydratePipWindows() {
|
||||
if (!isTauri) return
|
||||
try {
|
||||
const { getAllWebviewWindows } = await import('@tauri-apps/api/webviewWindow')
|
||||
const all = getAllWebviewWindows()
|
||||
|
||||
for (const wv of all) {
|
||||
const match = PIP_LABEL_RE.exec(wv.label)
|
||||
if (!match) continue
|
||||
|
||||
const idx = parseInt(match[1], 10)
|
||||
pipWindows.value.set(idx, true)
|
||||
attachCloseHandler(wv, idx)
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[PiP] Rehydrate failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Run once at module load (non-blocking)
|
||||
if (isTauri) {
|
||||
rehydratePromise = rehydratePipWindows()
|
||||
}
|
||||
|
||||
export function usePipWindow() {
|
||||
async function openPip(terminalIndex: number): Promise<boolean> {
|
||||
if (!isTauri) return false
|
||||
|
||||
// Ensure rehydration has completed before checking state
|
||||
if (rehydratePromise) await rehydratePromise
|
||||
|
||||
const pipLabel = `pip-terminal-${terminalIndex}`
|
||||
|
||||
try {
|
||||
@@ -73,29 +121,29 @@ export function usePipWindow() {
|
||||
|
||||
pipWindows.value.set(terminalIndex, true)
|
||||
|
||||
// When real window is ready: show it, remove from loading tracker
|
||||
pip.once('tauri://webview-created', () => {
|
||||
setTimeout(async () => {
|
||||
try { await pip.show() } catch {}
|
||||
try { await pip.setFocus() } catch {}
|
||||
await dismissLoading()
|
||||
}, 200)
|
||||
// Wait for the child page to signal it has mounted, then show
|
||||
let shown = false
|
||||
const { listen } = await import('@tauri-apps/api/event')
|
||||
const unlisten = await listen<string>('pip:ready', async (event) => {
|
||||
if (event.payload !== pipLabel || shown) return
|
||||
shown = true
|
||||
unlisten()
|
||||
try { await pip.show() } catch {}
|
||||
try { await pip.setFocus() } catch {}
|
||||
await dismissLoading()
|
||||
})
|
||||
|
||||
// Safety fallback if the child never signals (crash, JS error)
|
||||
setTimeout(() => {
|
||||
if (shown) return
|
||||
shown = true
|
||||
unlisten()
|
||||
pip.show().catch(() => {})
|
||||
dismissLoading()
|
||||
}, 3000)
|
||||
|
||||
// Persist geometry on close
|
||||
pip.onCloseRequested(async () => {
|
||||
try {
|
||||
const pos = await pip.outerPosition()
|
||||
const size = await pip.outerSize()
|
||||
await saveGeometry(terminalIndex, {
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
})
|
||||
} catch {}
|
||||
pipWindows.value.delete(terminalIndex)
|
||||
})
|
||||
attachCloseHandler(pip, terminalIndex)
|
||||
|
||||
return true
|
||||
} catch (e) {
|
||||
|
||||
60
frontend/src/composables/useWsMonitor.ts
Normal file
60
frontend/src/composables/useWsMonitor.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { ref } from 'vue'
|
||||
import { apiFetch } from '@/lib/tauri'
|
||||
|
||||
export interface WsMonitorTerminalSession {
|
||||
id: string
|
||||
clients: number
|
||||
pid: number
|
||||
bufferSize: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface WsMonitorData {
|
||||
terminal: {
|
||||
status: string
|
||||
sessions: WsMonitorTerminalSession[]
|
||||
broadcastClients: number
|
||||
cwd: string | null
|
||||
}
|
||||
sync: {
|
||||
status: string
|
||||
clients: number
|
||||
torch: unknown
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export function useWsMonitor() {
|
||||
const data = ref<WsMonitorData | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function fetch_() {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await apiFetch('/api/ws-monitor')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
data.value = await res.json()
|
||||
error.value = null
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function start(intervalMs = 5000) {
|
||||
fetch_()
|
||||
intervalId = setInterval(fetch_, intervalMs)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
return { data, error, loading, start, stop }
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
* Tiny transparent loading indicator — fixed to bottom-right of screen.
|
||||
* Shows a single spinner with a badge count when multiple windows are loading.
|
||||
* Static HTML (/loading.html?n=count), no Vue, no JS frameworks.
|
||||
*
|
||||
* The window is created once and updated via Tauri events (loading:count).
|
||||
* It is only destroyed when the pending count drops to 0.
|
||||
*/
|
||||
|
||||
const LABEL = 'loading-window'
|
||||
@@ -9,18 +12,36 @@ const SIZE = 56
|
||||
const MARGIN = 24
|
||||
|
||||
const pending = new Set<string>()
|
||||
let alive = false
|
||||
|
||||
async function syncWindow() {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
|
||||
// Destroy current loading window (recreate with new count)
|
||||
try {
|
||||
const prev = await WebviewWindow.getByLabel(LABEL)
|
||||
if (prev) await prev.destroy()
|
||||
} catch {}
|
||||
if (pending.size === 0) {
|
||||
// Nothing pending — tear down if alive
|
||||
if (alive) {
|
||||
try {
|
||||
const prev = await WebviewWindow.getByLabel(LABEL)
|
||||
if (prev) await prev.destroy()
|
||||
} catch {}
|
||||
alive = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pending.size === 0) return
|
||||
if (alive) {
|
||||
// Window should still exist — send updated count via event
|
||||
const existing = await WebviewWindow.getByLabel(LABEL)
|
||||
if (existing) {
|
||||
const { emitTo } = await import('@tauri-apps/api/event')
|
||||
await emitTo(LABEL, 'loading:count', pending.size)
|
||||
return
|
||||
}
|
||||
// Destroyed externally; fall through to recreate
|
||||
alive = false
|
||||
}
|
||||
|
||||
// Create fresh window
|
||||
const x = window.screen.width - SIZE - MARGIN
|
||||
const y = window.screen.height - SIZE - MARGIN - 48 // above taskbar
|
||||
|
||||
@@ -38,6 +59,7 @@ async function syncWindow() {
|
||||
transparent: true,
|
||||
skipTaskbar: true,
|
||||
})
|
||||
alive = true
|
||||
}
|
||||
|
||||
/** Call when a window starts loading. Returns a dismiss function. */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { watch, computed } from 'vue'
|
||||
import { watch, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { isTauri } from '@/lib/tauri'
|
||||
import { useGlobalApproval } from '@/composables/useGlobalApproval'
|
||||
import { useSessionState } from '@/stores/session-state'
|
||||
import PermissionApproval from '@/components/transcript-debug/PermissionApproval.vue'
|
||||
@@ -43,6 +44,17 @@ watch(totalPending, async (val) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Signal to the parent window that this approval page is ready to show
|
||||
onMounted(async () => {
|
||||
if (isTauri && isWindow.value) {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { emitTo } = await import('@tauri-apps/api/event')
|
||||
await emitTo('main', 'pip:ready', getCurrentWebviewWindow().label)
|
||||
} catch {}
|
||||
}
|
||||
})
|
||||
|
||||
async function closeWindow() {
|
||||
try {
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
|
||||
@@ -24,7 +24,7 @@ function connect() {
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
if (msg.type === 'session-state-snapshot' || msg.type === 'session-state-patch' || msg.type === 'terminal-registry-change') {
|
||||
if (msg.type === 'session-state-snapshot' || msg.type === 'pty-state-patch' || msg.type === 'terminal-registry-change') {
|
||||
store.handleMessage(msg)
|
||||
}
|
||||
// Ignore other message types (terminal output, legacy broadcasts, etc.)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { apiFetch } from '@/lib/tauri'
|
||||
import { terminalApiUrl } from '../config/endpoints'
|
||||
|
||||
// ── Types (mirror server/services/session-state.ts) ──
|
||||
|
||||
@@ -37,6 +36,7 @@ export interface HookHistoryEntry {
|
||||
event: string
|
||||
timestamp: number
|
||||
detail?: string
|
||||
ptySessionId?: string
|
||||
}
|
||||
|
||||
export interface SessionNotification {
|
||||
@@ -50,13 +50,6 @@ export interface SessionNotification {
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
||||
export interface AgentTerminalInfo {
|
||||
ptySessionId: string | null
|
||||
alive: boolean
|
||||
bufferSize: number
|
||||
connectedClients: number
|
||||
}
|
||||
|
||||
export interface LastError {
|
||||
tool: string
|
||||
message: string
|
||||
@@ -64,36 +57,42 @@ export interface LastError {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface AgentSessionState {
|
||||
// ── Session state (keyed by ephemeralSessionId / ptySessionId) ──
|
||||
|
||||
export interface PtySessionState {
|
||||
ptySessionId: string
|
||||
agent: string
|
||||
sessionId: string | null
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
transcriptPath: string | null
|
||||
permissionMode: string | null
|
||||
transcriptSessionId: 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
|
||||
sessionActive: boolean
|
||||
agentResponding: boolean
|
||||
subagentActive: boolean
|
||||
compacting: boolean
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
transcriptPath: string | null
|
||||
permissionMode: string | null
|
||||
}
|
||||
|
||||
// WS message types
|
||||
interface SessionStateSnapshot {
|
||||
type: 'session-state-snapshot'
|
||||
agents: Record<string, AgentSessionState>
|
||||
ptySessions: Record<string, PtySessionState>
|
||||
}
|
||||
|
||||
interface SessionStatePatch {
|
||||
type: 'session-state-patch'
|
||||
interface PtyStatePatch {
|
||||
type: 'pty-state-patch'
|
||||
ptySessionId: string
|
||||
agent: string
|
||||
patch: Partial<AgentSessionState>
|
||||
patch: Partial<PtySessionState>
|
||||
event: string
|
||||
timestamp: number
|
||||
}
|
||||
@@ -116,75 +115,60 @@ export interface TerminalRegistryEntry {
|
||||
bufferSize: number
|
||||
}
|
||||
|
||||
type SessionStateMessage = SessionStateSnapshot | SessionStatePatch | TerminalRegistryChange
|
||||
type SessionStateMessage = SessionStateSnapshot | PtyStatePatch | TerminalRegistryChange
|
||||
|
||||
// ── Store ──
|
||||
|
||||
export const useSessionState = defineStore('session-state', () => {
|
||||
const agents = ref<Record<string, AgentSessionState>>({})
|
||||
const ptySessions = ref<Record<string, PtySessionState>>({})
|
||||
const terminalRegistry = ref<TerminalRegistryEntry[]>([])
|
||||
const connected = ref(false)
|
||||
const lastUpdate = ref(0)
|
||||
|
||||
// ── Computed helpers ──
|
||||
|
||||
const agentList = computed(() => Object.values(agents.value))
|
||||
const ptySessionList = computed(() => Object.values(ptySessions.value))
|
||||
|
||||
const agentNames = computed(() => Object.keys(agents.value))
|
||||
|
||||
function getAgent(name: string) {
|
||||
return computed(() => agents.value[name] ?? null)
|
||||
function getPtySession(ptySessionId: string) {
|
||||
return computed(() => ptySessions.value[ptySessionId] ?? 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 }))
|
||||
/** Flattened pending approvals from all PTY sessions */
|
||||
const ptyPendingApprovals = computed(() =>
|
||||
ptySessionList.value.flatMap(ps =>
|
||||
ps.pendingApprovals.map(p => ({
|
||||
...p,
|
||||
agent: ps.agent,
|
||||
ptySessionId: ps.ptySessionId,
|
||||
transcriptSessionId: ps.transcriptSessionId,
|
||||
}))
|
||||
)
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
/** Check if a specific PTY is processing (not idle/ended) */
|
||||
function ptyIsProcessing(ptySessionId: string): boolean {
|
||||
const ps = ptySessions.value[ptySessionId]
|
||||
if (!ps) return false
|
||||
return !['idle', 'sessionStart', 'sessionEnd'].includes(ps.status)
|
||||
}
|
||||
|
||||
// ── Message handler ──
|
||||
|
||||
function handleMessage(msg: SessionStateMessage) {
|
||||
if (msg.type === 'session-state-snapshot') {
|
||||
agents.value = { ...msg.agents }
|
||||
ptySessions.value = { ...msg.ptySessions }
|
||||
lastUpdate.value = Date.now()
|
||||
} else if (msg.type === 'session-state-patch') {
|
||||
const current = agents.value[msg.agent]
|
||||
} else if (msg.type === 'pty-state-patch') {
|
||||
const current = ptySessions.value[msg.ptySessionId]
|
||||
if (current) {
|
||||
agents.value = {
|
||||
...agents.value,
|
||||
[msg.agent]: { ...current, ...msg.patch }
|
||||
ptySessions.value = {
|
||||
...ptySessions.value,
|
||||
[msg.ptySessionId]: { ...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
|
||||
ptySessions.value = {
|
||||
...ptySessions.value,
|
||||
[msg.ptySessionId]: msg.patch as PtySessionState
|
||||
}
|
||||
}
|
||||
lastUpdate.value = msg.timestamp
|
||||
@@ -212,36 +196,20 @@ export const useSessionState = defineStore('session-state', () => {
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshAgentState(agent: string) {
|
||||
try {
|
||||
const res = await apiFetch(terminalApiUrl(`/session-state/${agent}`))
|
||||
if (!res.ok) return
|
||||
const state = await res.json() as AgentSessionState
|
||||
agents.value = { ...agents.value, [agent]: state }
|
||||
lastUpdate.value = Date.now()
|
||||
} catch { /* silent — WS patches will catch up */ }
|
||||
}
|
||||
|
||||
function setConnected(value: boolean) {
|
||||
connected.value = value
|
||||
}
|
||||
|
||||
return {
|
||||
agents,
|
||||
ptySessions,
|
||||
terminalRegistry,
|
||||
connected,
|
||||
lastUpdate,
|
||||
agentList,
|
||||
agentNames,
|
||||
getAgent,
|
||||
anyPendingApprovals,
|
||||
totalPendingApprovals,
|
||||
allPendingApprovals,
|
||||
isProcessing,
|
||||
hasErrors,
|
||||
visibleNotifications,
|
||||
ptySessionList,
|
||||
getPtySession,
|
||||
ptyPendingApprovals,
|
||||
ptyIsProcessing,
|
||||
handleMessage,
|
||||
refreshAgentState,
|
||||
respondApproval,
|
||||
respondPlanApproval,
|
||||
setConnected,
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface HooksApprovalPermissionRequest {
|
||||
tool_input?: unknown
|
||||
agent_name?: string
|
||||
session_id?: string
|
||||
pty_session_id?: string
|
||||
cwd?: string
|
||||
timestamp: number
|
||||
}
|
||||
@@ -11,6 +12,7 @@ export interface HooksApprovalPermissionRequest {
|
||||
export interface HooksApprovalPlanRequest {
|
||||
requestId: string
|
||||
session_id?: string
|
||||
pty_session_id?: string
|
||||
permission_mode?: string
|
||||
lastAssistantText: string
|
||||
timestamp: number
|
||||
|
||||
@@ -13,9 +13,14 @@ try {
|
||||
} catch {
|
||||
Add-Content $logFile "[$ts] [PERM] WARN: stdin not valid JSON"
|
||||
}
|
||||
$url = 'http://localhost:4101/api/hooks-approval/permission'
|
||||
$sep = '?'
|
||||
$pty = $env:AGENT_UI_PTY_SESSION
|
||||
if ($pty) { $url += "${sep}pty_session=$pty"; $sep = '&' }
|
||||
if ($agent -and $agent -ne 'local') { $url += "${sep}agent=$agent" }
|
||||
try {
|
||||
Add-Content $logFile "[$ts] [PERM] POSTing to backend..."
|
||||
$r = Invoke-RestMethod -Uri 'http://localhost:4101/api/hooks-approval/permission' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
|
||||
$r = Invoke-RestMethod -Uri $url -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
|
||||
$ts2 = Get-Date -Format "HH:mm:ss.fff"
|
||||
if ($r -and $r.hookSpecificOutput) {
|
||||
$out = $r | ConvertTo-Json -Depth 10 -Compress
|
||||
|
||||
@@ -15,9 +15,14 @@ if ($j.permission_mode -ne 'plan' -or $j.stop_hook_active) {
|
||||
Add-Content $logFile "[$ts] [PLAN] Skipping (not plan mode or stop_hook_active)"
|
||||
exit 0
|
||||
}
|
||||
$url = 'http://localhost:4101/api/hooks-approval/plan'
|
||||
$sep = '?'
|
||||
$pty = $env:AGENT_UI_PTY_SESSION
|
||||
if ($pty) { $url += "${sep}pty_session=$pty"; $sep = '&' }
|
||||
if ($agent -and $agent -ne 'local') { $url += "${sep}agent=$agent" }
|
||||
try {
|
||||
Add-Content $logFile "[$ts] [PLAN] POSTing to backend..."
|
||||
$r = Invoke-RestMethod -Uri 'http://localhost:4101/api/hooks-approval/plan' -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
|
||||
$r = Invoke-RestMethod -Uri $url -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 125
|
||||
$ts2 = Get-Date -Format "HH:mm:ss.fff"
|
||||
if ($r -and $r.decision) {
|
||||
$out = $r | ConvertTo-Json -Depth 10 -Compress
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
# Usage: powershell -NoProfile -File hooks/notify.ps1 [agent]
|
||||
# If agent is specified, passes ?agent=<name> so the backend knows the source.
|
||||
# If omitted, the backend auto-detects from session_id/transcript_path.
|
||||
# Reads AGENT_UI_PTY_SESSION env var (injected by PTY spawn) to identify the terminal.
|
||||
param([string]$agent = "")
|
||||
$b = [Console]::In.ReadToEnd()
|
||||
$url = 'http://localhost:4101/api/claude-hook'
|
||||
if ($agent) { $url += "?agent=$agent" }
|
||||
$sep = '?'
|
||||
if ($agent) { $url += "${sep}agent=$agent"; $sep = '&' }
|
||||
$pty = $env:AGENT_UI_PTY_SESSION
|
||||
if ($pty) { $url += "${sep}pty_session=$pty" }
|
||||
try {
|
||||
Invoke-RestMethod -Uri $url -Method POST -Body $b -ContentType 'application/json' -TimeoutSec 3 | Out-Null
|
||||
} catch {}
|
||||
|
||||
@@ -12,6 +12,9 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
|
||||
let agent = url.searchParams.get('agent') || ''
|
||||
const body = await req.json() as HookPayload
|
||||
|
||||
// Read PTY session ID from env-injected query param
|
||||
const ptySession = url.searchParams.get('pty_session') || ''
|
||||
|
||||
// Auto-detect agent from session_id or transcript_path
|
||||
if (!agent && body.session_id) {
|
||||
agent = sessionState.findAgentBySessionId(body.session_id) || ''
|
||||
@@ -54,8 +57,8 @@ export async function handleClaudeHook(req: Request): Promise<Response | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Inject agent name into hook data for WS consumers
|
||||
const hookData = { ...body, agent_name: agent }
|
||||
// Inject agent name and PTY session into hook data for WS consumers
|
||||
const hookData = { ...body, agent_name: agent, pty_session: ptySession }
|
||||
|
||||
// 1. Broadcast full hook data via WebSocket (always, even for subagents)
|
||||
try {
|
||||
|
||||
@@ -71,11 +71,11 @@ function generateId(prefix: string): string {
|
||||
}
|
||||
|
||||
// Notify terminal server (4103) about approval lifecycle → broadcasts state patches to all clients
|
||||
function notifyAddApproval(agent: string, approval: Record<string, unknown>) {
|
||||
function notifyAddApproval(agent: string, approval: Record<string, unknown>, ptySessionId?: string) {
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/add-approval`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ agent, approval })
|
||||
body: JSON.stringify({ agent, approval, ptySessionId })
|
||||
}).catch(e => console.error('[HooksApproval] Failed to notify add-approval:', e.message))
|
||||
}
|
||||
|
||||
@@ -93,9 +93,11 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const ptySession = url.searchParams.get('pty_session') || ''
|
||||
const body = await req.json() as PermissionPayload
|
||||
const requestId = generateId('haperm')
|
||||
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id}`)
|
||||
console.log(`[HooksApproval] Permission request ${requestId}: tool=${body.tool_name} agent=${body.agent_name} session=${body.session_id} pty=${ptySession}`)
|
||||
|
||||
// Broadcast to UI
|
||||
broadcastMessage(JSON.stringify({
|
||||
@@ -105,6 +107,7 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
|
||||
tool_input: body.tool_input,
|
||||
agent_name: body.agent_name,
|
||||
session_id: body.session_id,
|
||||
pty_session: ptySession,
|
||||
cwd: body.cwd,
|
||||
timestamp: Date.now()
|
||||
}))
|
||||
@@ -115,9 +118,10 @@ export async function handleHooksApprovalPermission(req: Request): Promise<Respo
|
||||
type: 'permission',
|
||||
toolName: body.tool_name,
|
||||
toolInput: body.tool_input,
|
||||
ptySession,
|
||||
cwd: body.cwd,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}, ptySession || undefined)
|
||||
|
||||
// Long-poll: wait for UI decision or timeout
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
@@ -155,9 +159,11 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const url = new URL(req.url)
|
||||
const ptySession = url.searchParams.get('pty_session') || ''
|
||||
const body = await req.json() as StopPayload
|
||||
const requestId = generateId('haplan')
|
||||
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode}`)
|
||||
console.log(`[HooksApproval] Plan request ${requestId}: session=${body.session_id} mode=${body.permission_mode} pty=${ptySession}`)
|
||||
|
||||
// Extract last assistant message for display
|
||||
let lastAssistantText = ''
|
||||
@@ -183,6 +189,7 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
|
||||
type: 'hooks-approval-plan',
|
||||
requestId,
|
||||
session_id: body.session_id,
|
||||
pty_session: ptySession,
|
||||
permission_mode: body.permission_mode,
|
||||
lastAssistantText,
|
||||
timestamp: Date.now()
|
||||
@@ -192,9 +199,10 @@ export async function handleHooksApprovalPlan(req: Request): Promise<Response |
|
||||
notifyAddApproval(body.agent_name || 'claude', {
|
||||
requestId,
|
||||
type: 'plan',
|
||||
ptySession,
|
||||
lastAssistantText,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}, ptySession || undefined)
|
||||
|
||||
// Long-poll
|
||||
const result = await new Promise<unknown>((resolve) => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
handleHooksApprovalIgnore, handleHooksApprovalList
|
||||
} from './hooks-approval'
|
||||
import { handleSessionStateProxy } from './session-state-proxy'
|
||||
import { handleWsMonitor } from './ws-monitor'
|
||||
import { handleVoiceTranscript } from './voice-transcript'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
@@ -332,6 +333,11 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
return handleSessionStateProxy(url)
|
||||
}
|
||||
|
||||
// WS Monitor (proxy health from terminal + sync servers)
|
||||
if (path === '/api/ws-monitor' && req.method === 'GET') {
|
||||
return handleWsMonitor()
|
||||
}
|
||||
|
||||
// Hooks Approval (long-poll for permission/plan decisions)
|
||||
if (path === '/api/hooks-approval') {
|
||||
if (req.method === 'GET') {
|
||||
|
||||
@@ -16,11 +16,11 @@ export async function handleSessionStateProxy(url: URL): Promise<Response> {
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`, { signal: controller.signal })
|
||||
])
|
||||
|
||||
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
|
||||
const stateData = stateResp.ok ? await stateResp.json() : { ptySessions: {} }
|
||||
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
|
||||
|
||||
return jsonResponse({
|
||||
agents: stateData.agents ?? {},
|
||||
ptySessions: stateData.ptySessions ?? {},
|
||||
registry: registryData.registry ?? []
|
||||
})
|
||||
} catch (e: any) {
|
||||
|
||||
48
server/routes/ws-monitor.ts
Normal file
48
server/routes/ws-monitor.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
import { PORT_TERMINAL, PORT_GIT } from '../config'
|
||||
|
||||
/**
|
||||
* Proxy GET /api/ws-monitor → terminal server + sync server health.
|
||||
* Returns combined WebSocket connection stats from both servers.
|
||||
*/
|
||||
export async function handleWsMonitor(): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 4000)
|
||||
|
||||
try {
|
||||
const [terminalResp, syncResp] = await Promise.allSettled([
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/health`, { signal: controller.signal }),
|
||||
fetch(`http://localhost:${PORT_GIT}/health`, { signal: controller.signal })
|
||||
])
|
||||
|
||||
const terminalData = terminalResp.status === 'fulfilled' && terminalResp.value.ok
|
||||
? await terminalResp.value.json()
|
||||
: null
|
||||
|
||||
const syncData = syncResp.status === 'fulfilled' && syncResp.value.ok
|
||||
? await syncResp.value.json()
|
||||
: null
|
||||
|
||||
return jsonResponse({
|
||||
terminal: {
|
||||
status: terminalData?.status ?? 'unreachable',
|
||||
sessions: terminalData?.sessions ?? [],
|
||||
broadcastClients: terminalData?.broadcastClients ?? 0,
|
||||
cwd: terminalData?.cwd ?? null,
|
||||
},
|
||||
sync: {
|
||||
status: syncData?.status ?? 'unreachable',
|
||||
clients: syncData?.clients ?? 0,
|
||||
torch: syncData?.torch ?? null,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
} catch (e: any) {
|
||||
const msg = e.name === 'AbortError'
|
||||
? 'Server health timeout (4s)'
|
||||
: `Failed to reach servers: ${e.message}`
|
||||
return errorResponse(msg, 502)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export interface HookHistoryEntry {
|
||||
event: string
|
||||
timestamp: number
|
||||
detail?: string
|
||||
ptySessionId?: string
|
||||
}
|
||||
|
||||
export interface SessionNotification {
|
||||
@@ -49,13 +50,6 @@ export interface SessionNotification {
|
||||
expiresAt: number | null
|
||||
}
|
||||
|
||||
export interface AgentTerminalInfo {
|
||||
ptySessionId: string | null
|
||||
alive: boolean
|
||||
bufferSize: number
|
||||
connectedClients: number
|
||||
}
|
||||
|
||||
export interface LastError {
|
||||
tool: string
|
||||
message: string
|
||||
@@ -63,24 +57,30 @@ export interface LastError {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface AgentSessionState {
|
||||
agent: string
|
||||
sessionId: string | null
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
transcriptPath: string | null
|
||||
permissionMode: string | null
|
||||
// ── Session state (keyed by ephemeralSessionId / ptySessionId) ──
|
||||
|
||||
export interface PtySessionState {
|
||||
ptySessionId: string
|
||||
agent: string // tag/label, NOT primary key
|
||||
transcriptSessionId: 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
|
||||
sessionActive: boolean
|
||||
agentResponding: boolean
|
||||
subagentActive: boolean
|
||||
compacting: boolean
|
||||
// Identity fields from Claude Code
|
||||
model: string | null
|
||||
cwd: string | null
|
||||
transcriptPath: string | null
|
||||
permissionMode: string | null
|
||||
}
|
||||
|
||||
export interface HookPayload {
|
||||
@@ -109,17 +109,23 @@ export interface HookPayload {
|
||||
// WS message types
|
||||
export interface SessionStateSnapshot {
|
||||
type: 'session-state-snapshot'
|
||||
agents: Record<string, AgentSessionState>
|
||||
ptySessions: Record<string, PtySessionState>
|
||||
}
|
||||
|
||||
export interface SessionStatePatch {
|
||||
type: 'session-state-patch'
|
||||
export interface PtyStatePatch {
|
||||
type: 'pty-state-patch'
|
||||
ptySessionId: string
|
||||
agent: string
|
||||
patch: Partial<AgentSessionState>
|
||||
patch: Partial<PtySessionState>
|
||||
event: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export interface PtyStateSnapshot {
|
||||
type: 'pty-state-snapshot'
|
||||
ptySessions: Record<string, PtySessionState>
|
||||
}
|
||||
|
||||
// ── Notification builders ──
|
||||
|
||||
const MAX_NOTIFICATIONS = 30
|
||||
@@ -208,64 +214,113 @@ export function deriveStatus(payload: HookPayload): { status: AgentStatus, tool?
|
||||
|
||||
// ── SessionStateManager ──
|
||||
|
||||
function createDefaultState(agent: string): AgentSessionState {
|
||||
function createDefaultPtyState(ptySessionId: string, agent: string): PtySessionState {
|
||||
return {
|
||||
ptySessionId,
|
||||
agent,
|
||||
sessionId: null,
|
||||
model: null,
|
||||
cwd: null,
|
||||
transcriptPath: null,
|
||||
permissionMode: null,
|
||||
transcriptSessionId: null,
|
||||
status: 'idle',
|
||||
currentTool: null,
|
||||
lastActivity: Date.now(),
|
||||
lastStopResponse: null,
|
||||
lastError: null,
|
||||
pendingApprovals: [],
|
||||
terminal: {
|
||||
ptySessionId: null,
|
||||
alive: false,
|
||||
bufferSize: 0,
|
||||
connectedClients: 0,
|
||||
},
|
||||
notifications: [],
|
||||
hookHistory: [],
|
||||
lastHookEvent: null,
|
||||
lastHookDetail: null,
|
||||
sessionActive: false,
|
||||
agentResponding: false,
|
||||
subagentActive: false,
|
||||
compacting: false,
|
||||
model: null,
|
||||
cwd: null,
|
||||
transcriptPath: null,
|
||||
permissionMode: null,
|
||||
}
|
||||
}
|
||||
|
||||
class SessionStateManager {
|
||||
private agents = new Map<string, AgentSessionState>()
|
||||
private ptySessions = new Map<string, PtySessionState>()
|
||||
|
||||
getOrCreateAgent(agent: string): AgentSessionState {
|
||||
let state = this.agents.get(agent)
|
||||
/** Get or create a PTY-scoped state entry */
|
||||
getOrCreatePty(ptySessionId: string, agent: string): PtySessionState {
|
||||
let state = this.ptySessions.get(ptySessionId)
|
||||
if (!state) {
|
||||
state = createDefaultState(agent)
|
||||
this.agents.set(agent, state)
|
||||
state = createDefaultPtyState(ptySessionId, agent)
|
||||
this.ptySessions.set(ptySessionId, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/** Process a raw hook event and return the patch to broadcast */
|
||||
processHookEvent(payload: HookPayload): SessionStatePatch {
|
||||
/** Get full snapshot for new clients */
|
||||
getSnapshot(): Record<string, PtySessionState> {
|
||||
const result: Record<string, PtySessionState> = {}
|
||||
for (const [id, state] of this.ptySessions) {
|
||||
result[id] = { ...state }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get single PTY state */
|
||||
getPtyState(ptySessionId: string): PtySessionState | null {
|
||||
return this.ptySessions.get(ptySessionId) || null
|
||||
}
|
||||
|
||||
/** Add a pending approval to a PTY session */
|
||||
addApproval(ptySessionId: string, agent: string, approval: PendingApproval): Partial<PtySessionState> {
|
||||
const state = this.getOrCreatePty(ptySessionId, agent)
|
||||
state.pendingApprovals = [...state.pendingApprovals, approval]
|
||||
state.status = 'permissionRequest'
|
||||
state.lastActivity = Date.now()
|
||||
return { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
|
||||
}
|
||||
|
||||
/** Remove a pending approval by requestId */
|
||||
resolveApproval(requestId: string): { ptySessionId: string, agent: string, patch: Partial<PtySessionState> } | null {
|
||||
for (const [ptyId, state] of this.ptySessions) {
|
||||
const idx = state.pendingApprovals.findIndex(a => a.requestId === requestId)
|
||||
if (idx !== -1) {
|
||||
state.pendingApprovals = state.pendingApprovals.filter(a => a.requestId !== requestId)
|
||||
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
|
||||
state.status = 'thinking'
|
||||
}
|
||||
state.lastActivity = Date.now()
|
||||
return {
|
||||
ptySessionId: ptyId,
|
||||
agent: state.agent,
|
||||
patch: { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Process a raw hook event and return the PTY patch to broadcast. */
|
||||
processHookEvent(payload: HookPayload): PtyStatePatch | null {
|
||||
const agentName = payload.agent_name || 'claude'
|
||||
const state = this.getOrCreateAgent(agentName)
|
||||
const ptyId = payload.pty_session_id as string | undefined
|
||||
const now = Date.now()
|
||||
|
||||
// Without a PTY ID we can't write state — log and skip
|
||||
if (!ptyId) {
|
||||
console.warn(`[SessionState] Hook event without ptySessionId, skipping: ${payload.hook_event_name} (agent=${agentName})`)
|
||||
return null
|
||||
}
|
||||
|
||||
const state = this.getOrCreatePty(ptyId, agentName)
|
||||
|
||||
// Derive status (null means side-effect only, e.g. Notification)
|
||||
const derived = deriveStatus(payload)
|
||||
|
||||
// Build patch
|
||||
const patch: Partial<AgentSessionState> = {
|
||||
const patch: Partial<PtySessionState> = {
|
||||
lastActivity: now,
|
||||
lastHookEvent: payload.hook_event_name || null,
|
||||
lastHookDetail: payload.tool_name || payload.message || null,
|
||||
}
|
||||
|
||||
// Only update status/tool if deriveStatus returned a result
|
||||
if (derived) {
|
||||
const { status, tool } = derived
|
||||
const { status } = derived
|
||||
patch.status = status
|
||||
|
||||
// Current tool tracking
|
||||
@@ -279,7 +334,7 @@ class SessionStateManager {
|
||||
patch.currentTool = null
|
||||
}
|
||||
|
||||
// Track errors from PostToolUseFailure
|
||||
// Track errors
|
||||
if (status === 'error' || status === 'interrupted') {
|
||||
patch.lastError = {
|
||||
tool: payload.tool_name || 'unknown',
|
||||
@@ -294,60 +349,90 @@ class SessionStateManager {
|
||||
patch.lastError = null
|
||||
}
|
||||
|
||||
// SessionEnd: clean up session state
|
||||
// SessionEnd: clean up
|
||||
if (payload.hook_event_name === 'SessionEnd') {
|
||||
patch.currentTool = null
|
||||
patch.pendingApprovals = []
|
||||
}
|
||||
}
|
||||
|
||||
// Update session identity fields
|
||||
if (payload.session_id) patch.sessionId = payload.session_id
|
||||
// Session identity fields
|
||||
if (payload.session_id) patch.transcriptSessionId = payload.session_id
|
||||
if (payload.model) patch.model = payload.model as string
|
||||
if (payload.cwd) patch.cwd = payload.cwd as string
|
||||
if (payload.transcript_path) patch.transcriptPath = payload.transcript_path as string
|
||||
if (payload.permission_mode) patch.permissionMode = payload.permission_mode as string
|
||||
|
||||
// Last stop response
|
||||
if (payload.hook_event_name === 'Stop' && payload.assistant_response) {
|
||||
patch.lastStopResponse = payload.assistant_response as string
|
||||
}
|
||||
|
||||
// Reset session fields on SessionStart
|
||||
// Reset on SessionStart
|
||||
if (payload.hook_event_name === 'SessionStart') {
|
||||
patch.lastStopResponse = null
|
||||
patch.lastError = null
|
||||
patch.pendingApprovals = []
|
||||
}
|
||||
|
||||
// Build notification
|
||||
// Continuous state flags
|
||||
const evt = payload.hook_event_name
|
||||
switch (evt) {
|
||||
case 'SessionStart':
|
||||
patch.sessionActive = true
|
||||
patch.agentResponding = false
|
||||
patch.subagentActive = false
|
||||
patch.compacting = false
|
||||
break
|
||||
case 'SessionEnd':
|
||||
patch.sessionActive = false
|
||||
patch.agentResponding = false
|
||||
patch.subagentActive = false
|
||||
patch.compacting = false
|
||||
break
|
||||
case 'UserPromptSubmit':
|
||||
patch.agentResponding = true
|
||||
patch.compacting = false
|
||||
break
|
||||
case 'Stop':
|
||||
patch.agentResponding = false
|
||||
patch.subagentActive = false
|
||||
patch.compacting = false
|
||||
break
|
||||
case 'SubagentStart':
|
||||
patch.subagentActive = true
|
||||
break
|
||||
case 'SubagentStop':
|
||||
patch.subagentActive = false
|
||||
break
|
||||
case 'PreCompact':
|
||||
patch.compacting = true
|
||||
break
|
||||
case 'PostToolUse':
|
||||
case 'PostToolUseFailure':
|
||||
patch.compacting = false
|
||||
break
|
||||
}
|
||||
|
||||
// Notification
|
||||
const notification = buildNotification(payload)
|
||||
if (payload.hook_event_name === 'SessionStart') {
|
||||
// Reset notifications on new session, only keep the SessionStart notification
|
||||
patch.notifications = [notification]
|
||||
} else {
|
||||
patch.notifications = [...state.notifications, notification].slice(-MAX_NOTIFICATIONS)
|
||||
}
|
||||
|
||||
// Build hook history entry
|
||||
// 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 (ptyId) historyEntry.ptySessionId = ptyId
|
||||
|
||||
if (payload.hook_event_name === 'SessionStart') {
|
||||
patch.hookHistory = [historyEntry]
|
||||
} else {
|
||||
patch.hookHistory = [...state.hookHistory, historyEntry].slice(-MAX_HOOK_HISTORY)
|
||||
}
|
||||
patch.hookHistory = [...state.hookHistory, historyEntry].slice(-MAX_HOOK_HISTORY)
|
||||
|
||||
// Apply patch to state
|
||||
// Apply to state
|
||||
Object.assign(state, patch)
|
||||
|
||||
return {
|
||||
type: 'session-state-patch',
|
||||
type: 'pty-state-patch',
|
||||
ptySessionId: ptyId,
|
||||
agent: agentName,
|
||||
patch,
|
||||
event: payload.hook_event_name || 'unknown',
|
||||
@@ -355,77 +440,27 @@ class SessionStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a pending approval */
|
||||
addApproval(agentName: string, approval: PendingApproval): Partial<AgentSessionState> {
|
||||
const state = this.getOrCreateAgent(agentName)
|
||||
state.pendingApprovals = [...state.pendingApprovals, approval]
|
||||
state.status = 'permissionRequest'
|
||||
state.lastActivity = Date.now()
|
||||
return { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
|
||||
}
|
||||
|
||||
/** Remove a pending approval by requestId */
|
||||
resolveApproval(requestId: string): { agent: string, patch: Partial<AgentSessionState> } | null {
|
||||
for (const [agentName, state] of this.agents) {
|
||||
const idx = state.pendingApprovals.findIndex(a => a.requestId === requestId)
|
||||
if (idx !== -1) {
|
||||
state.pendingApprovals = state.pendingApprovals.filter(a => a.requestId !== requestId)
|
||||
// If no more pending approvals, go back to thinking
|
||||
if (state.pendingApprovals.length === 0 && state.status === 'permissionRequest') {
|
||||
state.status = 'thinking'
|
||||
}
|
||||
state.lastActivity = Date.now()
|
||||
return {
|
||||
agent: agentName,
|
||||
patch: { pendingApprovals: state.pendingApprovals, status: state.status, lastActivity: state.lastActivity }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Update terminal info for an agent */
|
||||
updateTerminalInfo(agentName: string, info: Partial<AgentTerminalInfo>): Partial<AgentSessionState> {
|
||||
const state = this.getOrCreateAgent(agentName)
|
||||
state.terminal = { ...state.terminal, ...info }
|
||||
return { terminal: state.terminal }
|
||||
}
|
||||
|
||||
/** Get full snapshot for new clients */
|
||||
getSnapshot(): Record<string, AgentSessionState> {
|
||||
const result: Record<string, AgentSessionState> = {}
|
||||
for (const [name, state] of this.agents) {
|
||||
result[name] = { ...state }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Get single agent state */
|
||||
getAgentState(agent: string): AgentSessionState | null {
|
||||
return this.agents.get(agent) || null
|
||||
}
|
||||
|
||||
/** Find agent name by session_id */
|
||||
/** Find agent name by transcript session_id (searches PTY sessions) */
|
||||
findAgentBySessionId(sessionId: string): string | null {
|
||||
for (const [name, state] of this.agents) {
|
||||
if (state.sessionId === sessionId) return name
|
||||
for (const state of this.ptySessions.values()) {
|
||||
if (state.transcriptSessionId === sessionId) return state.agent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Find all agents matching a transcript_path */
|
||||
/** Find all agent names matching a transcript_path (searches PTY sessions) */
|
||||
findAgentsByTranscript(transcriptPath: string): string[] {
|
||||
const matches: string[] = []
|
||||
for (const [name, state] of this.agents) {
|
||||
if (state.transcriptPath === transcriptPath) matches.push(name)
|
||||
const matches = new Set<string>()
|
||||
for (const state of this.ptySessions.values()) {
|
||||
if (state.transcriptPath === transcriptPath) matches.add(state.agent)
|
||||
}
|
||||
return matches
|
||||
return Array.from(matches)
|
||||
}
|
||||
|
||||
/** Clean up expired notifications (call periodically) */
|
||||
/** Clean up expired notifications */
|
||||
cleanExpiredNotifications(): void {
|
||||
const now = Date.now()
|
||||
for (const state of this.agents.values()) {
|
||||
for (const state of this.ptySessions.values()) {
|
||||
state.notifications = state.notifications.filter(
|
||||
n => n.persistent || !n.expiresAt || n.expiresAt > now
|
||||
)
|
||||
@@ -437,4 +472,6 @@ class SessionStateManager {
|
||||
export const sessionState = new SessionStateManager()
|
||||
|
||||
// Clean expired notifications every 5s
|
||||
setInterval(() => sessionState.cleanExpiredNotifications(), 5000)
|
||||
setInterval(() => {
|
||||
sessionState.cleanExpiredNotifications()
|
||||
}, 5000)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import { PORT_TERMINAL, WORKING_DIR, SHELL, SHELL_ARGS, DEFAULT_SESSION_ID, MAX_BUFFER_LINES, MAX_TERMINALS } from '../config'
|
||||
import { sessionState, type SessionStatePatch } from './session-state'
|
||||
import { sessionState, type PtyStatePatch } from './session-state'
|
||||
|
||||
// Agent transcript directories (mirrored from transcript-debug.ts)
|
||||
const AGENT_TRANSCRIPT_DIRS: Record<string, string> = {
|
||||
@@ -103,11 +103,9 @@ function broadcastToAll(message: string): number {
|
||||
return count
|
||||
}
|
||||
|
||||
// Broadcast session state patch to ALL clients
|
||||
function broadcastSessionStatePatch(patch: SessionStatePatch) {
|
||||
function broadcastPtyStatePatch(patch: PtyStatePatch) {
|
||||
const message = JSON.stringify(patch)
|
||||
const count = broadcastToAll(message)
|
||||
console.log(`[Terminal] State patch: ${patch.event} (${patch.agent}) → ${count} clients`)
|
||||
broadcastToAll(message)
|
||||
}
|
||||
|
||||
function broadcastRegistryChange() {
|
||||
@@ -129,9 +127,17 @@ function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSes
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: WORKING_DIR
|
||||
cwd: WORKING_DIR,
|
||||
})
|
||||
|
||||
// Inject AGENT_UI_PTY_SESSION env var into the shell session
|
||||
// (bun-pty FFI doesn't support env in spawn options)
|
||||
if (process.platform === 'win32') {
|
||||
pty.write(`$env:AGENT_UI_PTY_SESSION="${sessionId}"\r`)
|
||||
} else {
|
||||
pty.write(`export AGENT_UI_PTY_SESSION="${sessionId}"\n`)
|
||||
}
|
||||
|
||||
session = {
|
||||
id: sessionId,
|
||||
pty,
|
||||
@@ -285,6 +291,7 @@ export function startTerminalServer() {
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
sessions: sessionsInfo,
|
||||
broadcastClients: broadcastClients.size,
|
||||
cwd: WORKING_DIR
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
@@ -509,13 +516,15 @@ export function startTerminalServer() {
|
||||
// ── Session State endpoints (centralized state) ──
|
||||
|
||||
if (url.pathname === '/session-state' && req.method === 'GET') {
|
||||
return Response.json({ agents: sessionState.getSnapshot() }, { headers: corsHeaders })
|
||||
return Response.json({
|
||||
ptySessions: sessionState.getSnapshot(),
|
||||
}, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/session-state/') && req.method === 'GET') {
|
||||
const agent = url.pathname.replace('/session-state/', '')
|
||||
const state = sessionState.getAgentState(agent)
|
||||
if (!state) return Response.json({ error: 'Agent not found' }, { status: 404, headers: corsHeaders })
|
||||
const ptyId = url.pathname.replace('/session-state/', '')
|
||||
const state = sessionState.getPtyState(ptyId)
|
||||
if (!state) return Response.json({ error: 'PTY session not found' }, { status: 404, headers: corsHeaders })
|
||||
return Response.json(state, { headers: corsHeaders })
|
||||
}
|
||||
|
||||
@@ -523,10 +532,16 @@ export function startTerminalServer() {
|
||||
|
||||
if (url.pathname === '/add-approval' && req.method === 'POST') {
|
||||
try {
|
||||
const body = await req.json() as { agent: string, approval: any }
|
||||
const patch = sessionState.addApproval(body.agent, body.approval)
|
||||
broadcastSessionStatePatch({
|
||||
type: 'session-state-patch',
|
||||
const body = await req.json() as { agent: string, approval: any, ptySessionId?: string }
|
||||
const ptyId = body.ptySessionId
|
||||
if (!ptyId) {
|
||||
console.warn('[Terminal] /add-approval called without ptySessionId')
|
||||
return Response.json({ error: 'ptySessionId required' }, { status: 400, headers: corsHeaders })
|
||||
}
|
||||
const patch = sessionState.addApproval(ptyId, body.agent, body.approval)
|
||||
broadcastPtyStatePatch({
|
||||
type: 'pty-state-patch',
|
||||
ptySessionId: ptyId,
|
||||
agent: body.agent,
|
||||
patch,
|
||||
event: 'approval-added',
|
||||
@@ -543,8 +558,9 @@ export function startTerminalServer() {
|
||||
const body = await req.json() as { requestId: string, decision: string }
|
||||
const result = sessionState.resolveApproval(body.requestId)
|
||||
if (result) {
|
||||
broadcastSessionStatePatch({
|
||||
type: 'session-state-patch',
|
||||
broadcastPtyStatePatch({
|
||||
type: 'pty-state-patch',
|
||||
ptySessionId: result.ptySessionId,
|
||||
agent: result.agent,
|
||||
patch: result.patch,
|
||||
event: 'approval-resolved',
|
||||
@@ -591,7 +607,7 @@ export function startTerminalServer() {
|
||||
if (Object.keys(snapshot).length > 0) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'session-state-snapshot',
|
||||
agents: snapshot,
|
||||
ptySessions: snapshot,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -707,13 +723,48 @@ export function startTerminalServer() {
|
||||
return server
|
||||
}
|
||||
|
||||
// Process hook event and broadcast session state patch to ALL clients
|
||||
// Reverse lookup: find the ephemeralSessionId (PTY) for a given transcriptSessionId
|
||||
// If no exact match, auto-bind a '__new__' entry from the same agent (first hook on new session)
|
||||
function findPtySessionId(transcriptSessionId: string, agentName?: string): string | undefined {
|
||||
for (const [eid, entry] of terminalRegistry) {
|
||||
if (entry.transcriptSessionId === transcriptSessionId) {
|
||||
return eid
|
||||
}
|
||||
}
|
||||
// Fallback: auto-bind a '__new__' entry from the same agent
|
||||
if (agentName) {
|
||||
for (const [eid, entry] of terminalRegistry) {
|
||||
if (entry.transcriptSessionId === '__new__' && entry.agent === agentName) {
|
||||
entry.transcriptSessionId = transcriptSessionId
|
||||
console.log(`[Terminal] Auto-bound PTY ${eid} to transcript ${transcriptSessionId}`)
|
||||
broadcastRegistryChange()
|
||||
return eid
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Process hook event and broadcast PTY state patch to ALL clients
|
||||
export function broadcastClaudeHook(data: Record<string, unknown>) {
|
||||
const statePatch = sessionState.processHookEvent(data as any)
|
||||
broadcastSessionStatePatch(statePatch)
|
||||
// Resolve PTY session ID BEFORE processing so it gets tagged on the hook history entry
|
||||
const ptySession = data.pty_session as string
|
||||
const transcriptSid = data.session_id as string
|
||||
const agentName = (data.agent_name as string) || 'claude'
|
||||
const resolvedPtyId = ptySession || (transcriptSid ? findPtySessionId(transcriptSid, agentName) : undefined)
|
||||
|
||||
// Inject ptySessionId into payload so processHookEvent can write to the correct PTY state
|
||||
if (resolvedPtyId) {
|
||||
data.pty_session_id = resolvedPtyId
|
||||
}
|
||||
|
||||
const ptyPatch = sessionState.processHookEvent(data as any)
|
||||
|
||||
if (ptyPatch) {
|
||||
broadcastPtyStatePatch(ptyPatch)
|
||||
}
|
||||
|
||||
// Track agent running state in terminal sessions
|
||||
const agentName = (data.agent_name as string) || 'claude'
|
||||
const event = data.hook_event_name as string
|
||||
if (event === 'SessionStart' || event === 'SessionEnd') {
|
||||
const state = agentSessions.get(agentName)
|
||||
|
||||
Reference in New Issue
Block a user