From 25bca2625b2bda617fba19be19aba7fb991b0836 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Tue, 24 Feb 2026 20:10:31 -0600 Subject: [PATCH] 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 --- .claude-ejecutor/settings.json | 22 +- .claude-nucleo000/settings.json | 22 +- .claude/settings.local.json | 22 +- frontend/public/loading.html | 16 +- .../transcript-debug/AgentBadge.vue | 13 +- .../transcript-debug/ChatContainer.vue | 59 ++- .../SessionLifecycleStatus.vue | 494 +++++++++++++----- .../transcript-debug/SystemMessage.vue | 217 ++++++-- .../src/components/transcript-debug/index.ts | 1 + .../sync-engine/AgentStatesSection.vue | 223 ++++++++ .../sync-engine/HookTimelineSection.vue | 250 +++++++++ .../sync-engine/SyncEnginePanel.vue | 69 +++ .../sync-engine/TerminalRegistrySection.vue | 410 +++++++++++++++ .../sync-engine/WsMonitorSection.vue | 213 ++++++++ .../transcript-debug/sync-engine/index.ts | 1 + .../transcript-debug/useTranscriptDebug.ts | 38 +- frontend/src/composables/useApprovalWindow.ts | 26 +- frontend/src/composables/useGlobalApproval.ts | 25 +- .../src/composables/useLifecycleStates.ts | 130 +++++ frontend/src/composables/usePipWindow.ts | 88 +++- frontend/src/composables/useWsMonitor.ts | 60 +++ frontend/src/lib/loadingWindow.ts | 34 +- frontend/src/pages/ApprovalPage.vue | 14 +- frontend/src/services/session-state-ws.ts | 2 +- frontend/src/stores/session-state.ts | 138 ++--- frontend/src/types/hooks-approval.ts | 2 + hooks/approval-permission.ps1 | 7 +- hooks/approval-plan.ps1 | 7 +- hooks/notify.ps1 | 6 +- server/routes/claude-hook.ts | 7 +- server/routes/hooks-approval.ts | 20 +- server/routes/index.ts | 6 + server/routes/session-state-proxy.ts | 4 +- server/routes/ws-monitor.ts | 48 ++ server/services/session-state.ts | 289 +++++----- server/services/terminal.ts | 93 +++- 36 files changed, 2526 insertions(+), 550 deletions(-) create mode 100644 frontend/src/components/transcript-debug/sync-engine/AgentStatesSection.vue create mode 100644 frontend/src/components/transcript-debug/sync-engine/HookTimelineSection.vue create mode 100644 frontend/src/components/transcript-debug/sync-engine/SyncEnginePanel.vue create mode 100644 frontend/src/components/transcript-debug/sync-engine/TerminalRegistrySection.vue create mode 100644 frontend/src/components/transcript-debug/sync-engine/WsMonitorSection.vue create mode 100644 frontend/src/components/transcript-debug/sync-engine/index.ts create mode 100644 frontend/src/composables/useLifecycleStates.ts create mode 100644 frontend/src/composables/useWsMonitor.ts create mode 100644 server/routes/ws-monitor.ts diff --git a/.claude-ejecutor/settings.json b/.claude-ejecutor/settings.json index 651a7f5..d1ee69a 100644 --- a/.claude-ejecutor/settings.json +++ b/.claude-ejecutor/settings.json @@ -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 } ] diff --git a/.claude-nucleo000/settings.json b/.claude-nucleo000/settings.json index e60e78e..a37a6d8 100644 --- a/.claude-nucleo000/settings.json +++ b/.claude-nucleo000/settings.json @@ -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 } ] diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e0ab573..3712d69 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 } ] diff --git a/frontend/public/loading.html b/frontend/public/loading.html index 6121c78..c432745 100644 --- a/frontend/public/loading.html +++ b/frontend/public/loading.html @@ -35,8 +35,22 @@
diff --git a/frontend/src/components/transcript-debug/AgentBadge.vue b/frontend/src/components/transcript-debug/AgentBadge.vue index b84f2a4..e7c0b66 100644 --- a/frontend/src/components/transcript-debug/AgentBadge.vue +++ b/frontend/src/components/transcript-debug/AgentBadge.vue @@ -27,14 +27,23 @@ const STATUS_COLORS: Record = { 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' }) diff --git a/frontend/src/components/transcript-debug/ChatContainer.vue b/frontend/src/components/transcript-debug/ChatContainer.vue index 6cc86e7..98ef81d 100644 --- a/frontend/src/components/transcript-debug/ChatContainer.vue +++ b/frontend/src/components/transcript-debug/ChatContainer.vue @@ -64,9 +64,8 @@ const STATUS_DISPLAY: Record { - 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 {
+ + -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 = { - 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 = { @@ -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 | 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(() => { - if (!hasEvent.value) return null - return props.currentEvent as LifecycleEvent -}) - +const activeEvent = computed(() => 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)' +}) @@ -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; } diff --git a/frontend/src/components/transcript-debug/SystemMessage.vue b/frontend/src/components/transcript-debug/SystemMessage.vue index f781a92..a5aae70 100644 --- a/frontend/src/components/transcript-debug/SystemMessage.vue +++ b/frontend/src/components/transcript-debug/SystemMessage.vue @@ -1,90 +1,221 @@ diff --git a/frontend/src/components/transcript-debug/index.ts b/frontend/src/components/transcript-debug/index.ts index d43f000..3a1713b 100644 --- a/frontend/src/components/transcript-debug/index.ts +++ b/frontend/src/components/transcript-debug/index.ts @@ -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' diff --git a/frontend/src/components/transcript-debug/sync-engine/AgentStatesSection.vue b/frontend/src/components/transcript-debug/sync-engine/AgentStatesSection.vue new file mode 100644 index 0000000..255a273 --- /dev/null +++ b/frontend/src/components/transcript-debug/sync-engine/AgentStatesSection.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/components/transcript-debug/sync-engine/HookTimelineSection.vue b/frontend/src/components/transcript-debug/sync-engine/HookTimelineSection.vue new file mode 100644 index 0000000..78444d8 --- /dev/null +++ b/frontend/src/components/transcript-debug/sync-engine/HookTimelineSection.vue @@ -0,0 +1,250 @@ + + + + + diff --git a/frontend/src/components/transcript-debug/sync-engine/SyncEnginePanel.vue b/frontend/src/components/transcript-debug/sync-engine/SyncEnginePanel.vue new file mode 100644 index 0000000..6407751 --- /dev/null +++ b/frontend/src/components/transcript-debug/sync-engine/SyncEnginePanel.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/frontend/src/components/transcript-debug/sync-engine/TerminalRegistrySection.vue b/frontend/src/components/transcript-debug/sync-engine/TerminalRegistrySection.vue new file mode 100644 index 0000000..9cda11d --- /dev/null +++ b/frontend/src/components/transcript-debug/sync-engine/TerminalRegistrySection.vue @@ -0,0 +1,410 @@ + + + + + diff --git a/frontend/src/components/transcript-debug/sync-engine/WsMonitorSection.vue b/frontend/src/components/transcript-debug/sync-engine/WsMonitorSection.vue new file mode 100644 index 0000000..3b4fbe8 --- /dev/null +++ b/frontend/src/components/transcript-debug/sync-engine/WsMonitorSection.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/frontend/src/components/transcript-debug/sync-engine/index.ts b/frontend/src/components/transcript-debug/sync-engine/index.ts new file mode 100644 index 0000000..e8c806c --- /dev/null +++ b/frontend/src/components/transcript-debug/sync-engine/index.ts @@ -0,0 +1 @@ +export { default as SyncEnginePanel } from './SyncEnginePanel.vue' diff --git a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts index 962e2ce..0f7e59d 100644 --- a/frontend/src/composables/transcript-debug/useTranscriptDebug.ts +++ b/frontend/src/composables/transcript-debug/useTranscriptDebug.ts @@ -41,11 +41,17 @@ export function useTranscriptDebug() { const error = ref(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(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) { diff --git a/frontend/src/composables/useApprovalWindow.ts b/frontend/src/composables/useApprovalWindow.ts index 5fb8502..cf1d212 100644 --- a/frontend/src/composables/useApprovalWindow.ts +++ b/frontend/src/composables/useApprovalWindow.ts @@ -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('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 { diff --git a/frontend/src/composables/useGlobalApproval.ts b/frontend/src/composables/useGlobalApproval.ts index 8d21a5f..faddbcd 100644 --- a/frontend/src/composables/useGlobalApproval.ts +++ b/frontend/src/composables/useGlobalApproval.ts @@ -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(() => - sessionStore.allPendingApprovals + const pendingPermissions = computed(() => { + 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(() => - sessionStore.allPendingApprovals + const pendingPlans = computed(() => { + 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() 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 */ } diff --git a/frontend/src/composables/useLifecycleStates.ts b/frontend/src/composables/useLifecycleStates.ts new file mode 100644 index 0000000..bd73dcf --- /dev/null +++ b/frontend/src/composables/useLifecycleStates.ts @@ -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 = { + 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 = { + permission: 6, + compacting: 5, + tool: 4, + subagent: 3, + responding: 2, + session: 1, +} + +// ── Input interface (server-provided fields) ── + +export interface LifecycleStateInput { + sessionActive: Ref + agentResponding: Ref + subagentActive: Ref + compacting: Ref + status: Ref + currentTool: Ref + lastActivity: Ref +} + +// ── 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 } +} diff --git a/frontend/src/composables/usePipWindow.ts b/frontend/src/composables/usePipWindow.ts index 64418bd..bb7121f 100644 --- a/frontend/src/composables/usePipWindow.ts +++ b/frontend/src/composables/usePipWindow.ts @@ -11,7 +11,10 @@ interface WindowGeometry { height: number } +const PIP_LABEL_RE = /^pip-terminal-(\d+)$/ + const pipWindows = ref>(new Map()) +let rehydratePromise: Promise | null = null async function loadGeometry(idx: number): Promise { try { @@ -29,10 +32,55 @@ async function saveGeometry(idx: number, geo: WindowGeometry): Promise { } 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 { 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('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) { diff --git a/frontend/src/composables/useWsMonitor.ts b/frontend/src/composables/useWsMonitor.ts new file mode 100644 index 0000000..cc03bb4 --- /dev/null +++ b/frontend/src/composables/useWsMonitor.ts @@ -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(null) + const error = ref(null) + const loading = ref(false) + let intervalId: ReturnType | 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 } +} diff --git a/frontend/src/lib/loadingWindow.ts b/frontend/src/lib/loadingWindow.ts index 7c8c3f7..6429484 100644 --- a/frontend/src/lib/loadingWindow.ts +++ b/frontend/src/lib/loadingWindow.ts @@ -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() +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. */ diff --git a/frontend/src/pages/ApprovalPage.vue b/frontend/src/pages/ApprovalPage.vue index 73ba3d2..b2dff95 100644 --- a/frontend/src/pages/ApprovalPage.vue +++ b/frontend/src/pages/ApprovalPage.vue @@ -1,6 +1,7 @@