diff --git a/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx b/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx index e332376d..bb333ccb 100644 --- a/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx +++ b/ccw/frontend/src/components/a2ui/A2UIPopupCard.tsx @@ -3,9 +3,9 @@ // ======================================== // Centered popup dialog for A2UI surfaces with minimalist design // Used for displayMode: 'popup' surfaces (e.g., ask_question) -// Supports markdown content parsing +// Supports markdown content parsing and multi-page navigation -import { useCallback, useMemo } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useIntl } from 'react-intl'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -30,7 +30,14 @@ interface A2UIPopupCardProps { onClose: () => void; } -type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'unknown'; +type QuestionType = 'confirm' | 'select' | 'multi-select' | 'input' | 'multi-question' | 'unknown'; + +interface PageMeta { + index: number; + questionId: string; + title: string; + type: string; +} // ========== Helpers ========== @@ -73,6 +80,37 @@ function isActionButton(component: SurfaceComponent): boolean { return 'Button' in comp; } +// ========== "Other" Text Input Component ========== + +interface OtherInputProps { + visible: boolean; + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +function OtherInput({ visible, value, onChange, placeholder }: OtherInputProps) { + if (!visible) return null; + + return ( +
+ onChange(e.target.value)} + placeholder={placeholder || 'Enter your answer...'} + className={cn( + 'w-full px-3 py-2 text-sm rounded-md border border-border', + 'bg-background text-foreground placeholder:text-muted-foreground', + 'focus:outline-none focus:ring-2 focus:ring-ring focus:border-transparent', + 'transition-colors' + )} + autoFocus + /> +
+ ); +} + // ========== Markdown Component ========== interface MarkdownContentProps { @@ -114,15 +152,21 @@ function MarkdownContent({ content, className }: MarkdownContentProps) { ); } -// ========== Component ========== +// ========== Single-Page Popup (Legacy) ========== -export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { +function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) { const { formatMessage } = useIntl(); const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); // Detect question type const questionType = useMemo(() => detectQuestionType(surface), [surface]); + // "Other" option state + const [otherSelected, setOtherSelected] = useState(false); + const [otherText, setOtherText] = useState(''); + + const questionId = (surface.initialState as any)?.questionId as string | undefined; + // Extract title, message, and description from surface components const titleComponent = surface.components.find( (c) => c.id === 'title' && 'Text' in (c.component as any) @@ -171,9 +215,33 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { [surface, actionButtons] ); + // Handle "Other" text change + const handleOtherTextChange = useCallback( + (value: string) => { + setOtherText(value); + if (questionId) { + sendA2UIAction('input-change', surface.surfaceId, { + questionId: `__other__:${questionId}`, + value, + }); + } + }, + [sendA2UIAction, surface.surfaceId, questionId] + ); + // Handle A2UI actions const handleAction = useCallback( (actionId: string, params?: Record) => { + // Track "Other" selection state + if (actionId === 'select' && params?.value === '__other__') { + setOtherSelected(true); + } else if (actionId === 'select' && params?.value !== '__other__') { + setOtherSelected(false); + } + if (actionId === 'toggle' && params?.value === '__other__') { + setOtherSelected((prev) => !prev); + } + // Send action to backend via WebSocket sendA2UIAction(actionId, surface.surfaceId, params); @@ -211,6 +279,9 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { } }, [questionType]); + // Check if this question type supports "Other" input + const hasOtherOption = questionType === 'select' || questionType === 'multi-select'; + return ( )} + {/* "Other" text input — shown when Other is selected */} + {hasOtherOption && ( + + )} )} @@ -291,4 +370,308 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { ); } +// ========== Multi-Page Popup ========== + +function MultiPagePopup({ surface, onClose }: A2UIPopupCardProps) { + const { formatMessage } = useIntl(); + const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); + + const state = surface.initialState as Record; + const pages = state.pages as PageMeta[]; + const totalPages = state.totalPages as number; + const compositeId = state.questionId as string; + + const [currentPage, setCurrentPage] = useState(0); + + // "Other" per-page state + const [otherSelectedPages, setOtherSelectedPages] = useState>(new Set()); + const [otherTexts, setOtherTexts] = useState>(new Map()); + + // Group components by page + const pageComponentGroups = useMemo(() => { + const groups: SurfaceComponent[][] = []; + for (let i = 0; i < totalPages; i++) { + groups.push( + surface.components.filter((c) => (c as any).page === i) + ); + } + return groups; + }, [surface.components, totalPages]); + + // Extract current page title and body components + const currentPageData = useMemo(() => { + const comps = pageComponentGroups[currentPage] || []; + const titleComp = comps.find((c) => c.id.endsWith('-title')); + const messageComp = comps.find((c) => c.id.endsWith('-message')); + const descComp = comps.find((c) => c.id.endsWith('-description')); + const bodyComps = comps.filter( + (c) => !c.id.endsWith('-title') && !c.id.endsWith('-message') && !c.id.endsWith('-description') + ); + + return { + title: getTextContent(titleComp), + message: getTextContent(messageComp), + description: getTextContent(descComp), + bodyComponents: bodyComps, + pageMeta: pages[currentPage], + }; + }, [pageComponentGroups, currentPage, pages]); + + // Handle "Other" text change for a specific page + const handleOtherTextChange = useCallback( + (pageIdx: number, value: string) => { + setOtherTexts((prev) => { + const next = new Map(prev); + next.set(pageIdx, value); + return next; + }); + // Send input-change to backend with __other__:{questionId} + const qId = pages[pageIdx]?.questionId; + if (qId) { + sendA2UIAction('input-change', surface.surfaceId, { + questionId: `__other__:${qId}`, + value, + }); + } + }, + [sendA2UIAction, surface.surfaceId, pages] + ); + + // Handle A2UI actions (pass through to backend without closing dialog) + const handleAction = useCallback( + (actionId: string, params?: Record) => { + // Track "Other" selection state per page + if (actionId === 'select' && params?.value === '__other__') { + setOtherSelectedPages((prev) => new Set(prev).add(currentPage)); + } else if (actionId === 'select' && params?.value !== '__other__') { + setOtherSelectedPages((prev) => { + const next = new Set(prev); + next.delete(currentPage); + return next; + }); + } + if (actionId === 'toggle' && params?.value === '__other__') { + setOtherSelectedPages((prev) => { + const next = new Set(prev); + if (next.has(currentPage)) { + next.delete(currentPage); + } else { + next.add(currentPage); + } + return next; + }); + } + + sendA2UIAction(actionId, surface.surfaceId, params); + }, + [sendA2UIAction, surface.surfaceId, currentPage] + ); + + // Handle Cancel + const handleCancel = useCallback(() => { + sendA2UIAction('cancel', surface.surfaceId, { questionId: compositeId }); + onClose(); + }, [sendA2UIAction, surface.surfaceId, compositeId, onClose]); + + // Handle Submit All + const handleSubmitAll = useCallback(() => { + sendA2UIAction('submit-all', surface.surfaceId, { + compositeId, + questionIds: pages.map((p) => p.questionId), + }); + onClose(); + }, [sendA2UIAction, surface.surfaceId, compositeId, pages, onClose]); + + // Handle dialog close + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + handleCancel(); + } + }, + [handleCancel] + ); + + // Navigation + const goNext = useCallback(() => { + setCurrentPage((p) => Math.min(p + 1, totalPages - 1)); + }, [totalPages]); + + const goPrev = useCallback(() => { + setCurrentPage((p) => Math.max(p - 1, 0)); + }, []); + + const isFirstPage = currentPage === 0; + const isLastPage = currentPage === totalPages - 1; + + return ( + + e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + > + {/* Header with current page title */} + + + {currentPageData.title || + formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' })} + + {currentPageData.message && ( +
+ +
+ )} + {currentPageData.description && ( +
+ +
+ )} +
+ + {/* Page content with slide animation */} +
+
+ {pageComponentGroups.map((pageComps, pageIdx) => { + const bodyComps = pageComps.filter( + (c) => + !c.id.endsWith('-title') && + !c.id.endsWith('-message') && + !c.id.endsWith('-description') + ); + const pageType = pages[pageIdx]?.type || 'unknown'; + const hasOther = pageType === 'select' || pageType === 'multi-select'; + const isOtherSelected = otherSelectedPages.has(pageIdx); + + return ( +
+ {bodyComps.length > 0 && ( +
+ {pageType === 'multi-select' ? ( + bodyComps.map((comp) => ( +
+ +
+ )) + ) : ( + + )} + {/* "Other" text input */} + {hasOther && ( + handleOtherTextChange(pageIdx, v)} + /> + )} +
+ )} +
+ ); + })} +
+
+ + {/* Dot indicator */} +
+ {pages.map((_, i) => ( +
+ + {/* Footer - Navigation buttons */} + +
+ {/* Left: Cancel */} + + + {/* Right: Prev / Next / Submit */} +
+ {!isFirstPage && ( + + )} + {isLastPage ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +} + +// ========== Main Component ========== + +export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) { + const state = surface.initialState as Record | undefined; + const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1; + + if (isMultiPage) { + return ; + } + + return ; +} + export default A2UIPopupCard; diff --git a/ccw/frontend/src/components/codexlens/ModelCard.tsx b/ccw/frontend/src/components/codexlens/ModelCard.tsx index f405f956..0be86f27 100644 --- a/ccw/frontend/src/components/codexlens/ModelCard.tsx +++ b/ccw/frontend/src/components/codexlens/ModelCard.tsx @@ -68,7 +68,7 @@ export function ModelCard({ }; return ( - + {/* Header */}
@@ -105,12 +105,15 @@ export function ModelCard({
- Backend: {model.backend} - Size: {formatSize(model.size)} + {model.dimensions && {model.dimensions}d} + {formatSize(model.size)} + {model.recommended && ( + Rec + )}
- {model.cache_path && ( -

- {model.cache_path} + {model.description && ( +

+ {model.description}

)}
diff --git a/ccw/frontend/src/components/codexlens/ModelsTab.tsx b/ccw/frontend/src/components/codexlens/ModelsTab.tsx index aa6a837f..4450c173 100644 --- a/ccw/frontend/src/components/codexlens/ModelsTab.tsx +++ b/ccw/frontend/src/components/codexlens/ModelsTab.tsx @@ -47,7 +47,7 @@ function filterModels(models: CodexLensModel[], filter: FilterType, search: stri filtered = filtered.filter(m => m.name.toLowerCase().includes(query) || m.profile.toLowerCase().includes(query) || - m.backend.toLowerCase().includes(query) + (m.description?.toLowerCase().includes(query) ?? false) ); } diff --git a/ccw/frontend/src/components/codexlens/SearchTab.tsx b/ccw/frontend/src/components/codexlens/SearchTab.tsx index 647696ac..10e7e5dc 100644 --- a/ccw/frontend/src/components/codexlens/SearchTab.tsx +++ b/ccw/frontend/src/components/codexlens/SearchTab.tsx @@ -2,10 +2,11 @@ // CodexLens Search Tab // ======================================== // Semantic code search interface with multiple search types +// Includes LSP availability check and hybrid search mode switching import { useState } from 'react'; import { useIntl } from 'react-intl'; -import { Search, FileCode, Code } from 'lucide-react'; +import { Search, FileCode, Code, Sparkles, CheckCircle, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Label } from '@/components/ui/Label'; @@ -20,11 +21,13 @@ import { useCodexLensSearch, useCodexLensFilesSearch, useCodexLensSymbolSearch, + useCodexLensLspStatus, + useCodexLensSemanticSearch, } from '@/hooks/useCodexLens'; -import type { CodexLensSearchParams } from '@/lib/api'; +import type { CodexLensSearchParams, CodexLensSemanticSearchMode, CodexLensFusionStrategy } from '@/lib/api'; import { cn } from '@/lib/utils'; -type SearchType = 'search' | 'search_files' | 'symbol'; +type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic'; type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy'; interface SearchTabProps { @@ -35,14 +38,19 @@ export function SearchTab({ enabled }: SearchTabProps) { const { formatMessage } = useIntl(); const [searchType, setSearchType] = useState('search'); const [searchMode, setSearchMode] = useState('dense_rerank'); + const [semanticMode, setSemanticMode] = useState('fusion'); + const [fusionStrategy, setFusionStrategy] = useState('rrf'); const [query, setQuery] = useState(''); const [hasSearched, setHasSearched] = useState(false); + // LSP status check + const lspStatus = useCodexLensLspStatus({ enabled }); + // Build search params based on search type const searchParams: CodexLensSearchParams = { query, limit: 20, - mode: searchType !== 'symbol' ? searchMode : undefined, + mode: searchType !== 'symbol' && searchType !== 'semantic' ? searchMode : undefined, max_content_length: 200, extra_files_count: 10, }; @@ -63,12 +71,25 @@ export function SearchTab({ enabled }: SearchTabProps) { { enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 } ); + const semanticSearch = useCodexLensSemanticSearch( + { + query, + mode: semanticMode, + fusion_strategy: semanticMode === 'fusion' ? fusionStrategy : undefined, + limit: 20, + include_match_reason: true, + }, + { enabled: enabled && hasSearched && searchType === 'semantic' && query.trim().length > 0 } + ); + // Get loading state based on search type const isLoading = searchType === 'search' ? contentSearch.isLoading : searchType === 'search_files' ? fileSearch.isLoading - : symbolSearch.isLoading; + : searchType === 'symbol' + ? symbolSearch.isLoading + : semanticSearch.isLoading; const handleSearch = () => { if (query.trim()) { @@ -84,17 +105,52 @@ export function SearchTab({ enabled }: SearchTabProps) { const handleSearchTypeChange = (value: SearchType) => { setSearchType(value); - setHasSearched(false); // Reset search state when changing type + setHasSearched(false); }; const handleSearchModeChange = (value: SearchMode) => { setSearchMode(value); - setHasSearched(false); // Reset search state when changing mode + setHasSearched(false); + }; + + const handleSemanticModeChange = (value: CodexLensSemanticSearchMode) => { + setSemanticMode(value); + setHasSearched(false); + }; + + const handleFusionStrategyChange = (value: CodexLensFusionStrategy) => { + setFusionStrategy(value); + setHasSearched(false); }; const handleQueryChange = (value: string) => { setQuery(value); - setHasSearched(false); // Reset search state when query changes + setHasSearched(false); + }; + + // Get result count for display + const getResultCount = (): string => { + if (searchType === 'symbol') { + return symbolSearch.data?.success + ? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : ''; + } + if (searchType === 'search') { + return contentSearch.data?.success + ? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : ''; + } + if (searchType === 'search_files') { + return fileSearch.data?.success + ? `${fileSearch.data.files?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : ''; + } + if (searchType === 'semantic') { + return semanticSearch.data?.success + ? `${semanticSearch.data.count ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` + : ''; + } + return ''; }; if (!enabled) { @@ -115,6 +171,29 @@ export function SearchTab({ enabled }: SearchTabProps) { return (
+ {/* LSP Status Indicator */} +
+ {formatMessage({ id: 'codexlens.search.lspStatus' })}: + {lspStatus.isLoading ? ( + ... + ) : lspStatus.available ? ( + + + {formatMessage({ id: 'codexlens.search.lspAvailable' })} + + ) : !lspStatus.semanticAvailable ? ( + + + {formatMessage({ id: 'codexlens.search.lspNoSemantic' })} + + ) : ( + + + {formatMessage({ id: 'codexlens.search.lspNoVector' })} + + )} +
+ {/* Search Options */}
{/* Search Type */} @@ -143,12 +222,18 @@ export function SearchTab({ enabled }: SearchTabProps) { {formatMessage({ id: 'codexlens.search.symbol' })}
+ +
+ + {formatMessage({ id: 'codexlens.search.semantic' })} +
+
- {/* Search Mode - only for content and file search */} - {searchType !== 'symbol' && ( + {/* Search Mode - for CLI search types (content / file) */} + {(searchType === 'search' || searchType === 'search_files') && (
)} + + {/* Semantic Search Mode - for semantic search type */} + {searchType === 'semantic' && ( +
+ + +
+ )} + {/* Fusion Strategy - only when semantic + fusion mode */} + {searchType === 'semantic' && semanticMode === 'fusion' && ( +
+ + +
+ )} + {/* Query Input */}
@@ -205,21 +342,7 @@ export function SearchTab({ enabled }: SearchTabProps) { {formatMessage({ id: 'codexlens.search.results' })} - {searchType === 'symbol' - ? (symbolSearch.data?.success - ? `${symbolSearch.data.symbols?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` - : '' - ) - : searchType === 'search' - ? (contentSearch.data?.success - ? `${contentSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` - : '' - ) - : (fileSearch.data?.success - ? `${fileSearch.data.results?.length ?? 0} ${formatMessage({ id: 'codexlens.search.resultsCount' })}` - : '' - ) - } + {getResultCount()}
@@ -255,7 +378,7 @@ export function SearchTab({ enabled }: SearchTabProps) { fileSearch.data.success ? (
-                  {JSON.stringify(fileSearch.data.results, null, 2)}
+                  {JSON.stringify(fileSearch.data.files, null, 2)}
                 
) : ( @@ -264,6 +387,20 @@ export function SearchTab({ enabled }: SearchTabProps) { ) )} + + {searchType === 'semantic' && semanticSearch.data && ( + semanticSearch.data.success ? ( +
+
+                  {JSON.stringify(semanticSearch.data.results, null, 2)}
+                
+
+ ) : ( +
+ {semanticSearch.data.error || formatMessage({ id: 'common.error' })} +
+ ) + )} )} diff --git a/ccw/frontend/src/components/dashboard/DashboardHeader.tsx b/ccw/frontend/src/components/dashboard/DashboardHeader.tsx index 8e970e3b..c1d34649 100644 --- a/ccw/frontend/src/components/dashboard/DashboardHeader.tsx +++ b/ccw/frontend/src/components/dashboard/DashboardHeader.tsx @@ -43,7 +43,7 @@ export function DashboardHeader({ return (
-

+

{formatMessage({ id: titleKey })}

diff --git a/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx b/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx index 95fa6755..da173926 100644 --- a/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx +++ b/ccw/frontend/src/components/dashboard/widgets/WorkflowTaskWidget.tsx @@ -253,7 +253,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) { return (

{/* Project Info Banner - Separate Card */} - + {projectLoading ? (
diff --git a/ccw/frontend/src/components/layout/AppShell.tsx b/ccw/frontend/src/components/layout/AppShell.tsx index b9ebaecb..15170ef0 100644 --- a/ccw/frontend/src/components/layout/AppShell.tsx +++ b/ccw/frontend/src/components/layout/AppShell.tsx @@ -12,6 +12,7 @@ import { MainContent } from './MainContent'; import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor'; import { NotificationPanel } from '@/components/notification'; import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui'; +import { BackgroundImage } from '@/components/shared/BackgroundImage'; import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores'; import { useWorkflowStore } from '@/stores/workflowStore'; import { useWebSocketNotifications, useWebSocket } from '@/hooks'; @@ -160,6 +161,9 @@ export function AppShell({ return (
+ {/* Background image layer (z-index: -3 to -2) */} + + {/* Header - fixed at top */}
diff --git a/ccw/frontend/src/components/layout/Header.tsx b/ccw/frontend/src/components/layout/Header.tsx index f5083e34..456ba08c 100644 --- a/ccw/frontend/src/components/layout/Header.tsx +++ b/ccw/frontend/src/components/layout/Header.tsx @@ -59,7 +59,7 @@ export function Header({ return (
{/* Left side - Logo */} @@ -200,6 +200,7 @@ export function Header({
+