feat: add Unsplash search hook and API proxy routes

- Implemented `useUnsplashSearch` hook for searching Unsplash photos with debounce.
- Created Unsplash API client functions for searching photos and triggering downloads.
- Added proxy routes for Unsplash API to handle search requests and background image uploads.
- Introduced accessibility utilities for WCAG compliance checks and motion preference management.
- Developed theme sharing module for encoding and decoding theme configurations as base64url strings.
This commit is contained in:
catlog22
2026-02-08 20:01:28 +08:00
parent 87daccdc48
commit 166211dcd4
52 changed files with 5798 additions and 142 deletions

View File

@@ -3,9 +3,9 @@
// ======================================== // ========================================
// Centered popup dialog for A2UI surfaces with minimalist design // Centered popup dialog for A2UI surfaces with minimalist design
// Used for displayMode: 'popup' surfaces (e.g., ask_question) // 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 { useIntl } from 'react-intl';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
@@ -30,7 +30,14 @@ interface A2UIPopupCardProps {
onClose: () => void; 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 ========== // ========== Helpers ==========
@@ -73,6 +80,37 @@ function isActionButton(component: SurfaceComponent): boolean {
return 'Button' in comp; 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 (
<div className="mt-2 animate-in fade-in-0 slide-in-from-top-1 duration-200">
<input
type="text"
value={value}
onChange={(e) => 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
/>
</div>
);
}
// ========== Markdown Component ========== // ========== Markdown Component ==========
interface MarkdownContentProps { 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 { formatMessage } = useIntl();
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction); const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
// Detect question type // Detect question type
const questionType = useMemo(() => detectQuestionType(surface), [surface]); 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 // Extract title, message, and description from surface components
const titleComponent = surface.components.find( const titleComponent = surface.components.find(
(c) => c.id === 'title' && 'Text' in (c.component as any) (c) => c.id === 'title' && 'Text' in (c.component as any)
@@ -171,9 +215,33 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
[surface, actionButtons] [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 // Handle A2UI actions
const handleAction = useCallback( const handleAction = useCallback(
(actionId: string, params?: Record<string, unknown>) => { (actionId: string, params?: Record<string, unknown>) => {
// 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 // Send action to backend via WebSocket
sendA2UIAction(actionId, surface.surfaceId, params); sendA2UIAction(actionId, surface.surfaceId, params);
@@ -211,6 +279,9 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
} }
}, [questionType]); }, [questionType]);
// Check if this question type supports "Other" input
const hasOtherOption = questionType === 'select' || questionType === 'multi-select';
return ( return (
<Dialog open onOpenChange={handleOpenChange}> <Dialog open onOpenChange={handleOpenChange}>
<DialogContent <DialogContent
@@ -269,6 +340,14 @@ export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
) : ( ) : (
<A2UIRenderer surface={bodySurface} onAction={handleAction} /> <A2UIRenderer surface={bodySurface} onAction={handleAction} />
)} )}
{/* "Other" text input — shown when Other is selected */}
{hasOtherOption && (
<OtherInput
visible={otherSelected}
value={otherText}
onChange={handleOtherTextChange}
/>
)}
</div> </div>
)} )}
@@ -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<string, unknown>;
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<Set<number>>(new Set());
const [otherTexts, setOtherTexts] = useState<Map<number, string>>(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<string, unknown>) => {
// 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 (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
'sm:max-w-[480px]',
'max-h-[80vh]',
'bg-card p-6 rounded-xl shadow-lg border border-border/50',
// Animation classes
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
)}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
{/* Header with current page title */}
<DialogHeader className="space-y-2 pb-4">
<DialogTitle className="text-lg font-semibold leading-tight">
{currentPageData.title ||
formatMessage({ id: 'askQuestion.defaultTitle', defaultMessage: 'Question' })}
</DialogTitle>
{currentPageData.message && (
<div className="text-base text-foreground">
<MarkdownContent content={currentPageData.message} />
</div>
)}
{currentPageData.description && (
<div className="text-sm text-muted-foreground">
<MarkdownContent content={currentPageData.description} className="prose-muted" />
</div>
)}
</DialogHeader>
{/* Page content with slide animation */}
<div className="overflow-hidden">
<div
className="flex transition-transform duration-300 ease-in-out"
style={{ transform: `translateX(-${currentPage * 100}%)` }}
>
{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 (
<div key={pageIdx} className="w-full flex-shrink-0">
{bodyComps.length > 0 && (
<div
className={cn(
'py-3',
pageType === 'multi-select' && 'space-y-2 max-h-[300px] overflow-y-auto px-1'
)}
>
{pageType === 'multi-select' ? (
bodyComps.map((comp) => (
<div key={comp.id} className="py-1">
<A2UIRenderer
surface={{ ...surface, components: [comp] }}
onAction={handleAction}
/>
</div>
))
) : (
<A2UIRenderer
surface={{ ...surface, components: bodyComps }}
onAction={handleAction}
/>
)}
{/* "Other" text input */}
{hasOther && (
<OtherInput
visible={isOtherSelected}
value={otherTexts.get(pageIdx) || ''}
onChange={(v) => handleOtherTextChange(pageIdx, v)}
/>
)}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Dot indicator */}
<div className="flex justify-center gap-2 py-3">
{pages.map((_, i) => (
<button
key={i}
type="button"
onClick={() => setCurrentPage(i)}
className={cn(
'rounded-full transition-all duration-200',
i === currentPage
? 'bg-primary w-4 h-2'
: 'bg-muted-foreground/30 w-2 h-2 hover:bg-muted-foreground/50'
)}
aria-label={`Page ${i + 1}`}
/>
))}
</div>
{/* Footer - Navigation buttons */}
<DialogFooter className="pt-2">
<div className="flex flex-row justify-between w-full">
{/* Left: Cancel */}
<button
type="button"
onClick={handleCancel}
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{formatMessage({ id: 'askQuestion.cancel', defaultMessage: 'Cancel' })}
</button>
{/* Right: Prev / Next / Submit */}
<div className="flex flex-row gap-2">
{!isFirstPage && (
<button
type="button"
onClick={goPrev}
className="px-4 py-2 text-sm rounded-md border border-border hover:bg-muted transition-colors"
>
{formatMessage({ id: 'askQuestion.previous', defaultMessage: 'Previous' })}
</button>
)}
{isLastPage ? (
<button
type="button"
onClick={handleSubmitAll}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{formatMessage({ id: 'askQuestion.submit', defaultMessage: 'Submit' })}
</button>
) : (
<button
type="button"
onClick={goNext}
className="px-4 py-2 text-sm rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
{formatMessage({ id: 'askQuestion.next', defaultMessage: 'Next' })}
</button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ========== Main Component ==========
export function A2UIPopupCard({ surface, onClose }: A2UIPopupCardProps) {
const state = surface.initialState as Record<string, unknown> | undefined;
const isMultiPage = state?.questionType === 'multi-question' && (state?.totalPages as number) > 1;
if (isMultiPage) {
return <MultiPagePopup surface={surface} onClose={onClose} />;
}
return <SinglePagePopup surface={surface} onClose={onClose} />;
}
export default A2UIPopupCard; export default A2UIPopupCard;

View File

@@ -68,7 +68,7 @@ export function ModelCard({
}; };
return ( return (
<Card className={cn('overflow-hidden', !model.installed && 'opacity-80')}> <Card className={cn('overflow-hidden hover-glow', !model.installed && 'opacity-80')}>
{/* Header */} {/* Header */}
<div className="p-4"> <div className="p-4">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
@@ -105,12 +105,15 @@ export function ModelCard({
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground"> <div className="flex items-center gap-3 mt-1 text-xs text-muted-foreground">
<span>Backend: {model.backend}</span> {model.dimensions && <span>{model.dimensions}d</span>}
<span>Size: {formatSize(model.size)}</span> <span>{formatSize(model.size)}</span>
{model.recommended && (
<Badge variant="success" className="text-[10px] px-1 py-0">Rec</Badge>
)}
</div> </div>
{model.cache_path && ( {model.description && (
<p className="text-xs text-muted-foreground mt-1 font-mono truncate"> <p className="text-xs text-muted-foreground mt-1">
{model.cache_path} {model.description}
</p> </p>
)} )}
</div> </div>

View File

@@ -47,7 +47,7 @@ function filterModels(models: CodexLensModel[], filter: FilterType, search: stri
filtered = filtered.filter(m => filtered = filtered.filter(m =>
m.name.toLowerCase().includes(query) || m.name.toLowerCase().includes(query) ||
m.profile.toLowerCase().includes(query) || m.profile.toLowerCase().includes(query) ||
m.backend.toLowerCase().includes(query) (m.description?.toLowerCase().includes(query) ?? false)
); );
} }

View File

@@ -2,10 +2,11 @@
// CodexLens Search Tab // CodexLens Search Tab
// ======================================== // ========================================
// Semantic code search interface with multiple search types // Semantic code search interface with multiple search types
// Includes LSP availability check and hybrid search mode switching
import { useState } from 'react'; import { useState } from 'react';
import { useIntl } from 'react-intl'; 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 { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label'; import { Label } from '@/components/ui/Label';
@@ -20,11 +21,13 @@ import {
useCodexLensSearch, useCodexLensSearch,
useCodexLensFilesSearch, useCodexLensFilesSearch,
useCodexLensSymbolSearch, useCodexLensSymbolSearch,
useCodexLensLspStatus,
useCodexLensSemanticSearch,
} from '@/hooks/useCodexLens'; } from '@/hooks/useCodexLens';
import type { CodexLensSearchParams } from '@/lib/api'; import type { CodexLensSearchParams, CodexLensSemanticSearchMode, CodexLensFusionStrategy } from '@/lib/api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type SearchType = 'search' | 'search_files' | 'symbol'; type SearchType = 'search' | 'search_files' | 'symbol' | 'semantic';
type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy'; type SearchMode = 'dense_rerank' | 'fts' | 'fuzzy';
interface SearchTabProps { interface SearchTabProps {
@@ -35,14 +38,19 @@ export function SearchTab({ enabled }: SearchTabProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [searchType, setSearchType] = useState<SearchType>('search'); const [searchType, setSearchType] = useState<SearchType>('search');
const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank'); const [searchMode, setSearchMode] = useState<SearchMode>('dense_rerank');
const [semanticMode, setSemanticMode] = useState<CodexLensSemanticSearchMode>('fusion');
const [fusionStrategy, setFusionStrategy] = useState<CodexLensFusionStrategy>('rrf');
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [hasSearched, setHasSearched] = useState(false); const [hasSearched, setHasSearched] = useState(false);
// LSP status check
const lspStatus = useCodexLensLspStatus({ enabled });
// Build search params based on search type // Build search params based on search type
const searchParams: CodexLensSearchParams = { const searchParams: CodexLensSearchParams = {
query, query,
limit: 20, limit: 20,
mode: searchType !== 'symbol' ? searchMode : undefined, mode: searchType !== 'symbol' && searchType !== 'semantic' ? searchMode : undefined,
max_content_length: 200, max_content_length: 200,
extra_files_count: 10, extra_files_count: 10,
}; };
@@ -63,12 +71,25 @@ export function SearchTab({ enabled }: SearchTabProps) {
{ enabled: enabled && hasSearched && searchType === 'symbol' && query.trim().length > 0 } { 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 // Get loading state based on search type
const isLoading = searchType === 'search' const isLoading = searchType === 'search'
? contentSearch.isLoading ? contentSearch.isLoading
: searchType === 'search_files' : searchType === 'search_files'
? fileSearch.isLoading ? fileSearch.isLoading
: symbolSearch.isLoading; : searchType === 'symbol'
? symbolSearch.isLoading
: semanticSearch.isLoading;
const handleSearch = () => { const handleSearch = () => {
if (query.trim()) { if (query.trim()) {
@@ -84,17 +105,52 @@ export function SearchTab({ enabled }: SearchTabProps) {
const handleSearchTypeChange = (value: SearchType) => { const handleSearchTypeChange = (value: SearchType) => {
setSearchType(value); setSearchType(value);
setHasSearched(false); // Reset search state when changing type setHasSearched(false);
}; };
const handleSearchModeChange = (value: SearchMode) => { const handleSearchModeChange = (value: SearchMode) => {
setSearchMode(value); 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) => { const handleQueryChange = (value: string) => {
setQuery(value); 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) { if (!enabled) {
@@ -115,6 +171,29 @@ export function SearchTab({ enabled }: SearchTabProps) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* LSP Status Indicator */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{formatMessage({ id: 'codexlens.search.lspStatus' })}:</span>
{lspStatus.isLoading ? (
<span className="text-muted-foreground">...</span>
) : lspStatus.available ? (
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<CheckCircle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspAvailable' })}
</span>
) : !lspStatus.semanticAvailable ? (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoSemantic' })}
</span>
) : (
<span className="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle className="w-3.5 h-3.5" />
{formatMessage({ id: 'codexlens.search.lspNoVector' })}
</span>
)}
</div>
{/* Search Options */} {/* Search Options */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Search Type */} {/* Search Type */}
@@ -143,12 +222,18 @@ export function SearchTab({ enabled }: SearchTabProps) {
{formatMessage({ id: 'codexlens.search.symbol' })} {formatMessage({ id: 'codexlens.search.symbol' })}
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="semantic" disabled={!lspStatus.available}>
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4" />
{formatMessage({ id: 'codexlens.search.semantic' })}
</div>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{/* Search Mode - only for content and file search */} {/* Search Mode - for CLI search types (content / file) */}
{searchType !== 'symbol' && ( {(searchType === 'search' || searchType === 'search_files') && (
<div className="space-y-2"> <div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label> <Label>{formatMessage({ id: 'codexlens.search.mode' })}</Label>
<Select value={searchMode} onValueChange={handleSearchModeChange}> <Select value={searchMode} onValueChange={handleSearchModeChange}>
@@ -169,8 +254,60 @@ export function SearchTab({ enabled }: SearchTabProps) {
</Select> </Select>
</div> </div>
)} )}
{/* Semantic Search Mode - for semantic search type */}
{searchType === 'semantic' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.semanticMode' })}</Label>
<Select value={semanticMode} onValueChange={handleSemanticModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fusion">
{formatMessage({ id: 'codexlens.search.semanticMode.fusion' })}
</SelectItem>
<SelectItem value="vector">
{formatMessage({ id: 'codexlens.search.semanticMode.vector' })}
</SelectItem>
<SelectItem value="structural">
{formatMessage({ id: 'codexlens.search.semanticMode.structural' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div> </div>
{/* Fusion Strategy - only when semantic + fusion mode */}
{searchType === 'semantic' && semanticMode === 'fusion' && (
<div className="space-y-2">
<Label>{formatMessage({ id: 'codexlens.search.fusionStrategy' })}</Label>
<Select value={fusionStrategy} onValueChange={handleFusionStrategyChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="rrf">
{formatMessage({ id: 'codexlens.search.fusionStrategy.rrf' })}
</SelectItem>
<SelectItem value="dense_rerank">
{formatMessage({ id: 'codexlens.search.fusionStrategy.dense_rerank' })}
</SelectItem>
<SelectItem value="binary">
{formatMessage({ id: 'codexlens.search.fusionStrategy.binary' })}
</SelectItem>
<SelectItem value="hybrid">
{formatMessage({ id: 'codexlens.search.fusionStrategy.hybrid' })}
</SelectItem>
<SelectItem value="staged">
{formatMessage({ id: 'codexlens.search.fusionStrategy.staged' })}
</SelectItem>
</SelectContent>
</Select>
</div>
)}
{/* Query Input */} {/* Query Input */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label> <Label htmlFor="search-query">{formatMessage({ id: 'codexlens.search.query' })}</Label>
@@ -205,21 +342,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
{formatMessage({ id: 'codexlens.search.results' })} {formatMessage({ id: 'codexlens.search.results' })}
</h3> </h3>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{searchType === 'symbol' {getResultCount()}
? (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' })}`
: ''
)
}
</span> </span>
</div> </div>
@@ -255,7 +378,7 @@ export function SearchTab({ enabled }: SearchTabProps) {
fileSearch.data.success ? ( fileSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4"> <div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96"> <pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(fileSearch.data.results, null, 2)} {JSON.stringify(fileSearch.data.files, null, 2)}
</pre> </pre>
</div> </div>
) : ( ) : (
@@ -264,6 +387,20 @@ export function SearchTab({ enabled }: SearchTabProps) {
</div> </div>
) )
)} )}
{searchType === 'semantic' && semanticSearch.data && (
semanticSearch.data.success ? (
<div className="rounded-lg border bg-muted/50 p-4">
<pre className="text-xs overflow-auto max-h-96">
{JSON.stringify(semanticSearch.data.results, null, 2)}
</pre>
</div>
) : (
<div className="text-sm text-destructive">
{semanticSearch.data.error || formatMessage({ id: 'common.error' })}
</div>
)
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -43,7 +43,7 @@ export function DashboardHeader({
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-foreground"> <h1 className="text-2xl font-semibold text-foreground gradient-text">
{formatMessage({ id: titleKey })} {formatMessage({ id: titleKey })}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">

View File

@@ -253,7 +253,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
return ( return (
<div className={cn('flex flex-col gap-2', className)}> <div className={cn('flex flex-col gap-2', className)}>
{/* Project Info Banner - Separate Card */} {/* Project Info Banner - Separate Card */}
<Card className="shrink-0"> <Card className="shrink-0 border-gradient-brand">
{projectLoading ? ( {projectLoading ? (
<div className="px-4 py-3 flex items-center gap-4"> <div className="px-4 py-3 flex items-center gap-4">
<div className="h-5 w-32 bg-muted rounded animate-pulse" /> <div className="h-5 w-32 bg-muted rounded animate-pulse" />

View File

@@ -12,6 +12,7 @@ import { MainContent } from './MainContent';
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor'; import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
import { NotificationPanel } from '@/components/notification'; import { NotificationPanel } from '@/components/notification';
import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui'; import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui';
import { BackgroundImage } from '@/components/shared/BackgroundImage';
import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores'; import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores';
import { useWorkflowStore } from '@/stores/workflowStore'; import { useWorkflowStore } from '@/stores/workflowStore';
import { useWebSocketNotifications, useWebSocket } from '@/hooks'; import { useWebSocketNotifications, useWebSocket } from '@/hooks';
@@ -160,6 +161,9 @@ export function AppShell({
return ( return (
<div className="flex flex-col min-h-screen bg-background"> <div className="flex flex-col min-h-screen bg-background">
{/* Background image layer (z-index: -3 to -2) */}
<BackgroundImage />
{/* Header - fixed at top */} {/* Header - fixed at top */}
<Header <Header
onRefresh={onRefresh} onRefresh={onRefresh}
@@ -180,7 +184,7 @@ export function AppShell({
{/* Main content area */} {/* Main content area */}
<MainContent <MainContent
className={cn( className={cn(
'transition-all duration-300', 'app-shell-content transition-all duration-300',
sidebarCollapsed ? 'md:ml-16' : 'md:ml-64' sidebarCollapsed ? 'md:ml-16' : 'md:ml-64'
)} )}
> >

View File

@@ -59,7 +59,7 @@ export function Header({
return ( return (
<header <header
className="flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm" className="relative flex items-center justify-between px-4 md:px-5 h-14 bg-card border-b border-border sticky top-0 z-50 shadow-sm"
role="banner" role="banner"
> >
{/* Left side - Logo */} {/* Left side - Logo */}
@@ -200,6 +200,7 @@ export function Header({
</div> </div>
</div> </div>
</div> </div>
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-gradient-accent" aria-hidden="true" />
</header> </header>
); );
} }

View File

@@ -16,6 +16,7 @@ import {
FileText, FileText,
HardDrive, HardDrive,
MessageCircleQuestion, MessageCircleQuestion,
SearchCode,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Globe, Globe,
@@ -90,6 +91,7 @@ export const CCW_MCP_TOOLS: CcwTool[] = [
{ name: 'read_file', desc: 'Read file contents', core: true }, { name: 'read_file', desc: 'Read file contents', core: true },
{ name: 'core_memory', desc: 'Core memory management', core: true }, { name: 'core_memory', desc: 'Core memory management', core: true },
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false }, { name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
{ name: 'smart_search', desc: 'Intelligent code search', core: true },
]; ];
// ========== Component ========== // ========== Component ==========
@@ -470,6 +472,8 @@ function getToolIcon(toolName: string): React.ReactElement {
return <Settings {...iconProps} />; return <Settings {...iconProps} />;
case 'ask_question': case 'ask_question':
return <MessageCircleQuestion {...iconProps} />; return <MessageCircleQuestion {...iconProps} />;
case 'smart_search':
return <SearchCode {...iconProps} />;
default: default:
return <Settings {...iconProps} />; return <Settings {...iconProps} />;
} }

View File

@@ -0,0 +1,98 @@
import { useState, useCallback } from 'react';
import { useAppStore } from '@/stores/appStore';
import { DEFAULT_BACKGROUND_CONFIG } from '@/lib/theme';
import type { BackgroundConfig } from '@/types/store';
/**
* BackgroundImage Component
* Renders background image layer with visual effects (blur, darken, grain, vignette).
* Positioned behind all content via z-index layering.
*/
export function BackgroundImage() {
const activeSlotId = useAppStore((s) => s.activeSlotId);
const themeSlots = useAppStore((s) => s.themeSlots);
const activeSlot = themeSlots.find((s) => s.id === activeSlotId);
const config: BackgroundConfig = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState(false);
const handleLoad = useCallback(() => {
setLoaded(true);
setError(false);
}, []);
const handleError = useCallback(() => {
setError(true);
setLoaded(false);
}, []);
// Reset load state when image URL changes
const imageUrl = config.imageUrl;
const [prevUrl, setPrevUrl] = useState(imageUrl);
if (imageUrl !== prevUrl) {
setPrevUrl(imageUrl);
setLoaded(false);
setError(false);
}
// Don't render anything in gradient-only mode
if (config.mode === 'gradient-only') return null;
// Don't render if no image URL or image failed to load
if (!imageUrl || error) return null;
const { blur, darkenOpacity, saturation } = config.effects;
const imageStyle: React.CSSProperties = {
filter: [
blur > 0 ? `blur(${blur}px)` : '',
saturation !== 100 ? `saturate(${saturation / 100})` : '',
].filter(Boolean).join(' ') || undefined,
// Scale slightly when blurred to prevent white edges
transform: blur > 0 ? 'scale(1.05)' : undefined,
};
return (
<>
{/* Background image layer */}
<div
className="bg-image-layer"
style={{ opacity: loaded ? 1 : 0 }}
>
<img
src={imageUrl}
alt=""
role="presentation"
style={imageStyle}
onLoad={handleLoad}
onError={handleError}
draggable={false}
/>
</div>
{/* Darken overlay */}
{darkenOpacity > 0 && (
<div
className="bg-darken-overlay"
style={{ backgroundColor: `rgba(0, 0, 0, ${darkenOpacity / 100})` }}
/>
)}
{/* Grain texture overlay */}
<div className="bg-grain-overlay" />
{/* Vignette overlay */}
<div className="bg-vignette-overlay" />
{/* Loading spinner (hidden img for preloading) */}
{!loaded && !error && (
<div
className="fixed inset-0 z-[-3] flex items-center justify-center pointer-events-none"
>
<div className="w-6 h-6 border-2 border-border border-t-accent rounded-full animate-spin" />
</div>
)}
</>
);
}

View File

@@ -0,0 +1,405 @@
import { useState, useCallback, useRef } from 'react';
import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme';
import { useUnsplashSearch } from '@/hooks/useUnsplashSearch';
import { triggerUnsplashDownload, uploadBackgroundImage } from '@/lib/unsplash';
import type { UnsplashPhoto } from '@/lib/unsplash';
import type { BackgroundMode } from '@/types/store';
const MODES: { value: BackgroundMode; labelId: string }[] = [
{ value: 'gradient-only', labelId: 'theme.background.mode.gradientOnly' },
{ value: 'image-only', labelId: 'theme.background.mode.imageOnly' },
{ value: 'image-gradient', labelId: 'theme.background.mode.imageGradient' },
];
/**
* BackgroundImagePicker Component
* Allows users to search Unsplash, pick a background image,
* adjust visual effects, and switch between background modes.
*/
export function BackgroundImagePicker() {
const { formatMessage } = useIntl();
const {
backgroundConfig,
setBackgroundMode,
setBackgroundImage,
updateBackgroundEffect,
} = useTheme();
const [searchQuery, setSearchQuery] = useState('');
const [page, setPage] = useState(1);
const [customUrl, setCustomUrl] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading, isError, error } = useUnsplashSearch(searchQuery, page);
const showImageControls = backgroundConfig.mode !== 'gradient-only';
const handlePhotoSelect = useCallback(async (photo: UnsplashPhoto) => {
setBackgroundImage(photo.regularUrl, {
photographerName: photo.photographer,
photographerUrl: photo.photographerUrl,
photoUrl: photo.photoUrl,
});
// Trigger download event per Unsplash API guidelines
triggerUnsplashDownload(photo.downloadLocation).catch(() => {});
}, [setBackgroundImage]);
const handleCustomUrlApply = useCallback(() => {
if (customUrl.trim()) {
setBackgroundImage(customUrl.trim(), null);
setCustomUrl('');
}
}, [customUrl, setBackgroundImage]);
const handleRemoveImage = useCallback(() => {
setBackgroundImage(null, null);
}, [setBackgroundImage]);
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
// Reset file input so the same file can be re-selected
e.target.value = '';
setUploadError(null);
if (file.size > 10 * 1024 * 1024) {
setUploadError(formatMessage({ id: 'theme.background.fileTooLarge' }));
return;
}
if (!file.type.startsWith('image/') || !['image/jpeg', 'image/png', 'image/webp', 'image/gif'].includes(file.type)) {
setUploadError(formatMessage({ id: 'theme.background.invalidType' }));
return;
}
setIsUploading(true);
try {
const result = await uploadBackgroundImage(file);
setBackgroundImage(result.url, null);
} catch (err) {
setUploadError((err as Error).message || formatMessage({ id: 'theme.background.uploadError' }));
} finally {
setIsUploading(false);
}
}, [formatMessage, setBackgroundImage]);
return (
<div className="space-y-4">
{/* Section title */}
<h3 className="text-sm font-medium text-text">
{formatMessage({ id: 'theme.background.title' })}
</h3>
{/* Background mode selector */}
<div
className="flex gap-2"
role="radiogroup"
aria-label={formatMessage({ id: 'theme.background.title' })}
>
{MODES.map(({ value, labelId }) => (
<button
key={value}
onClick={() => setBackgroundMode(value)}
role="radio"
aria-checked={backgroundConfig.mode === value}
className={`
flex-1 px-3 py-2 rounded-lg text-xs font-medium
transition-all duration-200 border-2
${backgroundConfig.mode === value
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{formatMessage({ id: labelId })}
</button>
))}
</div>
{/* Image selection area */}
{showImageControls && (
<div className="space-y-3">
{/* Current image preview */}
{backgroundConfig.imageUrl && (
<div className="relative rounded-lg overflow-hidden border border-border">
<img
src={backgroundConfig.imageUrl}
alt="Current background"
className="w-full h-32 object-cover"
/>
<button
onClick={handleRemoveImage}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-black/60 text-white rounded hover:bg-black/80 transition-colors"
>
{formatMessage({ id: 'theme.background.removeImage' })}
</button>
{/* Unsplash attribution */}
{backgroundConfig.attribution && (
<div className="absolute bottom-0 left-0 right-0 px-2 py-1 bg-black/50 text-white text-xs">
Photo by{' '}
<a
href={`${backgroundConfig.attribution.photographerUrl}?utm_source=ccw&utm_medium=referral`}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{backgroundConfig.attribution.photographerName}
</a>{' '}
on{' '}
<a
href="https://unsplash.com/?utm_source=ccw&utm_medium=referral"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Unsplash
</a>
</div>
)}
</div>
)}
{/* Search box */}
<input
type="text"
value={searchQuery}
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
placeholder={formatMessage({ id: 'theme.background.searchPlaceholder' })}
className="w-full px-3 py-2 text-sm rounded-lg border border-border bg-bg text-text
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1"
/>
{/* Photo grid */}
{isLoading && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-border border-t-accent rounded-full animate-spin" />
</div>
)}
{isError && (
<p className="text-xs text-destructive py-2">
{(error as Error)?.message || formatMessage({ id: 'theme.background.searchError' })}
</p>
)}
{data && data.photos.length > 0 && (
<>
<div className="grid grid-cols-3 gap-2 max-h-60 overflow-y-auto">
{data.photos.map((photo) => (
<button
key={photo.id}
onClick={() => handlePhotoSelect(photo)}
className={`
relative rounded overflow-hidden border-2 transition-all
hover:border-accent focus:outline-none focus:ring-2 focus:ring-accent
${backgroundConfig.imageUrl === photo.regularUrl
? 'border-accent ring-2 ring-accent'
: 'border-transparent'
}
`}
>
<img
src={photo.thumbUrl}
alt={`Photo by ${photo.photographer}`}
className="w-full h-20 object-cover"
loading="lazy"
/>
</button>
))}
</div>
{/* Pagination */}
{data.totalPages > 1 && (
<div className="flex items-center justify-between">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-50 hover:bg-surface"
>
{formatMessage({ id: 'theme.background.prev' })}
</button>
<span className="text-xs text-text-secondary">
{page} / {data.totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(data.totalPages, p + 1))}
disabled={page >= data.totalPages}
className="px-2 py-1 text-xs rounded border border-border disabled:opacity-50 hover:bg-surface"
>
{formatMessage({ id: 'theme.background.next' })}
</button>
</div>
)}
</>
)}
{data && data.photos.length === 0 && searchQuery.trim() && (
<p className="text-xs text-text-secondary py-2 text-center">
{formatMessage({ id: 'theme.background.noResults' })}
</p>
)}
{/* Custom URL input */}
<div className="flex gap-2">
<input
type="url"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
placeholder={formatMessage({ id: 'theme.background.customUrlPlaceholder' })}
className="flex-1 px-3 py-2 text-xs rounded-lg border border-border bg-bg text-text
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1"
/>
<button
onClick={handleCustomUrlApply}
disabled={!customUrl.trim()}
className="px-3 py-2 text-xs rounded-lg bg-accent text-white disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{formatMessage({ id: 'theme.background.apply' })}
</button>
</div>
{/* Upload local image */}
<div className="space-y-1">
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/gif"
onChange={handleFileUpload}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="w-full px-3 py-2 text-xs rounded-lg border border-dashed border-border
bg-bg text-text hover:bg-surface hover:border-accent
disabled:opacity-50 transition-all flex items-center justify-center gap-2"
>
{isUploading ? (
<>
<div className="w-3.5 h-3.5 border-2 border-border border-t-accent rounded-full animate-spin" />
{formatMessage({ id: 'theme.background.uploading' })}
</>
) : (
formatMessage({ id: 'theme.background.upload' })
)}
</button>
{uploadError && (
<p className="text-xs text-destructive">{uploadError}</p>
)}
</div>
</div>
)}
{/* Effects panel */}
{showImageControls && (
<div className="space-y-3 pt-2 border-t border-border">
<h4 className="text-xs font-medium text-text-secondary">
{formatMessage({ id: 'theme.background.effects' })}
</h4>
{/* Blur slider */}
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-text">
{formatMessage({ id: 'theme.background.blur' })}
</label>
<span className="text-xs text-text-secondary">{backgroundConfig.effects.blur}px</span>
</div>
<input
type="range"
min="0"
max="20"
step="1"
value={backgroundConfig.effects.blur}
onChange={(e) => updateBackgroundEffect('blur', Number(e.target.value))}
className="w-full accent-[hsl(var(--accent))]"
/>
</div>
{/* Darken slider */}
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-text">
{formatMessage({ id: 'theme.background.darken' })}
</label>
<span className="text-xs text-text-secondary">{backgroundConfig.effects.darkenOpacity}%</span>
</div>
<input
type="range"
min="0"
max="80"
step="1"
value={backgroundConfig.effects.darkenOpacity}
onChange={(e) => updateBackgroundEffect('darkenOpacity', Number(e.target.value))}
className="w-full accent-[hsl(var(--accent))]"
/>
</div>
{/* Saturation slider */}
<div className="space-y-1">
<div className="flex justify-between">
<label className="text-xs text-text">
{formatMessage({ id: 'theme.background.saturation' })}
</label>
<span className="text-xs text-text-secondary">{backgroundConfig.effects.saturation}%</span>
</div>
<input
type="range"
min="0"
max="200"
step="5"
value={backgroundConfig.effects.saturation}
onChange={(e) => updateBackgroundEffect('saturation', Number(e.target.value))}
className="w-full accent-[hsl(var(--accent))]"
/>
</div>
{/* Frosted glass checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={backgroundConfig.effects.enableFrostedGlass}
onChange={(e) => updateBackgroundEffect('enableFrostedGlass', e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
<span className="text-sm text-text">
{formatMessage({ id: 'theme.background.frostedGlass' })}
</span>
</label>
{/* Grain checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={backgroundConfig.effects.enableGrain}
onChange={(e) => updateBackgroundEffect('enableGrain', e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
<span className="text-sm text-text">
{formatMessage({ id: 'theme.background.grain' })}
</span>
</label>
{/* Vignette checkbox */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={backgroundConfig.effects.enableVignette}
onChange={(e) => updateBackgroundEffect('enableVignette', e.target.checked)}
className="w-4 h-4 rounded border-border text-accent focus:ring-2 focus:ring-accent focus:ring-offset-2"
/>
<span className="text-sm text-text">
{formatMessage({ id: 'theme.background.vignette' })}
</span>
</label>
</div>
)}
</div>
);
}

View File

@@ -177,7 +177,7 @@ export function IssueCard({
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
'p-3 bg-card border border-border rounded-lg cursor-pointer', 'p-3 bg-card border border-border rounded-lg cursor-pointer',
'hover:shadow-md hover:border-primary/50 transition-all', 'hover:shadow-md hover:border-primary/50 transition-all hover-glow',
className className
)} )}
> >
@@ -198,7 +198,7 @@ export function IssueCard({
{...draggableProps} {...draggableProps}
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all', 'p-4 cursor-pointer hover:shadow-md hover:border-primary/50 transition-all hover-glow',
className className
)} )}
> >

View File

@@ -107,7 +107,7 @@ export function RuleCard({
return ( return (
<Card <Card
className={cn( className={cn(
'group transition-all duration-200 hover:shadow-md hover:border-primary/30', 'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
!rule.enabled && 'opacity-60', !rule.enabled && 'opacity-60',
className className
)} )}

View File

@@ -102,7 +102,7 @@ export function SkillCard({
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
'p-3 bg-card border rounded-lg cursor-pointer', 'p-3 bg-card border rounded-lg cursor-pointer',
'hover:shadow-md transition-all', 'hover:shadow-md transition-all hover-glow',
skill.enabled ? 'border-border hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/50 grayscale-[0.5]', skill.enabled ? 'border-border hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/50 grayscale-[0.5]',
className className
)} )}
@@ -140,7 +140,7 @@ export function SkillCard({
<Card <Card
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
'p-4 cursor-pointer hover:shadow-md transition-all', 'p-4 cursor-pointer hover:shadow-md transition-all hover-glow',
skill.enabled ? 'hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/30 grayscale-[0.3]', skill.enabled ? 'hover:border-primary/50' : 'border-dashed border-muted-foreground/50 bg-muted/30 grayscale-[0.3]',
className className
)} )}

View File

@@ -11,7 +11,7 @@ import { TrendingUp, TrendingDown, Minus, type LucideIcon } from 'lucide-react';
import { Sparkline } from '@/components/charts/Sparkline'; import { Sparkline } from '@/components/charts/Sparkline';
const statCardVariants = cva( const statCardVariants = cva(
'transition-all duration-200 hover:shadow-md', 'transition-all duration-200 hover:shadow-md hover-glow',
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -1,9 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme'; import { useNotifications } from '@/hooks/useNotifications';
import { COLOR_SCHEMES, THEME_MODES, getThemeName, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme'; import type { ColorScheme, ThemeMode } from '@/lib/theme';
import { generateThemeFromHue } from '@/lib/colorGenerator'; import type { ThemeSlotId, StyleTier } from '@/types/store';
import { generateThemeFromHue, applyStyleTier } from '@/lib/colorGenerator';
import { checkThemeContrast, generateContrastFix } from '@/lib/accessibility';
import type { ContrastResult, FixSuggestion } from '@/lib/accessibility';
import type { ThemeSharePayload } from '@/lib/themeShare';
import { BackgroundImagePicker } from './BackgroundImagePicker';
/** /**
* Theme Selector Component * Theme Selector Component
@@ -28,17 +34,52 @@ export function ThemeSelector() {
gradientLevel, gradientLevel,
enableHoverGlow, enableHoverGlow,
enableBackgroundAnimation, enableBackgroundAnimation,
motionPreference,
setColorScheme, setColorScheme,
setTheme, setTheme,
setCustomHue, setCustomHue,
setGradientLevel, setGradientLevel,
setEnableHoverGlow, setEnableHoverGlow,
setEnableBackgroundAnimation, setEnableBackgroundAnimation,
setMotionPreference,
styleTier,
setStyleTier,
themeSlots,
activeSlotId,
canAddSlot,
setActiveSlot,
copySlot,
renameSlot,
deleteSlot,
undoDeleteSlot,
exportThemeCode,
importThemeCode,
setBackgroundConfig,
} = useTheme(); } = useTheme();
const { addToast, removeToast } = useNotifications();
// Local state for preview hue (uncommitted changes) // Local state for preview hue (uncommitted changes)
const [previewHue, setPreviewHue] = useState<number | null>(customHue); const [previewHue, setPreviewHue] = useState<number | null>(customHue);
// Contrast warning state (non-blocking)
const [contrastWarnings, setContrastWarnings] = useState<ContrastResult[]>([]);
const [contrastFixes, setContrastFixes] = useState<Record<string, FixSuggestion[]>>({});
const [showContrastWarning, setShowContrastWarning] = useState(false);
// Slot management state
const [renamingSlotId, setRenamingSlotId] = useState<ThemeSlotId | null>(null);
const [renameValue, setRenameValue] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const undoToastIdRef = useRef<string | null>(null);
// Share/import state (local to component, not in store)
const [showImportPanel, setShowImportPanel] = useState(false);
const [importCode, setImportCode] = useState('');
const [importPreview, setImportPreview] = useState<ThemeSharePayload | null>(null);
const [importWarning, setImportWarning] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [copyFeedback, setCopyFeedback] = useState(false);
// Sync preview with customHue from store // Sync preview with customHue from store
useEffect(() => { useEffect(() => {
setPreviewHue(customHue); setPreviewHue(customHue);
@@ -55,6 +96,25 @@ export function ThemeSelector() {
return hslValue ? `hsl(${hslValue})` : '#888'; return hslValue ? `hsl(${hslValue})` : '#888';
}; };
// Style tier definitions for the selector UI
const STYLE_TIERS: Array<{ id: StyleTier; nameKey: string; descKey: string }> = [
{ id: 'soft', nameKey: 'theme.styleTier.soft', descKey: 'theme.styleTier.softDesc' },
{ id: 'standard', nameKey: 'theme.styleTier.standard', descKey: 'theme.styleTier.standardDesc' },
{ id: 'high-contrast', nameKey: 'theme.styleTier.highContrast', descKey: 'theme.styleTier.highContrastDesc' },
];
// Get tier preview swatch colors (bg, surface, accent for a sample tier)
const getTierPreviewColors = (tier: StyleTier): { bg: string; surface: string; accent: string } => {
const sampleHue = customHue ?? 220; // Use current hue or default blue
const baseVars = generateThemeFromHue(sampleHue, mode);
const tieredVars = tier === 'standard' ? baseVars : applyStyleTier(baseVars, tier, mode);
return {
bg: tieredVars['--bg'] ? `hsl(${tieredVars['--bg']})` : '#888',
surface: tieredVars['--surface'] ? `hsl(${tieredVars['--surface']})` : '#888',
accent: tieredVars['--accent'] ? `hsl(${tieredVars['--accent']})` : '#888',
};
};
const handleSchemeSelect = (scheme: ColorScheme) => { const handleSchemeSelect = (scheme: ColorScheme) => {
// When selecting a preset scheme, reset custom hue // When selecting a preset scheme, reset custom hue
if (isCustomTheme) { if (isCustomTheme) {
@@ -73,6 +133,28 @@ export function ThemeSelector() {
const handleHueSave = () => { const handleHueSave = () => {
if (previewHue !== null) { if (previewHue !== null) {
setCustomHue(previewHue); setCustomHue(previewHue);
// Run contrast check on the new custom theme
const mode: ThemeMode = resolvedTheme;
const vars = generateThemeFromHue(previewHue, mode);
const results = checkThemeContrast(vars);
const failures = results.filter(r => !r.passed);
if (failures.length > 0) {
setContrastWarnings(failures);
// Generate fixes for each failing pair
const fixes: Record<string, FixSuggestion[]> = {};
for (const failure of failures) {
const key = `${failure.fgVar}|${failure.bgVar}`;
fixes[key] = generateContrastFix(failure.fgVar, failure.bgVar, vars, failure.required);
}
setContrastFixes(fixes);
setShowContrastWarning(true);
} else {
setContrastWarnings([]);
setContrastFixes({});
setShowContrastWarning(false);
}
} }
}; };
@@ -99,8 +181,379 @@ export function ThemeSelector() {
} }
}; };
// ========== Slot Management Handlers ==========
const handleSlotSelect = useCallback((slotId: ThemeSlotId) => {
if (slotId !== activeSlotId) {
setActiveSlot(slotId);
}
}, [activeSlotId, setActiveSlot]);
const handleCopySlot = useCallback(() => {
if (!canAddSlot) return;
copySlot();
}, [canAddSlot, copySlot]);
const handleStartRename = useCallback((slotId: ThemeSlotId, currentName: string) => {
setRenamingSlotId(slotId);
setRenameValue(currentName);
// Focus input after render
setTimeout(() => renameInputRef.current?.focus(), 0);
}, []);
const handleConfirmRename = useCallback(() => {
if (renamingSlotId && renameValue.trim()) {
renameSlot(renamingSlotId, renameValue.trim());
}
setRenamingSlotId(null);
setRenameValue('');
}, [renamingSlotId, renameValue, renameSlot]);
const handleCancelRename = useCallback(() => {
setRenamingSlotId(null);
setRenameValue('');
}, []);
const handleRenameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirmRename();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelRename();
}
}, [handleConfirmRename, handleCancelRename]);
const handleDeleteSlot = useCallback((slotId: ThemeSlotId) => {
const slot = themeSlots.find(s => s.id === slotId);
if (!slot || slot.isDefault) return;
deleteSlot(slotId);
// Remove previous undo toast if exists
if (undoToastIdRef.current) {
removeToast(undoToastIdRef.current);
}
// Show undo toast with 10-second duration
const toastId = addToast('info',
formatMessage({ id: 'theme.slot.undoDelete' }),
undefined,
{
duration: 10000,
dismissible: true,
action: {
label: formatMessage({ id: 'theme.slot.undo' }),
onClick: () => {
undoDeleteSlot();
removeToast(toastId);
undoToastIdRef.current = null;
},
},
}
);
undoToastIdRef.current = toastId;
}, [themeSlots, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
// ========== Share/Import Handlers ==========
const handleCopyThemeCode = useCallback(async () => {
try {
const code = exportThemeCode();
await navigator.clipboard.writeText(code);
setCopyFeedback(true);
setTimeout(() => setCopyFeedback(false), 2000);
} catch {
// Clipboard API may not be available
addToast('error', 'Failed to copy to clipboard');
}
}, [exportThemeCode, addToast]);
const handleOpenImport = useCallback(() => {
setShowImportPanel(true);
setImportCode('');
setImportPreview(null);
setImportWarning(null);
setImportError(null);
}, []);
const handleCloseImport = useCallback(() => {
setShowImportPanel(false);
setImportCode('');
setImportPreview(null);
setImportWarning(null);
setImportError(null);
}, []);
const handleImportCodeChange = useCallback((value: string) => {
setImportCode(value);
setImportError(null);
setImportWarning(null);
setImportPreview(null);
if (!value.trim()) return;
const result = importThemeCode(value);
if (result.ok) {
setImportPreview(result.payload);
if (result.warning) {
setImportWarning(result.warning);
}
} else {
setImportError(result.error);
}
}, [importThemeCode]);
const handleApplyImport = useCallback(() => {
if (!importPreview) return;
// Check if we can add a slot or overwrite current
if (!canAddSlot && activeSlotId === 'default') {
// Apply to the default slot directly via individual setters
if (importPreview.customHue !== null) {
setCustomHue(importPreview.customHue);
} else {
setCustomHue(null);
setColorScheme(importPreview.colorScheme);
}
setGradientLevel(importPreview.gradientLevel);
setEnableHoverGlow(importPreview.enableHoverGlow);
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
setStyleTier(importPreview.styleTier);
} else if (canAddSlot) {
// Create a new slot via copySlot then apply settings
copySlot();
// After copySlot, the new slot is active. Apply imported settings.
if (importPreview.customHue !== null) {
setCustomHue(importPreview.customHue);
} else {
setCustomHue(null);
setColorScheme(importPreview.colorScheme);
}
setGradientLevel(importPreview.gradientLevel);
setEnableHoverGlow(importPreview.enableHoverGlow);
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
setStyleTier(importPreview.styleTier);
} else {
// Apply to current active slot via individual setters
if (importPreview.customHue !== null) {
setCustomHue(importPreview.customHue);
} else {
setCustomHue(null);
setColorScheme(importPreview.colorScheme);
}
setGradientLevel(importPreview.gradientLevel);
setEnableHoverGlow(importPreview.enableHoverGlow);
setEnableBackgroundAnimation(importPreview.enableBackgroundAnimation);
setStyleTier(importPreview.styleTier);
}
// Apply background config from import (v2+ feature)
setBackgroundConfig(importPreview.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
addToast('success', formatMessage({ id: 'theme.share.importSuccess' }));
handleCloseImport();
}, [
importPreview, canAddSlot, activeSlotId, copySlot,
setCustomHue, setColorScheme, setGradientLevel,
setEnableHoverGlow, setEnableBackgroundAnimation, setStyleTier,
setBackgroundConfig,
addToast, formatMessage, handleCloseImport,
]);
/** Generate preview swatch colors from an import payload */
const getImportPreviewColors = useCallback((payload: ThemeSharePayload) => {
const hue = payload.customHue ?? 220;
const baseVars = generateThemeFromHue(hue, mode);
const tieredVars = payload.styleTier === 'standard'
? baseVars
: applyStyleTier(baseVars, payload.styleTier, mode);
return {
bg: tieredVars['--bg'] ? `hsl(${tieredVars['--bg']})` : '#888',
surface: tieredVars['--surface'] ? `hsl(${tieredVars['--surface']})` : '#888',
accent: tieredVars['--accent'] ? `hsl(${tieredVars['--accent']})` : '#888',
text: tieredVars['--text'] ? `hsl(${tieredVars['--text']})` : '#888',
};
}, [mode]);
/** Map error keys to i18n message IDs */
const getShareErrorMessageId = (errorKey: string): string => {
switch (errorKey) {
case 'incompatible_version':
return 'theme.share.incompatibleVersion';
default:
return 'theme.share.invalidCode';
}
};
// Focus rename input when entering rename mode
useEffect(() => {
if (renamingSlotId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [renamingSlotId]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Theme Slot Switcher */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-text">
{formatMessage({ id: 'theme.slot.title' })}
</h3>
<button
onClick={handleCopySlot}
disabled={!canAddSlot}
title={
canAddSlot
? formatMessage({ id: 'theme.slot.copy' })
: formatMessage({ id: 'theme.slot.limitReached' }, { limit: THEME_SLOT_LIMIT })
}
className={`
px-2 py-1 rounded text-xs font-medium
transition-all duration-200
${canAddSlot
? 'bg-accent text-white hover:bg-accent-hover focus:ring-2 focus:ring-accent focus:ring-offset-1'
: 'bg-muted text-muted-text cursor-not-allowed'
}
focus:outline-none
`}
>
+ {formatMessage({ id: 'theme.slot.copy' })}
</button>
</div>
<div className="flex gap-2" role="tablist" aria-label={formatMessage({ id: 'theme.slot.title' })}>
{themeSlots.map((slot) => {
const isActive = slot.id === activeSlotId;
const isRenaming = slot.id === renamingSlotId;
return (
<div
key={slot.id}
role="tab"
aria-selected={isActive}
tabIndex={isActive ? 0 : -1}
onClick={() => handleSlotSelect(slot.id)}
className={`
relative flex-1 min-w-0 p-2.5 rounded-lg cursor-pointer
transition-all duration-200 border-2 group
${isActive
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-1
`}
>
{/* Active indicator */}
{isActive && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-accent border-2 border-bg" />
)}
{/* Slot name - inline rename or display */}
<div className="flex items-center gap-1 min-w-0">
{isRenaming ? (
<input
ref={renameInputRef}
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={handleConfirmRename}
onClick={(e) => e.stopPropagation()}
className="
w-full px-1 py-0.5 text-xs font-medium text-text
bg-bg border border-accent rounded
focus:outline-none focus:ring-1 focus:ring-accent
"
maxLength={20}
/>
) : (
<span
className="text-xs font-medium text-text truncate"
title={slot.name}
onDoubleClick={(e) => {
e.stopPropagation();
if (!slot.isDefault) {
handleStartRename(slot.id, slot.name);
}
}}
>
{slot.name}
</span>
)}
</div>
{/* Active label */}
{isActive && !isRenaming && (
<span className="text-[10px] text-accent font-medium mt-0.5 block">
{formatMessage({ id: 'theme.slot.active' })}
</span>
)}
{/* Action buttons - show on hover for non-default slots */}
{!slot.isDefault && !isRenaming && (
<div className="
absolute top-1 right-1 flex gap-0.5
opacity-0 group-hover:opacity-100 transition-opacity duration-150
">
<button
onClick={(e) => {
e.stopPropagation();
handleStartRename(slot.id, slot.name);
}}
title={formatMessage({ id: 'theme.slot.rename' })}
className="
p-0.5 rounded text-text-tertiary hover:text-text hover:bg-surface-hover
transition-colors duration-150 focus:outline-none
"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11.5 1.5L14.5 4.5L5 14H2V11L11.5 1.5Z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSlot(slot.id);
}}
title={formatMessage({ id: 'theme.slot.delete' })}
className="
p-0.5 rounded text-text-tertiary hover:text-error hover:bg-error-light
transition-colors duration-150 focus:outline-none
"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
)}
{/* Default slot: show disabled delete tooltip */}
{slot.isDefault && (
<div className="
absolute top-1 right-1 flex gap-0.5
opacity-0 group-hover:opacity-100 transition-opacity duration-150
">
<button
disabled
title={formatMessage({ id: 'theme.slot.cannotDeleteDefault' })}
className="p-0.5 rounded text-muted-text cursor-not-allowed"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
</button>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Color Scheme Selection */} {/* Color Scheme Selection */}
<div> <div>
<h3 className="text-sm font-medium text-text mb-3"> <h3 className="text-sm font-medium text-text mb-3">
@@ -265,6 +718,64 @@ export function ThemeSelector() {
</div> </div>
)} )}
{/* Style Tier Selection */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.styleTier.label' })}
</h3>
<div
className="grid grid-cols-3 gap-3"
role="radiogroup"
aria-label={formatMessage({ id: 'theme.styleTier.label' })}
>
{STYLE_TIERS.map((tier) => {
const preview = getTierPreviewColors(tier.id);
const isSelected = styleTier === tier.id;
return (
<button
key={tier.id}
onClick={() => setStyleTier(tier.id)}
role="radio"
aria-checked={isSelected}
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${isSelected
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{/* Preview swatches */}
<div className="flex gap-1" aria-hidden="true">
<div
className="w-5 h-5 rounded-sm border border-border"
style={{ backgroundColor: preview.bg }}
/>
<div
className="w-5 h-5 rounded-sm border border-border"
style={{ backgroundColor: preview.surface }}
/>
<div
className="w-5 h-5 rounded-sm border border-border"
style={{ backgroundColor: preview.accent }}
/>
</div>
{/* Tier name */}
<span className="text-xs font-medium text-text text-center">
{formatMessage({ id: tier.nameKey })}
</span>
{/* Description */}
<span className="text-[10px] text-text-tertiary text-center leading-tight">
{formatMessage({ id: tier.descKey })}
</span>
</button>
);
})}
</div>
</div>
{/* Gradient Effects Settings */} {/* Gradient Effects Settings */}
<div> <div>
<h3 className="text-sm font-medium text-text mb-3"> <h3 className="text-sm font-medium text-text mb-3">
@@ -333,6 +844,86 @@ export function ThemeSelector() {
</div> </div>
</div> </div>
{/* Background Image */}
<BackgroundImagePicker />
{/* Motion Preference */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.motion.label' })}
</h3>
<div
className="flex gap-2"
role="radiogroup"
aria-label={formatMessage({ id: 'theme.motion.label' })}
>
{(['system', 'reduce', 'enable'] as const).map((pref) => (
<button
key={pref}
onClick={() => setMotionPreference(pref)}
role="radio"
aria-checked={motionPreference === pref}
className={`
flex-1 px-3 py-2 rounded-lg text-sm font-medium
transition-all duration-200 border-2
${motionPreference === pref
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{formatMessage({ id: `theme.motion.${pref}` })}
</button>
))}
</div>
</div>
{/* Contrast Warning Banner (non-blocking) */}
{showContrastWarning && contrastWarnings.length > 0 && (
<div className="p-3 rounded-lg bg-warning-light border border-warning text-warning-text space-y-2">
<p className="text-xs font-medium">
{formatMessage({ id: 'theme.accessibility.contrastWarning' })}
</p>
<ul className="text-xs space-y-1">
{contrastWarnings.map((w) => {
const key = `${w.fgVar}|${w.bgVar}`;
const fixes = contrastFixes[key] || [];
return (
<li key={key} className="space-y-1">
<span>
{w.fgVar} / {w.bgVar}: {w.ratio}:1 (min {w.required}:1)
</span>
{fixes.length > 0 && (
<div className="ml-2 text-[10px]">
{fixes.slice(0, 1).map((fix, i) => (
<span key={i} className="block">
{formatMessage(
{ id: 'theme.accessibility.fixSuggestion' },
{
target: fix.target === 'fg' ? w.fgVar : w.bgVar,
original: fix.original,
suggested: fix.suggested,
ratio: fix.resultRatio,
}
)}
</span>
))}
</div>
)}
</li>
);
})}
</ul>
<button
onClick={() => setShowContrastWarning(false)}
className="text-xs font-medium underline"
>
{formatMessage({ id: 'theme.accessibility.dismiss' })}
</button>
</div>
)}
{/* Theme Mode Selection */} {/* Theme Mode Selection */}
<div> <div>
<h3 className="text-sm font-medium text-text mb-3"> <h3 className="text-sm font-medium text-text mb-3">
@@ -379,6 +970,173 @@ export function ThemeSelector() {
{formatMessage({ id: 'theme.current' }, { name: getThemeName(colorScheme, mode) })} {formatMessage({ id: 'theme.current' }, { name: getThemeName(colorScheme, mode) })}
</p> </p>
</div> </div>
{/* Theme Sharing Section */}
<div>
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.share.label' })}
</h3>
<div className="flex gap-2">
{/* Copy Theme Code button */}
<button
onClick={handleCopyThemeCode}
className="
flex-1 px-3 py-2 rounded-lg text-sm font-medium
border-2 border-border bg-bg text-text
hover:bg-surface transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{copyFeedback
? formatMessage({ id: 'theme.share.copied' })
: formatMessage({ id: 'theme.share.copyCode' })
}
</button>
{/* Import Theme button */}
<button
onClick={showImportPanel ? handleCloseImport : handleOpenImport}
className={`
flex-1 px-3 py-2 rounded-lg text-sm font-medium
transition-all duration-200 border-2
${showImportPanel
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg text-text hover:bg-surface'
}
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
`}
>
{formatMessage({ id: 'theme.share.import' })}
</button>
</div>
{/* Import Panel */}
{showImportPanel && (
<div className="mt-3 space-y-3">
{/* Paste textarea */}
<textarea
value={importCode}
onChange={(e) => handleImportCodeChange(e.target.value)}
placeholder={formatMessage({ id: 'theme.share.paste' })}
rows={3}
className="
w-full px-3 py-2 rounded-lg text-sm font-mono
bg-bg border-2 border-border text-text
placeholder-text-tertiary resize-none
focus:outline-none focus:ring-2 focus:ring-accent focus:border-accent
"
/>
{/* Error message */}
{importError && (
<div className="p-2 rounded-lg bg-error-light border border-error text-error-text text-xs">
{formatMessage({ id: getShareErrorMessageId(importError) })}
</div>
)}
{/* Version warning */}
{importWarning && !importError && (
<div className="p-2 rounded-lg bg-warning-light border border-warning text-warning-text text-xs">
{formatMessage({ id: 'theme.share.versionWarning' })}
</div>
)}
{/* Import Preview Card */}
{importPreview && !importError && (
<div className="p-3 rounded-lg bg-surface border border-border space-y-3">
<p className="text-xs font-medium text-text">
{formatMessage({ id: 'theme.share.preview' })}
</p>
{/* Preview swatches */}
<div className="flex gap-3 items-end">
{(() => {
const previewColors = getImportPreviewColors(importPreview);
return (
<>
<div className="flex flex-col items-center gap-1">
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: previewColors.bg }}
/>
<span className="text-[10px] text-text-tertiary">
{formatMessage({ id: 'theme.preview.background' })}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: previewColors.surface }}
/>
<span className="text-[10px] text-text-tertiary">
{formatMessage({ id: 'theme.preview.surface' })}
</span>
</div>
<div className="flex flex-col items-center gap-1">
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: previewColors.accent }}
/>
<span className="text-[10px] text-text-tertiary">
{formatMessage({ id: 'theme.preview.accent' })}
</span>
</div>
</>
);
})()}
</div>
{/* Settings summary */}
<div className="text-xs text-text-secondary space-y-1">
<p>
{formatMessage({ id: 'theme.styleTier.label' })}: {formatMessage({ id: `theme.styleTier.${importPreview.styleTier === 'high-contrast' ? 'highContrast' : importPreview.styleTier}` })}
</p>
<p>
{formatMessage({ id: 'theme.gradient.title' })}: {formatMessage({ id: `theme.gradient.${importPreview.gradientLevel}` })}
</p>
{importPreview.customHue !== null && (
<p>
{formatMessage({ id: 'theme.hueValue' }, { value: importPreview.customHue })}
</p>
)}
{importPreview.customHue === null && (
<p>
{formatMessage({ id: 'theme.title.colorScheme' })}: {formatMessage({ id: `theme.colorScheme.${importPreview.colorScheme}` })}
</p>
)}
</div>
{/* Apply / Cancel buttons */}
<div className="flex gap-2 pt-1">
<button
onClick={handleApplyImport}
className="
flex-1 px-4 py-2 rounded-lg text-sm font-medium
bg-accent text-white hover:bg-accent-hover
transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{formatMessage({ id: 'theme.share.apply' })}
</button>
<button
onClick={handleCloseImport}
className="
px-4 py-2 rounded-lg text-sm font-medium
border-2 border-border bg-bg text-text
hover:bg-surface transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-accent focus:ring-offset-2
"
>
{formatMessage({ id: 'theme.share.cancel' })}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@@ -23,6 +23,8 @@ const badgeVariants = cva(
"border-transparent bg-info text-white", "border-transparent bg-info text-white",
review: review:
"border-transparent bg-purple-600 text-white", "border-transparent bg-purple-600 text-white",
gradient:
"border-transparent bg-gradient-brand bg-primary text-primary-foreground",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -56,11 +56,16 @@ import {
type CodexLensWorkspaceStatus, type CodexLensWorkspaceStatus,
type CodexLensSearchParams, type CodexLensSearchParams,
type CodexLensSearchResponse, type CodexLensSearchResponse,
type CodexLensFileSearchResponse,
type CodexLensSymbolSearchResponse, type CodexLensSymbolSearchResponse,
type CodexLensIndexesResponse, type CodexLensIndexesResponse,
type CodexLensIndexingStatusResponse, type CodexLensIndexingStatusResponse,
type CodexLensSemanticInstallResponse,
type CodexLensWatcherStatusResponse, type CodexLensWatcherStatusResponse,
type CodexLensLspStatusResponse,
type CodexLensSemanticSearchParams,
type CodexLensSemanticSearchResponse,
fetchCodexLensLspStatus,
semanticSearchCodexLens,
} from '../lib/api'; } from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -83,6 +88,8 @@ export const codexLensKeys = {
search: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'search', params] as const, search: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'search', params] as const,
filesSearch: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'filesSearch', params] as const, filesSearch: (params: CodexLensSearchParams) => [...codexLensKeys.all, 'filesSearch', params] as const,
symbolSearch: (params: Pick<CodexLensSearchParams, 'query' | 'limit'>) => [...codexLensKeys.all, 'symbolSearch', params] as const, symbolSearch: (params: Pick<CodexLensSearchParams, 'query' | 'limit'>) => [...codexLensKeys.all, 'symbolSearch', params] as const,
lspStatus: () => [...codexLensKeys.all, 'lspStatus'] as const,
semanticSearch: (params: CodexLensSemanticSearchParams) => [...codexLensKeys.all, 'semanticSearch', params] as const,
watcher: () => [...codexLensKeys.all, 'watcher'] as const, watcher: () => [...codexLensKeys.all, 'watcher'] as const,
}; };
@@ -1288,10 +1295,18 @@ export function useCodexLensSearch(params: CodexLensSearchParams, options: UseCo
}; };
} }
export interface UseCodexLensFileSearchReturn {
data: CodexLensFileSearchResponse | undefined;
files: string[] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/** /**
* Hook for file search using CodexLens * Hook for file search using CodexLens
*/ */
export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensSearchReturn { export function useCodexLensFilesSearch(params: CodexLensSearchParams, options: UseCodexLensSearchOptions = {}): UseCodexLensFileSearchReturn {
const { enabled = false } = options; const { enabled = false } = options;
const query = useQuery({ const query = useQuery({
@@ -1308,7 +1323,7 @@ export function useCodexLensFilesSearch(params: CodexLensSearchParams, options:
return { return {
data: query.data, data: query.data,
results: query.data?.results, files: query.data?.files,
isLoading: query.isLoading, isLoading: query.isLoading,
error: query.error, error: query.error,
refetch, refetch,
@@ -1357,6 +1372,98 @@ export function useCodexLensSymbolSearch(
}; };
} }
// ========== LSP / Semantic Search Hooks ==========
export interface UseCodexLensLspStatusOptions {
enabled?: boolean;
staleTime?: number;
}
export interface UseCodexLensLspStatusReturn {
data: CodexLensLspStatusResponse | undefined;
available: boolean;
semanticAvailable: boolean;
vectorIndex: boolean;
modes: string[];
strategies: string[];
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for checking CodexLens LSP/semantic search availability
*/
export function useCodexLensLspStatus(options: UseCodexLensLspStatusOptions = {}): UseCodexLensLspStatusReturn {
const { enabled = true, staleTime = STALE_TIME_MEDIUM } = options;
const query = useQuery({
queryKey: codexLensKeys.lspStatus(),
queryFn: fetchCodexLensLspStatus,
staleTime,
enabled,
retry: 2,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
available: query.data?.available ?? false,
semanticAvailable: query.data?.semantic_available ?? false,
vectorIndex: query.data?.vector_index ?? false,
modes: query.data?.modes ?? [],
strategies: query.data?.strategies ?? [],
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
export interface UseCodexLensSemanticSearchOptions {
enabled?: boolean;
}
export interface UseCodexLensSemanticSearchReturn {
data: CodexLensSemanticSearchResponse | undefined;
results: CodexLensSemanticSearchResponse['results'] | undefined;
isLoading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
/**
* Hook for semantic search using CodexLens Python API
*/
export function useCodexLensSemanticSearch(
params: CodexLensSemanticSearchParams,
options: UseCodexLensSemanticSearchOptions = {}
): UseCodexLensSemanticSearchReturn {
const { enabled = false } = options;
const query = useQuery({
queryKey: codexLensKeys.semanticSearch(params),
queryFn: () => semanticSearchCodexLens(params),
enabled,
staleTime: STALE_TIME_SHORT,
retry: 1,
});
const refetch = async () => {
await query.refetch();
};
return {
data: query.data,
results: query.data?.results,
isLoading: query.isLoading,
error: query.error,
refetch,
};
}
// ========== File Watcher Hooks ========== // ========== File Watcher Hooks ==========
export interface UseCodexLensWatcherOptions { export interface UseCodexLensWatcherOptions {

View File

@@ -3,7 +3,7 @@
// ======================================== // ========================================
// Convenient hook for theme management with multi-color scheme support // Convenient hook for theme management with multi-color scheme support
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { import {
useAppStore, useAppStore,
selectTheme, selectTheme,
@@ -13,8 +13,16 @@ import {
selectGradientLevel, selectGradientLevel,
selectEnableHoverGlow, selectEnableHoverGlow,
selectEnableBackgroundAnimation, selectEnableBackgroundAnimation,
selectMotionPreference,
selectThemeSlots,
selectActiveSlotId,
selectDeletedSlotBuffer,
} from '../stores/appStore'; } from '../stores/appStore';
import type { Theme, ColorScheme, GradientLevel } from '../types/store'; import type { Theme, ColorScheme, GradientLevel, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
import { resolveMotionPreference } from '../lib/accessibility';
import { THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
import { encodeTheme, decodeTheme } from '../lib/themeShare';
import type { ImportResult } from '../lib/themeShare';
export interface UseThemeReturn { export interface UseThemeReturn {
/** Current theme preference ('light', 'dark', 'system') */ /** Current theme preference ('light', 'dark', 'system') */
@@ -35,6 +43,10 @@ export interface UseThemeReturn {
enableHoverGlow: boolean; enableHoverGlow: boolean;
/** Whether background gradient animation is enabled */ /** Whether background gradient animation is enabled */
enableBackgroundAnimation: boolean; enableBackgroundAnimation: boolean;
/** User's motion preference setting */
motionPreference: MotionPreference;
/** Resolved motion preference (true = reduce motion) */
resolvedMotion: boolean;
/** Set theme preference */ /** Set theme preference */
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
/** Set color scheme */ /** Set color scheme */
@@ -49,6 +61,46 @@ export interface UseThemeReturn {
setEnableHoverGlow: (enabled: boolean) => void; setEnableHoverGlow: (enabled: boolean) => void;
/** Set background animation enabled */ /** Set background animation enabled */
setEnableBackgroundAnimation: (enabled: boolean) => void; setEnableBackgroundAnimation: (enabled: boolean) => void;
/** Set motion preference */
setMotionPreference: (pref: MotionPreference) => void;
/** Current style tier ('soft', 'standard', 'high-contrast') */
styleTier: StyleTier;
/** Set style tier */
setStyleTier: (tier: StyleTier) => void;
/** All theme slots */
themeSlots: ThemeSlot[];
/** Currently active slot ID */
activeSlotId: ThemeSlotId;
/** Currently active slot object */
activeSlot: ThemeSlot | undefined;
/** Buffer holding recently deleted slot for undo */
deletedSlotBuffer: ThemeSlot | null;
/** Whether user can add more slots (below THEME_SLOT_LIMIT) */
canAddSlot: boolean;
/** Switch to a different theme slot */
setActiveSlot: (slotId: ThemeSlotId) => void;
/** Copy current slot to a new slot */
copySlot: () => void;
/** Rename a slot */
renameSlot: (slotId: ThemeSlotId, name: string) => void;
/** Delete a slot (moves to deletedSlotBuffer for undo) */
deleteSlot: (slotId: ThemeSlotId) => void;
/** Undo the last slot deletion */
undoDeleteSlot: () => void;
/** Export current active slot as a shareable theme code string */
exportThemeCode: () => string;
/** Decode and validate an imported theme code string */
importThemeCode: (code: string) => ImportResult;
/** Current background configuration for the active slot */
backgroundConfig: BackgroundConfig;
/** Set full background config */
setBackgroundConfig: (config: BackgroundConfig) => void;
/** Update a single background effect property */
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => void;
/** Set background mode */
setBackgroundMode: (mode: BackgroundMode) => void;
/** Set background image URL and attribution */
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => void;
} }
/** /**
@@ -78,6 +130,7 @@ export function useTheme(): UseThemeReturn {
const gradientLevel = useAppStore(selectGradientLevel); const gradientLevel = useAppStore(selectGradientLevel);
const enableHoverGlow = useAppStore(selectEnableHoverGlow); const enableHoverGlow = useAppStore(selectEnableHoverGlow);
const enableBackgroundAnimation = useAppStore(selectEnableBackgroundAnimation); const enableBackgroundAnimation = useAppStore(selectEnableBackgroundAnimation);
const motionPreference = useAppStore(selectMotionPreference);
const setThemeAction = useAppStore((state) => state.setTheme); const setThemeAction = useAppStore((state) => state.setTheme);
const setColorSchemeAction = useAppStore((state) => state.setColorScheme); const setColorSchemeAction = useAppStore((state) => state.setColorScheme);
const setCustomHueAction = useAppStore((state) => state.setCustomHue); const setCustomHueAction = useAppStore((state) => state.setCustomHue);
@@ -85,6 +138,26 @@ export function useTheme(): UseThemeReturn {
const setGradientLevelAction = useAppStore((state) => state.setGradientLevel); const setGradientLevelAction = useAppStore((state) => state.setGradientLevel);
const setEnableHoverGlowAction = useAppStore((state) => state.setEnableHoverGlow); const setEnableHoverGlowAction = useAppStore((state) => state.setEnableHoverGlow);
const setEnableBackgroundAnimationAction = useAppStore((state) => state.setEnableBackgroundAnimation); const setEnableBackgroundAnimationAction = useAppStore((state) => state.setEnableBackgroundAnimation);
const setMotionPreferenceAction = useAppStore((state) => state.setMotionPreference);
const setStyleTierAction = useAppStore((state) => state.setStyleTier);
// Slot state
const themeSlots = useAppStore(selectThemeSlots);
const activeSlotId = useAppStore(selectActiveSlotId);
const deletedSlotBuffer = useAppStore(selectDeletedSlotBuffer);
// Background actions
const setBackgroundConfigAction = useAppStore((state) => state.setBackgroundConfig);
const updateBackgroundEffectAction = useAppStore((state) => state.updateBackgroundEffect);
const setBackgroundModeAction = useAppStore((state) => state.setBackgroundMode);
const setBackgroundImageAction = useAppStore((state) => state.setBackgroundImage);
// Slot actions
const setActiveSlotAction = useAppStore((state) => state.setActiveSlot);
const copySlotAction = useAppStore((state) => state.copySlot);
const renameSlotAction = useAppStore((state) => state.renameSlot);
const deleteSlotAction = useAppStore((state) => state.deleteSlot);
const undoDeleteSlotAction = useAppStore((state) => state.undoDeleteSlot);
const setTheme = useCallback( const setTheme = useCallback(
(newTheme: Theme) => { (newTheme: Theme) => {
@@ -132,6 +205,85 @@ export function useTheme(): UseThemeReturn {
[setEnableBackgroundAnimationAction] [setEnableBackgroundAnimationAction]
); );
const setMotionPreference = useCallback(
(pref: MotionPreference) => {
setMotionPreferenceAction(pref);
},
[setMotionPreferenceAction]
);
const setStyleTier = useCallback(
(tier: StyleTier) => {
setStyleTierAction(tier);
},
[setStyleTierAction]
);
const resolvedMotion = resolveMotionPreference(motionPreference);
// Slot computed values
const activeSlot = useMemo(
() => themeSlots.find(s => s.id === activeSlotId),
[themeSlots, activeSlotId]
);
const canAddSlot = themeSlots.length < THEME_SLOT_LIMIT;
const styleTier = activeSlot?.styleTier ?? 'standard';
const backgroundConfig = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
// Slot callbacks
const setActiveSlot = useCallback(
(slotId: ThemeSlotId) => {
setActiveSlotAction(slotId);
},
[setActiveSlotAction]
);
const copySlot = useCallback(() => {
copySlotAction();
}, [copySlotAction]);
const renameSlot = useCallback(
(slotId: ThemeSlotId, name: string) => {
renameSlotAction(slotId, name);
},
[renameSlotAction]
);
const deleteSlot = useCallback(
(slotId: ThemeSlotId) => {
deleteSlotAction(slotId);
},
[deleteSlotAction]
);
const undoDeleteSlot = useCallback(() => {
undoDeleteSlotAction();
}, [undoDeleteSlotAction]);
const exportThemeCode = useCallback((): string => {
if (!activeSlot) {
// Fallback: build a minimal slot from current state
const fallbackSlot: ThemeSlot = {
id: activeSlotId,
name: '',
colorScheme,
customHue,
isCustomTheme,
gradientLevel,
enableHoverGlow,
enableBackgroundAnimation,
styleTier,
isDefault: false,
};
return encodeTheme(fallbackSlot);
}
return encodeTheme(activeSlot);
}, [activeSlot, activeSlotId, colorScheme, customHue, isCustomTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation, styleTier]);
const importThemeCode = useCallback((code: string): ImportResult => {
return decodeTheme(code);
}, []);
return { return {
theme, theme,
resolvedTheme, resolvedTheme,
@@ -142,6 +294,8 @@ export function useTheme(): UseThemeReturn {
gradientLevel, gradientLevel,
enableHoverGlow, enableHoverGlow,
enableBackgroundAnimation, enableBackgroundAnimation,
motionPreference,
resolvedMotion,
setTheme, setTheme,
setColorScheme, setColorScheme,
setCustomHue, setCustomHue,
@@ -149,5 +303,25 @@ export function useTheme(): UseThemeReturn {
setGradientLevel, setGradientLevel,
setEnableHoverGlow, setEnableHoverGlow,
setEnableBackgroundAnimation, setEnableBackgroundAnimation,
setMotionPreference,
styleTier,
setStyleTier,
themeSlots,
activeSlotId,
activeSlot,
deletedSlotBuffer,
canAddSlot,
setActiveSlot,
copySlot,
renameSlot,
deleteSlot,
undoDeleteSlot,
exportThemeCode,
importThemeCode,
backgroundConfig,
setBackgroundConfig: setBackgroundConfigAction,
updateBackgroundEffect: updateBackgroundEffectAction,
setBackgroundMode: setBackgroundModeAction,
setBackgroundImage: setBackgroundImageAction,
}; };
} }

View File

@@ -0,0 +1,26 @@
/**
* React Query hook for searching Unsplash photos with debounce.
*/
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { searchUnsplash } from '@/lib/unsplash';
import type { UnsplashSearchResult } from '@/lib/unsplash';
export function useUnsplashSearch(query: string, page = 1, perPage = 20) {
const [debouncedQuery, setDebouncedQuery] = useState(query);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query);
}, 500);
return () => clearTimeout(timer);
}, [query]);
return useQuery<UnsplashSearchResult>({
queryKey: ['unsplash-search', debouncedQuery, page, perPage],
queryFn: () => searchUnsplash(debouncedQuery, page, perPage),
enabled: debouncedQuery.trim().length > 0,
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
});
}

View File

@@ -340,6 +340,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Schedule reconnection with exponential backoff // Schedule reconnection with exponential backoff
// Define this first to avoid circular dependency // Define this first to avoid circular dependency
const scheduleReconnect = useCallback(() => { const scheduleReconnect = useCallback(() => {
// Don't reconnect after unmount
if (!mountedRef.current) return;
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
} }
@@ -363,7 +366,14 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
}, []); // No dependencies - uses connectRef and getStoreState() }, []); // No dependencies - uses connectRef and getStoreState()
const connect = useCallback(() => { const connect = useCallback(() => {
if (!enabled) return; if (!enabled || !mountedRef.current) return;
// Close existing connection to avoid orphaned sockets
if (wsRef.current) {
wsRef.current.onclose = null; // Prevent onclose from triggering reconnect
wsRef.current.close();
wsRef.current = null;
}
// Construct WebSocket URL // Construct WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -430,6 +440,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
// Connect on mount, cleanup on unmount // Connect on mount, cleanup on unmount
useEffect(() => { useEffect(() => {
// Reset mounted flag (needed after React Strict Mode remount)
mountedRef.current = true;
if (enabled) { if (enabled) {
connect(); connect();
} }
@@ -455,6 +468,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
clearTimeout(reconnectTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
} }
if (wsRef.current) { if (wsRef.current) {
wsRef.current.onclose = null; // Prevent onclose from triggering orphaned reconnect
wsRef.current.close(); wsRef.current.close();
wsRef.current = null; wsRef.current = null;
} }

View File

@@ -454,6 +454,10 @@
-webkit-text-fill-color: inherit; -webkit-text-fill-color: inherit;
} }
[data-gradient="off"] .border-gradient-brand::before {
display: none;
}
/* Standard gradients (default) */ /* Standard gradients (default) */
[data-gradient="standard"] .bg-gradient-primary { [data-gradient="standard"] .bg-gradient-primary {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%); background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
@@ -467,6 +471,13 @@
background: linear-gradient(90deg, hsl(var(--accent)) 0%, hsl(var(--primary)) 100%); background: linear-gradient(90deg, hsl(var(--accent)) 0%, hsl(var(--primary)) 100%);
} }
[data-gradient="standard"] .gradient-text {
background: linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--accent)) 100%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Enhanced gradients - more vibrant with multiple color stops */ /* Enhanced gradients - more vibrant with multiple color stops */
[data-gradient="enhanced"] .bg-gradient-primary { [data-gradient="enhanced"] .bg-gradient-primary {
background: linear-gradient(135deg, background: linear-gradient(135deg,
@@ -492,6 +503,17 @@
); );
} }
[data-gradient="enhanced"] .gradient-text {
background: linear-gradient(135deg,
hsl(var(--primary)) 0%,
hsl(var(--accent)) 50%,
hsl(var(--secondary)) 100%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
/* Hover glow effects - disabled when data-hover-glow="false" */ /* Hover glow effects - disabled when data-hover-glow="false" */
.hover-glow, .hover-glow,
.hover-glow-primary { .hover-glow-primary {
@@ -581,3 +603,144 @@
0%, 100% { transform: translate(0, 0); } 0%, 100% { transform: translate(0, 0); }
50% { transform: translate(-2%, -2%); } 50% { transform: translate(-2%, -2%); }
} }
/* ===========================
Reduced Motion System
data-reduced-motion attribute + prefers-reduced-motion fallback
=========================== */
/* Disable View Transition animations when reduced motion is active */
[data-reduced-motion="true"]::view-transition-old(*),
[data-reduced-motion="true"]::view-transition-new(*),
[data-reduced-motion="true"]::view-transition-group(*) {
animation-duration: 0s !important;
}
/* Disable background gradient animation */
[data-reduced-motion="true"] .animate-slow-gradient {
animation: none;
background-size: 100% 100%;
}
/* Disable hover glow pulse */
[data-reduced-motion="true"] .hover-glow:hover,
[data-reduced-motion="true"] .hover-glow-primary:hover {
box-shadow: none;
}
/* Disable ambient gradient shift animation */
[data-reduced-motion="true"] body::before {
animation: none !important;
}
/* Reduce gradient opacity for enhanced mode */
[data-reduced-motion="true"][data-gradient="enhanced"] body::before {
opacity: 0.5;
}
/* OS-level prefers-reduced-motion fallback (applies when data attr not set) */
@media (prefers-reduced-motion: reduce) {
::view-transition-old(*),
::view-transition-new(*),
::view-transition-group(*) {
animation-duration: 0s !important;
}
.animate-slow-gradient {
animation: none;
background-size: 100% 100%;
}
.hover-glow:hover,
.hover-glow-primary:hover {
box-shadow: none;
}
body::before {
animation: none !important;
}
[data-gradient="enhanced"] body::before {
opacity: 0.5;
}
}
/* ===========================
Background Image System
Layered rendering with effects
=========================== */
/* Hide gradient layer when image-only mode */
[data-bg-mode="image-only"] body::before {
display: none;
}
/* Reduce gradient opacity when overlaying on image */
[data-bg-mode="image-gradient"] body::before {
opacity: 0.6;
}
/* Background image layer */
.bg-image-layer {
position: fixed;
inset: 0;
z-index: -3;
overflow: hidden;
transition: opacity 0.5s ease;
}
.bg-image-layer img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Darken overlay */
.bg-darken-overlay {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
}
/* Grain texture overlay */
.bg-grain-overlay {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
opacity: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.7' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.5'/%3E%3C/svg%3E");
background-repeat: repeat;
background-size: 256px 256px;
}
[data-bg-grain="true"] .bg-grain-overlay {
opacity: 0.08;
}
/* Vignette overlay */
.bg-vignette-overlay {
position: fixed;
inset: 0;
z-index: -2;
pointer-events: none;
opacity: 0;
background: radial-gradient(ellipse at center, transparent 50%, rgba(0, 0, 0, 0.5) 100%);
}
[data-bg-vignette="true"] .bg-vignette-overlay {
opacity: 1;
}
/* Frosted glass effect on content area */
[data-bg-frosted="true"] .app-shell-content {
backdrop-filter: blur(12px) saturate(1.2);
-webkit-backdrop-filter: blur(12px) saturate(1.2);
background-color: hsla(var(--bg), 0.75);
}
/* Disable image layer transitions when reduced motion is active */
[data-reduced-motion="true"] .bg-image-layer {
transition: none !important;
}

View File

@@ -0,0 +1,336 @@
// ========================================
// Accessibility Utilities
// ========================================
// WCAG 2.1 contrast checking and motion preference management
// for the theme system. Operates on HSL 'H S% L%' format strings.
// ========== Types ==========
/** User preference for animation behavior */
export type MotionPreference = 'system' | 'reduce' | 'enable';
/** Result of evaluating one critical color pair against WCAG thresholds */
export interface ContrastResult {
/** Foreground CSS variable name (e.g. '--text') */
fgVar: string;
/** Background CSS variable name (e.g. '--bg') */
bgVar: string;
/** Computed contrast ratio (e.g. 4.52) */
ratio: number;
/** Required minimum contrast ratio for this pair */
required: number;
/** Whether the pair passes WCAG AA */
passed: boolean;
}
/** A single suggested fix to improve contrast, with visual distance metric */
export interface FixSuggestion {
/** Which variable to adjust ('fg' or 'bg') */
target: 'fg' | 'bg';
/** Original HSL value */
original: string;
/** Suggested replacement HSL value */
suggested: string;
/** Resulting contrast ratio after applying the fix */
resultRatio: number;
/** Visual distance from original (lower = less visible change) */
distance: number;
}
// ========== Critical Color Pairs ==========
/**
* Whitelist of critical UI color pairs to check for WCAG AA compliance.
* Each entry: [foreground variable, background variable, required ratio]
*
* - 4.5:1 for normal text (WCAG AA)
* - 3.0:1 for large text / UI components (WCAG AA)
*/
export const CRITICAL_COLOR_PAIRS: ReadonlyArray<[string, string, number]> = [
// Text on backgrounds (4.5:1 - normal text)
['--text', '--bg', 4.5],
['--text', '--surface', 4.5],
['--text-secondary', '--bg', 4.5],
['--text-secondary', '--surface', 4.5],
['--muted-text', '--muted', 4.5],
['--muted-text', '--bg', 4.5],
// Tertiary/disabled text (3:1 - large text threshold)
['--text-tertiary', '--bg', 3.0],
['--text-disabled', '--bg', 3.0],
// Accent/interactive on backgrounds (3:1 - UI component threshold)
['--accent', '--bg', 3.0],
['--accent', '--surface', 3.0],
// Semantic text on semantic light backgrounds (4.5:1)
['--success-text', '--success-light', 4.5],
['--warning-text', '--warning-light', 4.5],
['--error-text', '--error-light', 4.5],
['--info-text', '--info-light', 4.5],
// Foreground on primary/destructive buttons (4.5:1)
['--primary-foreground', '--primary', 4.5],
['--destructive-foreground', '--destructive', 4.5],
// Text on muted surface (4.5:1)
['--text', '--muted', 4.5],
];
// ========== HSL Parsing and Color Conversion ==========
/**
* Parse 'H S% L%' format HSL string into numeric [h, s, l] values.
* h in degrees (0-360), s and l as fractions (0-1).
*/
function parseHSL(hslString: string): [number, number, number] | null {
const trimmed = hslString.trim();
// Match patterns: "220 60% 65%" or "220 60% 65"
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
if (!match) return null;
const h = parseFloat(match[1]);
const s = parseFloat(match[2]) / 100;
const l = parseFloat(match[3]) / 100;
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
return [h, s, l];
}
/**
* Convert HSL values to linear sRGB [R, G, B] in 0-1 range.
* Then apply sRGB linearization per WCAG 2.1 spec.
*/
function hslToLinearRGB(h: number, s: number, l: number): [number, number, number] {
// HSL to sRGB conversion
const c = (1 - Math.abs(2 * l - 1)) * s;
const hPrime = h / 60;
const x = c * (1 - Math.abs((hPrime % 2) - 1));
const m = l - c / 2;
let r1: number, g1: number, b1: number;
if (hPrime < 1) { r1 = c; g1 = x; b1 = 0; }
else if (hPrime < 2) { r1 = x; g1 = c; b1 = 0; }
else if (hPrime < 3) { r1 = 0; g1 = c; b1 = x; }
else if (hPrime < 4) { r1 = 0; g1 = x; b1 = c; }
else if (hPrime < 5) { r1 = x; g1 = 0; b1 = c; }
else { r1 = c; g1 = 0; b1 = x; }
const rSRGB = r1 + m;
const gSRGB = g1 + m;
const bSRGB = b1 + m;
// sRGB linearization per WCAG 2.1
const linearize = (v: number): number => {
if (v <= 0.04045) return v / 12.92;
return Math.pow((v + 0.055) / 1.055, 2.4);
};
return [linearize(rSRGB), linearize(gSRGB), linearize(bSRGB)];
}
/**
* Parse 'H S% L%' format HSL string, convert to sRGB,
* compute WCAG 2.1 relative luminance.
*
* Formula: L = 0.2126*R + 0.7152*G + 0.0722*B
* with sRGB linearization applied.
*
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
* @returns Relative luminance (0-1), or -1 if parsing fails
*/
export function hslToRelativeLuminance(hslString: string): number {
const parsed = parseHSL(hslString);
if (!parsed) return -1;
const [h, s, l] = parsed;
const [rLin, gLin, bLin] = hslToLinearRGB(h, s, l);
// Round to 4-decimal precision to avoid floating-point edge cases
return Math.round((0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin) * 10000) / 10000;
}
/**
* Compute WCAG contrast ratio from two relative luminance values.
* Returns ratio in format X:1 (just the X number).
*
* @param l1 - Relative luminance of first color
* @param l2 - Relative luminance of second color
* @returns Contrast ratio (always >= 1)
*/
export function getContrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return Math.round(((lighter + 0.05) / (darker + 0.05)) * 100) / 100;
}
// ========== Theme Contrast Checking ==========
/**
* Evaluate all critical color pairs in a generated theme against WCAG AA thresholds.
* Only checks pairs where both variables exist in the provided vars map.
*
* @param vars - Record of CSS variable names to HSL values (e.g. from generateThemeFromHue)
* @returns Array of ContrastResult for each evaluated pair
*/
export function checkThemeContrast(vars: Record<string, string>): ContrastResult[] {
const results: ContrastResult[] = [];
for (const [fgVar, bgVar, required] of CRITICAL_COLOR_PAIRS) {
const fgValue = vars[fgVar];
const bgValue = vars[bgVar];
if (!fgValue || !bgValue) continue;
const fgLum = hslToRelativeLuminance(fgValue);
const bgLum = hslToRelativeLuminance(bgValue);
if (fgLum < 0 || bgLum < 0) continue;
const ratio = getContrastRatio(fgLum, bgLum);
// Use 0.01 tolerance buffer for borderline cases
const passed = ratio >= (required - 0.01);
results.push({ fgVar, bgVar, ratio, required, passed });
}
return results;
}
// ========== Contrast Fix Generation ==========
/**
* Reconstruct 'H S% L%' string from components.
*/
function toHSLString(h: number, s: number, l: number): string {
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
}
/**
* Generate 2-3 lightness-adjusted alternatives that achieve target contrast ratio.
* Preserves hue and saturation, only adjusts lightness.
* Sorted by minimal visual change (distance).
*
* @param fgVar - Foreground CSS variable name
* @param bgVar - Background CSS variable name
* @param currentVars - Current theme variable values
* @param targetRatio - Target contrast ratio to achieve
* @returns Array of 2-3 FixSuggestion sorted by distance (ascending)
*/
export function generateContrastFix(
fgVar: string,
bgVar: string,
currentVars: Record<string, string>,
targetRatio: number
): FixSuggestion[] {
const fgValue = currentVars[fgVar];
const bgValue = currentVars[bgVar];
if (!fgValue || !bgValue) return [];
const fgParsed = parseHSL(fgValue);
const bgParsed = parseHSL(bgValue);
if (!fgParsed || !bgParsed) return [];
const suggestions: FixSuggestion[] = [];
// Strategy 1: Adjust foreground lightness (darken or lighten)
const bgLum = hslToRelativeLuminance(bgValue);
const fgSuggestions = findLightnessForContrast(
fgParsed[0], fgParsed[1], fgParsed[2], bgLum, targetRatio
);
for (const newL of fgSuggestions) {
const suggested = toHSLString(fgParsed[0], fgParsed[1] * 100, newL * 100);
const newFgLum = hslToRelativeLuminance(suggested);
if (newFgLum < 0) continue;
const resultRatio = getContrastRatio(newFgLum, bgLum);
if (resultRatio >= targetRatio - 0.01) {
suggestions.push({
target: 'fg',
original: fgValue,
suggested,
resultRatio,
distance: Math.abs(newL - fgParsed[2]),
});
}
}
// Strategy 2: Adjust background lightness
const fgLum = hslToRelativeLuminance(fgValue);
const bgSuggestions = findLightnessForContrast(
bgParsed[0], bgParsed[1], bgParsed[2], fgLum, targetRatio
);
for (const newL of bgSuggestions) {
const suggested = toHSLString(bgParsed[0], bgParsed[1] * 100, newL * 100);
const newBgLum = hslToRelativeLuminance(suggested);
if (newBgLum < 0) continue;
const resultRatio = getContrastRatio(fgLum, newBgLum);
if (resultRatio >= targetRatio - 0.01) {
suggestions.push({
target: 'bg',
original: bgValue,
suggested,
resultRatio,
distance: Math.abs(newL - bgParsed[2]),
});
}
}
// Sort by distance (minimal visual change first) and take up to 3
suggestions.sort((a, b) => a.distance - b.distance);
return suggestions.slice(0, 3);
}
/**
* Find lightness values that achieve target contrast against a reference luminance.
* Searches in both lighter and darker directions from current lightness.
* Returns up to 2 candidates (one lighter, one darker if found).
*/
function findLightnessForContrast(
h: number,
s: number,
currentL: number,
refLum: number,
targetRatio: number
): number[] {
const candidates: number[] = [];
const step = 0.01;
// Search darker direction (decreasing lightness)
for (let l = currentL - step; l >= 0; l -= step) {
const hsl = toHSLString(h, s * 100, l * 100);
const lum = hslToRelativeLuminance(hsl);
if (lum < 0) continue;
const ratio = getContrastRatio(lum, refLum);
if (ratio >= targetRatio) {
candidates.push(l);
break;
}
}
// Search lighter direction (increasing lightness)
for (let l = currentL + step; l <= 1; l += step) {
const hsl = toHSLString(h, s * 100, l * 100);
const lum = hslToRelativeLuminance(hsl);
if (lum < 0) continue;
const ratio = getContrastRatio(lum, refLum);
if (ratio >= targetRatio) {
candidates.push(l);
break;
}
}
return candidates;
}
// ========== Motion Preference ==========
/**
* Resolve user preference to actual reduced-motion boolean.
* 'system' checks matchMedia, 'reduce' returns true, 'enable' returns false.
*
* @param pref - User's motion preference setting
* @returns true if motion should be reduced, false otherwise
*/
export function resolveMotionPreference(pref: MotionPreference): boolean {
if (pref === 'reduce') return true;
if (pref === 'enable') return false;
// 'system' - check OS preference
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

View File

@@ -3239,7 +3239,7 @@ function buildCcwMcpServerConfig(config: {
if (config.enabledTools && config.enabledTools.length > 0) { if (config.enabledTools && config.enabledTools.length > 0) {
env.CCW_ENABLED_TOOLS = config.enabledTools.join(','); env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
} else { } else {
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question'; env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
} }
if (config.projectRoot) { if (config.projectRoot) {
@@ -3352,7 +3352,7 @@ export async function installCcwMcp(
projectPath?: string projectPath?: string
): Promise<CcwMcpConfig> { ): Promise<CcwMcpConfig> {
const serverConfig = buildCcwMcpServerConfig({ const serverConfig = buildCcwMcpServerConfig({
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'], enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
}); });
if (scope === 'project' && projectPath) { if (scope === 'project' && projectPath) {
@@ -3853,7 +3853,7 @@ export interface CodexLensGpuListResponse {
} }
/** /**
* Model info * Model info (normalized from CLI output)
*/ */
export interface CodexLensModel { export interface CodexLensModel {
profile: string; profile: string;
@@ -3863,10 +3863,26 @@ export interface CodexLensModel {
size?: string; size?: string;
installed: boolean; installed: boolean;
cache_path?: string; cache_path?: string;
/** Original HuggingFace model name */
model_name?: string;
/** Model description */
description?: string;
/** Use case description */
use_case?: string;
/** Embedding dimensions */
dimensions?: number;
/** Whether this model is recommended */
recommended?: boolean;
/** Model source: 'predefined' | 'discovered' */
source?: string;
/** Estimated size in MB */
estimated_size_mb?: number;
/** Actual size in MB (when installed) */
actual_size_mb?: number | null;
} }
/** /**
* Model list response * Model list response (normalized)
*/ */
export interface CodexLensModelsResponse { export interface CodexLensModelsResponse {
success: boolean; success: boolean;
@@ -4067,9 +4083,43 @@ export async function uninstallCodexLens(): Promise<CodexLensUninstallResponse>
/** /**
* Fetch CodexLens models list * Fetch CodexLens models list
* Normalizes the CLI response format to match the frontend interface.
* CLI returns: { success, result: { models: [{ model_name, estimated_size_mb, ... }] } }
* Frontend expects: { success, models: [{ name, size, type, backend, ... }] }
*/ */
export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> { export async function fetchCodexLensModels(): Promise<CodexLensModelsResponse> {
return fetchApi<CodexLensModelsResponse>('/api/codexlens/models'); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw = await fetchApi<any>('/api/codexlens/models');
// Handle nested result structure from CLI
const rawModels = raw?.result?.models ?? raw?.models ?? [];
const models: CodexLensModel[] = rawModels.map((m: Record<string, unknown>) => ({
profile: (m.profile as string) || '',
name: (m.model_name as string) || (m.name as string) || (m.profile as string) || '',
type: (m.type as 'embedding' | 'reranker') || 'embedding',
backend: (m.source as string) || 'fastembed',
size: m.installed && m.actual_size_mb
? `${(m.actual_size_mb as number).toFixed(0)} MB`
: m.estimated_size_mb
? `~${m.estimated_size_mb} MB`
: undefined,
installed: (m.installed as boolean) ?? false,
cache_path: m.cache_path as string | undefined,
model_name: m.model_name as string | undefined,
description: m.description as string | undefined,
use_case: m.use_case as string | undefined,
dimensions: m.dimensions as number | undefined,
recommended: m.recommended as boolean | undefined,
source: m.source as string | undefined,
estimated_size_mb: m.estimated_size_mb as number | undefined,
actual_size_mb: m.actual_size_mb as number | null | undefined,
}));
return {
success: raw?.success ?? true,
models,
};
} }
/** /**
@@ -4240,6 +4290,17 @@ export interface CodexLensSymbolSearchResponse {
error?: string; error?: string;
} }
/**
* CodexLens file search response (returns file paths only)
*/
export interface CodexLensFileSearchResponse {
success: boolean;
query?: string;
count?: number;
files: string[];
error?: string;
}
/** /**
* Perform content search using CodexLens * Perform content search using CodexLens
*/ */
@@ -4257,7 +4318,7 @@ export async function searchCodexLens(params: CodexLensSearchParams): Promise<Co
/** /**
* Perform file search using CodexLens * Perform file search using CodexLens
*/ */
export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensSearchResponse> { export async function searchFilesCodexLens(params: CodexLensSearchParams): Promise<CodexLensFileSearchResponse> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
queryParams.append('query', params.query); queryParams.append('query', params.query);
if (params.limit) queryParams.append('limit', String(params.limit)); if (params.limit) queryParams.append('limit', String(params.limit));
@@ -4265,7 +4326,7 @@ export async function searchFilesCodexLens(params: CodexLensSearchParams): Promi
if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length)); if (params.max_content_length) queryParams.append('max_content_length', String(params.max_content_length));
if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count)); if (params.extra_files_count) queryParams.append('extra_files_count', String(params.extra_files_count));
return fetchApi<CodexLensSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`); return fetchApi<CodexLensFileSearchResponse>(`/api/codexlens/search_files?${queryParams.toString()}`);
} }
/** /**
@@ -4279,6 +4340,84 @@ export async function searchSymbolCodexLens(params: Pick<CodexLensSearchParams,
return fetchApi<CodexLensSymbolSearchResponse>(`/api/codexlens/symbol?${queryParams.toString()}`); return fetchApi<CodexLensSymbolSearchResponse>(`/api/codexlens/symbol?${queryParams.toString()}`);
} }
// ========== CodexLens LSP / Semantic Search API ==========
/**
* CodexLens LSP status response
*/
export interface CodexLensLspStatusResponse {
available: boolean;
semantic_available: boolean;
vector_index: boolean;
project_count?: number;
embeddings?: Record<string, unknown>;
modes?: string[];
strategies?: string[];
error?: string;
}
/**
* CodexLens semantic search params (Python API)
*/
export type CodexLensSemanticSearchMode = 'fusion' | 'vector' | 'structural';
export type CodexLensFusionStrategy = 'rrf' | 'staged' | 'binary' | 'hybrid' | 'dense_rerank';
export interface CodexLensSemanticSearchParams {
query: string;
path?: string;
mode?: CodexLensSemanticSearchMode;
fusion_strategy?: CodexLensFusionStrategy;
vector_weight?: number;
structural_weight?: number;
keyword_weight?: number;
kind_filter?: string[];
limit?: number;
include_match_reason?: boolean;
}
/**
* CodexLens semantic search result
*/
export interface CodexLensSemanticSearchResult {
name?: string;
kind?: string;
file_path?: string;
score?: number;
match_reason?: string;
range?: { start_line: number; end_line: number };
[key: string]: unknown;
}
/**
* CodexLens semantic search response
*/
export interface CodexLensSemanticSearchResponse {
success: boolean;
results?: CodexLensSemanticSearchResult[];
query?: string;
mode?: string;
fusion_strategy?: string;
count?: number;
error?: string;
}
/**
* Fetch CodexLens LSP status
*/
export async function fetchCodexLensLspStatus(): Promise<CodexLensLspStatusResponse> {
return fetchApi<CodexLensLspStatusResponse>('/api/codexlens/lsp/status');
}
/**
* Perform semantic search using CodexLens Python API
*/
export async function semanticSearchCodexLens(params: CodexLensSemanticSearchParams): Promise<CodexLensSemanticSearchResponse> {
return fetchApi<CodexLensSemanticSearchResponse>('/api/codexlens/lsp/search', {
method: 'POST',
body: JSON.stringify(params),
});
}
// ========== CodexLens Index Management API ========== // ========== CodexLens Index Management API ==========
/** /**

View File

@@ -10,6 +10,126 @@
* @module colorGenerator * @module colorGenerator
*/ */
import type { StyleTier } from '../types/store';
// ========== Style Tier System ==========
/** Per-tier adjustment factors for saturation, lightness, and contrast */
export interface StyleTierCoefficients {
saturationScale: number;
lightnessOffset: { light: number; dark: number };
contrastBoost: number;
}
/**
* Style tier coefficient definitions.
* - soft: reduced saturation, lighter feel, lower contrast
* - standard: identity transform (no change)
* - high-contrast: boosted saturation, sharper text/background separation
*/
export const STYLE_TIER_COEFFICIENTS: Record<StyleTier, StyleTierCoefficients> = {
soft: {
saturationScale: 0.6,
lightnessOffset: { light: 5, dark: -3 },
contrastBoost: 0.9,
},
standard: {
saturationScale: 1.0,
lightnessOffset: { light: 0, dark: 0 },
contrastBoost: 1.0,
},
'high-contrast': {
saturationScale: 1.3,
lightnessOffset: { light: -5, dark: 3 },
contrastBoost: 1.2,
},
};
/**
* Parse 'H S% L%' format HSL string into numeric components.
* H in degrees (0-360), S and L as percentages (0-100).
*
* @param hslString - HSL string in 'H S% L%' format (e.g. '220 60% 65%')
* @returns Parsed components or null if parsing fails
*/
export function parseHSL(hslString: string): { h: number; s: number; l: number } | null {
const trimmed = hslString.trim();
const match = trimmed.match(/^([\d.]+)\s+([\d.]+)%?\s+([\d.]+)%?$/);
if (!match) return null;
const h = parseFloat(match[1]);
const s = parseFloat(match[2]);
const l = parseFloat(match[3]);
if (isNaN(h) || isNaN(s) || isNaN(l)) return null;
return { h, s, l };
}
/**
* Format numeric HSL values back to 'H S% L%' string.
* Values are clamped to valid ranges.
*
* @param h - Hue in degrees (0-360)
* @param s - Saturation as percentage (0-100)
* @param l - Lightness as percentage (0-100)
* @returns Formatted HSL string
*/
export function formatHSL(h: number, s: number, l: number): string {
const clampedS = Math.max(0, Math.min(100, Math.round(s * 10) / 10));
const clampedL = Math.max(0, Math.min(100, Math.round(l * 10) / 10));
return `${Math.round(h)} ${clampedS}% ${clampedL}%`;
}
/**
* Apply style tier coefficients to a set of CSS variable values.
* Adjusts saturation and lightness per tier, preserving hue.
*
* Processing pipeline per variable:
* 1. Scale saturation: s * saturationScale
* 2. Apply contrast boost: stretch lightness from midpoint (50%)
* 3. Apply lightness offset (mode-specific)
* 4. Clamp to valid ranges
*
* Standard tier is an identity transform (returns input unchanged).
*
* @param vars - Record of CSS variable names to HSL values in 'H S% L%' format
* @param tier - Style tier to apply
* @param mode - Current theme mode
* @returns Modified CSS variables record
*/
export function applyStyleTier(
vars: Record<string, string>,
tier: StyleTier,
mode: 'light' | 'dark'
): Record<string, string> {
if (tier === 'standard') return vars;
const coeffs = STYLE_TIER_COEFFICIENTS[tier];
const offset = mode === 'light' ? coeffs.lightnessOffset.light : coeffs.lightnessOffset.dark;
const result: Record<string, string> = {};
for (const [varName, value] of Object.entries(vars)) {
const parsed = parseHSL(value);
if (!parsed) {
result[varName] = value;
continue;
}
// 1. Apply saturation scaling
let s = parsed.s * coeffs.saturationScale;
s = Math.max(0, Math.min(100, s));
// 2. Apply contrast boost (stretch lightness from midpoint)
let l = 50 + (parsed.l - 50) * coeffs.contrastBoost;
// 3. Apply lightness offset
l = l + offset;
l = Math.max(0, Math.min(100, l));
result[varName] = formatHSL(parsed.h, s, l);
}
return result;
}
/** /**
* Generate a complete theme from a single hue value * Generate a complete theme from a single hue value
* *

View File

@@ -3,6 +3,8 @@
* Defines available color schemes and theme modes for the CCW application * Defines available color schemes and theme modes for the CCW application
*/ */
import type { ThemeSlot, ThemeSlotId, BackgroundEffects, BackgroundConfig } from '../types/store';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple'; export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type ThemeMode = 'light' | 'dark'; export type ThemeMode = 'light' | 'dark';
export type ThemeId = `${ThemeMode}-${ColorScheme}`; export type ThemeId = `${ThemeMode}-${ColorScheme}`;
@@ -112,3 +114,62 @@ export const DEFAULT_THEME: Theme = {
mode: 'light', mode: 'light',
name: '经典蓝 · 浅色' name: '经典蓝 · 浅色'
}; };
// ========== Background Defaults ==========
export const DEFAULT_BACKGROUND_EFFECTS: BackgroundEffects = {
blur: 0,
darkenOpacity: 0,
saturation: 100,
enableFrostedGlass: false,
enableGrain: false,
enableVignette: false,
};
export const DEFAULT_BACKGROUND_CONFIG: BackgroundConfig = {
mode: 'gradient-only',
imageUrl: null,
attribution: null,
effects: DEFAULT_BACKGROUND_EFFECTS,
};
// ========== Theme Slot Constants ==========
/** Maximum number of theme slots a user can have */
export const THEME_SLOT_LIMIT = 3;
/** Default theme slot with preset values */
export const DEFAULT_SLOT: ThemeSlot = {
id: 'default',
name: 'Default',
colorScheme: 'blue',
customHue: null,
isCustomTheme: false,
gradientLevel: 'standard',
enableHoverGlow: true,
enableBackgroundAnimation: false,
styleTier: 'standard',
isDefault: true,
};
/**
* Factory function to create a new empty theme slot with default values.
*
* @param id - Slot identifier
* @param name - Display name for the slot
* @returns A new ThemeSlot with default theme values
*/
export function createEmptySlot(id: ThemeSlotId, name: string): ThemeSlot {
return {
id,
name,
colorScheme: 'blue',
customHue: null,
isCustomTheme: false,
gradientLevel: 'standard',
enableHoverGlow: true,
enableBackgroundAnimation: false,
styleTier: 'standard',
isDefault: false,
};
}

View File

@@ -0,0 +1,327 @@
/**
* Theme Sharing Module
* Encodes/decodes theme configurations as compact base64url strings
* for copy-paste sharing between users.
*
* Format: 'ccw{version}:{base64url_payload}'
* Payload uses short field names for compactness:
* v=version, c=colorScheme, h=customHue, t=styleTier,
* g=gradientLevel, w=enableHoverGlow, a=enableBackgroundAnimation
* bm=backgroundMode, bi=backgroundImageUrl, bp=photographerName,
* bu=photographerUrl, bpu=photoUrl, be=backgroundEffects
*
* @module themeShare
*/
import type { ThemeSlot, BackgroundConfig, BackgroundEffects } from '../types/store';
import type { ColorScheme, GradientLevel, StyleTier, BackgroundMode } from '../types/store';
import { DEFAULT_BACKGROUND_EFFECTS } from './theme';
// ========== Constants ==========
/** Current share format version. Bump when payload schema changes. */
export const SHARE_VERSION = 2;
/** Maximum encoded string length accepted for import */
const MAX_ENCODED_LENGTH = 800;
/** Version prefix pattern: 'ccw' followed by version number and colon */
const PREFIX_PATTERN = /^ccw(\d+):(.+)$/;
// ========== Types ==========
/** Serializable theme state for encoding/decoding */
export interface ThemeSharePayload {
version: number;
colorScheme: ColorScheme;
customHue: number | null;
styleTier: StyleTier;
gradientLevel: GradientLevel;
enableHoverGlow: boolean;
enableBackgroundAnimation: boolean;
backgroundConfig?: BackgroundConfig;
}
/** Compact wire format using short keys for smaller base64 output */
interface CompactPayload {
v: number;
c: string;
h: number | null;
t: string;
g: string;
w: boolean;
a: boolean;
// v2 background fields (optional)
bm?: string;
bi?: string;
bp?: string;
bu?: string;
bpu?: string;
be?: CompactEffects;
}
/** Compact background effects */
interface CompactEffects {
b: number; // blur
d: number; // darkenOpacity
s: number; // saturation
f: boolean; // enableFrostedGlass
g: boolean; // enableGrain
v: boolean; // enableVignette
}
/** Result of decoding and validating an import string */
export type ImportResult =
| { ok: true; payload: ThemeSharePayload; warning?: string }
| { ok: false; error: string };
/** Version compatibility check result */
export interface VersionCheckResult {
compatible: boolean;
warning?: string;
}
// ========== Validation Constants ==========
const VALID_COLOR_SCHEMES: readonly string[] = ['blue', 'green', 'orange', 'purple'];
const VALID_STYLE_TIERS: readonly string[] = ['soft', 'standard', 'high-contrast'];
const VALID_GRADIENT_LEVELS: readonly string[] = ['off', 'standard', 'enhanced'];
const VALID_BACKGROUND_MODES: readonly string[] = ['gradient-only', 'image-only', 'image-gradient'];
// ========== Encoding ==========
/**
* Encode a theme slot into a compact URL-safe base64 string with version prefix.
*
* Output format: 'ccw2:{base64url}'
* The base64url payload contains JSON with short keys for compactness.
* Background fields are only included when mode != gradient-only.
*
* @param slot - Theme slot to encode
* @returns Encoded theme string (typically under 300 characters)
*/
export function encodeTheme(slot: ThemeSlot): string {
const compact: CompactPayload = {
v: SHARE_VERSION,
c: slot.colorScheme,
h: slot.customHue,
t: slot.styleTier,
g: slot.gradientLevel,
w: slot.enableHoverGlow,
a: slot.enableBackgroundAnimation,
};
// Only include background fields when mode != gradient-only
const bg = slot.backgroundConfig;
if (bg && bg.mode !== 'gradient-only') {
compact.bm = bg.mode;
if (bg.imageUrl) compact.bi = bg.imageUrl;
if (bg.attribution) {
compact.bp = bg.attribution.photographerName;
compact.bu = bg.attribution.photographerUrl;
compact.bpu = bg.attribution.photoUrl;
}
compact.be = {
b: bg.effects.blur,
d: bg.effects.darkenOpacity,
s: bg.effects.saturation,
f: bg.effects.enableFrostedGlass,
g: bg.effects.enableGrain,
v: bg.effects.enableVignette,
};
}
const json = JSON.stringify(compact);
// Use TextEncoder for consistent UTF-8 handling
const encoder = new TextEncoder();
const bytes = encoder.encode(json);
// Convert bytes to binary string for btoa
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
// Base64 encode, then make URL-safe
const base64 = btoa(binary);
const base64url = base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return `ccw${SHARE_VERSION}:${base64url}`;
}
// ========== Decoding ==========
/**
* Decode and validate a theme share string.
* Checks version compatibility and validates all field types/ranges.
* Invalid input never causes side effects.
* Handles both v1 (no background) and v2 (with background) payloads.
*
* @param encoded - The encoded theme string to decode
* @returns ImportResult with decoded payload on success, or error message on failure
*/
export function decodeTheme(encoded: string): ImportResult {
// Guard: reject empty input
if (!encoded || typeof encoded !== 'string') {
return { ok: false, error: 'empty_input' };
}
const trimmed = encoded.trim();
// Guard: reject strings exceeding max length
if (trimmed.length > MAX_ENCODED_LENGTH) {
return { ok: false, error: 'too_long' };
}
// Extract version and payload from prefix
const prefixMatch = trimmed.match(PREFIX_PATTERN);
if (!prefixMatch) {
return { ok: false, error: 'invalid_format' };
}
const prefixVersion = parseInt(prefixMatch[1], 10);
const base64url = prefixMatch[2];
// Restore standard base64 from URL-safe variant
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const remainder = base64.length % 4;
if (remainder === 2) base64 += '==';
else if (remainder === 3) base64 += '=';
// Decode base64 to bytes
let json: string;
try {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Use TextDecoder for consistent UTF-8 handling
const decoder = new TextDecoder();
json = decoder.decode(bytes);
} catch {
return { ok: false, error: 'decode_failed' };
}
// Parse JSON
let compact: unknown;
try {
compact = JSON.parse(json);
} catch {
return { ok: false, error: 'parse_failed' };
}
// Validate object shape
if (!compact || typeof compact !== 'object' || Array.isArray(compact)) {
return { ok: false, error: 'invalid_payload' };
}
const obj = compact as Record<string, unknown>;
// Extract and validate version
const payloadVersion = typeof obj.v === 'number' ? obj.v : prefixVersion;
// Check version compatibility
const versionCheck = isVersionCompatible(payloadVersion);
if (!versionCheck.compatible) {
return { ok: false, error: 'incompatible_version' };
}
// Validate colorScheme
if (typeof obj.c !== 'string' || !VALID_COLOR_SCHEMES.includes(obj.c)) {
return { ok: false, error: 'invalid_field' };
}
// Validate customHue
if (obj.h !== null && (typeof obj.h !== 'number' || !isFinite(obj.h))) {
return { ok: false, error: 'invalid_field' };
}
// Validate styleTier
if (typeof obj.t !== 'string' || !VALID_STYLE_TIERS.includes(obj.t)) {
return { ok: false, error: 'invalid_field' };
}
// Validate gradientLevel
if (typeof obj.g !== 'string' || !VALID_GRADIENT_LEVELS.includes(obj.g)) {
return { ok: false, error: 'invalid_field' };
}
// Validate booleans
if (typeof obj.w !== 'boolean' || typeof obj.a !== 'boolean') {
return { ok: false, error: 'invalid_field' };
}
const payload: ThemeSharePayload = {
version: payloadVersion,
colorScheme: obj.c as ColorScheme,
customHue: obj.h as number | null,
styleTier: obj.t as StyleTier,
gradientLevel: obj.g as GradientLevel,
enableHoverGlow: obj.w,
enableBackgroundAnimation: obj.a,
};
// Decode v2 background fields (optional — v1 payloads simply lack them)
if (typeof obj.bm === 'string' && VALID_BACKGROUND_MODES.includes(obj.bm)) {
const effects: BackgroundEffects = { ...DEFAULT_BACKGROUND_EFFECTS };
// Parse compact effects
if (obj.be && typeof obj.be === 'object' && !Array.isArray(obj.be)) {
const be = obj.be as Record<string, unknown>;
if (typeof be.b === 'number') effects.blur = be.b;
if (typeof be.d === 'number') effects.darkenOpacity = be.d;
if (typeof be.s === 'number') effects.saturation = be.s;
if (typeof be.f === 'boolean') effects.enableFrostedGlass = be.f;
if (typeof be.g === 'boolean') effects.enableGrain = be.g;
if (typeof be.v === 'boolean') effects.enableVignette = be.v;
}
payload.backgroundConfig = {
mode: obj.bm as BackgroundMode,
imageUrl: typeof obj.bi === 'string' ? obj.bi : null,
attribution: (typeof obj.bp === 'string' && typeof obj.bu === 'string' && typeof obj.bpu === 'string')
? { photographerName: obj.bp, photographerUrl: obj.bu, photoUrl: obj.bpu }
: null,
effects,
};
}
return {
ok: true,
payload,
warning: versionCheck.warning,
};
}
// ========== Version Compatibility ==========
/**
* Check if a payload version is within +/-2 of the current SHARE_VERSION.
* Versions outside range are incompatible. Versions within range but
* not equal get a warning that accuracy may vary.
*
* @param payloadVersion - Version number from the decoded payload
* @returns Compatibility result with optional warning
*/
export function isVersionCompatible(payloadVersion: number): VersionCheckResult {
const diff = Math.abs(payloadVersion - SHARE_VERSION);
if (diff > 2) {
return { compatible: false };
}
if (diff > 0) {
return {
compatible: true,
warning: 'version_mismatch',
};
}
return { compatible: true };
}

View File

@@ -0,0 +1,102 @@
/**
* Unsplash API Client
* Frontend functions to search Unsplash via the backend proxy.
*/
export interface UnsplashPhoto {
id: string;
thumbUrl: string;
smallUrl: string;
regularUrl: string;
photographer: string;
photographerUrl: string;
photoUrl: string;
blurHash: string | null;
downloadLocation: string;
}
export interface UnsplashSearchResult {
photos: UnsplashPhoto[];
total: number;
totalPages: number;
}
function getCsrfToken(): string | null {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
/**
* Search Unsplash photos via backend proxy.
*/
export async function searchUnsplash(
query: string,
page = 1,
perPage = 20
): Promise<UnsplashSearchResult> {
const params = new URLSearchParams({
query,
page: String(page),
per_page: String(perPage),
});
const response = await fetch(`/api/unsplash/search?${params}`, {
credentials: 'same-origin',
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Unsplash search failed: ${response.status}`);
}
return response.json();
}
/**
* Upload a local image as background.
* Sends raw binary to avoid base64 overhead.
*/
export async function uploadBackgroundImage(file: File): Promise<{ url: string; filename: string }> {
const headers: Record<string, string> = {
'Content-Type': file.type,
'X-Filename': file.name,
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
const response = await fetch('/api/background/upload', {
method: 'POST',
headers,
credentials: 'same-origin',
body: file,
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error || `Upload failed: ${response.status}`);
}
return response.json();
}
/**
* Trigger Unsplash download event (required by API guidelines).
*/
export async function triggerUnsplashDownload(downloadLocation: string): Promise<void> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
const csrfToken = getCsrfToken();
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
await fetch('/api/unsplash/download', {
method: 'POST',
headers,
credentials: 'same-origin',
body: JSON.stringify({ downloadLocation }),
});
}

View File

@@ -224,10 +224,26 @@
"content": "Content Search", "content": "Content Search",
"files": "File Search", "files": "File Search",
"symbol": "Symbol Search", "symbol": "Symbol Search",
"semantic": "Semantic Search (LSP)",
"mode": "Mode", "mode": "Mode",
"mode.semantic": "Semantic (default)", "mode.semantic": "Semantic (default)",
"mode.exact": "Exact (FTS)", "mode.exact": "Exact (FTS)",
"mode.fuzzy": "Fuzzy", "mode.fuzzy": "Fuzzy",
"semanticMode": "Search Mode",
"semanticMode.fusion": "Fusion Search",
"semanticMode.vector": "Vector Search",
"semanticMode.structural": "Structural Search",
"fusionStrategy": "Fusion Strategy",
"fusionStrategy.rrf": "RRF (default)",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"lspStatus": "LSP Status",
"lspAvailable": "Semantic search available",
"lspUnavailable": "Semantic search unavailable",
"lspNoVector": "Vector index required",
"lspNoSemantic": "Semantic dependencies required",
"query": "Query", "query": "Query",
"queryPlaceholder": "Enter search query...", "queryPlaceholder": "Enter search query...",
"button": "Search", "button": "Search",

View File

@@ -132,6 +132,10 @@
"ask_question": { "ask_question": {
"name": "ask_question", "name": "ask_question",
"desc": "Ask interactive questions through A2UI interface" "desc": "Ask interactive questions through A2UI interface"
},
"smart_search": {
"name": "smart_search",
"desc": "Intelligent code search with fuzzy and semantic modes"
} }
}, },
"paths": { "paths": {

View File

@@ -34,5 +34,87 @@
"enhanced": "Enhanced", "enhanced": "Enhanced",
"hoverGlow": "Enable hover glow effects", "hoverGlow": "Enable hover glow effects",
"bgAnimation": "Enable background gradient animation" "bgAnimation": "Enable background gradient animation"
},
"accessibility": {
"contrastWarning": "Some color combinations may not meet WCAG AA contrast requirements:",
"fixSuggestion": "Suggested fix: adjust {target} from {original} to {suggested} (ratio: {ratio}:1)",
"applyFix": "Apply Fix",
"dismiss": "Dismiss"
},
"motion": {
"label": "Motion Preference",
"system": "System",
"reduce": "Reduce",
"enable": "Enable"
},
"slot": {
"title": "Theme Slots",
"default": "Default",
"custom1": "Custom 1",
"custom2": "Custom 2",
"copy": "Copy Slot",
"rename": "Rename",
"delete": "Delete",
"undoDelete": "Slot deleted. Undo?",
"undo": "Undo",
"limitReached": "Maximum of {limit} theme slots reached",
"deleteConfirm": "Delete this theme slot?",
"cannotDeleteDefault": "Cannot delete the default slot",
"renameEmpty": "Slot name cannot be empty",
"copyOf": "Copy of {name}",
"active": "Active"
},
"styleTier": {
"label": "Style Tier",
"soft": "Soft",
"standard": "Standard",
"highContrast": "High Contrast",
"softDesc": "Reduced saturation, gentle colors",
"standardDesc": "Default balanced appearance",
"highContrastDesc": "Enhanced readability, sharper colors"
},
"background": {
"title": "Background Image",
"mode": {
"gradientOnly": "Gradient",
"imageOnly": "Image",
"imageGradient": "Image+Gradient"
},
"searchPlaceholder": "Search Unsplash photos...",
"customUrlPlaceholder": "Custom image URL...",
"apply": "Apply",
"removeImage": "Remove",
"effects": "Visual Effects",
"blur": "Blur",
"darken": "Darken",
"saturation": "Saturation",
"frostedGlass": "Frosted glass effect on content",
"grain": "Noise texture overlay",
"vignette": "Vignette (dark edges)",
"searchError": "Failed to search photos. Check if Unsplash API is configured.",
"noResults": "No photos found",
"prev": "Prev",
"next": "Next",
"loadFailed": "Image failed to load, fallback to gradient",
"upload": "Upload local image",
"uploading": "Uploading...",
"uploadError": "Upload failed",
"fileTooLarge": "File too large (max 10MB)",
"invalidType": "Only JPG, PNG, WebP, GIF supported"
},
"share": {
"label": "Theme Sharing",
"copyCode": "Copy Theme Code",
"copied": "Theme code copied to clipboard",
"import": "Import Theme",
"paste": "Paste theme code here...",
"preview": "Import Preview",
"apply": "Apply Theme",
"cancel": "Cancel",
"invalidCode": "Invalid theme code. Please check and try again.",
"incompatibleVersion": "This theme code is from an incompatible version and cannot be imported.",
"versionWarning": "This theme code is from a different version. Some settings may not be accurate.",
"importSuccess": "Theme imported successfully",
"noSlotAvailable": "No available theme slot. Delete a custom slot first."
} }
} }

View File

@@ -224,10 +224,26 @@
"content": "内容搜索", "content": "内容搜索",
"files": "文件搜索", "files": "文件搜索",
"symbol": "符号搜索", "symbol": "符号搜索",
"semantic": "语义搜索 (LSP)",
"mode": "模式", "mode": "模式",
"mode.semantic": "语义(默认)", "mode.semantic": "语义(默认)",
"mode.exact": "精确FTS", "mode.exact": "精确FTS",
"mode.fuzzy": "模糊", "mode.fuzzy": "模糊",
"semanticMode": "搜索模式",
"semanticMode.fusion": "融合搜索",
"semanticMode.vector": "向量搜索",
"semanticMode.structural": "结构搜索",
"fusionStrategy": "融合策略",
"fusionStrategy.rrf": "RRF默认",
"fusionStrategy.dense_rerank": "Dense Rerank",
"fusionStrategy.binary": "Binary",
"fusionStrategy.hybrid": "Hybrid",
"fusionStrategy.staged": "Staged",
"lspStatus": "LSP 状态",
"lspAvailable": "语义搜索可用",
"lspUnavailable": "语义搜索不可用",
"lspNoVector": "需要先建立向量索引",
"lspNoSemantic": "需要先安装语义依赖",
"query": "查询", "query": "查询",
"queryPlaceholder": "输入搜索查询...", "queryPlaceholder": "输入搜索查询...",
"button": "搜索", "button": "搜索",
@@ -294,6 +310,7 @@
"installing": "安装中..." "installing": "安装中..."
}, },
"watcher": { "watcher": {
"title": "文件监听器",
"status": { "status": {
"running": "运行中", "running": "运行中",
"stopped": "已停止" "stopped": "已停止"

View File

@@ -132,6 +132,10 @@
"ask_question": { "ask_question": {
"name": "ask_question", "name": "ask_question",
"desc": "通过 A2UI 界面发起交互式问答" "desc": "通过 A2UI 界面发起交互式问答"
},
"smart_search": {
"name": "smart_search",
"desc": "智能代码搜索,支持模糊和语义搜索模式"
} }
}, },
"paths": { "paths": {

View File

@@ -34,5 +34,87 @@
"enhanced": "增强", "enhanced": "增强",
"hoverGlow": "启用悬停光晕效果", "hoverGlow": "启用悬停光晕效果",
"bgAnimation": "启用背景渐变动画" "bgAnimation": "启用背景渐变动画"
},
"accessibility": {
"contrastWarning": "部分颜色组合可能不符合 WCAG AA 对比度要求:",
"fixSuggestion": "建议修复: 将{target}从 {original} 调整为 {suggested} (对比度: {ratio}:1)",
"applyFix": "应用修复",
"dismiss": "忽略"
},
"motion": {
"label": "动效偏好",
"system": "跟随系统",
"reduce": "减少动效",
"enable": "启用动效"
},
"slot": {
"title": "主题槽位",
"default": "默认",
"custom1": "自定义 1",
"custom2": "自定义 2",
"copy": "复制槽位",
"rename": "重命名",
"delete": "删除",
"undoDelete": "槽位已删除,是否撤销?",
"undo": "撤销",
"limitReached": "最多只能创建 {limit} 个主题槽位",
"deleteConfirm": "确定删除此主题槽位?",
"cannotDeleteDefault": "无法删除默认槽位",
"renameEmpty": "槽位名称不能为空",
"copyOf": "{name}的副本",
"active": "使用中"
},
"styleTier": {
"label": "风格档位",
"soft": "柔和",
"standard": "标准",
"highContrast": "高对比",
"softDesc": "降低饱和度,柔和色彩",
"standardDesc": "默认均衡外观",
"highContrastDesc": "增强可读性,色彩更鲜明"
},
"background": {
"title": "背景图片",
"mode": {
"gradientOnly": "仅渐变",
"imageOnly": "仅图片",
"imageGradient": "图片+渐变"
},
"searchPlaceholder": "搜索 Unsplash 图片...",
"customUrlPlaceholder": "自定义图片 URL...",
"apply": "应用",
"removeImage": "移除",
"effects": "视觉效果",
"blur": "模糊",
"darken": "暗化",
"saturation": "饱和度",
"frostedGlass": "内容区毛玻璃效果",
"grain": "噪点纹理叠加",
"vignette": "暗角效果",
"searchError": "搜索图片失败,请检查 Unsplash API 是否已配置。",
"noResults": "未找到图片",
"prev": "上一页",
"next": "下一页",
"loadFailed": "图片加载失败,已回退到渐变模式",
"upload": "上传本地图片",
"uploading": "上传中...",
"uploadError": "上传失败",
"fileTooLarge": "文件过大(最大 10MB",
"invalidType": "仅支持 JPG、PNG、WebP、GIF"
},
"share": {
"label": "主题分享",
"copyCode": "复制主题代码",
"copied": "主题代码已复制到剪贴板",
"import": "导入主题",
"paste": "在此粘贴主题代码...",
"preview": "导入预览",
"apply": "应用主题",
"cancel": "取消",
"invalidCode": "无效的主题代码,请检查后重试。",
"incompatibleVersion": "此主题代码来自不兼容的版本,无法导入。",
"versionWarning": "此主题代码来自不同版本,部分设置可能不准确。",
"importSuccess": "主题导入成功",
"noSlotAvailable": "没有可用的主题槽位,请先删除一个自定义槽位。"
} }
} }

View File

@@ -116,6 +116,7 @@ export const CheckboxComponentSchema = z.object({
checked: BooleanContentSchema.optional(), checked: BooleanContentSchema.optional(),
onChange: ActionSchema, onChange: ActionSchema,
label: TextContentSchema.optional(), label: TextContentSchema.optional(),
description: TextContentSchema.optional(),
}), }),
}); });
@@ -202,6 +203,8 @@ export const ComponentSchema: z.ZodType<any> = z.union([
export const SurfaceComponentSchema = z.object({ export const SurfaceComponentSchema = z.object({
id: z.string(), id: z.string(),
component: ComponentSchema, component: ComponentSchema,
/** Page index for multi-page surfaces (0-based) */
page: z.number().int().min(0).optional(),
}); });
/** Display mode for A2UI surfaces */ /** Display mode for A2UI surfaces */

View File

@@ -51,17 +51,28 @@ export const A2UICheckbox: ComponentRenderer = ({ component, state, onAction, re
? resolveTextContent(checkboxConfig.label, resolveBinding) ? resolveTextContent(checkboxConfig.label, resolveBinding)
: ''; : '';
// Resolve description text
const descriptionText = checkboxConfig.description
? resolveTextContent(checkboxConfig.description, resolveBinding)
: '';
return ( return (
<div className="flex items-center space-x-2"> <div className="flex items-start space-x-2">
<Checkbox <Checkbox
className="mt-0.5"
checked={checked} checked={checked}
onCheckedChange={handleChange} onCheckedChange={handleChange}
/> />
{labelText && ( <div className="grid gap-0.5">
<Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> {labelText && (
{labelText} <Label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
</Label> {labelText}
)} </Label>
)}
{descriptionText && (
<p className="text-xs text-muted-foreground">{descriptionText}</p>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -286,7 +286,7 @@ export function HelpPage() {
</div> </div>
{/* Search Documentation CTA */} {/* Search Documentation CTA */}
<Card className="p-6 sm:p-8 bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20"> <Card className="p-6 sm:p-8 bg-gradient-accent border-primary/20">
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:justify-between"> <div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:justify-between">
<div className="flex items-start gap-4 flex-1 min-w-0"> <div className="flex items-start gap-4 flex-1 min-w-0">
<div className="p-3 rounded-lg bg-primary/20 flex-shrink-0"> <div className="p-3 rounded-lg bg-primary/20 flex-shrink-0">

View File

@@ -235,7 +235,7 @@ export function ProjectOverviewPage() {
{/* Header Row */} {/* Header Row */}
<div className="flex items-start justify-between mb-4 pb-3 border-b border-border"> <div className="flex items-start justify-between mb-4 pb-3 border-b border-border">
<div className="flex-1"> <div className="flex-1">
<h1 className="text-lg font-semibold text-foreground mb-1"> <h1 className="text-lg font-semibold text-foreground gradient-text mb-1">
{projectOverview.projectName} {projectOverview.projectName}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -5,11 +5,12 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware'; import { persist, devtools } from 'zustand/middleware';
import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig } from '../types/store'; import type { AppStore, Theme, ColorScheme, GradientLevel, Locale, ViewMode, SessionFilter, LiteTaskType, DashboardLayouts, WidgetConfig, MotionPreference, StyleTier, ThemeSlot, ThemeSlotId, BackgroundConfig, BackgroundEffects, BackgroundMode, UnsplashAttribution } from '../types/store';
import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts'; import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts';
import { getInitialLocale, updateIntl } from '../lib/i18n'; import { getInitialLocale, updateIntl } from '../lib/i18n';
import { getThemeId } from '../lib/theme'; import { getThemeId, DEFAULT_SLOT, THEME_SLOT_LIMIT, DEFAULT_BACKGROUND_CONFIG } from '../lib/theme';
import { generateThemeFromHue } from '../lib/colorGenerator'; import { generateThemeFromHue, applyStyleTier } from '../lib/colorGenerator';
import { resolveMotionPreference, checkThemeContrast } from '../lib/accessibility';
// Helper to resolve system theme // Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => { const getSystemTheme = (): 'light' | 'dark' => {
@@ -25,6 +26,12 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => {
return theme; return theme;
}; };
/** Get the style tier from the active slot */
const getActiveStyleTier = (themeSlots: ThemeSlot[], activeSlotId: ThemeSlotId): StyleTier => {
const slot = themeSlots.find(s => s.id === activeSlotId);
return slot?.styleTier ?? 'standard';
};
/** /**
* DOM Theme Application Helper * DOM Theme Application Helper
* *
@@ -44,7 +51,9 @@ const applyThemeToDocument = (
customHue: number | null, customHue: number | null,
gradientLevel: GradientLevel = 'standard', gradientLevel: GradientLevel = 'standard',
enableHoverGlow: boolean = true, enableHoverGlow: boolean = true,
enableBackgroundAnimation: boolean = false enableBackgroundAnimation: boolean = false,
motionPreference: MotionPreference = 'system',
styleTier: StyleTier = 'standard'
): void => { ): void => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
@@ -78,11 +87,29 @@ const applyThemeToDocument = (
// Apply custom theme or preset theme // Apply custom theme or preset theme
if (customHue !== null) { if (customHue !== null) {
const cssVars = generateThemeFromHue(customHue, resolvedTheme); let cssVars = generateThemeFromHue(customHue, resolvedTheme);
// Apply style tier post-processing
if (styleTier !== 'standard') {
cssVars = applyStyleTier(cssVars, styleTier, resolvedTheme);
}
Object.entries(cssVars).forEach(([varName, varValue]) => { Object.entries(cssVars).forEach(([varName, varValue]) => {
document.documentElement.style.setProperty(varName, varValue); document.documentElement.style.setProperty(varName, varValue);
}); });
document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`); document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`);
// Contrast validation for non-standard tiers
if (styleTier !== 'standard') {
const contrastResults = checkThemeContrast(cssVars);
const failures = contrastResults.filter(r => !r.passed);
if (failures.length > 0) {
console.warn(
'[Theme] Style tier "%s" caused %d WCAG AA contrast failures:',
styleTier,
failures.length,
failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
);
}
}
} else { } else {
// Clear custom CSS variables // Clear custom CSS variables
customVars.forEach(varName => { customVars.forEach(varName => {
@@ -91,6 +118,35 @@ const applyThemeToDocument = (
// Apply preset theme // Apply preset theme
const themeId = getThemeId(colorScheme, resolvedTheme); const themeId = getThemeId(colorScheme, resolvedTheme);
document.documentElement.setAttribute('data-theme', themeId); document.documentElement.setAttribute('data-theme', themeId);
// Apply style tier to preset theme (if not standard)
if (styleTier !== 'standard') {
const computed = getComputedStyle(document.documentElement);
const presetVars: Record<string, string> = {};
for (const varName of customVars) {
const value = computed.getPropertyValue(varName).trim();
if (value) {
presetVars[varName] = value;
}
}
const tieredVars = applyStyleTier(presetVars, styleTier, resolvedTheme);
Object.entries(tieredVars).forEach(([varName, varValue]) => {
document.documentElement.style.setProperty(varName, varValue);
});
// Contrast validation for preset themes with non-standard tiers
const contrastResults = checkThemeContrast(tieredVars);
const failures = contrastResults.filter(r => !r.passed);
if (failures.length > 0) {
console.warn(
'[Theme] Style tier "%s" on preset "%s" caused %d WCAG AA contrast failures:',
styleTier,
colorScheme,
failures.length,
failures.map(f => `${f.fgVar}/${f.bgVar}: ${f.ratio}:1 (min ${f.required}:1)`)
);
}
}
} }
// Set color scheme attribute // Set color scheme attribute
@@ -100,10 +156,19 @@ const applyThemeToDocument = (
document.documentElement.setAttribute('data-gradient', gradientLevel); document.documentElement.setAttribute('data-gradient', gradientLevel);
document.documentElement.setAttribute('data-hover-glow', String(enableHoverGlow)); document.documentElement.setAttribute('data-hover-glow', String(enableHoverGlow));
document.documentElement.setAttribute('data-bg-animation', String(enableBackgroundAnimation)); document.documentElement.setAttribute('data-bg-animation', String(enableBackgroundAnimation));
// Apply reduced motion preference
const reducedMotion = resolveMotionPreference(motionPreference);
document.documentElement.setAttribute('data-reduced-motion', String(reducedMotion));
// Set style tier data attribute
document.documentElement.setAttribute('data-style-tier', styleTier);
}; };
// Use View Transition API for smooth transitions (progressive enhancement) // Use View Transition API for smooth transitions (progressive enhancement)
if (typeof document !== 'undefined' && 'startViewTransition' in document) { // Skip view transition when reduced motion is active
const reducedMotion = resolveMotionPreference(motionPreference);
if (!reducedMotion && typeof document !== 'undefined' && 'startViewTransition' in document) {
(document as unknown as { startViewTransition: (callback: () => void) => void }).startViewTransition(performThemeUpdate); (document as unknown as { startViewTransition: (callback: () => void) => void }).startViewTransition(performThemeUpdate);
} else { } else {
// Fallback: apply immediately without transition // Fallback: apply immediately without transition
@@ -111,6 +176,23 @@ const applyThemeToDocument = (
} }
}; };
/**
* Apply background configuration to document data attributes.
* Sets data-bg-* attributes on <html> that CSS rules respond to.
*/
const applyBackgroundToDocument = (config: BackgroundConfig): void => {
if (typeof document === 'undefined') return;
const el = document.documentElement;
el.setAttribute('data-bg-mode', config.mode);
el.setAttribute('data-bg-blur', String(config.effects.blur));
el.setAttribute('data-bg-darken', String(config.effects.darkenOpacity));
el.setAttribute('data-bg-saturation', String(config.effects.saturation));
el.setAttribute('data-bg-frosted', String(config.effects.enableFrostedGlass));
el.setAttribute('data-bg-grain', String(config.effects.enableGrain));
el.setAttribute('data-bg-vignette', String(config.effects.enableVignette));
};
// Initial state // Initial state
const initialState = { const initialState = {
// Theme // Theme
@@ -125,6 +207,9 @@ const initialState = {
enableHoverGlow: true, enableHoverGlow: true,
enableBackgroundAnimation: false, enableBackgroundAnimation: false,
// Motion preference
motionPreference: 'system' as MotionPreference,
// Locale // Locale
locale: getInitialLocale() as Locale, locale: getInitialLocale() as Locale,
@@ -146,6 +231,11 @@ const initialState = {
// Dashboard layout // Dashboard layout
dashboardLayout: null, dashboardLayout: null,
// Theme slots
themeSlots: [DEFAULT_SLOT] as ThemeSlot[],
activeSlotId: 'default' as ThemeSlotId,
deletedSlotBuffer: null as ThemeSlot | null,
}; };
export const useAppStore = create<AppStore>()( export const useAppStore = create<AppStore>()(
@@ -161,31 +251,60 @@ export const useAppStore = create<AppStore>()(
set({ theme, resolvedTheme: resolved }, false, 'setTheme'); set({ theme, resolvedTheme: resolved }, false, 'setTheme');
// Apply theme using helper (encapsulates DOM manipulation) // Apply theme using helper (encapsulates DOM manipulation)
const { colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get(); const state = get();
applyThemeToDocument(resolved, colorScheme, customHue, gradientLevel, enableHoverGlow, enableBackgroundAnimation); const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(resolved, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
}, },
setColorScheme: (colorScheme: ColorScheme) => { setColorScheme: (colorScheme: ColorScheme) => {
set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme'); set((state) => ({
colorScheme,
customHue: null,
isCustomTheme: false,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, colorScheme, customHue: null, isCustomTheme: false }
: slot
),
}), false, 'setColorScheme');
// Apply color scheme using helper (encapsulates DOM manipulation) // Apply color scheme using helper (encapsulates DOM manipulation)
const { resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get(); const state = get();
applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation); const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
}, },
setCustomHue: (hue: number | null) => { setCustomHue: (hue: number | null) => {
if (hue === null) { if (hue === null) {
// Reset to preset theme // Reset to preset theme
const { colorScheme, resolvedTheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get(); const state = get();
set({ customHue: null, isCustomTheme: false }, false, 'setCustomHue'); const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(resolvedTheme, colorScheme, null, gradientLevel, enableHoverGlow, enableBackgroundAnimation); set((s) => ({
customHue: null,
isCustomTheme: false,
themeSlots: s.themeSlots.map(slot =>
slot.id === s.activeSlotId
? { ...slot, customHue: null, isCustomTheme: false }
: slot
),
}), false, 'setCustomHue');
applyThemeToDocument(state.resolvedTheme, state.colorScheme, null, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
return; return;
} }
// Apply custom hue // Apply custom hue
set({ customHue: hue, isCustomTheme: true }, false, 'setCustomHue'); set((state) => ({
const { resolvedTheme, colorScheme, gradientLevel, enableHoverGlow, enableBackgroundAnimation } = get(); customHue: hue,
applyThemeToDocument(resolvedTheme, colorScheme, hue, gradientLevel, enableHoverGlow, enableBackgroundAnimation); isCustomTheme: true,
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, customHue: hue, isCustomTheme: true }
: slot
),
}), false, 'setCustomHue');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, hue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
}, },
toggleTheme: () => { toggleTheme: () => {
@@ -197,21 +316,64 @@ export const useAppStore = create<AppStore>()(
// ========== Gradient Settings Actions ========== // ========== Gradient Settings Actions ==========
setGradientLevel: (level: GradientLevel) => { setGradientLevel: (level: GradientLevel) => {
set({ gradientLevel: level }, false, 'setGradientLevel'); set((state) => ({
const { resolvedTheme, colorScheme, customHue, enableHoverGlow, enableBackgroundAnimation } = get(); gradientLevel: level,
applyThemeToDocument(resolvedTheme, colorScheme, customHue, level, enableHoverGlow, enableBackgroundAnimation); themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, gradientLevel: level }
: slot
),
}), false, 'setGradientLevel');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, level, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, styleTier);
}, },
setEnableHoverGlow: (enabled: boolean) => { setEnableHoverGlow: (enabled: boolean) => {
set({ enableHoverGlow: enabled }, false, 'setEnableHoverGlow'); set((state) => ({
const { resolvedTheme, colorScheme, customHue, gradientLevel, enableBackgroundAnimation } = get(); enableHoverGlow: enabled,
applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enabled, enableBackgroundAnimation); themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, enableHoverGlow: enabled }
: slot
),
}), false, 'setEnableHoverGlow');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, enabled, state.enableBackgroundAnimation, state.motionPreference, styleTier);
}, },
setEnableBackgroundAnimation: (enabled: boolean) => { setEnableBackgroundAnimation: (enabled: boolean) => {
set({ enableBackgroundAnimation: enabled }, false, 'setEnableBackgroundAnimation'); set((state) => ({
const { resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow } = get(); enableBackgroundAnimation: enabled,
applyThemeToDocument(resolvedTheme, colorScheme, customHue, gradientLevel, enableHoverGlow, enabled); themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, enableBackgroundAnimation: enabled }
: slot
),
}), false, 'setEnableBackgroundAnimation');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, enabled, state.motionPreference, styleTier);
},
setMotionPreference: (pref: MotionPreference) => {
set({ motionPreference: pref }, false, 'setMotionPreference');
const state = get();
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, pref, styleTier);
},
setStyleTier: (tier: StyleTier) => {
set((state) => ({
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, styleTier: tier }
: slot
),
}), false, 'setStyleTier');
const state = get();
applyThemeToDocument(state.resolvedTheme, state.colorScheme, state.customHue, state.gradientLevel, state.enableHoverGlow, state.enableBackgroundAnimation, state.motionPreference, tier);
}, },
// ========== Locale Actions ========== // ========== Locale Actions ==========
@@ -302,10 +464,216 @@ export const useAppStore = create<AppStore>()(
resetDashboardLayout: () => { resetDashboardLayout: () => {
set({ dashboardLayout: DEFAULT_DASHBOARD_LAYOUT }, false, 'resetDashboardLayout'); set({ dashboardLayout: DEFAULT_DASHBOARD_LAYOUT }, false, 'resetDashboardLayout');
}, },
// ========== Theme Slot Actions ==========
setActiveSlot: (slotId: ThemeSlotId) => {
const { themeSlots, motionPreference } = get();
const slot = themeSlots.find(s => s.id === slotId);
if (!slot) return;
const resolved = resolveTheme(get().theme);
set({
activeSlotId: slotId,
colorScheme: slot.colorScheme,
customHue: slot.customHue,
isCustomTheme: slot.isCustomTheme,
gradientLevel: slot.gradientLevel,
enableHoverGlow: slot.enableHoverGlow,
enableBackgroundAnimation: slot.enableBackgroundAnimation,
}, false, 'setActiveSlot');
applyThemeToDocument(
resolved,
slot.colorScheme,
slot.customHue,
slot.gradientLevel,
slot.enableHoverGlow,
slot.enableBackgroundAnimation,
motionPreference,
slot.styleTier
);
applyBackgroundToDocument(slot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
},
copySlot: () => {
const state = get();
if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
// Determine next available slot id
const usedIds = new Set(state.themeSlots.map(s => s.id));
const candidateIds: ThemeSlotId[] = ['custom-1', 'custom-2'];
const nextId = candidateIds.find(id => !usedIds.has(id));
if (!nextId) return;
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
if (!activeSlot) return;
const newSlot: ThemeSlot = {
id: nextId,
name: `Copy of ${activeSlot.name}`,
colorScheme: state.colorScheme,
customHue: state.customHue,
isCustomTheme: state.isCustomTheme,
gradientLevel: state.gradientLevel,
enableHoverGlow: state.enableHoverGlow,
enableBackgroundAnimation: state.enableBackgroundAnimation,
styleTier: activeSlot.styleTier,
isDefault: false,
backgroundConfig: activeSlot.backgroundConfig,
};
set({
themeSlots: [...state.themeSlots, newSlot],
activeSlotId: nextId,
}, false, 'copySlot');
},
renameSlot: (slotId: ThemeSlotId, name: string) => {
set((state) => ({
themeSlots: state.themeSlots.map(slot =>
slot.id === slotId ? { ...slot, name } : slot
),
}), false, 'renameSlot');
},
deleteSlot: (slotId: ThemeSlotId) => {
const state = get();
const slot = state.themeSlots.find(s => s.id === slotId);
if (!slot || slot.isDefault) return;
set({
themeSlots: state.themeSlots.filter(s => s.id !== slotId),
deletedSlotBuffer: slot,
activeSlotId: 'default',
}, false, 'deleteSlot');
// Load default slot values into active state
const defaultSlot = state.themeSlots.find(s => s.id === 'default');
if (defaultSlot) {
const resolved = resolveTheme(state.theme);
set({
colorScheme: defaultSlot.colorScheme,
customHue: defaultSlot.customHue,
isCustomTheme: defaultSlot.isCustomTheme,
gradientLevel: defaultSlot.gradientLevel,
enableHoverGlow: defaultSlot.enableHoverGlow,
enableBackgroundAnimation: defaultSlot.enableBackgroundAnimation,
}, false, 'deleteSlot/applyDefault');
applyThemeToDocument(
resolved,
defaultSlot.colorScheme,
defaultSlot.customHue,
defaultSlot.gradientLevel,
defaultSlot.enableHoverGlow,
defaultSlot.enableBackgroundAnimation,
state.motionPreference,
defaultSlot.styleTier
);
applyBackgroundToDocument(defaultSlot.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
}
// Clear buffer after 10 seconds
setTimeout(() => {
const current = useAppStore.getState();
if (current.deletedSlotBuffer?.id === slotId) {
useAppStore.setState({ deletedSlotBuffer: null }, false);
}
}, 10000);
},
undoDeleteSlot: () => {
const state = get();
const restored = state.deletedSlotBuffer;
if (!restored) return;
if (state.themeSlots.length >= THEME_SLOT_LIMIT) return;
set({
themeSlots: [...state.themeSlots, restored],
deletedSlotBuffer: null,
activeSlotId: restored.id,
}, false, 'undoDeleteSlot');
// Apply restored slot values
const resolved = resolveTheme(state.theme);
set({
colorScheme: restored.colorScheme,
customHue: restored.customHue,
isCustomTheme: restored.isCustomTheme,
gradientLevel: restored.gradientLevel,
enableHoverGlow: restored.enableHoverGlow,
enableBackgroundAnimation: restored.enableBackgroundAnimation,
}, false, 'undoDeleteSlot/apply');
applyThemeToDocument(
resolved,
restored.colorScheme,
restored.customHue,
restored.gradientLevel,
restored.enableHoverGlow,
restored.enableBackgroundAnimation,
state.motionPreference,
restored.styleTier
);
applyBackgroundToDocument(restored.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
},
// ========== Background Actions ==========
setBackgroundConfig: (config: BackgroundConfig) => {
set((state) => ({
themeSlots: state.themeSlots.map(slot =>
slot.id === state.activeSlotId
? { ...slot, backgroundConfig: config }
: slot
),
}), false, 'setBackgroundConfig');
applyBackgroundToDocument(config);
},
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => {
const state = get();
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const updated: BackgroundConfig = {
...current,
effects: { ...current.effects, [key]: value },
};
get().setBackgroundConfig(updated);
},
setBackgroundMode: (mode: BackgroundMode) => {
const state = get();
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const updated: BackgroundConfig = { ...current, mode };
get().setBackgroundConfig(updated);
},
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => {
const state = get();
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
const current = activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG;
const updated: BackgroundConfig = {
...current,
imageUrl: url,
attribution,
};
// Auto-switch mode if currently gradient-only and setting an image
if (url && current.mode === 'gradient-only') {
updated.mode = 'image-gradient';
}
// Auto-switch to gradient-only if removing image
if (!url && current.mode !== 'gradient-only') {
updated.mode = 'gradient-only';
}
get().setBackgroundConfig(updated);
},
}), }),
{ {
name: 'ccw-app-store', name: 'ccw-app-store',
// Only persist theme and locale preferences // Only persist theme, locale, and slot preferences
partialize: (state) => ({ partialize: (state) => ({
theme: state.theme, theme: state.theme,
colorScheme: state.colorScheme, colorScheme: state.colorScheme,
@@ -313,26 +681,59 @@ export const useAppStore = create<AppStore>()(
gradientLevel: state.gradientLevel, gradientLevel: state.gradientLevel,
enableHoverGlow: state.enableHoverGlow, enableHoverGlow: state.enableHoverGlow,
enableBackgroundAnimation: state.enableBackgroundAnimation, enableBackgroundAnimation: state.enableBackgroundAnimation,
motionPreference: state.motionPreference,
locale: state.locale, locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed, sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups, expandedNavGroups: state.expandedNavGroups,
dashboardLayout: state.dashboardLayout, dashboardLayout: state.dashboardLayout,
themeSlots: state.themeSlots,
activeSlotId: state.activeSlotId,
}), }),
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
// Apply theme on rehydration
if (state) { if (state) {
// Migrate legacy schema: if no themeSlots, construct from flat fields
if (!state.themeSlots || !Array.isArray(state.themeSlots) || state.themeSlots.length === 0) {
const migratedSlot: ThemeSlot = {
id: 'default',
name: 'Default',
colorScheme: state.colorScheme ?? 'blue',
customHue: state.customHue ?? null,
isCustomTheme: (state.customHue ?? null) !== null,
gradientLevel: state.gradientLevel ?? 'standard',
enableHoverGlow: state.enableHoverGlow ?? true,
enableBackgroundAnimation: state.enableBackgroundAnimation ?? false,
styleTier: 'standard',
isDefault: true,
};
state.themeSlots = [migratedSlot];
state.activeSlotId = 'default';
}
// Ensure activeSlotId is valid
if (!state.activeSlotId || !state.themeSlots.find(s => s.id === state.activeSlotId)) {
state.activeSlotId = 'default';
}
// Apply theme on rehydration
const resolved = resolveTheme(state.theme); const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved; state.resolvedTheme = resolved;
state.isCustomTheme = state.customHue !== null; state.isCustomTheme = state.customHue !== null;
// Apply theme using helper (encapsulates DOM manipulation) // Apply theme using helper (encapsulates DOM manipulation)
const rehydratedStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument( applyThemeToDocument(
resolved, resolved,
state.colorScheme, state.colorScheme,
state.customHue, state.customHue,
state.gradientLevel ?? 'standard', state.gradientLevel ?? 'standard',
state.enableHoverGlow ?? true, state.enableHoverGlow ?? true,
state.enableBackgroundAnimation ?? false state.enableBackgroundAnimation ?? false,
state.motionPreference ?? 'system',
rehydratedStyleTier
); );
// Apply background config on rehydration
const activeSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
applyBackgroundToDocument(activeSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
} }
// Apply locale on rehydration // Apply locale on rehydration
if (state) { if (state) {
@@ -354,13 +755,16 @@ if (typeof window !== 'undefined') {
const resolved = getSystemTheme(); const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved }); useAppStore.setState({ resolvedTheme: resolved });
// Apply theme using helper (encapsulates DOM manipulation) // Apply theme using helper (encapsulates DOM manipulation)
const styleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument( applyThemeToDocument(
resolved, resolved,
state.colorScheme, state.colorScheme,
state.customHue, state.customHue,
state.gradientLevel, state.gradientLevel,
state.enableHoverGlow, state.enableHoverGlow,
state.enableBackgroundAnimation state.enableBackgroundAnimation,
state.motionPreference,
styleTier
); );
} }
}); });
@@ -368,14 +772,21 @@ if (typeof window !== 'undefined') {
// Apply initial theme immediately (before localStorage rehydration) // Apply initial theme immediately (before localStorage rehydration)
// This ensures gradient attributes are set from the start // This ensures gradient attributes are set from the start
const state = useAppStore.getState(); const state = useAppStore.getState();
const initialStyleTier = getActiveStyleTier(state.themeSlots, state.activeSlotId);
applyThemeToDocument( applyThemeToDocument(
state.resolvedTheme, state.resolvedTheme,
state.colorScheme, state.colorScheme,
state.customHue, state.customHue,
state.gradientLevel, state.gradientLevel,
state.enableHoverGlow, state.enableHoverGlow,
state.enableBackgroundAnimation state.enableBackgroundAnimation,
state.motionPreference,
initialStyleTier
); );
// Apply initial background config
const initialActiveSlot = state.themeSlots.find(s => s.id === state.activeSlotId);
applyBackgroundToDocument(initialActiveSlot?.backgroundConfig ?? DEFAULT_BACKGROUND_CONFIG);
} }
// Selectors for common access patterns // Selectors for common access patterns
@@ -387,8 +798,12 @@ export const selectIsCustomTheme = (state: AppStore) => state.isCustomTheme;
export const selectGradientLevel = (state: AppStore) => state.gradientLevel; export const selectGradientLevel = (state: AppStore) => state.gradientLevel;
export const selectEnableHoverGlow = (state: AppStore) => state.enableHoverGlow; export const selectEnableHoverGlow = (state: AppStore) => state.enableHoverGlow;
export const selectEnableBackgroundAnimation = (state: AppStore) => state.enableBackgroundAnimation; export const selectEnableBackgroundAnimation = (state: AppStore) => state.enableBackgroundAnimation;
export const selectMotionPreference = (state: AppStore) => state.motionPreference;
export const selectLocale = (state: AppStore) => state.locale; export const selectLocale = (state: AppStore) => state.locale;
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen; export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
export const selectCurrentView = (state: AppStore) => state.currentView; export const selectCurrentView = (state: AppStore) => state.currentView;
export const selectIsLoading = (state: AppStore) => state.isLoading; export const selectIsLoading = (state: AppStore) => state.isLoading;
export const selectError = (state: AppStore) => state.error; export const selectError = (state: AppStore) => state.error;
export const selectThemeSlots = (state: AppStore) => state.themeSlots;
export const selectActiveSlotId = (state: AppStore) => state.activeSlotId;
export const selectDeletedSlotBuffer = (state: AppStore) => state.deletedSlotBuffer;

View File

@@ -8,6 +8,32 @@
export type Theme = 'light' | 'dark' | 'system'; export type Theme = 'light' | 'dark' | 'system';
export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple'; export type ColorScheme = 'blue' | 'green' | 'orange' | 'purple';
export type GradientLevel = 'off' | 'standard' | 'enhanced'; export type GradientLevel = 'off' | 'standard' | 'enhanced';
export type MotionPreference = 'system' | 'reduce' | 'enable';
export type StyleTier = 'soft' | 'standard' | 'high-contrast';
export type ThemeSlotId = 'default' | 'custom-1' | 'custom-2';
export type BackgroundMode = 'gradient-only' | 'image-only' | 'image-gradient';
export interface BackgroundEffects {
blur: number; // 0-20 px
darkenOpacity: number; // 0-80 %
saturation: number; // 0-200 % (100=normal)
enableFrostedGlass: boolean;
enableGrain: boolean;
enableVignette: boolean;
}
export interface UnsplashAttribution {
photographerName: string;
photographerUrl: string;
photoUrl: string;
}
export interface BackgroundConfig {
mode: BackgroundMode;
imageUrl: string | null;
attribution: UnsplashAttribution | null;
effects: BackgroundEffects;
}
export type Locale = 'en' | 'zh'; export type Locale = 'en' | 'zh';
export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionDetail' | 'liteTaskDetail' | 'loop-monitor' | 'issue-manager' | 'orchestrator'; export type ViewMode = 'sessions' | 'liteTasks' | 'project-overview' | 'sessionDetail' | 'liteTaskDetail' | 'loop-monitor' | 'issue-manager' | 'orchestrator';
export type SessionFilter = 'all' | 'active' | 'archived'; export type SessionFilter = 'all' | 'active' | 'archived';
@@ -35,6 +61,20 @@ export type LiteTaskType = 'lite-plan' | 'lite-fix' | null;
*/ */
export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs' | 'lite-plan' | 'lite-fix'; export type SessionType = 'workflow' | 'review' | 'tdd' | 'test' | 'docs' | 'lite-plan' | 'lite-fix';
export interface ThemeSlot {
id: ThemeSlotId;
name: string;
colorScheme: ColorScheme;
customHue: number | null;
isCustomTheme: boolean;
gradientLevel: GradientLevel;
enableHoverGlow: boolean;
enableBackgroundAnimation: boolean;
styleTier: StyleTier;
isDefault: boolean;
backgroundConfig?: BackgroundConfig;
}
export interface AppState { export interface AppState {
// Theme // Theme
theme: Theme; theme: Theme;
@@ -48,6 +88,9 @@ export interface AppState {
enableHoverGlow: boolean; // Enable hover glow effects enableHoverGlow: boolean; // Enable hover glow effects
enableBackgroundAnimation: boolean; // Enable background gradient animation enableBackgroundAnimation: boolean; // Enable background gradient animation
// Motion preference
motionPreference: MotionPreference; // Reduced motion preference: system, reduce, enable
// Locale // Locale
locale: Locale; locale: Locale;
@@ -69,6 +112,11 @@ export interface AppState {
// Dashboard layout // Dashboard layout
dashboardLayout: DashboardLayoutState | null; dashboardLayout: DashboardLayoutState | null;
// Theme slots
themeSlots: ThemeSlot[];
activeSlotId: ThemeSlotId;
deletedSlotBuffer: ThemeSlot | null;
} }
export interface AppActions { export interface AppActions {
@@ -82,6 +130,8 @@ export interface AppActions {
setGradientLevel: (level: GradientLevel) => void; setGradientLevel: (level: GradientLevel) => void;
setEnableHoverGlow: (enabled: boolean) => void; setEnableHoverGlow: (enabled: boolean) => void;
setEnableBackgroundAnimation: (enabled: boolean) => void; setEnableBackgroundAnimation: (enabled: boolean) => void;
setMotionPreference: (pref: MotionPreference) => void;
setStyleTier: (tier: StyleTier) => void;
// Locale actions // Locale actions
setLocale: (locale: Locale) => void; setLocale: (locale: Locale) => void;
@@ -107,6 +157,19 @@ export interface AppActions {
setDashboardLayouts: (layouts: DashboardLayouts) => void; setDashboardLayouts: (layouts: DashboardLayouts) => void;
setDashboardWidgets: (widgets: WidgetConfig[]) => void; setDashboardWidgets: (widgets: WidgetConfig[]) => void;
resetDashboardLayout: () => void; resetDashboardLayout: () => void;
// Theme slot actions
setActiveSlot: (slotId: ThemeSlotId) => void;
copySlot: () => void;
renameSlot: (slotId: ThemeSlotId, name: string) => void;
deleteSlot: (slotId: ThemeSlotId) => void;
undoDeleteSlot: () => void;
// Background actions
setBackgroundConfig: (config: BackgroundConfig) => void;
updateBackgroundEffect: <K extends keyof BackgroundEffects>(key: K, value: BackgroundEffects[K]) => void;
setBackgroundMode: (mode: BackgroundMode) => void;
setBackgroundImage: (url: string | null, attribution: UnsplashAttribution | null) => void;
} }
export type AppStore = AppState & AppActions; export type AppStore = AppState & AppActions;

View File

@@ -65,9 +65,37 @@ export const QuestionAnswerSchema = z.object({
export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>; export type QuestionAnswer = z.infer<typeof QuestionAnswerSchema>;
// ========== AskUserQuestion-style Types ==========
/** AskUserQuestion-style option (value auto-generated from label) */
export const SimpleOptionSchema = z.object({
label: z.string(),
description: z.string().optional(),
});
export type SimpleOption = z.infer<typeof SimpleOptionSchema>;
/** AskUserQuestion-style question */
export const SimpleQuestionSchema = z.object({
question: z.string(), // 问题文本 → 映射到 title
header: z.string(), // 短标签 → 映射到 id
multiSelect: z.boolean().default(false),
options: z.array(SimpleOptionSchema).optional(),
});
export type SimpleQuestion = z.infer<typeof SimpleQuestionSchema>;
/** 新格式参数 (questions 数组) */
export const AskQuestionSimpleParamsSchema = z.object({
questions: z.array(SimpleQuestionSchema).min(1).max(4),
timeout: z.number().optional(),
});
export type AskQuestionSimpleParams = z.infer<typeof AskQuestionSimpleParamsSchema>;
// ========== Ask Question Parameters ========== // ========== Ask Question Parameters ==========
/** Parameters for ask_question tool */ /** Parameters for ask_question tool (legacy format) */
export const AskQuestionParamsSchema = z.object({ export const AskQuestionParamsSchema = z.object({
question: QuestionSchema, question: QuestionSchema,
timeout: z.number().optional().default(300000), // 5 minutes default timeout: z.number().optional().default(300000), // 5 minutes default

View File

@@ -4,10 +4,13 @@
// WebSocket transport for A2UI surfaces and actions // WebSocket transport for A2UI surfaces and actions
import type { Duplex } from 'stream'; import type { Duplex } from 'stream';
import http from 'http';
import type { IncomingMessage } from 'http'; import type { IncomingMessage } from 'http';
import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js'; import { createWebSocketFrame, parseWebSocketFrame, wsClients } from '../websocket.js';
import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js'; import type { QuestionAnswer, AskQuestionParams, Question } from './A2UITypes.js';
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
// ========== A2UI Message Types ========== // ========== A2UI Message Types ==========
/** A2UI WebSocket message types */ /** A2UI WebSocket message types */
@@ -60,8 +63,20 @@ export class A2UIWebSocketHandler {
private multiSelectSelections = new Map<string, Set<string>>(); private multiSelectSelections = new Map<string, Set<string>>();
private singleSelectSelections = new Map<string, string>(); private singleSelectSelections = new Map<string, string>();
private inputValues = new Map<string, string>();
/** Answers resolved by Dashboard but not yet consumed by MCP polling */
private resolvedAnswers = new Map<string, { answer: QuestionAnswer; timestamp: number }>();
private resolvedMultiAnswers = new Map<string, { compositeId: string; answers: QuestionAnswer[]; timestamp: number }>();
private answerCallback?: (answer: QuestionAnswer) => boolean; private answerCallback?: (answer: QuestionAnswer) => boolean;
private multiAnswerCallback?: (compositeId: string, answers: QuestionAnswer[]) => boolean;
/** Buffered surfaces waiting to be replayed to newly connected clients */
private pendingSurfaces: Array<{
surfaceUpdate: { surfaceId: string; components: unknown[]; initialState: Record<string, unknown>; displayMode?: 'popup' | 'panel' };
message: unknown;
}> = [];
/** /**
* Register callback for handling question answers * Register callback for handling question answers
@@ -71,6 +86,14 @@ export class A2UIWebSocketHandler {
this.answerCallback = callback; this.answerCallback = callback;
} }
/**
* Register callback for handling multi-question composite answers (submit-all)
* @param callback - Function to handle composite answers
*/
registerMultiAnswerCallback(callback: (compositeId: string, answers: QuestionAnswer[]) => boolean): void {
this.multiAnswerCallback = callback;
}
/** /**
* Get the registered answer callback * Get the registered answer callback
*/ */
@@ -78,6 +101,20 @@ export class A2UIWebSocketHandler {
return this.answerCallback; return this.answerCallback;
} }
/**
* Initialize multi-select tracking for a question (used by multi-page surfaces)
*/
initMultiSelect(questionId: string): void {
this.multiSelectSelections.set(questionId, new Set<string>());
}
/**
* Initialize single-select tracking for a question (used by multi-page surfaces)
*/
initSingleSelect(questionId: string): void {
this.singleSelectSelections.set(questionId, '');
}
/** /**
* Send A2UI surface to all connected clients * Send A2UI surface to all connected clients
* @param surfaceUpdate - A2UI surface update to send * @param surfaceUpdate - A2UI surface update to send
@@ -115,6 +152,13 @@ export class A2UIWebSocketHandler {
} }
} }
// No local WebSocket clients — forward via HTTP to Dashboard server
// (Happens when running in MCP stdio process, separate from Dashboard)
if (wsClients.size === 0) {
this.forwardSurfaceViaDashboard(surfaceUpdate);
return 0;
}
// Broadcast to all clients // Broadcast to all clients
const frame = createWebSocketFrame(message); const frame = createWebSocketFrame(message);
let sentCount = 0; let sentCount = 0;
@@ -132,6 +176,72 @@ export class A2UIWebSocketHandler {
return sentCount; return sentCount;
} }
/**
* Replay buffered surfaces to a newly connected client, then clear the buffer.
* @param client - The newly connected WebSocket client
* @returns Number of surfaces replayed
*/
replayPendingSurfaces(client: Duplex): number {
if (this.pendingSurfaces.length === 0) {
return 0;
}
const count = this.pendingSurfaces.length;
for (const { surfaceUpdate, message } of this.pendingSurfaces) {
try {
const frame = createWebSocketFrame(message);
client.write(frame);
} catch (e) {
console.error(`[A2UI] Failed to replay surface ${surfaceUpdate.surfaceId}:`, e);
}
}
console.log(`[A2UI] Replayed ${count} buffered surface(s) to new client`);
this.pendingSurfaces = [];
return count;
}
/**
* Forward surface to Dashboard server via HTTP POST /api/hook.
* Used when running in a separate process (MCP stdio) without local WebSocket clients.
*/
private forwardSurfaceViaDashboard(surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode?: 'popup' | 'panel';
}): void {
// Send flat so the hook handler wraps it as { type, payload: { ...fields } }
// which matches the frontend's expected format: data.type === 'a2ui-surface' && data.payload
const body = JSON.stringify({
type: 'a2ui-surface',
surfaceId: surfaceUpdate.surfaceId,
components: surfaceUpdate.components,
initialState: surfaceUpdate.initialState,
displayMode: surfaceUpdate.displayMode,
});
const req = http.request({
hostname: 'localhost',
port: DASHBOARD_PORT,
path: '/api/hook',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
},
});
req.on('error', (err) => {
console.error(`[A2UI] Failed to forward surface ${surfaceUpdate.surfaceId} to Dashboard:`, err.message);
});
req.write(body);
req.end();
console.log(`[A2UI] Forwarded surface ${surfaceUpdate.surfaceId} to Dashboard via HTTP`);
}
/** /**
* Send A2UI surface to specific client * Send A2UI surface to specific client
* @param client - Specific WebSocket client * @param client - Specific WebSocket client
@@ -226,12 +336,16 @@ export class A2UIWebSocketHandler {
const resolveAndCleanup = (answer: QuestionAnswer): boolean => { const resolveAndCleanup = (answer: QuestionAnswer): boolean => {
const handled = answerCallback(answer); const handled = answerCallback(answer);
if (handled) { if (!handled) {
this.activeSurfaces.delete(questionId); // answerCallback couldn't deliver (MCP process has no local pendingQuestions)
this.multiSelectSelections.delete(questionId); // Store answer for HTTP polling retrieval
this.singleSelectSelections.delete(questionId); this.resolvedAnswers.set(questionId, { answer, timestamp: Date.now() });
} }
return handled; // Always clean up UI state regardless of delivery
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.singleSelectSelections.delete(questionId);
return true;
}; };
switch (action.actionId) { switch (action.actionId) {
@@ -278,15 +392,88 @@ export class A2UIWebSocketHandler {
} }
case 'submit': { case 'submit': {
const otherText = this.inputValues.get(`__other__:${questionId}`);
// Check if this is a single-select or multi-select // Check if this is a single-select or multi-select
const singleSelection = this.singleSelectSelections.get(questionId); const singleSelection = this.singleSelectSelections.get(questionId);
if (singleSelection !== undefined) { if (singleSelection !== undefined) {
// Single-select submit // Resolve __other__ to actual text input
return resolveAndCleanup({ questionId, value: singleSelection, cancelled: false }); const value = singleSelection === '__other__' && otherText ? otherText : singleSelection;
this.inputValues.delete(`__other__:${questionId}`);
return resolveAndCleanup({ questionId, value, cancelled: false });
} }
// Multi-select submit // Multi-select submit
const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set<string>(); const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
return resolveAndCleanup({ questionId, value: Array.from(multiSelected), cancelled: false }); // Resolve __other__ in multi-select: replace with actual text
const values = Array.from(multiSelected).map(v =>
v === '__other__' && otherText ? otherText : v
);
this.inputValues.delete(`__other__:${questionId}`);
return resolveAndCleanup({ questionId, value: values, cancelled: false });
}
case 'input-change': {
// Track text input value for multi-page surfaces
const value = params.value;
if (typeof value !== 'string') {
return false;
}
this.inputValues.set(questionId, value);
return true;
}
case 'submit-all': {
// Multi-question composite submit
const compositeId = typeof params.compositeId === 'string' ? params.compositeId : undefined;
const questionIds = Array.isArray(params.questionIds) ? params.questionIds as string[] : undefined;
if (!compositeId || !questionIds) {
return false;
}
// Collect answers for all sub-questions
const answers: QuestionAnswer[] = [];
for (const qId of questionIds) {
const singleSel = this.singleSelectSelections.get(qId);
const multiSel = this.multiSelectSelections.get(qId);
const inputVal = this.inputValues.get(qId);
const otherText = this.inputValues.get(`__other__:${qId}`);
if (singleSel !== undefined) {
// Resolve __other__ to actual text input
const value = singleSel === '__other__' && otherText ? otherText : singleSel;
answers.push({ questionId: qId, value, cancelled: false });
} else if (multiSel !== undefined) {
// Resolve __other__ in multi-select: replace with actual text
const values = Array.from(multiSel).map(v =>
v === '__other__' && otherText ? otherText : v
);
answers.push({ questionId: qId, value: values, cancelled: false });
} else if (inputVal !== undefined) {
answers.push({ questionId: qId, value: inputVal, cancelled: false });
} else {
// No value recorded — include empty
answers.push({ questionId: qId, value: '', cancelled: false });
}
// Cleanup per-question tracking
this.singleSelectSelections.delete(qId);
this.multiSelectSelections.delete(qId);
this.inputValues.delete(qId);
this.inputValues.delete(`__other__:${qId}`);
}
// Call multi-answer callback
let handled = false;
if (this.multiAnswerCallback) {
handled = this.multiAnswerCallback(compositeId, answers);
}
if (!handled) {
// Store for HTTP polling retrieval
this.resolvedMultiAnswers.set(compositeId, { compositeId, answers, timestamp: Date.now() });
}
// Always clean up UI state
this.activeSurfaces.delete(compositeId);
return true;
} }
default: default:
@@ -324,6 +511,7 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.delete(questionId); this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId); this.multiSelectSelections.delete(questionId);
this.inputValues.delete(questionId);
return true; return true;
} }
@@ -346,6 +534,32 @@ export class A2UIWebSocketHandler {
this.activeSurfaces.clear(); this.activeSurfaces.clear();
} }
/**
* Get and remove a resolved answer (one-shot read).
* Used by MCP HTTP polling to retrieve answers stored by the Dashboard.
*/
getResolvedAnswer(questionId: string): QuestionAnswer | undefined {
const entry = this.resolvedAnswers.get(questionId);
if (entry) {
this.resolvedAnswers.delete(questionId);
return entry.answer;
}
return undefined;
}
/**
* Get and remove a resolved multi-answer (one-shot read).
* Used by MCP HTTP polling to retrieve composite answers stored by the Dashboard.
*/
getResolvedMultiAnswer(compositeId: string): QuestionAnswer[] | undefined {
const entry = this.resolvedMultiAnswers.get(compositeId);
if (entry) {
this.resolvedMultiAnswers.delete(compositeId);
return entry.answers;
}
return undefined;
}
/** /**
* Remove stale surfaces (older than specified time) * Remove stale surfaces (older than specified time)
* @param maxAge - Maximum age in milliseconds * @param maxAge - Maximum age in milliseconds
@@ -362,6 +576,18 @@ export class A2UIWebSocketHandler {
} }
} }
// Clean up stale resolved answers
for (const [id, entry] of this.resolvedAnswers) {
if (now - entry.timestamp > maxAge) {
this.resolvedAnswers.delete(id);
}
}
for (const [id, entry] of this.resolvedMultiAnswers) {
if (now - entry.timestamp > maxAge) {
this.resolvedMultiAnswers.delete(id);
}
}
return removed; return removed;
} }
} }

View File

@@ -259,7 +259,8 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
// Build CLI arguments based on index type // Build CLI arguments based on index type
// Use 'index init' subcommand (new CLI structure) // Use 'index init' subcommand (new CLI structure)
const args = ['index', 'init', targetPath, '--json']; // --force flag ensures full reindex (not incremental)
const args = ['index', 'init', targetPath, '--force', '--json'];
if (resolvedIndexType === 'normal') { if (resolvedIndexType === 'normal') {
args.push('--no-embeddings'); args.push('--no-embeddings');
} else { } else {
@@ -380,8 +381,10 @@ export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boo
} }
} }
// Build CLI arguments for incremental update using 'index update' subcommand // Build CLI arguments for incremental update using 'index init' without --force
const args = ['index', 'update', targetPath, '--json']; // 'index init' defaults to incremental mode (skip unchanged files)
// 'index update' is only for single-file updates in hooks
const args = ['index', 'init', targetPath, '--json'];
if (resolvedIndexType === 'normal') { if (resolvedIndexType === 'normal') {
args.push('--no-embeddings'); args.push('--no-embeddings');
} else { } else {

View File

@@ -8,6 +8,8 @@ import {
executeCodexLens, executeCodexLens,
installSemantic, installSemantic,
} from '../../../tools/codex-lens.js'; } from '../../../tools/codex-lens.js';
import { getCodexLensPython } from '../../../utils/codexlens-path.js';
import { spawn } from 'child_process';
import type { GpuMode } from '../../../tools/codex-lens.js'; import type { GpuMode } from '../../../tools/codex-lens.js';
import { loadLiteLLMApiConfig, getAvailableModelsForType, getProvider, getAllProviders } from '../../../config/litellm-api-config-manager.js'; import { loadLiteLLMApiConfig, getAvailableModelsForType, getProvider, getAllProviders } from '../../../config/litellm-api-config-manager.js';
import { import {
@@ -19,6 +21,86 @@ import { extractJSON } from './utils.js';
import { getDefaultTool } from '../../../tools/claude-cli-tools.js'; import { getDefaultTool } from '../../../tools/claude-cli-tools.js';
import { getCodexLensDataDir } from '../../../utils/codexlens-path.js'; import { getCodexLensDataDir } from '../../../utils/codexlens-path.js';
/**
* Execute CodexLens Python API call directly (bypasses CLI for richer API access).
*/
async function executeCodexLensPythonAPI(
apiFunction: string,
args: Record<string, unknown>,
timeout: number = 60000
): Promise<{ success: boolean; results?: unknown; error?: string }> {
return new Promise((resolve) => {
const pythonScript = `
import json
import sys
from dataclasses import is_dataclass, asdict
from codexlens.api import ${apiFunction}
def to_serializable(obj):
if obj is None:
return None
if is_dataclass(obj) and not isinstance(obj, type):
return asdict(obj)
if isinstance(obj, list):
return [to_serializable(item) for item in obj]
if isinstance(obj, dict):
return {key: to_serializable(value) for key, value in obj.items()}
if isinstance(obj, tuple):
return tuple(to_serializable(item) for item in obj)
return obj
try:
args = ${JSON.stringify(args)}
result = ${apiFunction}(**args)
output = to_serializable(result)
print(json.dumps({"success": True, "result": output}))
except Exception as e:
print(json.dumps({"success": False, "error": str(e)}))
sys.exit(1)
`;
const pythonPath = getCodexLensPython();
const child = spawn(pythonPath, ['-c', pythonScript], {
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on('data', (data: Buffer) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code !== 0) {
try {
const errorData = JSON.parse(stderr || stdout);
resolve({ success: false, error: errorData.error || 'Unknown error' });
} catch {
resolve({ success: false, error: stderr || stdout || `Process exited with code ${code}` });
}
return;
}
try {
const data = JSON.parse(stdout);
resolve({ success: data.success, results: data.result, error: data.error });
} catch (err) {
resolve({ success: false, error: `Failed to parse output: ${(err as Error).message}` });
}
});
child.on('error', (err) => {
resolve({ success: false, error: `Failed to execute: ${err.message}` });
});
});
}
export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> { export async function handleCodexLensSemanticRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx; const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
@@ -928,5 +1010,154 @@ except Exception as e:
return true; return true;
} }
// ============================================================
// LSP / SEMANTIC SEARCH API ENDPOINTS
// ============================================================
// API: LSP Status - Check if LSP/semantic search capabilities are available
if (pathname === '/api/codexlens/lsp/status') {
try {
const venvStatus = await checkVenvStatus();
if (!venvStatus.ready) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: false,
semantic_available: false,
vector_index: false,
error: 'CodexLens not installed'
}));
return true;
}
// Check semantic deps and vector index availability in parallel
const [semanticStatus, workspaceResult] = await Promise.all([
checkSemanticStatus(),
executeCodexLens(['status', '--json'])
]);
let hasVectorIndex = false;
let projectCount = 0;
let embeddingsInfo: Record<string, unknown> = {};
if (workspaceResult.success) {
try {
const status = extractJSON(workspaceResult.output ?? '');
if (status.success !== false && status.result) {
projectCount = status.result.projects_count || 0;
embeddingsInfo = status.result.embeddings || {};
// Check if any projects have embeddings
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
} else if (status.projects_count !== undefined) {
projectCount = status.projects_count || 0;
embeddingsInfo = status.embeddings || {};
hasVectorIndex = projectCount > 0 && Object.keys(embeddingsInfo).length > 0;
}
} catch {
// Parse failed
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: semanticStatus.available && hasVectorIndex,
semantic_available: semanticStatus.available,
vector_index: hasVectorIndex,
project_count: projectCount,
embeddings: embeddingsInfo,
modes: ['fusion', 'vector', 'structural'],
strategies: ['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'],
}));
} catch (err: unknown) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
available: false,
semantic_available: false,
vector_index: false,
error: err instanceof Error ? err.message : String(err)
}));
}
return true;
}
// API: LSP Semantic Search - Advanced semantic search via Python API
if (pathname === '/api/codexlens/lsp/search' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const {
query,
path: projectPath,
mode = 'fusion',
fusion_strategy = 'rrf',
vector_weight = 0.5,
structural_weight = 0.3,
keyword_weight = 0.2,
kind_filter,
limit = 20,
include_match_reason = false,
} = body as {
query?: unknown;
path?: unknown;
mode?: unknown;
fusion_strategy?: unknown;
vector_weight?: unknown;
structural_weight?: unknown;
keyword_weight?: unknown;
kind_filter?: unknown;
limit?: unknown;
include_match_reason?: unknown;
};
const resolvedQuery = typeof query === 'string' ? query.trim() : '';
if (!resolvedQuery) {
return { success: false, error: 'Query parameter is required', status: 400 };
}
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
const resolvedMode = typeof mode === 'string' && ['fusion', 'vector', 'structural'].includes(mode) ? mode : 'fusion';
const resolvedStrategy = typeof fusion_strategy === 'string' &&
['rrf', 'staged', 'binary', 'hybrid', 'dense_rerank'].includes(fusion_strategy) ? fusion_strategy : 'rrf';
const resolvedVectorWeight = typeof vector_weight === 'number' ? vector_weight : 0.5;
const resolvedStructuralWeight = typeof structural_weight === 'number' ? structural_weight : 0.3;
const resolvedKeywordWeight = typeof keyword_weight === 'number' ? keyword_weight : 0.2;
const resolvedLimit = typeof limit === 'number' ? limit : 20;
const resolvedIncludeReason = typeof include_match_reason === 'boolean' ? include_match_reason : false;
// Build Python API call args
const apiArgs: Record<string, unknown> = {
project_root: targetPath,
query: resolvedQuery,
mode: resolvedMode,
vector_weight: resolvedVectorWeight,
structural_weight: resolvedStructuralWeight,
keyword_weight: resolvedKeywordWeight,
fusion_strategy: resolvedStrategy,
limit: resolvedLimit,
include_match_reason: resolvedIncludeReason,
};
if (Array.isArray(kind_filter) && kind_filter.length > 0) {
apiArgs.kind_filter = kind_filter;
}
try {
const result = await executeCodexLensPythonAPI('semantic_search', apiArgs);
if (result.success) {
return {
success: true,
results: result.results,
query: resolvedQuery,
mode: resolvedMode,
fusion_strategy: resolvedStrategy,
count: Array.isArray(result.results) ? result.results.length : 0,
};
} else {
return { success: false, error: result.error || 'Semantic search failed', status: 500 };
}
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
return false; return false;
} }

View File

@@ -1197,7 +1197,7 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
// Parse enabled tools from request body // Parse enabled tools from request body
const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0 const enabledTools = Array.isArray(body.enabledTools) && body.enabledTools.length > 0
? (body.enabledTools as string[]).join(',') ? (body.enabledTools as string[]).join(',')
: 'write_file,edit_file,read_file,core_memory,ask_question'; : 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
// Generate CCW MCP server config // Generate CCW MCP server config
// Use cmd /c on Windows to inherit Claude Code's working directory // Use cmd /c on Windows to inherit Claude Code's working directory

View File

@@ -648,5 +648,104 @@ export async function handleSystemRoutes(ctx: SystemRouteContext): Promise<boole
return true; return true;
} }
// API: Test multi-page ask_question popup (for development testing)
if (pathname === '/api/test/ask-question-multi' && req.method === 'GET') {
try {
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
const compositeId = `multi-${Date.now()}`;
const surfaceId = `question-${compositeId}`;
const testSurface = {
surfaceId,
components: [
// Page 0: Select question
{ id: 'page-0-title', page: 0, component: { Text: { text: { literalString: 'Which framework?' }, usageHint: 'h3' } } },
{ id: 'page-0-radio-group', page: 0, component: {
RadioGroup: {
options: [
{ label: { literalString: 'React' }, value: 'React', description: { literalString: 'UI library' } },
{ label: { literalString: 'Vue' }, value: 'Vue', description: { literalString: 'Progressive framework' } },
{ label: { literalString: 'Other' }, value: '__other__', description: { literalString: 'Provide a custom answer' } },
],
onChange: { actionId: 'select', parameters: { questionId: 'Framework' } },
},
}},
// Page 1: Multi-select question
{ id: 'page-1-title', page: 1, component: { Text: { text: { literalString: 'Which features?' }, usageHint: 'h3' } } },
{ id: 'page-1-checkbox-0', page: 1, component: {
Checkbox: { label: { literalString: 'Auth' }, description: { literalString: 'Authentication' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Auth' } }, checked: { literalBoolean: false } },
}},
{ id: 'page-1-checkbox-1', page: 1, component: {
Checkbox: { label: { literalString: 'Cache' }, description: { literalString: 'Caching layer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: 'Cache' } }, checked: { literalBoolean: false } },
}},
{ id: 'page-1-checkbox-other', page: 1, component: {
Checkbox: { label: { literalString: 'Other' }, description: { literalString: 'Provide a custom answer' }, onChange: { actionId: 'toggle', parameters: { questionId: 'Features', value: '__other__' } }, checked: { literalBoolean: false } },
}},
],
initialState: {
questionId: compositeId,
questionType: 'multi-question',
pages: [
{ index: 0, questionId: 'Framework', title: 'Which framework?', type: 'select' },
{ index: 1, questionId: 'Features', title: 'Which features?', type: 'multi-select' },
],
totalPages: 2,
},
displayMode: 'popup' as const,
};
const sentCount = a2uiWebSocketHandler.sendSurface(testSurface);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
message: 'Multi-page test popup sent',
sentToClients: sentCount,
surfaceId,
compositeId,
}));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to send test popup', details: String(err) }));
}
return true;
}
// API: A2UI answer broker — retrieve answers stored for MCP polling
if (pathname === '/api/a2ui/answer' && req.method === 'GET') {
const questionId = url.searchParams.get('questionId');
const isComposite = url.searchParams.get('composite') === 'true';
if (!questionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'questionId is required' }));
return true;
}
const { a2uiWebSocketHandler } = await import('../a2ui/A2UIWebSocketHandler.js');
if (isComposite) {
const answers = a2uiWebSocketHandler.getResolvedMultiAnswer(questionId);
if (answers) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: false, answers }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: true }));
}
} else {
const answer = a2uiWebSocketHandler.getResolvedAnswer(questionId);
if (answer) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: false, answer }));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ pending: true }));
}
}
return true;
}
return false; return false;
} }

View File

@@ -0,0 +1,238 @@
/**
* Unsplash Proxy Routes & Background Image Upload
* Proxies Unsplash API requests to keep API key server-side.
* API key is read from process.env.UNSPLASH_ACCESS_KEY.
* Also handles local background image upload and serving.
*/
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
import { join } from 'path';
import { randomBytes } from 'crypto';
import { homedir } from 'os';
import type { RouteContext } from './types.js';
const UNSPLASH_API = 'https://api.unsplash.com';
// Background upload config
const UPLOADS_DIR = join(homedir(), '.ccw', 'uploads', 'backgrounds');
const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const EXT_MAP: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
const MIME_MAP: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
gif: 'image/gif',
};
function getAccessKey(): string | undefined {
return process.env.UNSPLASH_ACCESS_KEY;
}
interface UnsplashPhoto {
id: string;
urls: { thumb: string; small: string; regular: string };
user: { name: string; links: { html: string } };
links: { html: string; download_location: string };
blur_hash: string | null;
}
interface UnsplashSearchResult {
results: UnsplashPhoto[];
total: number;
total_pages: number;
}
export async function handleBackgroundRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, req, res } = ctx;
// POST /api/background/upload
if (pathname === '/api/background/upload' && req.method === 'POST') {
const contentType = req.headers['content-type'] || '';
if (!ALLOWED_TYPES.has(contentType)) {
res.writeHead(415, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsupported image type. Only JPEG, PNG, WebP, GIF allowed.' }));
return true;
}
try {
const chunks: Buffer[] = [];
let totalSize = 0;
for await (const chunk of req) {
totalSize += chunk.length;
if (totalSize > MAX_UPLOAD_SIZE) {
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File too large. Maximum size is 10MB.' }));
return true;
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
const ext = EXT_MAP[contentType] || 'bin';
const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.${ext}`;
mkdirSync(UPLOADS_DIR, { recursive: true });
writeFileSync(join(UPLOADS_DIR, filename), buffer);
const url = `/api/background/uploads/${filename}`;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ url, filename }));
} catch (err) {
console.error('[background] Upload error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Upload failed' }));
}
return true;
}
// GET /api/background/uploads/:filename
if (pathname.startsWith('/api/background/uploads/') && req.method === 'GET') {
const filename = pathname.slice('/api/background/uploads/'.length);
// Security: reject path traversal
if (!filename || filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid filename' }));
return true;
}
const filePath = join(UPLOADS_DIR, filename);
if (!existsSync(filePath)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found' }));
return true;
}
const ext = filename.split('.').pop()?.toLowerCase() || '';
const mime = MIME_MAP[ext] || 'application/octet-stream';
try {
const data = readFileSync(filePath);
res.writeHead(200, {
'Content-Type': mime,
'Content-Length': data.length,
'Cache-Control': 'public, max-age=86400',
});
res.end(data);
} catch (err) {
console.error('[background] Serve error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to read file' }));
}
return true;
}
return false;
}
export async function handleUnsplashRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res } = ctx;
// GET /api/unsplash/search?query=...&page=1&per_page=20
if (pathname === '/api/unsplash/search' && req.method === 'GET') {
const accessKey = getAccessKey();
if (!accessKey) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
return true;
}
const query = url.searchParams.get('query') || '';
const page = url.searchParams.get('page') || '1';
const perPage = url.searchParams.get('per_page') || '20';
if (!query.trim()) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing query parameter' }));
return true;
}
try {
const apiUrl = `${UNSPLASH_API}/search/photos?query=${encodeURIComponent(query)}&page=${page}&per_page=${perPage}&orientation=landscape`;
const response = await fetch(apiUrl, {
headers: { Authorization: `Client-ID ${accessKey}` },
});
if (!response.ok) {
const status = response.status;
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: `Unsplash API error: ${status}` }));
return true;
}
const data = (await response.json()) as UnsplashSearchResult;
// Return simplified data
const photos = data.results.map((photo) => ({
id: photo.id,
thumbUrl: photo.urls.thumb,
smallUrl: photo.urls.small,
regularUrl: photo.urls.regular,
photographer: photo.user.name,
photographerUrl: photo.user.links.html,
photoUrl: photo.links.html,
blurHash: photo.blur_hash,
downloadLocation: photo.links.download_location,
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
photos,
total: data.total,
totalPages: data.total_pages,
}));
} catch (err) {
console.error('[unsplash] Search error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to search Unsplash' }));
}
return true;
}
// POST /api/unsplash/download — trigger download event (Unsplash API requirement)
if (pathname === '/api/unsplash/download' && req.method === 'POST') {
const accessKey = getAccessKey();
if (!accessKey) {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Unsplash API key not configured' }));
return true;
}
try {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const body = JSON.parse(Buffer.concat(chunks).toString());
const downloadLocation = body.downloadLocation;
if (!downloadLocation) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing downloadLocation' }));
return true;
}
// Trigger download event (Unsplash API guideline)
await fetch(downloadLocation, {
headers: { Authorization: `Client-ID ${accessKey}` },
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
console.error('[unsplash] Download trigger error:', err);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to trigger download' }));
}
return true;
}
return false;
}

View File

@@ -13,6 +13,7 @@ import { handleMemoryRoutes } from './routes/memory-routes.js';
import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js'; import { handleCoreMemoryRoutes } from './routes/core-memory-routes.js';
import { handleMcpRoutes } from './routes/mcp-routes.js'; import { handleMcpRoutes } from './routes/mcp-routes.js';
import { handleHooksRoutes } from './routes/hooks-routes.js'; import { handleHooksRoutes } from './routes/hooks-routes.js';
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
import { handleCodexLensRoutes } from './routes/codexlens-routes.js'; import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
import { handleGraphRoutes } from './routes/graph-routes.js'; import { handleGraphRoutes } from './routes/graph-routes.js';
import { handleSystemRoutes } from './routes/system-routes.js'; import { handleSystemRoutes } from './routes/system-routes.js';
@@ -461,7 +462,7 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
const tokenManager = getTokenManager(); const tokenManager = getTokenManager();
const secretKey = tokenManager.getSecretKey(); const secretKey = tokenManager.getSecretKey();
tokenManager.getOrCreateAuthToken(); tokenManager.getOrCreateAuthToken();
const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question']); const unauthenticatedPaths = new Set<string>(['/api/auth/token', '/api/csrf-token', '/api/hook', '/api/test/ask-question', '/api/a2ui/answer']);
const server = http.createServer(async (req, res) => { const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`); const url = new URL(req.url ?? '/', `http://localhost:${serverPort}`);
@@ -627,6 +628,16 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleHooksRoutes(routeContext)) return; if (await handleHooksRoutes(routeContext)) return;
} }
// Background image upload/serve routes (/api/background/*)
if (pathname.startsWith('/api/background/')) {
if (await handleBackgroundRoutes(routeContext)) return;
}
// Unsplash proxy routes (/api/unsplash/*)
if (pathname.startsWith('/api/unsplash/')) {
if (await handleUnsplashRoutes(routeContext)) return;
}
// CodexLens routes (/api/codexlens/*) // CodexLens routes (/api/codexlens/*)
if (pathname.startsWith('/api/codexlens/')) { if (pathname.startsWith('/api/codexlens/')) {
if (await handleCodexLensRoutes(routeContext)) return; if (await handleCodexLensRoutes(routeContext)) return;
@@ -728,11 +739,12 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
if (await handleFilesRoutes(routeContext)) return; if (await handleFilesRoutes(routeContext)) return;
} }
// System routes (data, health, version, paths, shutdown, notify, storage, dialog) // System routes (data, health, version, paths, shutdown, notify, storage, dialog, a2ui answer broker)
if (pathname === '/api/data' || pathname === '/api/health' || if (pathname === '/api/data' || pathname === '/api/health' ||
pathname === '/api/version-check' || pathname === '/api/shutdown' || pathname === '/api/version-check' || pathname === '/api/shutdown' ||
pathname === '/api/recent-paths' || pathname === '/api/switch-path' || pathname === '/api/recent-paths' || pathname === '/api/switch-path' ||
pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' || pathname === '/api/remove-recent-path' || pathname === '/api/system/notify' ||
pathname === '/api/a2ui/answer' ||
pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) { pathname.startsWith('/api/storage/') || pathname.startsWith('/api/dialog/')) {
if (await handleSystemRoutes(routeContext)) return; if (await handleSystemRoutes(routeContext)) return;
} }

View File

@@ -164,6 +164,9 @@ export function handleWebSocketUpgrade(req: IncomingMessage, socket: Duplex, _he
wsClients.add(socket); wsClients.add(socket);
console.log(`[WS] Client connected (${wsClients.size} total)`); console.log(`[WS] Client connected (${wsClients.size} total)`);
// Replay any buffered A2UI surfaces to the new client
a2uiWebSocketHandler.replayPendingSurfaces(socket);
// Handle incoming messages // Handle incoming messages
let pendingBuffer = Buffer.alloc(0); let pendingBuffer = Buffer.alloc(0);

View File

@@ -21,8 +21,8 @@ const SERVER_VERSION = '6.2.0';
const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT'; const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS'; const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
// Default enabled tools (core set - file operations and core memory only) // Default enabled tools (core set - file operations, core memory, and smart search)
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory']; const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory', 'smart_search'];
/** /**
* Get list of enabled tools from environment or defaults * Get list of enabled tools from environment or defaults

View File

@@ -7,16 +7,18 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { import {
execute, execute,
handleAnswer, handleAnswer,
handleMultiAnswer,
cancelQuestion, cancelQuestion,
getPendingQuestions, getPendingQuestions,
clearPendingQuestions, clearPendingQuestions,
} from '../tools/ask-question'; handler,
} from '../ask-question';
import type { import type {
Question, Question,
QuestionAnswer, QuestionAnswer,
AskQuestionParams, AskQuestionParams,
AskQuestionResult, AskQuestionResult,
} from '../core/a2ui/A2UITypes'; } from '../../core/a2ui/A2UITypes';
describe('ask_question Tool', () => { describe('ask_question Tool', () => {
beforeEach(() => { beforeEach(() => {
@@ -44,8 +46,11 @@ describe('ask_question Tool', () => {
}; };
// Should not throw during validation // Should not throw during validation
const result = await execute(params); const executePromise = execute(params);
expect(result).toBeDefined(); expect(getPendingQuestions()).toHaveLength(1);
cancelQuestion('test-question-1');
await executePromise;
}); });
it('should validate a valid select question with options', async () => { it('should validate a valid select question with options', async () => {
@@ -60,9 +65,11 @@ describe('ask_question Tool', () => {
}; };
const params: AskQuestionParams = { question }; const params: AskQuestionParams = { question };
const result = await execute(params); const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
expect(result).toBeDefined(); cancelQuestion('test-select');
await executePromise;
}); });
it('should validate a valid input question', async () => { it('should validate a valid input question', async () => {
@@ -74,9 +81,11 @@ describe('ask_question Tool', () => {
}; };
const params: AskQuestionParams = { question }; const params: AskQuestionParams = { question };
const result = await execute(params); const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
expect(result).toBeDefined(); cancelQuestion('test-input');
await executePromise;
}); });
it('should validate a valid multi-select question', async () => { it('should validate a valid multi-select question', async () => {
@@ -92,9 +101,11 @@ describe('ask_question Tool', () => {
}; };
const params: AskQuestionParams = { question }; const params: AskQuestionParams = { question };
const result = await execute(params); const executePromise = execute(params);
expect(getPendingQuestions()).toHaveLength(1);
expect(result).toBeDefined(); cancelQuestion('test-multi');
await executePromise;
}); });
it('should reject question with missing id', async () => { it('should reject question with missing id', async () => {
@@ -162,7 +173,7 @@ describe('ask_question Tool', () => {
const result = await execute(params); const result = await execute(params);
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('options'); expect(result.error).toContain('option');
}); });
it('should reject options with missing value', async () => { it('should reject options with missing value', async () => {
@@ -554,10 +565,9 @@ describe('ask_question Tool', () => {
id: 'test-timeout', id: 'test-timeout',
type: 'confirm', type: 'confirm',
title: 'Test', title: 'Test',
timeout: 5000, // 5 seconds
}; };
const params: AskQuestionParams = { question }; const params: AskQuestionParams = { question, timeout: 5000 };
const executePromise = execute(params); const executePromise = execute(params);
// Fast-forward time // Fast-forward time
@@ -658,7 +668,8 @@ describe('ask_question Tool', () => {
}; };
const params1: AskQuestionParams = { question }; const params1: AskQuestionParams = { question };
const executePromise1 = execute(params1); // Don't await — first promise becomes orphaned when id is reused
execute(params1);
// Second execution with same ID should replace first // Second execution with same ID should replace first
const question2: Question = { const question2: Question = {
@@ -671,10 +682,11 @@ describe('ask_question Tool', () => {
// There should still be only one pending // There should still be only one pending
expect(getPendingQuestions()).toHaveLength(1); expect(getPendingQuestions()).toHaveLength(1);
expect(getPendingQuestions()[0].question.title).toBe('Second');
// Clean up // Clean up the active pending question
cancelQuestion('duplicate-id'); cancelQuestion('duplicate-id');
await Promise.all([executePromise1, executePromise2]); await executePromise2;
}); });
it('should handle answer after question is cancelled', async () => { it('should handle answer after question is cancelled', async () => {
@@ -730,4 +742,204 @@ describe('ask_question Tool', () => {
} }
}); });
}); });
describe('AskUserQuestion-style Format (via handler)', () => {
it('should handle single select question', async () => {
const params = {
questions: [{
question: 'Which library?',
header: 'Library',
multiSelect: false,
options: [
{ label: 'React', description: 'UI library' },
{ label: 'Vue', description: 'Progressive framework' },
],
}],
};
const handlerPromise = handler(params);
// Answer the normalized question (id = header)
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toBe('Library');
expect(pending[0].question.type).toBe('select');
expect(pending[0].question.title).toBe('Which library?');
// Options should use label as value
expect(pending[0].question.options).toEqual([
{ value: 'React', label: 'React', description: 'UI library' },
{ value: 'Vue', label: 'Vue', description: 'Progressive framework' },
]);
const answer: QuestionAnswer = {
questionId: 'Library',
value: 'React',
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({ Library: 'React' });
});
it('should handle multiSelect question', async () => {
const params = {
questions: [{
question: 'Which features?',
header: 'Features',
multiSelect: true,
options: [
{ label: 'Auth', description: 'Authentication' },
{ label: 'Cache', description: 'Caching layer' },
{ label: 'Logging' },
],
}],
};
const handlerPromise = handler(params);
const pending = getPendingQuestions();
expect(pending[0].question.type).toBe('multi-select');
const answer: QuestionAnswer = {
questionId: 'Features',
value: ['Auth', 'Logging'],
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({ Features: ['Auth', 'Logging'] });
});
it('should handle input question (no options)', async () => {
const params = {
questions: [{
question: 'What is your name?',
header: 'Name',
multiSelect: false,
}],
};
const handlerPromise = handler(params);
const pending = getPendingQuestions();
expect(pending[0].question.type).toBe('input');
const answer: QuestionAnswer = {
questionId: 'Name',
value: 'John',
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({ Name: 'John' });
});
it('should handle multiple questions in single multi-page surface', async () => {
const params = {
questions: [
{
question: 'Which library?',
header: 'Library',
multiSelect: false,
options: [
{ label: 'React' },
{ label: 'Vue' },
],
},
{
question: 'Which level?',
header: 'Level',
multiSelect: false,
options: [
{ label: 'Beginner' },
{ label: 'Advanced' },
],
},
],
};
const handlerPromise = handler(params);
// A single composite question should be pending
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toMatch(/^multi-/);
const compositeId = pending[0].id;
// Simulate submit-all with answers for all pages
handleMultiAnswer(compositeId, [
{ questionId: 'Library', value: 'React', cancelled: false },
{ questionId: 'Level', value: 'Advanced', cancelled: false },
]);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect((result.result as any).answersDict).toEqual({
Library: 'React',
Level: 'Advanced',
});
});
it('should cancel multi-question composite on cancel', async () => {
const params = {
questions: [
{
question: 'First?',
header: 'Q1',
multiSelect: false,
options: [{ label: 'A' }],
},
{
question: 'Second?',
header: 'Q2',
multiSelect: false,
options: [{ label: 'B' }],
},
],
};
const handlerPromise = handler(params);
// Cancel the composite question
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
cancelQuestion(pending[0].id);
const result = await handlerPromise;
expect(result.success).toBe(true);
expect(result.result?.cancelled).toBe(true);
});
it('should still support legacy format via handler', async () => {
const params = {
question: {
id: 'legacy-test',
type: 'confirm',
title: 'Legacy question?',
},
};
const handlerPromise = handler(params as any);
const pending = getPendingQuestions();
expect(pending).toHaveLength(1);
expect(pending[0].id).toBe('legacy-test');
const answer: QuestionAnswer = {
questionId: 'legacy-test',
value: true,
};
handleAnswer(answer);
const result = await handlerPromise;
expect(result.success).toBe(true);
// Legacy format should NOT have answersDict
expect((result.result as any).answersDict).toBeUndefined();
});
});
}); });

View File

@@ -13,9 +13,19 @@ import type {
AskQuestionParams, AskQuestionParams,
AskQuestionResult, AskQuestionResult,
PendingQuestion, PendingQuestion,
SimpleQuestion,
} from '../core/a2ui/A2UITypes.js'; } from '../core/a2ui/A2UITypes.js';
import http from 'http';
import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js'; import { a2uiWebSocketHandler } from '../core/a2ui/A2UIWebSocketHandler.js';
const DASHBOARD_PORT = Number(process.env.CCW_PORT || 3456);
const POLL_INTERVAL_MS = 1000;
// Register multi-answer callback for multi-page question surfaces
a2uiWebSocketHandler.registerMultiAnswerCallback(
(compositeId: string, answers: QuestionAnswer[]) => handleMultiAnswer(compositeId, answers)
);
// ========== Constants ========== // ========== Constants ==========
/** Default question timeout (5 minutes) */ /** Default question timeout (5 minutes) */
@@ -114,6 +124,10 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
if (!question.options) { if (!question.options) {
return false; return false;
} }
// Accept __other__ as a valid value (custom input)
if (answer.value === '__other__' || answer.value.startsWith('__other__:')) {
return true;
}
return question.options.some((opt) => opt.value === answer.value); return question.options.some((opt) => opt.value === answer.value);
case 'multi-select': case 'multi-select':
@@ -124,13 +138,51 @@ function validateAnswer(question: Question, answer: QuestionAnswer): boolean {
return false; return false;
} }
const validValues = new Set(question.options.map((opt) => opt.value)); const validValues = new Set(question.options.map((opt) => opt.value));
return answer.value.every((v) => validValues.has(v)); // Accept __other__ as a valid value (custom input)
validValues.add('__other__');
return answer.value.every((v) => typeof v === 'string' && (validValues.has(v) || v.startsWith('__other__:')));
default: default:
return false; return false;
} }
} }
// ========== Simple Format Normalization ==========
/**
* Normalize a SimpleQuestion (AskUserQuestion-style) to internal Question format
* @param simple - SimpleQuestion to normalize
* @returns Normalized Question
*/
function normalizeSimpleQuestion(simple: SimpleQuestion): Question {
let type: QuestionType;
if (simple.options && simple.options.length > 0) {
type = simple.multiSelect ? 'multi-select' : 'select';
} else {
type = 'input';
}
const options: QuestionOption[] | undefined = simple.options?.map((opt) => ({
value: opt.label,
label: opt.label,
description: opt.description,
}));
return {
id: simple.header,
type,
title: simple.question,
options,
} as Question;
}
/**
* Detect if params use the new "questions" array format
*/
function isSimpleFormat(params: Record<string, unknown>): params is { questions: SimpleQuestion[]; timeout?: number } {
return Array.isArray(params.questions);
}
// ========== A2UI Surface Generation ========== // ========== A2UI Surface Generation ==========
/** /**
@@ -223,6 +275,13 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
description: opt.description ? { literalString: opt.description } : undefined, description: opt.description ? { literalString: opt.description } : undefined,
})) || []; })) || [];
// Add "Other" option for custom input
options.push({
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
});
// Use RadioGroup for direct selection display (not dropdown) // Use RadioGroup for direct selection display (not dropdown)
components.push({ components.push({
id: 'radio-group', id: 'radio-group',
@@ -267,6 +326,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
const options = question.options?.map((opt) => ({ const options = question.options?.map((opt) => ({
label: { literalString: opt.label }, label: { literalString: opt.label },
value: opt.value, value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || []; })) || [];
// Add each checkbox as a separate component for better layout control // Add each checkbox as a separate component for better layout control
@@ -276,6 +336,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
component: { component: {
Checkbox: { Checkbox: {
label: opt.label, label: opt.label,
...(opt.description && { description: opt.description }),
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } }, onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
checked: { literalBoolean: false }, checked: { literalBoolean: false },
}, },
@@ -283,6 +344,19 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
}); });
}); });
// Add "Other" checkbox for custom input
components.push({
id: 'checkbox-other',
component: {
Checkbox: {
label: { literalString: 'Other' },
description: { literalString: 'Provide a custom answer' },
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: '__other__' } },
checked: { literalBoolean: false },
},
},
});
// Submit/cancel actions for multi-select so users can choose multiple options before resolving // Submit/cancel actions for multi-select so users can choose multiple options before resolving
components.push({ components.push({
id: 'submit-btn', id: 'submit-btn',
@@ -390,7 +464,12 @@ export async function execute(params: AskQuestionParams): Promise<ToolResult<Ask
// Send A2UI surface via WebSocket to frontend // Send A2UI surface via WebSocket to frontend
const a2uiSurface = generateQuestionSurface(question, surfaceId); const a2uiSurface = generateQuestionSurface(question, surfaceId);
a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate); const sentCount = a2uiWebSocketHandler.sendSurface(a2uiSurface.surfaceUpdate);
// If no local WS clients, start HTTP polling for answer from Dashboard
if (sentCount === 0) {
startAnswerPolling(question.id);
}
// Wait for answer // Wait for answer
const result = await resultPromise; const result = await resultPromise;
@@ -440,6 +519,85 @@ export function handleAnswer(answer: QuestionAnswer): boolean {
return true; return true;
} }
/**
* Handle multi-question composite answer from frontend (submit-all)
* @param compositeId - The composite question ID (multi-xxx)
* @param answers - Array of answers for each page
* @returns True if answer was processed
*/
export function handleMultiAnswer(compositeId: string, answers: QuestionAnswer[]): boolean {
const pending = pendingQuestions.get(compositeId);
if (!pending) {
return false;
}
pending.resolve({
success: true,
surfaceId: pending.surfaceId,
cancelled: false,
answers,
timestamp: new Date().toISOString(),
});
pendingQuestions.delete(compositeId);
return true;
}
// ========== Answer Polling (MCP stdio mode) ==========
/**
* Poll Dashboard server for answers when running in a separate MCP process.
* Starts polling GET /api/a2ui/answer and resolves the pending promise when an answer arrives.
* Automatically stops when the questionId is no longer in pendingQuestions (timeout cleanup).
*/
function startAnswerPolling(questionId: string, isComposite: boolean = false): void {
const path = `/api/a2ui/answer?questionId=${encodeURIComponent(questionId)}&composite=${isComposite}`;
const poll = () => {
// Stop if the question was already resolved or timed out
if (!pendingQuestions.has(questionId)) {
return;
}
const req = http.get({ hostname: 'localhost', port: DASHBOARD_PORT, path }, (res) => {
let data = '';
res.on('data', (chunk: Buffer) => { data += chunk.toString(); });
res.on('end', () => {
try {
const parsed = JSON.parse(data);
if (parsed.pending) {
// No answer yet, schedule next poll
setTimeout(poll, POLL_INTERVAL_MS);
return;
}
if (isComposite && Array.isArray(parsed.answers)) {
handleMultiAnswer(questionId, parsed.answers as QuestionAnswer[]);
} else if (!isComposite && parsed.answer) {
handleAnswer(parsed.answer as QuestionAnswer);
} else {
// Unexpected shape, keep polling
setTimeout(poll, POLL_INTERVAL_MS);
}
} catch {
// Parse error, keep polling
setTimeout(poll, POLL_INTERVAL_MS);
}
});
});
req.on('error', () => {
// Network error (Dashboard not reachable), keep trying
if (pendingQuestions.has(questionId)) {
setTimeout(poll, POLL_INTERVAL_MS);
}
});
};
// Start first poll after a short delay to give the Dashboard time to receive the surface
setTimeout(poll, POLL_INTERVAL_MS);
}
// ========== Cleanup ========== // ========== Cleanup ==========
/** /**
@@ -488,12 +646,70 @@ export function clearPendingQuestions(): void {
export const schema: ToolSchema = { export const schema: ToolSchema = {
name: 'ask_question', name: 'ask_question',
description: 'Ask the user a question through an interactive A2UI interface. Supports confirmation dialogs, selection from options, text input, and multi-select checkboxes.', description: `Ask the user a question through an interactive A2UI interface. Supports two calling styles:
**Style 1 - AskUserQuestion-compatible (recommended)**:
\`\`\`json
{
"questions": [{
"question": "Which library?",
"header": "Library",
"multiSelect": false,
"options": [
{ "label": "React", "description": "UI library" },
{ "label": "Vue", "description": "Progressive framework" }
]
}]
}
\`\`\`
Response includes \`answersDict\`: \`{ "Library": "React" }\`
Type inference: options + multiSelect=true → multi-select; options + multiSelect=false → select; no options → input.
**Style 2 - Legacy format**:
\`\`\`json
{
"question": {
"id": "q1",
"type": "select",
"title": "Which library?",
"options": [{ "value": "react", "label": "React" }]
}
}
\`\`\``,
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { properties: {
questions: {
type: 'array',
description: 'AskUserQuestion-style questions array (1-4 questions). Use this OR "question", not both.',
items: {
type: 'object',
properties: {
question: { type: 'string', description: 'The question text' },
header: { type: 'string', description: 'Short label, also used as response key (max 12 chars)' },
multiSelect: { type: 'boolean', description: 'Allow multiple selections (default: false)' },
options: {
type: 'array',
description: 'Available choices. Omit for text input.',
items: {
type: 'object',
properties: {
label: { type: 'string', description: 'Display text, also used as value' },
description: { type: 'string', description: 'Option description' },
},
required: ['label'],
},
},
},
required: ['question', 'header'],
},
minItems: 1,
maxItems: 4,
},
question: { question: {
type: 'object', type: 'object',
description: 'Legacy format: single question object. Use this OR "questions", not both.',
properties: { properties: {
id: { type: 'string', description: 'Unique identifier for this question' }, id: { type: 'string', description: 'Unique identifier for this question' },
type: { type: {
@@ -524,16 +740,343 @@ export const schema: ToolSchema = {
required: ['id', 'type', 'title'], required: ['id', 'type', 'title'],
}, },
timeout: { type: 'number', description: 'Timeout in milliseconds (default: 300000 / 5 minutes)' }, timeout: { type: 'number', description: 'Timeout in milliseconds (default: 300000 / 5 minutes)' },
surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided)' }, surfaceId: { type: 'string', description: 'Custom surface ID (auto-generated if not provided). Legacy format only.' },
}, },
required: ['question'],
}, },
}; };
/** /**
* Tool handler for MCP integration * Tool handler for MCP integration
* Wraps the execute function to match the expected handler signature * Supports both legacy format (question object) and AskUserQuestion-style format (questions array)
*/ */
export async function handler(params: Record<string, unknown>): Promise<ToolResult<AskQuestionResult>> { export async function handler(params: Record<string, unknown>): Promise<ToolResult<AskQuestionResult>> {
if (isSimpleFormat(params)) {
return executeSimpleFormat(params.questions, params.timeout);
}
return execute(params as AskQuestionParams); return execute(params as AskQuestionParams);
} }
// ========== Multi-Question Surface Generation ==========
/**
* Page metadata for multi-question surfaces
*/
interface PageMeta {
index: number;
questionId: string;
title: string;
type: string;
}
/**
* Generate a single A2UI surface containing all questions, each tagged with a page index.
* @param questions - Array of SimpleQuestion
* @returns Surface update with page-tagged components and page metadata
*/
function generateMultiQuestionSurface(
questions: SimpleQuestion[],
surfaceId: string,
): {
surfaceUpdate: {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode: 'popup';
};
pages: PageMeta[];
} {
const components: unknown[] = [];
const pages: PageMeta[] = [];
for (let pageIdx = 0; pageIdx < questions.length; pageIdx++) {
const simpleQ = questions[pageIdx];
const question = normalizeSimpleQuestion(simpleQ);
const qId = question.id; // header used as id
pages.push({
index: pageIdx,
questionId: qId,
title: question.title,
type: question.type,
});
// Title
components.push({
id: `page-${pageIdx}-title`,
page: pageIdx,
component: {
Text: {
text: { literalString: question.title },
usageHint: 'h3',
},
},
});
// Message
if (question.message) {
components.push({
id: `page-${pageIdx}-message`,
page: pageIdx,
component: {
Text: {
text: { literalString: question.message },
usageHint: 'p',
},
},
});
}
// Description
if (question.description) {
components.push({
id: `page-${pageIdx}-description`,
page: pageIdx,
component: {
Text: {
text: { literalString: question.description },
usageHint: 'small',
},
},
});
}
// Interactive components based on question type
switch (question.type) {
case 'select': {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
// Add "Other" option for custom input
options.push({
label: { literalString: 'Other' },
value: '__other__',
description: { literalString: 'Provide a custom answer' },
});
components.push({
id: `page-${pageIdx}-radio-group`,
page: pageIdx,
component: {
RadioGroup: {
options,
selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'select', parameters: { questionId: qId } },
},
},
});
break;
}
case 'multi-select': {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
options.forEach((opt, idx) => {
components.push({
id: `page-${pageIdx}-checkbox-${idx}`,
page: pageIdx,
component: {
Checkbox: {
label: opt.label,
...(opt.description && { description: opt.description }),
onChange: { actionId: 'toggle', parameters: { questionId: qId, value: opt.value } },
checked: { literalBoolean: false },
},
},
});
});
// Add "Other" checkbox for custom input
components.push({
id: `page-${pageIdx}-checkbox-other`,
page: pageIdx,
component: {
Checkbox: {
label: { literalString: 'Other' },
description: { literalString: 'Provide a custom answer' },
onChange: { actionId: 'toggle', parameters: { questionId: qId, value: '__other__' } },
checked: { literalBoolean: false },
},
},
});
break;
}
case 'input': {
components.push({
id: `page-${pageIdx}-input`,
page: pageIdx,
component: {
TextField: {
value: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'input-change', parameters: { questionId: qId } },
placeholder: question.placeholder || 'Enter your answer',
type: 'text',
},
},
});
break;
}
case 'confirm': {
// Confirm type gets handled as a single boolean per page
// No extra component — the page navigation handles yes/no
break;
}
}
}
return {
surfaceUpdate: {
surfaceId,
components,
initialState: {
questionId: `multi-${Date.now()}`,
questionType: 'multi-question',
pages,
totalPages: questions.length,
},
displayMode: 'popup',
},
pages,
};
}
/**
* Execute questions in AskUserQuestion-style format.
* Single question: falls back to legacy sequential popup.
* Multiple questions: generates a single multi-page surface.
*/
async function executeSimpleFormat(
questions: SimpleQuestion[],
timeout?: number,
): Promise<ToolResult<AskQuestionResult>> {
// Single question: use legacy single-popup flow
if (questions.length === 1) {
const simpleQ = questions[0];
const question = normalizeSimpleQuestion(simpleQ);
const params = {
question,
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
} satisfies AskQuestionParams;
const result = await execute(params);
if (!result.success || !result.result) {
return result;
}
if (result.result.cancelled) {
return result;
}
const answersDict: Record<string, string | string[]> = {};
if (result.result.answers.length > 0) {
const answer = result.result.answers[0];
answersDict[simpleQ.header] = answer.value as string | string[];
}
return {
success: true,
result: {
success: true,
surfaceId: result.result.surfaceId,
cancelled: false,
answers: result.result.answers,
timestamp: new Date().toISOString(),
answersDict,
} as AskQuestionResult & { answersDict: Record<string, string | string[]> },
};
}
// Multiple questions: single multi-page surface
const compositeId = `multi-${Date.now()}`;
const surfaceId = `question-${compositeId}`;
const { surfaceUpdate, pages } = generateMultiQuestionSurface(questions, surfaceId);
// Create promise for the composite answer
const resultPromise = new Promise<AskQuestionResult>((resolve, reject) => {
const pendingQuestion: PendingQuestion = {
id: compositeId,
surfaceId,
question: {
id: compositeId,
type: 'input', // placeholder type — multi-question uses custom answer handling
title: 'Multi-question',
required: false,
},
timestamp: Date.now(),
timeout: timeout ?? DEFAULT_TIMEOUT_MS,
resolve,
reject,
};
pendingQuestions.set(compositeId, pendingQuestion);
// Also register each sub-question's questionId pointing to the same pending entry
// so that select/toggle actions on individual questions get tracked
for (const page of pages) {
// Initialize selection tracking in the websocket handler
if (page.type === 'multi-select') {
a2uiWebSocketHandler.initMultiSelect(page.questionId);
} else if (page.type === 'select') {
a2uiWebSocketHandler.initSingleSelect(page.questionId);
}
}
setTimeout(() => {
if (pendingQuestions.has(compositeId)) {
pendingQuestions.delete(compositeId);
resolve({
success: false,
surfaceId,
cancelled: false,
answers: [],
timestamp: new Date().toISOString(),
error: 'Question timed out',
});
}
}, timeout ?? DEFAULT_TIMEOUT_MS);
});
// Send the surface
const sentCount = a2uiWebSocketHandler.sendSurface(surfaceUpdate);
// If no local WS clients, start HTTP polling for answer from Dashboard
if (sentCount === 0) {
startAnswerPolling(compositeId, true);
}
// Wait for answer
const result = await resultPromise;
// If cancelled, return as-is
if (result.cancelled) {
return { success: true, result };
}
// Build answersDict from the answers array
const answersDict: Record<string, string | string[]> = {};
if (result.answers) {
for (const answer of result.answers) {
// Find the matching SimpleQuestion by questionId (which maps to header)
const simpleQ = questions.find(q => q.header === answer.questionId);
if (simpleQ) {
answersDict[simpleQ.header] = answer.value as string | string[];
}
}
}
return {
success: true,
result: {
...result,
answersDict,
} as AskQuestionResult & { answersDict: Record<string, string | string[]> },
};
}