diff --git a/Dockerfile b/Dockerfile index 8829eb3..c6bda41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,9 +70,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Create directories RUN mkdir -p /app /data /var/log/supervisor -# Copy binaries and static files +# Copy binaries and static files. The frontend keeps production exports in a +# separate dist dir so `next build` doesn't clobber a live dev server's `.next` +# artifacts. COPY --from=backend /app/target/release/cxdb-server /app/cxdb -COPY --from=frontend /app/out /usr/share/nginx/html +COPY --from=frontend /app/.next-build/. /usr/share/nginx/html/ # Copy nginx config COPY deploy/nginx.conf /etc/nginx/nginx.conf diff --git a/frontend/app/globals.css b/frontend/app/globals.css index ff90fed..f032506 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -79,6 +79,8 @@ --theme-tag-dotrunner-bg: rgba(37, 99, 235, 0.2); --theme-tag-claude-code: #c084fc; --theme-tag-claude-code-bg: rgba(147, 51, 234, 0.2); + --theme-tag-codex: #22c55e; + --theme-tag-codex-bg: rgba(34, 197, 94, 0.2); --theme-tag-gen: #34d399; --theme-tag-gen-bg: rgba(16, 185, 129, 0.2); --theme-tag-test: #fbbf24; 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/ContextList.tsx b/frontend/components/ContextList.tsx index bb9c39c..8d97d4f 100644 --- a/frontend/components/ContextList.tsx +++ b/frontend/components/ContextList.tsx @@ -2,26 +2,13 @@ import { memo, useMemo, useState, useEffect, useCallback, useRef } from 'react'; import type { ContextEntry, StoreEvent } from '@/types'; +import { getTagColor } from '@/lib/clientTags'; import { cn, formatTimestamp } from '@/lib/utils'; import { Database, GitBranch, GitFork, ChevronRight, Folder, User, Tag } from './icons'; import { PresenceIndicator, LiveTimestamp } from './live'; import type { PresenceState } from './live'; import { getSourceStyle } from '@/types/provenance'; -// Tag color mapping - uses 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; -} - function getContextBadgeLabel(context: ContextEntry): string | null { const provenance = context.provenance; const username = @@ -68,6 +55,8 @@ interface ContextListProps { selectedId?: string; focusedIndex?: number; onSelect: (contextId: string) => void; + onTagClick?: (tag: string) => void; + onLabelClick?: (label: string) => void; lastEvent?: StoreEvent | null; } @@ -86,6 +75,8 @@ const ContextListItem = memo(function ContextListItem({ isNew, isUpdated, itemRef, + onTagClick, + onLabelClick, }: { context: ContextEntry; isSelected: boolean; @@ -94,6 +85,8 @@ const ContextListItem = memo(function ContextListItem({ isNew?: boolean; isUpdated?: boolean; itemRef?: React.RefObject; + onTagClick?: (tag: string) => void; + onLabelClick?: (label: string) => void; }) { const presenceState: PresenceState = useMemo(() => { if (context.is_live) { @@ -116,6 +109,20 @@ 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]); + const handleLabelClick = useCallback((event: React.MouseEvent) => { + const label = event.currentTarget.dataset.contextLabelFilter; + if (!label) return; + event.preventDefault(); + event.stopPropagation(); + onLabelClick?.(label); + }, [onLabelClick]); return ( + ))} + + + + {/* 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/hooks/useEventStream.ts b/frontend/hooks/useEventStream.ts index fb084cf..f464666 100644 --- a/frontend/hooks/useEventStream.ts +++ b/frontend/hooks/useEventStream.ts @@ -13,6 +13,7 @@ import type { ClientDisconnectedEvent, ErrorOccurredEvent, } from '@/types'; +import { MOCK_CLIENT_TAGS } from '@/lib/clientTags'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE || '/v1'; @@ -242,7 +243,7 @@ export function useMockEventGenerator(mockEmit?: (event: StoreEvent) => void) { let contextCounter = 100; let turnCounter = 1000; - const clientTags = ['claude-code', 'dotrunner', 'test-harness', 'aider']; + const clientTags = [...MOCK_CLIENT_TAGS]; const activeContexts: string[] = []; intervalRef.current = setInterval(() => { 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/lib/clientTags.ts b/frontend/lib/clientTags.ts new file mode 100644 index 0000000..20391f1 --- /dev/null +++ b/frontend/lib/clientTags.ts @@ -0,0 +1,44 @@ +export const DEFAULT_TAG_COLOR = { + bg: 'bg-theme-tag-default-bg', + text: 'text-theme-tag-default', + border: 'border-theme-tag-default/30', +}; + +const TAG_COLORS: Record = { + dotrunner: { + bg: 'bg-theme-tag-dotrunner-bg', + text: 'text-theme-tag-dotrunner', + border: 'border-theme-tag-dotrunner/30', + }, + claude: { + bg: 'bg-theme-tag-claude-code-bg', + text: 'text-theme-tag-claude-code', + border: 'border-theme-tag-claude-code/30', + }, + 'claude-code': { + bg: 'bg-theme-tag-claude-code-bg', + text: 'text-theme-tag-claude-code', + border: 'border-theme-tag-claude-code/30', + }, + codex: { + bg: 'bg-theme-tag-codex-bg', + text: 'text-theme-tag-codex', + border: 'border-theme-tag-codex/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', + }, +}; + +export const MOCK_CLIENT_TAGS = ['claude', 'codex', 'dotrunner', 'test-harness', 'aider'] as const; + +export function getTagColor(tag: string) { + return TAG_COLORS[tag.toLowerCase()] || DEFAULT_TAG_COLOR; +} diff --git a/frontend/lib/cql/index.ts b/frontend/lib/cql/index.ts index 2cf525a..43a6eb5 100644 --- a/frontend/lib/cql/index.ts +++ b/frontend/lib/cql/index.ts @@ -63,6 +63,87 @@ export function formatError(error: import('./types').CqlError): string { return `${error.message} (line ${error.position.line}, column ${error.position.column})`; } +export interface SearchCriterionClause { + field: 'tag' | 'label'; + value: string; +} + +const TRAILING_CRITERION_PATTERN = /\s+AND\s+(tag|label)\s*=\s*"((?:\\"|[^"])*)"$/i; +const EXACT_CRITERION_PATTERN = /^(tag|label)\s*=\s*"((?:\\"|[^"])*)"$/i; + +/** + * Split the visible search text into its base query and any appended trailing search criteria. + */ +export function extractSearchCriteriaClauses(query: string): { + baseQuery: string; + criteria: SearchCriterionClause[]; +} { + const trimmedQuery = query.trim(); + const exactMatch = trimmedQuery.match(EXACT_CRITERION_PATTERN); + if (exactMatch) { + return { + baseQuery: '', + criteria: [{ + field: exactMatch[1].toLowerCase() as SearchCriterionClause['field'], + value: exactMatch[2].replace(/\\"/g, '"'), + }], + }; + } + + const criteria: SearchCriterionClause[] = []; + let baseQuery = trimmedQuery; + + while (true) { + const trailingMatch = baseQuery.match(TRAILING_CRITERION_PATTERN); + if (!trailingMatch || trailingMatch.index === undefined) { + break; + } + criteria.unshift({ + field: trailingMatch[1].toLowerCase() as SearchCriterionClause['field'], + value: trailingMatch[2].replace(/\\"/g, '"'), + }); + baseQuery = baseQuery.slice(0, trailingMatch.index).trim(); + } + + return { + baseQuery, + criteria, + }; +} + +/** + * Append a clickable search facet to the visible query. + * + * `tag` criteria replace any prior appended `tag` criterion because contexts only + * have one client tag. `label` criteria accumulate so users can narrow by multiple + * labels from the sidebar. + */ +export function appendSearchCriterionClause( + query: string, + criterion: SearchCriterionClause +): string { + const normalizedValue = criterion.value.trim(); + const escapedValue = normalizedValue.replace(/"/g, '\\"'); + const { baseQuery, criteria } = extractSearchCriteriaClauses(query); + + if (!normalizedValue) { + return baseQuery; + } + + const nextCriteria = + criterion.field === 'tag' + ? [...criteria.filter(existing => existing.field !== 'tag'), { field: 'tag', value: normalizedValue }] + : criteria.some(existing => existing.field === 'label' && existing.value === normalizedValue) + ? criteria + : [...criteria, { field: 'label', value: normalizedValue }]; + + const renderedCriteria = nextCriteria.map( + existing => `${existing.field} = "${existing.value.replace(/"/g, '\\"')}"` + ); + + return [baseQuery, ...renderedCriteria].filter(Boolean).join(' AND '); +} + /** * Build a fallback query that searches across all text fields. * Used when input doesn't parse as valid CQL - treats it as a keyword search. diff --git a/frontend/lib/themes/context.tsx b/frontend/lib/themes/context.tsx index 82f785b..b9ef2b0 100644 --- a/frontend/lib/themes/context.tsx +++ b/frontend/lib/themes/context.tsx @@ -86,6 +86,8 @@ function applyThemeToDocument(colors: ThemeColors): void { root.style.setProperty('--theme-tag-dotrunner-bg', colors.tagDotrunnerBg); root.style.setProperty('--theme-tag-claude-code', colors.tagClaudeCode); root.style.setProperty('--theme-tag-claude-code-bg', colors.tagClaudeCodeBg); + root.style.setProperty('--theme-tag-codex', colors.tagCodex); + root.style.setProperty('--theme-tag-codex-bg', colors.tagCodexBg); root.style.setProperty('--theme-tag-gen', colors.tagGen); root.style.setProperty('--theme-tag-gen-bg', colors.tagGenBg); root.style.setProperty('--theme-tag-test', colors.tagTest); diff --git a/frontend/lib/themes/definitions.ts b/frontend/lib/themes/definitions.ts index 9eb867a..60fb4a6 100644 --- a/frontend/lib/themes/definitions.ts +++ b/frontend/lib/themes/definitions.ts @@ -60,6 +60,8 @@ const originalColors: ThemeColors = { tagDotrunnerBg: 'rgba(37, 99, 235, 0.2)', tagClaudeCode: '#c084fc', tagClaudeCodeBg: 'rgba(147, 51, 234, 0.2)', + tagCodex: '#22c55e', + tagCodexBg: 'rgba(34, 197, 94, 0.2)', tagGen: '#34d399', tagGenBg: 'rgba(16, 185, 129, 0.2)', tagTest: '#fbbf24', @@ -150,6 +152,8 @@ const emberColors: ThemeColors = { tagDotrunnerBg: 'rgba(56, 189, 248, 0.2)', tagClaudeCode: '#f59e0b', tagClaudeCodeBg: 'rgba(245, 158, 11, 0.2)', + tagCodex: '#fb7185', + tagCodexBg: 'rgba(251, 113, 133, 0.2)', tagGen: '#34d399', tagGenBg: 'rgba(52, 211, 153, 0.2)', tagTest: '#fbbf24', @@ -240,6 +244,8 @@ const terminalColors: ThemeColors = { tagDotrunnerBg: 'rgba(56, 189, 248, 0.2)', tagClaudeCode: '#06b6d4', tagClaudeCodeBg: 'rgba(6, 182, 212, 0.2)', + tagCodex: '#4ade80', + tagCodexBg: 'rgba(74, 222, 128, 0.2)', tagGen: '#2dd4bf', tagGenBg: 'rgba(45, 212, 191, 0.2)', tagTest: '#fbbf24', @@ -330,6 +336,8 @@ const forestColors: ThemeColors = { tagDotrunnerBg: 'rgba(56, 189, 248, 0.2)', tagClaudeCode: '#10b981', tagClaudeCodeBg: 'rgba(16, 185, 129, 0.2)', + tagCodex: '#f59e0b', + tagCodexBg: 'rgba(245, 158, 11, 0.2)', tagGen: '#2dd4bf', tagGenBg: 'rgba(45, 212, 191, 0.2)', tagTest: '#fbbf24', @@ -420,6 +428,8 @@ const coralColors: ThemeColors = { tagDotrunnerBg: 'rgba(56, 189, 248, 0.2)', tagClaudeCode: '#f43f5e', tagClaudeCodeBg: 'rgba(244, 63, 94, 0.2)', + tagCodex: '#fb923c', + tagCodexBg: 'rgba(251, 146, 60, 0.2)', tagGen: '#34d399', tagGenBg: 'rgba(52, 211, 153, 0.2)', tagTest: '#fbbf24', @@ -510,6 +520,8 @@ const obsidianColors: ThemeColors = { tagDotrunnerBg: 'rgba(96, 165, 250, 0.2)', tagClaudeCode: '#fafafa', tagClaudeCodeBg: 'rgba(250, 250, 250, 0.15)', + tagCodex: '#4ade80', + tagCodexBg: 'rgba(74, 222, 128, 0.15)', tagGen: '#a1a1aa', tagGenBg: 'rgba(161, 161, 170, 0.15)', tagTest: '#71717a', @@ -600,6 +612,8 @@ const sdmBrandColors: ThemeColors = { tagDotrunnerBg: 'rgba(112, 150, 177, 0.2)', tagClaudeCode: '#22a6b8', // teal tagClaudeCodeBg: 'rgba(34, 166, 184, 0.2)', + tagCodex: '#9ad04b', // green-400 + tagCodexBg: 'rgba(154, 208, 75, 0.2)', tagGen: '#28a745', // green-600 tagGenBg: 'rgba(40, 167, 69, 0.2)', tagTest: '#d4a017', // yellow-600 diff --git a/frontend/lib/themes/types.ts b/frontend/lib/themes/types.ts index 923332a..909fd25 100644 --- a/frontend/lib/themes/types.ts +++ b/frontend/lib/themes/types.ts @@ -58,6 +58,8 @@ export interface ThemeColors { tagDotrunnerBg: string; tagClaudeCode: string; tagClaudeCodeBg: string; + tagCodex: string; + tagCodexBg: string; tagGen: string; tagGenBg: string; tagTest: string; diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index 25c3981..d42717c 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -1,34 +1,58 @@ +import { + PHASE_DEVELOPMENT_SERVER, + PHASE_PRODUCTION_BUILD, + PHASE_PRODUCTION_SERVER, +} from 'next/constants.js'; + /** @type {import('next').NextConfig} */ -const nextConfig = { - // Static export for production (served by nginx) - output: 'export', +export default function nextConfig(phase) { + const isProductionBuild = phase === PHASE_PRODUCTION_BUILD; + const usesProductionArtifacts = + phase === PHASE_PRODUCTION_BUILD || phase === PHASE_PRODUCTION_SERVER; - // Disable image optimization for static export - images: { - unoptimized: true, - }, + return { + // Static export in production builds, but keep the dev server 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: usesProductionArtifacts ? '.next-build' : '.next', - // Trailing slashes for static file serving - trailingSlash: false, + // Disable image optimization for static export + images: { + unoptimized: true, + }, - // 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', - }, - ]; - }, -}; + // Trailing slashes for static file serving + trailingSlash: false, -export default nextConfig; + ...(phase === PHASE_DEVELOPMENT_SERVER + ? { + // 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', + }, + ]; + }, + } + : {}), + }; +} diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 2160b46..9647bfe 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -73,6 +73,8 @@ const config: Config = { 'tag-dotrunner-bg': 'var(--theme-tag-dotrunner-bg)', 'tag-claude-code': 'var(--theme-tag-claude-code)', 'tag-claude-code-bg': 'var(--theme-tag-claude-code-bg)', + 'tag-codex': 'var(--theme-tag-codex)', + 'tag-codex-bg': 'var(--theme-tag-codex-bg)', 'tag-gen': 'var(--theme-tag-gen)', 'tag-gen-bg': 'var(--theme-tag-gen-bg)', 'tag-test': 'var(--theme-tag-test)', diff --git a/frontend/tests/tag-filter.spec.ts b/frontend/tests/tag-filter.spec.ts new file mode 100644 index 0000000..cf1c050 --- /dev/null +++ b/frontend/tests/tag-filter.spec.ts @@ -0,0 +1,133 @@ +// Copyright 2025 StrongDM Inc +// SPDX-License-Identifier: Apache-2.0 + +import { test, expect } from './fixtures'; + +const PROVIDER_CASES = [ + { tag: 'claude', titlePrefix: 'Claude' }, + { tag: 'codex', titlePrefix: 'Codex' }, +] as const; + +async function createTaggedContext( + serverHttpUrl: string, + tag: string, + title: string, + labels: string[] = [] +): Promise { + const createResponse = await fetch(`${serverHttpUrl}/v1/contexts`, { + method: 'POST', + }); + expect(createResponse.ok).toBeTruthy(); + + const createPayload = await createResponse.json() as { context_id: number }; + const contextId = String(createPayload.context_id); + + const appendResponse = await fetch(`${serverHttpUrl}/v1/contexts/${contextId}/turns`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type_id: 'test.context-metadata', + type_version: 1, + data: { + '30': { + '1': tag, + '2': title, + '3': labels, + }, + }, + }), + }); + expect(appendResponse.ok).toBeTruthy(); + + return contextId; +} + +test.describe('Tag Filter', () => { + for (const { tag, titlePrefix } of PROVIDER_CASES) { + test(`clicking a ${tag} context tag chip appends a CQL tag clause and filters the context list`, async ({ + apiPage, + serverHttpUrl, + }) => { + const providerOneId = await createTaggedContext(serverHttpUrl, tag, `${titlePrefix} One`); + const dotrunnerId = await createTaggedContext(serverHttpUrl, 'dotrunner', 'Dotrunner One'); + const providerTwoId = await createTaggedContext(serverHttpUrl, tag, `${titlePrefix} Two`); + + await apiPage.goto('/'); + + await expect(apiPage.locator(`[data-context-id="${providerOneId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${dotrunnerId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${providerTwoId}"]`)).toBeVisible(); + + const searchInput = apiPage.locator('input[placeholder*="Search"]'); + await apiPage.locator(`[data-context-tag-filter="${tag}"]`).first().click(); + + await expect(searchInput).toHaveValue(`tag = "${tag}"`); + await expect(apiPage.getByRole('button', { name: 'All tags' })).toBeVisible(); + await expect(apiPage.locator('[data-context-id]')).toHaveCount(2); + await expect(apiPage.locator(`[data-context-id="${providerOneId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${providerTwoId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${dotrunnerId}"]`)).toHaveCount(0); + }); + + test(`clicking a ${tag} context tag chip appends the tag clause to an existing search query`, async ({ + apiPage, + serverHttpUrl, + }) => { + const providerId = await createTaggedContext(serverHttpUrl, tag, `${titlePrefix} Search Compose`); + const dotrunnerId = await createTaggedContext(serverHttpUrl, 'dotrunner', 'Dotrunner Search Compose'); + + await apiPage.goto('/'); + + const searchInput = apiPage.locator('input[placeholder*="Search"]'); + await searchInput.fill(`title ^~= "${titlePrefix}"`); + await apiPage.locator(`[data-context-tag-filter="${tag}"]`).first().click(); + + await expect(searchInput).toHaveValue(`title ^~= "${titlePrefix}" AND tag = "${tag}"`); + await expect(apiPage.locator(`[data-context-id="${providerId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${dotrunnerId}"]`)).toHaveCount(0); + }); + + test(`clicking a label chip appends a CQL label clause and can compose with ${tag} tag clicks`, async ({ + apiPage, + serverHttpUrl, + }) => { + const matchingId = await createTaggedContext( + serverHttpUrl, + tag, + `${titlePrefix} Interactive`, + ['interactive', 'shared'] + ); + const wrongLabelId = await createTaggedContext( + serverHttpUrl, + tag, + `${titlePrefix} Batch`, + ['batch'] + ); + const wrongTagId = await createTaggedContext( + serverHttpUrl, + 'dotrunner', + 'Dotrunner Interactive', + ['interactive'] + ); + + await apiPage.goto('/'); + + const searchInput = apiPage.locator('input[placeholder*="Search"]'); + await apiPage.locator('[data-context-label-filter="interactive"]').first().click(); + + await expect(searchInput).toHaveValue('label = "interactive"'); + await expect(apiPage.locator(`[data-context-id="${matchingId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${wrongTagId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${wrongLabelId}"]`)).toHaveCount(0); + + await apiPage.locator(`[data-context-tag-filter="${tag}"]`).first().click(); + + await expect(searchInput).toHaveValue(`label = "interactive" AND tag = "${tag}"`); + await expect(apiPage.locator(`[data-context-id="${matchingId}"]`)).toBeVisible(); + await expect(apiPage.locator(`[data-context-id="${wrongLabelId}"]`)).toHaveCount(0); + await expect(apiPage.locator(`[data-context-id="${wrongTagId}"]`)).toHaveCount(0); + }); + } +}); diff --git a/gateway/Dockerfile b/gateway/Dockerfile index d91a52a..e3e1071 100644 --- a/gateway/Dockerfile +++ b/gateway/Dockerfile @@ -37,7 +37,7 @@ RUN go mod download COPY gateway/ ./ # Copy frontend build output for embedding -COPY --from=frontend /app/out ./pkg/proxy/web/ +COPY --from=frontend /app/.next-build/. ./pkg/proxy/web/ # Build with CGO enabled for SQLite RUN --mount=type=cache,target=/root/.cache/go-build \