From 98bd82bb693d41a6affb66e51d0cb989e21c0bb2 Mon Sep 17 00:00:00 2001 From: Jay Taylor Date: Wed, 18 Mar 2026 14:37:18 -0700 Subject: [PATCH 01/10] feat(frontend): stabilize dev routing and asset loading Serve context deep links through dev rewrites so /c/:contextId and /c/:contextId/t/:turnId no longer fall into Next's 404 path during local work. Normalize backend ids in the frontend API layer so deep links select the requested turn reliably, and separate production build output from the live dev dist directory so running next build no longer breaks the dev server's asset manifest. --- frontend/app/page.tsx | 819 +------------------------------- frontend/components/CxdbApp.tsx | 818 +++++++++++++++++++++++++++++++ frontend/lib/api.ts | 60 ++- frontend/next.config.mjs | 54 ++- 4 files changed, 910 insertions(+), 841 deletions(-) create mode 100644 frontend/components/CxdbApp.tsx diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e4548be..f23a2c7 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,818 +1,5 @@ -'use client'; +import CxdbApp from '@/components/CxdbApp'; -import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { ContextDebugger } from '@/components/ContextDebugger'; -import { ContextList } from '@/components/ContextList'; -import type { ContextEntry, StoreEvent } from '@/types'; -import { Database, Layers, Plus, X, AlertCircle, Check, Zap, Radio, ChevronDown, Filter } from '@/components/icons'; -import { ThemeSelector } from '@/components/ThemeSelector'; -import { cn, normalizeContextId } from '@/lib/utils'; -import { healthCheck, fetchContexts, searchContexts } from '@/lib/api'; -import { parse as parseCql, validate as validateCql, formatError as formatCqlError, buildFallbackQuery } from '@/lib/cql'; -import { useEventStream, useMockEventGenerator, useUrlRouter, parseUrl, type RouteState } from '@/hooks'; -import { ConnectionStatus, ActivityFeed } from '@/components/live'; -import { ServerHealthDashboard } from '@/components/dashboard'; - -// Predefined colors for tags - using theme-aware classes -const TAG_COLORS: Record = { - dotrunner: { bg: 'bg-theme-tag-dotrunner-bg', text: 'text-theme-tag-dotrunner', border: 'border-theme-tag-dotrunner/30' }, - 'claude-code': { bg: 'bg-theme-tag-claude-code-bg', text: 'text-theme-tag-claude-code', border: 'border-theme-tag-claude-code/30' }, - gen: { bg: 'bg-theme-tag-gen-bg', text: 'text-theme-tag-gen', border: 'border-theme-tag-gen/30' }, - test: { bg: 'bg-theme-tag-test-bg', text: 'text-theme-tag-test', border: 'border-theme-tag-test/30' }, -}; - -const DEFAULT_TAG_COLOR = { bg: 'bg-theme-tag-default-bg', text: 'text-theme-tag-default', border: 'border-theme-tag-default/30' }; - -function getTagColor(tag: string) { - return TAG_COLORS[tag.toLowerCase()] || DEFAULT_TAG_COLOR; -} - -export default function Home() { - const [contexts, setContexts] = useState([]); - const [selectedContextId, setSelectedContextId] = useState(null); - const [selectedTurnId, setSelectedTurnId] = useState(null); - const [debuggerOpen, setDebuggerOpen] = useState(false); - const [serverStatus, setServerStatus] = useState<'checking' | 'online' | 'offline'>('checking'); - const [showActivityFeed, setShowActivityFeed] = useState(false); - const [mockMode, setMockMode] = useState(false); // Default to live mode in production - const [activeTags, setActiveTags] = useState([]); - const [selectedTag, setSelectedTag] = useState(null); - const [sortByTag, setSortByTag] = useState(false); - const [tagFilterOpen, setTagFilterOpen] = useState(false); - const [urlInitialized, setUrlInitialized] = useState(false); - const [focusedContextIndex, setFocusedContextIndex] = useState(0); - - // CQL Search state - const [searchQuery, setSearchQuery] = useState(''); - const [searchError, setSearchError] = useState(null); - const [isSearching, setIsSearching] = useState(false); - const [searchResults, setSearchResults] = useState<{ contexts: ContextEntry[]; total: number } | null>(null); - - // Environment filter state - const [selectedEnv, setSelectedEnv] = useState<'all' | 'prod' | 'stage' | 'dev'>('all'); - - // URL routing - parse URL on mount and handle changes - const handleRouteChange = useCallback((state: RouteState) => { - if (state.contextId) { - setSelectedContextId(state.contextId); - setSelectedTurnId(state.turnId); - setDebuggerOpen(true); - } else { - setSelectedContextId(null); - setSelectedTurnId(null); - setDebuggerOpen(false); - } - setUrlInitialized(true); - }, []); - - const { navigateToContext, navigateHome, setTurn } = useUrlRouter({ - onRouteChange: handleRouteChange, - }); - - // Handle turn changes from ContextDebugger - const handleTurnChange = useCallback((turnId: string | null) => { - if (selectedContextId && turnId) { - setSelectedTurnId(turnId); - // Replace history state to avoid polluting browser history with every keystroke - setTurn(selectedContextId, turnId, true); - } - }, [selectedContextId, setTurn]); - - // Stable callback for SSE events - prevents useEffect re-runs - const handleSSEEvent = useCallback((event: StoreEvent) => { - // Handle new contexts from SSE - if (event.type === 'context_created') { - const newContext: ContextEntry = { - context_id: event.data.context_id, - client_tag: event.data.client_tag, - session_id: event.data.session_id, - is_live: true, - last_activity_at: event.data.created_at, - }; - setContexts(prev => { - // Add to top if not exists - if (prev.some(c => c.context_id === event.data.context_id)) { - return prev; - } - return [newContext, ...prev]; - }); - } - - // Update context metadata when extracted from first turn - if (event.type === 'context_metadata_updated') { - setContexts(prev => - prev.map(c => - c.context_id === event.data.context_id - ? { - ...c, - client_tag: event.data.client_tag ?? c.client_tag, - title: event.data.title ?? c.title, - labels: event.data.labels ?? c.labels, - } - : c - ) - ); - } - - // Link child context to parent context lineage - if (event.type === 'context_linked') { - setContexts(prev => - prev.map(c => { - if (c.context_id === event.data.child_context_id) { - return { - ...c, - lineage: { - parent_context_id: event.data.parent_context_id, - root_context_id: event.data.root_context_id, - spawn_reason: event.data.spawn_reason, - child_context_count: c.lineage?.child_context_count ?? 0, - child_context_ids: c.lineage?.child_context_ids ?? [], - }, - provenance: { - ...(c.provenance ?? {}), - parent_context_id: Number(event.data.parent_context_id), - root_context_id: event.data.root_context_id - ? Number(event.data.root_context_id) - : c.provenance?.root_context_id, - spawn_reason: event.data.spawn_reason ?? c.provenance?.spawn_reason, - }, - }; - } - - if (c.context_id === event.data.parent_context_id) { - const existingChildren = c.lineage?.child_context_ids ?? []; - const childContextIds = existingChildren.includes(event.data.child_context_id) - ? existingChildren - : [...existingChildren, event.data.child_context_id]; - return { - ...c, - lineage: { - parent_context_id: c.lineage?.parent_context_id, - root_context_id: c.lineage?.root_context_id, - spawn_reason: c.lineage?.spawn_reason, - child_context_count: childContextIds.length, - child_context_ids: childContextIds, - }, - }; - } - - return c; - }) - ); - } - - // Update context activity timestamp on turn append - if (event.type === 'turn_appended') { - setContexts(prev => - prev.map(c => - c.context_id === event.data.context_id - ? { ...c, last_activity_at: Date.now(), is_live: true } - : c - ) - ); - } - - // Handle client disconnects - if (event.type === 'client_disconnected') { - setContexts(prev => - prev.map(c => - event.data.contexts.includes(c.context_id) - ? { ...c, is_live: false } - : c - ) - ); - } - }, []); // Empty deps - setContexts is stable - - // Event stream hook - const { - connectionState, - lastEvent, - activityFeed, - mockEmit, - } = useEventStream({ - enabled: serverStatus === 'online' || mockMode, - mockMode, - onEvent: handleSSEEvent, - }); - - // Mock event generator for demo - const { startMockEvents, stopMockEvents } = useMockEventGenerator(mockEmit); - - // Fetch contexts helper - const fetchContextsData = useCallback(async () => { - try { - const response = await fetchContexts({ limit: 1000, include_provenance: true }); - setContexts(response.contexts); - // Derive tags from all fetched contexts (not just active sessions) - const allTags = new Set(); - for (const ctx of response.contexts) { - if (ctx.client_tag) { - allTags.add(ctx.client_tag); - } - } - setActiveTags(Array.from(allTags).sort()); - } catch { - // Ignore errors - contexts list just stays empty - } - }, []); - - // Check server health and load contexts on mount - useEffect(() => { - const checkServer = async () => { - const isOnline = await healthCheck(); - setServerStatus(isOnline ? 'online' : 'offline'); - - // Fetch recent contexts if server is online - if (isOnline) { - await fetchContextsData(); - } - }; - checkServer(); - // Re-check every 30 seconds - const interval = setInterval(checkServer, 30000); - return () => clearInterval(interval); - }, [fetchContextsData]); - - // Fallback polling when SSE is not connected - // This provides real-time-ish updates when SSE fails - useEffect(() => { - if (serverStatus !== 'online') return; - if (connectionState === 'connected') return; // SSE working, no fallback needed - - // Poll every 15 seconds when SSE is disconnected or reconnecting - const pollInterval = setInterval(async () => { - console.log('[Fallback] Polling for updates (SSE state:', connectionState, ')'); - await fetchContextsData(); - }, 15000); - - return () => clearInterval(pollInterval); - }, [serverStatus, connectionState, fetchContextsData]); - - // Filter and sort contexts - const filteredContexts = useMemo(() => { - // If we have search results, use those instead - if (searchResults) { - return searchResults.contexts; - } - - let result = contexts; - - // Filter by selected tag - if (selectedTag) { - result = result.filter(c => c.client_tag === selectedTag); - } - - // Sort by tag if enabled, otherwise sort by most recent activity - if (sortByTag) { - result = [...result].sort((a, b) => { - const tagA = a.client_tag || ''; - const tagB = b.client_tag || ''; - if (tagA !== tagB) { - return tagA.localeCompare(tagB); - } - // Secondary sort by context_id descending - return Number(b.context_id) - Number(a.context_id); - }); - } else { - // Default: sort by most recent activity (descending) - result = [...result].sort((a, b) => { - const timeA = a.last_activity_at ?? 0; - const timeB = b.last_activity_at ?? 0; - if (timeA !== timeB) { - return timeB - timeA; // Most recent first - } - // Secondary sort by context_id descending for stable ordering - return Number(b.context_id) - Number(a.context_id); - }); - } - - return result; - }, [contexts, selectedTag, sortByTag, searchResults]); - - // Reset focused index when filtered contexts change - useEffect(() => { - setFocusedContextIndex(prev => - Math.min(prev, Math.max(0, filteredContexts.length - 1)) - ); - }, [filteredContexts.length]); - - const handleSelectContext = useCallback((contextId: string) => { - setSelectedContextId(contextId); - setSelectedTurnId(null); - setDebuggerOpen(true); - navigateToContext(contextId); - }, [navigateToContext]); - - const handleRemoveContext = useCallback((contextId: string) => { - setContexts(prev => prev.filter(c => c.context_id !== contextId)); - if (selectedContextId === contextId) { - setSelectedContextId(null); - setSelectedTurnId(null); - setDebuggerOpen(false); - navigateHome(); - } - }, [selectedContextId, navigateHome]); - - // Debounced live search - triggers automatically as user types - const searchAbortRef = useRef(null); - - useEffect(() => { - const query = searchQuery.trim(); - - // Clear results if query is empty - if (!query) { - setSearchResults(null); - setSearchError(null); - setIsSearching(false); - return; - } - - // Debounce: wait 300ms after user stops typing - const debounceTimer = setTimeout(async () => { - // Cancel any in-flight request - if (searchAbortRef.current) { - searchAbortRef.current.abort(); - } - searchAbortRef.current = new AbortController(); - - // Try to validate as CQL first - const validation = validateCql(query); - let effectiveQuery = query; - - // If not valid CQL, treat as keyword search across all fields - if (!validation.ok) { - effectiveQuery = buildFallbackQuery(query); - } - - setIsSearching(true); - setSearchError(null); - - try { - const results = await searchContexts(effectiveQuery, 100); - // Only update if this request wasn't aborted - if (!searchAbortRef.current?.signal.aborted) { - setSearchResults({ - contexts: results.contexts, - total: results.total_count, - }); - } - } catch (err) { - // Ignore abort errors - if (err instanceof Error && err.name === 'AbortError') return; - if (!searchAbortRef.current?.signal.aborted) { - setSearchError(err instanceof Error ? err.message : 'Search failed'); - setSearchResults(null); - } - } finally { - if (!searchAbortRef.current?.signal.aborted) { - setIsSearching(false); - } - } - }, 300); - - // Cleanup: cancel debounce timer on new input - return () => { - clearTimeout(debounceTimer); - }; - }, [searchQuery]); - - const handleSearchKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - // Clear search - setSearchQuery(''); - setSearchResults(null); - setSearchError(null); - } - }; - - const clearSearch = useCallback(() => { - setSearchQuery(''); - setSearchResults(null); - setSearchError(null); - setSelectedEnv('all'); - }, []); - - // Handle env pill selection - const handleEnvSelect = useCallback((env: 'all' | 'prod' | 'stage' | 'dev') => { - setSelectedEnv(env); - if (env === 'all') { - setSearchQuery(''); - setSearchResults(null); - setSearchError(null); - } else { - setSearchQuery(`label = "env=${env}"`); - } - }, []); - - // Keyboard shortcuts - useEffect(() => { - const handleKey = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; - if (e.metaKey || e.ctrlKey || e.altKey) return; - - // Only handle j/k/o when debugger is closed and viewing contexts (not activity) - if (!debuggerOpen && !showActivityFeed) { - if (e.key === 'j' || e.key === 'ArrowDown') { - e.preventDefault(); - setFocusedContextIndex(prev => - Math.min(prev + 1, filteredContexts.length - 1) - ); - return; - } - if (e.key === 'k' || e.key === 'ArrowUp') { - e.preventDefault(); - setFocusedContextIndex(prev => Math.max(prev - 1, 0)); - return; - } - if (e.key === 'o' || e.key === 'Enter') { - e.preventDefault(); - const context = filteredContexts[focusedContextIndex]; - if (context) { - handleSelectContext(context.context_id); - } - return; - } - } - - // Activity feed toggle (works anytime) - if (e.key === 'a') { - setShowActivityFeed(prev => !prev); - } - }; - window.addEventListener('keydown', handleKey); - return () => window.removeEventListener('keydown', handleKey); - }, [debuggerOpen, showActivityFeed, filteredContexts, focusedContextIndex, handleSelectContext]); - - return ( -
- {/* Header */} -
- {/* Left: Logo + Title + Env Pills */} -
-
- -
-
-

CXDB

-

AI Context Store

-
- - {/* Environment Filter Pills - vertically centered with logo */} -
- {(['all', 'prod', 'stage', 'dev'] as const).map((env) => ( - - ))} -
-
- - {/* Right: Controls */} -
- {/* Theme selector */} - - - {/* Mock mode toggle */} - - - {/* Demo button (mock mode only) */} - {mockMode && ( - - )} - - {/* Connection status */} - - - {/* Server status indicator */} -
- - {serverStatus === 'online' ? 'Server online' : - serverStatus === 'offline' ? 'Server offline' : - 'Checking...'} -
-
-
- - {/* Main content */} -
- {/* Sidebar */} - - - {/* Main area */} -
- {!debuggerOpen && ( - - )} -
-
- - {/* Keyboard hints footer */} -
- j/k Navigate - | - o Open context - | - a Activity feed -
- - {/* Context Debugger Modal */} - {selectedContextId && ( - { - setDebuggerOpen(false); - navigateHome(); - }} - lastEvent={lastEvent} - initialTurnId={selectedTurnId} - onTurnChange={handleTurnChange} - onNavigateToContext={handleSelectContext} - /> - )} -
- ); +export default function Page() { + return ; } diff --git a/frontend/components/CxdbApp.tsx b/frontend/components/CxdbApp.tsx new file mode 100644 index 0000000..f4f8cb0 --- /dev/null +++ b/frontend/components/CxdbApp.tsx @@ -0,0 +1,818 @@ +'use client'; + +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import { ContextDebugger } from '@/components/ContextDebugger'; +import { ContextList } from '@/components/ContextList'; +import type { ContextEntry, StoreEvent } from '@/types'; +import { Database, Layers, Plus, X, AlertCircle, Check, Zap, Radio, ChevronDown, Filter } from '@/components/icons'; +import { ThemeSelector } from '@/components/ThemeSelector'; +import { cn, normalizeContextId } from '@/lib/utils'; +import { healthCheck, fetchContexts, searchContexts } from '@/lib/api'; +import { parse as parseCql, validate as validateCql, formatError as formatCqlError, buildFallbackQuery } from '@/lib/cql'; +import { useEventStream, useMockEventGenerator, useUrlRouter, parseUrl, type RouteState } from '@/hooks'; +import { ConnectionStatus, ActivityFeed } from '@/components/live'; +import { ServerHealthDashboard } from '@/components/dashboard'; + +// Predefined colors for tags - using theme-aware classes +const TAG_COLORS: Record = { + dotrunner: { bg: 'bg-theme-tag-dotrunner-bg', text: 'text-theme-tag-dotrunner', border: 'border-theme-tag-dotrunner/30' }, + 'claude-code': { bg: 'bg-theme-tag-claude-code-bg', text: 'text-theme-tag-claude-code', border: 'border-theme-tag-claude-code/30' }, + gen: { bg: 'bg-theme-tag-gen-bg', text: 'text-theme-tag-gen', border: 'border-theme-tag-gen/30' }, + test: { bg: 'bg-theme-tag-test-bg', text: 'text-theme-tag-test', border: 'border-theme-tag-test/30' }, +}; + +const DEFAULT_TAG_COLOR = { bg: 'bg-theme-tag-default-bg', text: 'text-theme-tag-default', border: 'border-theme-tag-default/30' }; + +function getTagColor(tag: string) { + return TAG_COLORS[tag.toLowerCase()] || DEFAULT_TAG_COLOR; +} + +export default function CxdbApp() { + const [contexts, setContexts] = useState([]); + const [selectedContextId, setSelectedContextId] = useState(null); + const [selectedTurnId, setSelectedTurnId] = useState(null); + const [debuggerOpen, setDebuggerOpen] = useState(false); + const [serverStatus, setServerStatus] = useState<'checking' | 'online' | 'offline'>('checking'); + const [showActivityFeed, setShowActivityFeed] = useState(false); + const [mockMode, setMockMode] = useState(false); // Default to live mode in production + const [activeTags, setActiveTags] = useState([]); + const [selectedTag, setSelectedTag] = useState(null); + const [sortByTag, setSortByTag] = useState(false); + const [tagFilterOpen, setTagFilterOpen] = useState(false); + const [urlInitialized, setUrlInitialized] = useState(false); + const [focusedContextIndex, setFocusedContextIndex] = useState(0); + + // CQL Search state + const [searchQuery, setSearchQuery] = useState(''); + const [searchError, setSearchError] = useState(null); + const [isSearching, setIsSearching] = useState(false); + const [searchResults, setSearchResults] = useState<{ contexts: ContextEntry[]; total: number } | null>(null); + + // Environment filter state + const [selectedEnv, setSelectedEnv] = useState<'all' | 'prod' | 'stage' | 'dev'>('all'); + + // URL routing - parse URL on mount and handle changes + const handleRouteChange = useCallback((state: RouteState) => { + if (state.contextId) { + setSelectedContextId(state.contextId); + setSelectedTurnId(state.turnId); + setDebuggerOpen(true); + } else { + setSelectedContextId(null); + setSelectedTurnId(null); + setDebuggerOpen(false); + } + setUrlInitialized(true); + }, []); + + const { navigateToContext, navigateHome, setTurn } = useUrlRouter({ + onRouteChange: handleRouteChange, + }); + + // Handle turn changes from ContextDebugger + const handleTurnChange = useCallback((turnId: string | null) => { + if (selectedContextId && turnId) { + setSelectedTurnId(turnId); + // Replace history state to avoid polluting browser history with every keystroke + setTurn(selectedContextId, turnId, true); + } + }, [selectedContextId, setTurn]); + + // Stable callback for SSE events - prevents useEffect re-runs + const handleSSEEvent = useCallback((event: StoreEvent) => { + // Handle new contexts from SSE + if (event.type === 'context_created') { + const newContext: ContextEntry = { + context_id: event.data.context_id, + client_tag: event.data.client_tag, + session_id: event.data.session_id, + is_live: true, + last_activity_at: event.data.created_at, + }; + setContexts(prev => { + // Add to top if not exists + if (prev.some(c => c.context_id === event.data.context_id)) { + return prev; + } + return [newContext, ...prev]; + }); + } + + // Update context metadata when extracted from first turn + if (event.type === 'context_metadata_updated') { + setContexts(prev => + prev.map(c => + c.context_id === event.data.context_id + ? { + ...c, + client_tag: event.data.client_tag ?? c.client_tag, + title: event.data.title ?? c.title, + labels: event.data.labels ?? c.labels, + } + : c + ) + ); + } + + // Link child context to parent context lineage + if (event.type === 'context_linked') { + setContexts(prev => + prev.map(c => { + if (c.context_id === event.data.child_context_id) { + return { + ...c, + lineage: { + parent_context_id: event.data.parent_context_id, + root_context_id: event.data.root_context_id, + spawn_reason: event.data.spawn_reason, + child_context_count: c.lineage?.child_context_count ?? 0, + child_context_ids: c.lineage?.child_context_ids ?? [], + }, + provenance: { + ...(c.provenance ?? {}), + parent_context_id: Number(event.data.parent_context_id), + root_context_id: event.data.root_context_id + ? Number(event.data.root_context_id) + : c.provenance?.root_context_id, + spawn_reason: event.data.spawn_reason ?? c.provenance?.spawn_reason, + }, + }; + } + + if (c.context_id === event.data.parent_context_id) { + const existingChildren = c.lineage?.child_context_ids ?? []; + const childContextIds = existingChildren.includes(event.data.child_context_id) + ? existingChildren + : [...existingChildren, event.data.child_context_id]; + return { + ...c, + lineage: { + parent_context_id: c.lineage?.parent_context_id, + root_context_id: c.lineage?.root_context_id, + spawn_reason: c.lineage?.spawn_reason, + child_context_count: childContextIds.length, + child_context_ids: childContextIds, + }, + }; + } + + return c; + }) + ); + } + + // Update context activity timestamp on turn append + if (event.type === 'turn_appended') { + setContexts(prev => + prev.map(c => + c.context_id === event.data.context_id + ? { ...c, last_activity_at: Date.now(), is_live: true } + : c + ) + ); + } + + // Handle client disconnects + if (event.type === 'client_disconnected') { + setContexts(prev => + prev.map(c => + event.data.contexts.includes(c.context_id) + ? { ...c, is_live: false } + : c + ) + ); + } + }, []); // Empty deps - setContexts is stable + + // Event stream hook + const { + connectionState, + lastEvent, + activityFeed, + mockEmit, + } = useEventStream({ + enabled: serverStatus === 'online' || mockMode, + mockMode, + onEvent: handleSSEEvent, + }); + + // Mock event generator for demo + const { startMockEvents, stopMockEvents } = useMockEventGenerator(mockEmit); + + // Fetch contexts helper + const fetchContextsData = useCallback(async () => { + try { + const response = await fetchContexts({ limit: 1000, include_provenance: true }); + setContexts(response.contexts); + // Derive tags from all fetched contexts (not just active sessions) + const allTags = new Set(); + for (const ctx of response.contexts) { + if (ctx.client_tag) { + allTags.add(ctx.client_tag); + } + } + setActiveTags(Array.from(allTags).sort()); + } catch { + // Ignore errors - contexts list just stays empty + } + }, []); + + // Check server health and load contexts on mount + useEffect(() => { + const checkServer = async () => { + const isOnline = await healthCheck(); + setServerStatus(isOnline ? 'online' : 'offline'); + + // Fetch recent contexts if server is online + if (isOnline) { + await fetchContextsData(); + } + }; + checkServer(); + // Re-check every 30 seconds + const interval = setInterval(checkServer, 30000); + return () => clearInterval(interval); + }, [fetchContextsData]); + + // Fallback polling when SSE is not connected + // This provides real-time-ish updates when SSE fails + useEffect(() => { + if (serverStatus !== 'online') return; + if (connectionState === 'connected') return; // SSE working, no fallback needed + + // Poll every 15 seconds when SSE is disconnected or reconnecting + const pollInterval = setInterval(async () => { + console.log('[Fallback] Polling for updates (SSE state:', connectionState, ')'); + await fetchContextsData(); + }, 15000); + + return () => clearInterval(pollInterval); + }, [serverStatus, connectionState, fetchContextsData]); + + // Filter and sort contexts + const filteredContexts = useMemo(() => { + // If we have search results, use those instead + if (searchResults) { + return searchResults.contexts; + } + + let result = contexts; + + // Filter by selected tag + if (selectedTag) { + result = result.filter(c => c.client_tag === selectedTag); + } + + // Sort by tag if enabled, otherwise sort by most recent activity + if (sortByTag) { + result = [...result].sort((a, b) => { + const tagA = a.client_tag || ''; + const tagB = b.client_tag || ''; + if (tagA !== tagB) { + return tagA.localeCompare(tagB); + } + // Secondary sort by context_id descending + return Number(b.context_id) - Number(a.context_id); + }); + } else { + // Default: sort by most recent activity (descending) + result = [...result].sort((a, b) => { + const timeA = a.last_activity_at ?? 0; + const timeB = b.last_activity_at ?? 0; + if (timeA !== timeB) { + return timeB - timeA; // Most recent first + } + // Secondary sort by context_id descending for stable ordering + return Number(b.context_id) - Number(a.context_id); + }); + } + + return result; + }, [contexts, selectedTag, sortByTag, searchResults]); + + // Reset focused index when filtered contexts change + useEffect(() => { + setFocusedContextIndex(prev => + Math.min(prev, Math.max(0, filteredContexts.length - 1)) + ); + }, [filteredContexts.length]); + + const handleSelectContext = useCallback((contextId: string) => { + setSelectedContextId(contextId); + setSelectedTurnId(null); + setDebuggerOpen(true); + navigateToContext(contextId); + }, [navigateToContext]); + + const handleRemoveContext = useCallback((contextId: string) => { + setContexts(prev => prev.filter(c => c.context_id !== contextId)); + if (selectedContextId === contextId) { + setSelectedContextId(null); + setSelectedTurnId(null); + setDebuggerOpen(false); + navigateHome(); + } + }, [selectedContextId, navigateHome]); + + // Debounced live search - triggers automatically as user types + const searchAbortRef = useRef(null); + + useEffect(() => { + const query = searchQuery.trim(); + + // Clear results if query is empty + if (!query) { + setSearchResults(null); + setSearchError(null); + setIsSearching(false); + return; + } + + // Debounce: wait 300ms after user stops typing + const debounceTimer = setTimeout(async () => { + // Cancel any in-flight request + if (searchAbortRef.current) { + searchAbortRef.current.abort(); + } + searchAbortRef.current = new AbortController(); + + // Try to validate as CQL first + const validation = validateCql(query); + let effectiveQuery = query; + + // If not valid CQL, treat as keyword search across all fields + if (!validation.ok) { + effectiveQuery = buildFallbackQuery(query); + } + + setIsSearching(true); + setSearchError(null); + + try { + const results = await searchContexts(effectiveQuery, 100); + // Only update if this request wasn't aborted + if (!searchAbortRef.current?.signal.aborted) { + setSearchResults({ + contexts: results.contexts, + total: results.total_count, + }); + } + } catch (err) { + // Ignore abort errors + if (err instanceof Error && err.name === 'AbortError') return; + if (!searchAbortRef.current?.signal.aborted) { + setSearchError(err instanceof Error ? err.message : 'Search failed'); + setSearchResults(null); + } + } finally { + if (!searchAbortRef.current?.signal.aborted) { + setIsSearching(false); + } + } + }, 300); + + // Cleanup: cancel debounce timer on new input + return () => { + clearTimeout(debounceTimer); + }; + }, [searchQuery]); + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + // Clear search + setSearchQuery(''); + setSearchResults(null); + setSearchError(null); + } + }; + + const clearSearch = useCallback(() => { + setSearchQuery(''); + setSearchResults(null); + setSearchError(null); + setSelectedEnv('all'); + }, []); + + // Handle env pill selection + const handleEnvSelect = useCallback((env: 'all' | 'prod' | 'stage' | 'dev') => { + setSelectedEnv(env); + if (env === 'all') { + setSearchQuery(''); + setSearchResults(null); + setSearchError(null); + } else { + setSearchQuery(`label = "env=${env}"`); + } + }, []); + + // Keyboard shortcuts + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + if (e.metaKey || e.ctrlKey || e.altKey) return; + + // Only handle j/k/o when debugger is closed and viewing contexts (not activity) + if (!debuggerOpen && !showActivityFeed) { + if (e.key === 'j' || e.key === 'ArrowDown') { + e.preventDefault(); + setFocusedContextIndex(prev => + Math.min(prev + 1, filteredContexts.length - 1) + ); + return; + } + if (e.key === 'k' || e.key === 'ArrowUp') { + e.preventDefault(); + setFocusedContextIndex(prev => Math.max(prev - 1, 0)); + return; + } + if (e.key === 'o' || e.key === 'Enter') { + e.preventDefault(); + const context = filteredContexts[focusedContextIndex]; + if (context) { + handleSelectContext(context.context_id); + } + return; + } + } + + // Activity feed toggle (works anytime) + if (e.key === 'a') { + setShowActivityFeed(prev => !prev); + } + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [debuggerOpen, showActivityFeed, filteredContexts, focusedContextIndex, handleSelectContext]); + + return ( +
+ {/* Header */} +
+ {/* Left: Logo + Title + Env Pills */} +
+
+ +
+
+

CXDB

+

AI Context Store

+
+ + {/* Environment Filter Pills - vertically centered with logo */} +
+ {(['all', 'prod', 'stage', 'dev'] as const).map((env) => ( + + ))} +
+
+ + {/* Right: Controls */} +
+ {/* Theme selector */} + + + {/* Mock mode toggle */} + + + {/* Demo button (mock mode only) */} + {mockMode && ( + + )} + + {/* Connection status */} + + + {/* Server status indicator */} +
+ + {serverStatus === 'online' ? 'Server online' : + serverStatus === 'offline' ? 'Server offline' : + 'Checking...'} +
+
+
+ + {/* Main content */} +
+ {/* Sidebar */} + + + {/* Main area */} +
+ {!debuggerOpen && ( + + )} +
+
+ + {/* Keyboard hints footer */} +
+ j/k Navigate + | + o Open context + | + a Activity feed +
+ + {/* Context Debugger Modal */} + {selectedContextId && ( + { + setDebuggerOpen(false); + navigateHome(); + }} + lastEvent={lastEvent} + initialTurnId={selectedTurnId} + onTurnChange={handleTurnChange} + onNavigateToContext={handleSelectContext} + /> + )} +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 1878f4e..c4f021a 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -21,6 +21,43 @@ export class ApiError extends Error { } } +function normalizeContextEntry(context: ContextEntry): ContextEntry { + return { + ...context, + context_id: String(context.context_id), + head_turn_id: context.head_turn_id !== undefined ? String(context.head_turn_id) : undefined, + lineage: context.lineage ? { + ...context.lineage, + parent_context_id: context.lineage.parent_context_id !== undefined + ? String(context.lineage.parent_context_id) + : undefined, + root_context_id: context.lineage.root_context_id !== undefined + ? String(context.lineage.root_context_id) + : undefined, + child_context_ids: context.lineage.child_context_ids.map(String), + } : context.lineage, + }; +} + +function normalizeTurnResponse(response: TurnResponse): TurnResponse { + return { + ...response, + meta: { + ...response.meta, + context_id: String(response.meta.context_id), + head_turn_id: String(response.meta.head_turn_id), + }, + next_before_turn_id: response.next_before_turn_id !== undefined + ? String(response.next_before_turn_id) + : undefined, + turns: response.turns.map(turn => ({ + ...turn, + turn_id: String(turn.turn_id), + parent_turn_id: String(turn.parent_turn_id), + })), + }; +} + /** * Fetch turns for a context from the HTTP gateway. */ @@ -77,7 +114,7 @@ export async function fetchTurns( ); } - return response.json(); + return normalizeTurnResponse(await response.json()); } /** @@ -155,7 +192,11 @@ export async function fetchContexts(limitOrOptions: number | FetchContextsOption ); } - return response.json(); + const payload = await response.json() as ContextsResponse; + return { + ...payload, + contexts: payload.contexts.map(normalizeContextEntry), + }; } export interface FetchContextOptions { @@ -196,7 +237,7 @@ export async function fetchContext( ); } - return response.json(); + return normalizeContextEntry(await response.json()); } export interface FetchContextChildrenOptions { @@ -252,7 +293,12 @@ export async function fetchContextChildren( ); } - return response.json(); + const payload = await response.json() as ContextChildrenResponse; + return { + ...payload, + context_id: String(payload.context_id), + children: payload.children.map(normalizeContextEntry), + }; } /** @@ -305,7 +351,11 @@ export async function searchContexts( ); } - return response.json(); + const payload = await response.json() as SearchResponse; + return { + ...payload, + contexts: payload.contexts.map(normalizeContextEntry), + }; } /** diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 25c3981..8967fc4 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,7 +1,14 @@ +const isProductionBuild = process.env.NODE_ENV === 'production'; + /** @type {import('next').NextConfig} */ const nextConfig = { - // Static export for production (served by nginx) - output: 'export', + // Static export in production builds, but keep dev mode dynamic so route + // changes and rewrites behave like a normal app server. + output: isProductionBuild ? 'export' : undefined, + // Keep production builds from clobbering the live dev server's `.next` + // artifacts. Running `next build` while `next dev` is active otherwise leaves + // the dev server pointing at stale asset aliases that 404. + distDir: isProductionBuild ? '.next-build' : '.next', // Disable image optimization for static export images: { @@ -11,24 +18,31 @@ const nextConfig = { // Trailing slashes for static file serving trailingSlash: false, - // Proxy API requests to the Rust service during development - // Note: rewrites don't work with static export, but we keep them for dev mode - async rewrites() { - // Skip rewrites in production/export mode - if (process.env.NODE_ENV === 'production') { - return []; - } - return [ - { - source: '/v1/:path*', - destination: 'http://127.0.0.1:9010/v1/:path*', - }, - { - source: '/healthz', - destination: 'http://127.0.0.1:9010/healthz', - }, - ]; - }, + ...(!isProductionBuild ? { + // Dev-only rewrites keep deep links loadable without defining export-hostile + // dynamic app routes. The browser URL stays intact and the client router + // still parses the pathname after hydration. + async rewrites() { + return [ + { + source: '/c/:contextId/t/:turnId', + destination: '/', + }, + { + source: '/c/:contextId', + destination: '/', + }, + { + source: '/v1/:path*', + destination: 'http://127.0.0.1:9010/v1/:path*', + }, + { + source: '/healthz', + destination: 'http://127.0.0.1:9010/healthz', + }, + ]; + }, + } : {}), }; export default nextConfig; From 18a58c59e70bfc9652c148ddcf4e05f7e52b7aa0 Mon Sep 17 00:00:00 2001 From: Jay Taylor Date: Wed, 18 Mar 2026 15:16:21 -0700 Subject: [PATCH 02/10] feat(frontend): filter contexts from tag chips Make the context list's rendered tag badges clickable so selecting a tag chip drives the existing sidebar tag filter instead of only opening the context row. Apply the selected tag filter to both the full context list and active search results, and cover the behavior with a Playwright regression test for mixed-tag contexts. --- frontend/components/ContextList.tsx | 38 ++++++++++++++++- frontend/components/CxdbApp.tsx | 14 ++++--- frontend/tests/tag-filter.spec.ts | 63 +++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 frontend/tests/tag-filter.spec.ts diff --git a/frontend/components/ContextList.tsx b/frontend/components/ContextList.tsx index bb9c39c..e1619c4 100644 --- a/frontend/components/ContextList.tsx +++ b/frontend/components/ContextList.tsx @@ -68,6 +68,7 @@ interface ContextListProps { selectedId?: string; focusedIndex?: number; onSelect: (contextId: string) => void; + onTagClick?: (tag: string) => void; lastEvent?: StoreEvent | null; } @@ -86,6 +87,7 @@ const ContextListItem = memo(function ContextListItem({ isNew, isUpdated, itemRef, + onTagClick, }: { context: ContextEntry; isSelected: boolean; @@ -94,6 +96,7 @@ const ContextListItem = memo(function ContextListItem({ isNew?: boolean; isUpdated?: boolean; itemRef?: React.RefObject; + onTagClick?: (tag: string) => void; }) { const presenceState: PresenceState = useMemo(() => { if (context.is_live) { @@ -116,6 +119,13 @@ const ContextListItem = memo(function ContextListItem({ const sourceStyle = provenance?.on_behalf_of_source ? getSourceStyle(provenance.on_behalf_of_source) : null; const badgeLabel = getContextBadgeLabel(context); const titleLabel = getContextTitleLabel(context); + const showDedicatedTagChip = !!context.client_tag && badgeLabel !== context.client_tag; + const handleTagClick = useCallback((event: React.MouseEvent) => { + if (!context.client_tag) return; + event.preventDefault(); + event.stopPropagation(); + onTagClick?.(context.client_tag); + }, [context.client_tag, onTagClick]); return (