From 75c44e4ba1b2b2e5811628c015943bea8e02044f Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 4 Mar 2026 00:00:59 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20console=20navigation=20=E2=80=94?= =?UTF-8?q?=20use=20arrow=20keys=20like=20main=20console?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sidebar open: arrows navigate sessions, Enter selects, Escape closes - Sidebar closed: arrows navigate timeline, Escape reopens sidebar - Fix crash on `data.events.reverse()` when API returns non-array - Fix blinking from useCallback re-creating polling intervals (use useRef) - Remove 's' key session cycling — use standard arrow+Enter pattern Co-Authored-By: Claude Opus 4.6 --- src/cli/src/commands/console/audit-app.tsx | 167 ++++++++++++++------- 1 file changed, 109 insertions(+), 58 deletions(-) diff --git a/src/cli/src/commands/console/audit-app.tsx b/src/cli/src/commands/console/audit-app.tsx index adacf00..37cd978 100644 --- a/src/cli/src/commands/console/audit-app.tsx +++ b/src/cli/src/commands/console/audit-app.tsx @@ -1,11 +1,12 @@ /** * AuditConsoleApp — TUI for browsing audit events from mcpd. * - * Shows sessions in a sidebar and events in a timeline. - * Polls mcpd periodically for new data. + * Navigation follows the same patterns as the main unified console: + * - Sidebar open: arrows navigate sessions, Enter selects, Escape closes + * - Sidebar closed: arrows navigate timeline, Escape reopens sidebar */ -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import { render, Box, Text, useInput, useApp, useStdout } from 'ink'; import type { AuditSession, AuditEvent, AuditConsoleState } from './audit-types.js'; import { EVENT_KIND_COLORS, EVENT_KIND_LABELS } from './audit-types.js'; @@ -208,42 +209,58 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { kindFilter: null, }); - // Fetch sessions + // Use refs for polling to avoid re-creating intervals on every state change + const stateRef = useRef(state); + stateRef.current = state; + + // Fetch sessions (stable — no state deps) const fetchSessions = useCallback(async () => { try { const params = new URLSearchParams(); - if (state.projectFilter) params.set('projectName', state.projectFilter); + const pf = stateRef.current.projectFilter; + if (pf) params.set('projectName', pf); params.set('limit', '50'); const url = `${mcpdUrl}/api/v1/audit/sessions?${params.toString()}`; - const data = await fetchJson<{ sessions: AuditSession[]; total: number }>(url, token); - setState((prev) => ({ ...prev, sessions: data.sessions, phase: 'ready' })); + const data = await fetchJson<{ sessions?: AuditSession[]; total?: number }>(url, token); + if (data.sessions && Array.isArray(data.sessions)) { + setState((prev) => ({ ...prev, sessions: data.sessions!, phase: 'ready' })); + } } catch (err) { - setState((prev) => ({ ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) })); + setState((prev) => { + // Only show error if we haven't loaded anything yet + if (prev.phase === 'loading') { + return { ...prev, phase: 'error', error: err instanceof Error ? err.message : String(err) }; + } + return prev; // Keep existing data on transient errors + }); } - }, [mcpdUrl, token, state.projectFilter]); + }, [mcpdUrl, token]); - // Fetch events + // Fetch events (stable — no state deps) const fetchEvents = useCallback(async () => { try { + const s = stateRef.current; const params = new URLSearchParams(); - const selectedSession = state.selectedSessionIdx >= 0 ? state.sessions[state.selectedSessionIdx] : undefined; + const selectedSession = s.selectedSessionIdx >= 0 ? s.sessions[s.selectedSessionIdx] : undefined; if (selectedSession) { params.set('sessionId', selectedSession.sessionId); - } else if (state.projectFilter) { - params.set('projectName', state.projectFilter); + } else if (s.projectFilter) { + params.set('projectName', s.projectFilter); } - if (state.kindFilter) params.set('eventKind', state.kindFilter); + if (s.kindFilter) params.set('eventKind', s.kindFilter); params.set('limit', String(MAX_EVENTS)); const url = `${mcpdUrl}/api/v1/audit/events?${params.toString()}`; - const data = await fetchJson<{ events: AuditEvent[]; total: number }>(url, token); - // API returns newest first — reverse for timeline display - setState((prev) => ({ ...prev, events: data.events.reverse(), totalEvents: data.total })); + const data = await fetchJson<{ events?: AuditEvent[]; total?: number }>(url, token); + if (data.events && Array.isArray(data.events)) { + // API returns newest first — reverse for timeline display + setState((prev) => ({ ...prev, events: data.events!.reverse(), totalEvents: data.total ?? data.events!.length })); + } } catch { // Non-fatal — keep existing events } - }, [mcpdUrl, token, state.selectedSessionIdx, state.sessions, state.projectFilter, state.kindFilter]); + }, [mcpdUrl, token]); - // Initial load + polling + // Initial load + polling (single stable interval) useEffect(() => { void fetchSessions(); void fetchEvents(); @@ -256,21 +273,17 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { // Keyboard input useInput((input, key) => { + const s = stateRef.current; + // Quit if (input === 'q') { exit(); return; } - // Toggle sidebar - if (key.escape) { - setState((prev) => ({ ...prev, showSidebar: !prev.showSidebar })); - return; - } - - // Detail view navigation - if (state.detailEvent) { - if (input === 'q' || key.escape) { + // ── Detail view navigation ── + if (s.detailEvent) { + if (key.escape) { setState((prev) => ({ ...prev, detailEvent: null, detailScrollOffset: 0 })); return; } @@ -295,33 +308,57 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { return; } - // Sidebar mode: Tab toggles focus between sidebar and timeline - if (key.tab && state.showSidebar) { - // Tab cycles: session list focus ↔ timeline focus - // Use selectedSessionIdx >= -2 as "sidebar focused" indicator - // Actually, let's use a simpler approach: Shift+Tab or 's' focuses sidebar - return; + // ── Sidebar navigation (arrows = sessions, Enter = select, Escape = close) ── + if (s.showSidebar) { + if (key.downArrow) { + setState((prev) => ({ + ...prev, + selectedSessionIdx: Math.min(prev.sessions.length - 1, prev.selectedSessionIdx + 1), + focusedEventIdx: -1, + })); + // Re-fetch events for the newly selected session + void fetchEvents(); + return; + } + if (key.upArrow) { + setState((prev) => ({ + ...prev, + selectedSessionIdx: Math.max(-1, prev.selectedSessionIdx - 1), + focusedEventIdx: -1, + })); + void fetchEvents(); + return; + } + if (key.return) { + // Enter closes sidebar, keeping the selected session + setState((prev) => ({ ...prev, showSidebar: false, focusedEventIdx: -1 })); + return; + } + if (key.escape) { + setState((prev) => ({ ...prev, showSidebar: false })); + return; + } + + // Kind filter: k cycles through event kinds + if (input === 'k') { + const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind']; + setState((prev) => { + const currentIdx = kinds.indexOf(prev.kindFilter); + const nextIdx = (currentIdx + 1) % kinds.length; + return { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 }; + }); + void fetchEvents(); + return; + } + + return; // Absorb all other input when sidebar is open } - // Session navigation (when sidebar visible) - if (state.showSidebar && (input === 'S' || input === 's')) { - // s/S cycles through sessions - setState((prev) => { - const max = prev.sessions.length - 1; - const next = prev.selectedSessionIdx >= max ? -1 : prev.selectedSessionIdx + 1; - return { ...prev, selectedSessionIdx: next, focusedEventIdx: -1 }; - }); - return; - } + // ── Timeline navigation (sidebar closed) ── - // Kind filter: k cycles through event kinds - if (input === 'k') { - const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind']; - setState((prev) => { - const currentIdx = kinds.indexOf(prev.kindFilter); - const nextIdx = (currentIdx + 1) % kinds.length; - return { ...prev, kindFilter: kinds[nextIdx]!, focusedEventIdx: -1 }; - }); + // Escape reopens sidebar + if (key.escape) { + setState((prev) => ({ ...prev, showSidebar: true, focusedEventIdx: -1 })); return; } @@ -331,6 +368,18 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { return; } + // Kind filter + if (input === 'k') { + const kinds = [null, 'tool_call_trace', 'gate_decision', 'pipeline_execution', 'stage_execution', 'prompt_delivery', 'session_bind']; + setState((prev) => { + const currentIdx = kinds.indexOf(prev.kindFilter); + const nextIdx = (currentIdx + 1) % kinds.length; + return { ...prev, kindFilter: kinds[nextIdx] ?? null, focusedEventIdx: -1 }; + }); + void fetchEvents(); + return; + } + // Enter: detail view if (key.return) { setState((prev) => { @@ -342,7 +391,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { return; } - // Timeline navigation + // Arrow navigation if (key.downArrow) { setState((prev) => { if (prev.focusedEventIdx === -1) return prev; @@ -413,6 +462,12 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { } // Main view + const sidebarHint = state.showSidebar + ? '[\u2191\u2193] session [Enter] select [k] kind [Esc] close [q] quit' + : state.focusedEventIdx === -1 + ? '[\u2191] nav [k] kind [Enter] detail [Esc] sidebar [q] quit' + : '[\u2191\u2193] nav [PgUp/Dn] page [a] follow [k] kind [Enter] detail [Esc] sidebar [q] quit'; + return ( {/* Header */} @@ -432,11 +487,7 @@ function AuditApp({ mcpdUrl, token, projectFilter }: AuditAppProps) { {/* Footer */} - - {state.focusedEventIdx === -1 - ? `[\u2191] nav [PgUp] page [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit` - : `[\u2191\u2193] nav [PgUp/Dn] page [a] follow [s] session [k] kind [Enter] detail [Esc] sidebar [q] quit`} - + {sidebarHint} );