feat: enhance theme customization and UI components

- Implemented a new color generation module to create CSS variables based on a single hue value, supporting both light and dark modes.
- Added unit tests for the color generation logic to ensure accuracy and robustness.
- Replaced dropdown location filter with tab navigation in RulesManagerPage and SkillsManagerPage for improved UX.
- Updated app store to manage custom theme hues and states, allowing for dynamic theme adjustments.
- Sanitized notification content before persisting to localStorage to prevent sensitive data exposure.
- Refactored memory retrieval logic to handle archived status more flexibly.
- Improved Tailwind CSS configuration with new gradient utilities and animations.
- Minor adjustments to SettingsPage layout for better visual consistency.
This commit is contained in:
catlog22
2026-02-04 17:20:40 +08:00
parent 88616224e0
commit e260a3f77b
30 changed files with 1377 additions and 388 deletions

View File

@@ -532,12 +532,18 @@ Use fix_strategy.test_pattern to run affected tests:
### Error Handling ### Error Handling
**Planning Failures**: **Batching Failures (Phase 1.5)**:
- Invalid template → Abort with error message - Invalid findings data → Abort with error message
- Insufficient findings data → Request complete export - Empty batches after grouping → Warn and skip empty batches
- Planning timeout → Retry once, then fail gracefully
**Execution Failures**: **Planning Failures (Phase 2)**:
- Planning agent timeout → Mark batch as failed, continue with other batches
- Partial plan missing → Skip batch, warn user
- Agent crash → Collect available partial plans, proceed with aggregation
- All agents fail → Abort entire fix session with error
- Aggregation conflicts → Apply conflict resolution (serialize conflicting groups)
**Execution Failures (Phase 3)**:
- Agent crash → Mark group as failed, continue with other groups - Agent crash → Mark group as failed, continue with other groups
- Test command not found → Skip test verification, warn user - Test command not found → Skip test verification, warn user
- Git operations fail → Abort with error, preserve state - Git operations fail → Abort with error, preserve state
@@ -549,14 +555,34 @@ Use fix_strategy.test_pattern to run affected tests:
### TodoWrite Structure ### TodoWrite Structure
**Initialization**: **Initialization (after Phase 1.5 batching)**:
```javascript ```javascript
TodoWrite({ TodoWrite({
todos: [ todos: [
{content: "Phase 1: Discovery & Initialization", status: "completed"}, {content: "Phase 1: Discovery & Initialization", status: "completed", activeForm: "Discovering"},
{content: "Phase 2: Planning", status: "in_progress"}, {content: "Phase 1.5: Intelligent Batching", status: "completed", activeForm: "Batching"},
{content: "Phase 3: Execution", status: "pending"}, {content: "Phase 2: Parallel Planning", status: "in_progress", activeForm: "Planning"},
{content: "Phase 4: Completion", status: "pending"} {content: " → Batch 1: 4 findings (auth.ts:security)", status: "pending", activeForm: "Planning batch 1"},
{content: " → Batch 2: 3 findings (query.ts:security)", status: "pending", activeForm: "Planning batch 2"},
{content: " → Batch 3: 2 findings (config.ts:quality)", status: "pending", activeForm: "Planning batch 3"},
{content: "Phase 3: Execution", status: "pending", activeForm: "Executing"},
{content: "Phase 4: Completion", status: "pending", activeForm: "Completing"}
]
});
```
**During Planning (parallel agents running)**:
```javascript
TodoWrite({
todos: [
{content: "Phase 1: Discovery & Initialization", status: "completed", activeForm: "Discovering"},
{content: "Phase 1.5: Intelligent Batching", status: "completed", activeForm: "Batching"},
{content: "Phase 2: Parallel Planning", status: "in_progress", activeForm: "Planning"},
{content: " → Batch 1: 4 findings (auth.ts:security)", status: "completed", activeForm: "Planning batch 1"},
{content: " → Batch 2: 3 findings (query.ts:security)", status: "in_progress", activeForm: "Planning batch 2"},
{content: " → Batch 3: 2 findings (config.ts:quality)", status: "in_progress", activeForm: "Planning batch 3"},
{content: "Phase 3: Execution", status: "pending", activeForm: "Executing"},
{content: "Phase 4: Completion", status: "pending", activeForm: "Completing"}
] ]
}); });
``` ```
@@ -565,23 +591,25 @@ TodoWrite({
```javascript ```javascript
TodoWrite({ TodoWrite({
todos: [ todos: [
{content: "Phase 1: Discovery & Initialization", status: "completed"}, {content: "Phase 1: Discovery & Initialization", status: "completed", activeForm: "Discovering"},
{content: "Phase 2: Planning", status: "completed"}, {content: "Phase 1.5: Intelligent Batching", status: "completed", activeForm: "Batching"},
{content: "Phase 3: Execution", status: "in_progress"}, {content: "Phase 2: Parallel Planning (3 batches → 5 groups)", status: "completed", activeForm: "Planning"},
{content: " → Stage 1: Parallel execution (3 groups)", status: "completed"}, {content: "Phase 3: Execution", status: "in_progress", activeForm: "Executing"},
{content: " • Group G1: Auth validation (2 findings)", status: "completed"}, {content: " → Stage 1: Parallel execution (3 groups)", status: "completed", activeForm: "Executing stage 1"},
{content: " • Group G2: Query security (3 findings)", status: "completed"}, {content: " • Group G1: Auth validation (2 findings)", status: "completed", activeForm: "Fixing G1"},
{content: " • Group G3: Config quality (1 finding)", status: "completed"}, {content: " • Group G2: Query security (3 findings)", status: "completed", activeForm: "Fixing G2"},
{content: " → Stage 2: Serial execution (1 group)", status: "in_progress"}, {content: " • Group G3: Config quality (1 finding)", status: "completed", activeForm: "Fixing G3"},
{content: " • Group G4: Dependent fixes (2 findings)", status: "in_progress"}, {content: " → Stage 2: Serial execution (1 group)", status: "in_progress", activeForm: "Executing stage 2"},
{content: "Phase 4: Completion", status: "pending"} {content: " • Group G4: Dependent fixes (2 findings)", status: "in_progress", activeForm: "Fixing G4"},
{content: "Phase 4: Completion", status: "pending", activeForm: "Completing"}
] ]
}); });
``` ```
**Update Rules**: **Update Rules**:
- Add stage items dynamically based on fix-plan.json timeline - Add batch items dynamically during Phase 1.5
- Add group items per stage - Mark batch items completed as parallel agents return results
- Add stage/group items dynamically after Phase 2 plan aggregation
- Mark completed immediately after each group finishes - Mark completed immediately after each group finishes
- Update parent phase status when all child items complete - Update parent phase status when all child items complete
@@ -591,12 +619,13 @@ TodoWrite({
## Best Practices ## Best Practices
1. **Trust AI Planning**: Planning agent's grouping and execution strategy are based on dependency analysis 1. **Leverage Parallel Planning**: For 10+ findings, parallel batching significantly reduces planning time
2. **Conservative Approach**: Test verification is mandatory - no fixes kept without passing tests 2. **Tune Batch Size**: Use `--batch-size` to control granularity (smaller batches = more parallelism, larger = better grouping context)
3. **Parallel Efficiency**: Default 3 concurrent agents balances speed and resource usage 3. **Conservative Approach**: Test verification is mandatory - no fixes kept without passing tests
4. **Resume Support**: Fix sessions can resume from checkpoints after interruption 4. **Parallel Efficiency**: MAX_PARALLEL=10 for planning agents, 3 concurrent execution agents per stage
5. **Manual Review**: Always review failed fixes manually - may require architectural changes 5. **Resume Support**: Fix sessions can resume from checkpoints after interruption
6. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes 6. **Manual Review**: Always review failed fixes manually - may require architectural changes
7. **Incremental Fixing**: Start with small batches (5-10 findings) before large-scale fixes
## Related Commands ## Related Commands

View File

@@ -1,9 +1,9 @@
// ======================================== // ========================================
// TabBar Component // TabBar Component
// ======================================== // ========================================
// Tab management for CLI viewer panes // Tab management for CLI viewer panes with drag-and-drop support
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useIntl } from 'react-intl'; import { useIntl } from 'react-intl';
import { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react'; import { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -14,7 +14,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu'; } from '@/components/ui/Dropdown';
import { import {
useViewerStore, useViewerStore,
useViewerPanes, useViewerPanes,
@@ -32,6 +32,7 @@ export interface TabBarProps {
interface TabItemProps { interface TabItemProps {
tab: TabState; tab: TabState;
paneId: PaneId;
isActive: boolean; isActive: boolean;
onSelect: () => void; onSelect: () => void;
onClose: (e: React.MouseEvent) => void; onClose: (e: React.MouseEvent) => void;
@@ -49,10 +50,23 @@ const STATUS_COLORS = {
// ========== Helper Components ========== // ========== Helper Components ==========
// Data transfer key for tab drag-and-drop
const TAB_DRAG_DATA_TYPE = 'application/x-cli-viewer-tab';
interface TabDragData {
tabId: string;
sourcePaneId: string;
}
/** /**
* Individual tab item * Individual tab item with drag-and-drop support
*/ */
function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps) { function TabItem({ tab, paneId, isActive, onSelect, onClose, onTogglePin }: TabItemProps) {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const moveTab = useViewerStore((state) => state.moveTab);
const panes = useViewerPanes();
// Simplify title for display // Simplify title for display
const displayTitle = useMemo(() => { const displayTitle = useMemo(() => {
// If title contains tool name pattern, extract it // If title contains tool name pattern, extract it
@@ -60,17 +74,93 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
return parts[0] || tab.title; return parts[0] || tab.title;
}, [tab.title]); }, [tab.title]);
// Drag start handler
const handleDragStart = useCallback((e: React.DragEvent) => {
const dragData: TabDragData = {
tabId: tab.id,
sourcePaneId: paneId,
};
e.dataTransfer.setData(TAB_DRAG_DATA_TYPE, JSON.stringify(dragData));
e.dataTransfer.effectAllowed = 'move';
setIsDragging(true);
}, [tab.id, paneId]);
// Drag end handler
const handleDragEnd = useCallback(() => {
setIsDragging(false);
}, []);
// Drag over handler
const handleDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}
}, []);
// Drag leave handler
const handleDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
// Drop handler
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE);
if (!rawData) return;
try {
const dragData: TabDragData = JSON.parse(rawData);
const { tabId: sourceTabId, sourcePaneId } = dragData;
// Don't do anything if dropping on the same tab
if (sourceTabId === tab.id) return;
// Find the target index
const targetPane = panes[paneId];
if (!targetPane) return;
const targetIndex = targetPane.tabs.findIndex((t) => t.id === tab.id);
if (targetIndex === -1) return;
// Move the tab
moveTab(sourcePaneId, sourceTabId, paneId, targetIndex);
} catch (err) {
console.error('[TabBar] Failed to parse drag data:', err);
}
}, [tab.id, paneId, panes, moveTab]);
return ( return (
<button <div
role="tab"
tabIndex={0}
draggable={!tab.isPinned}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onSelect} onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
className={cn( className={cn(
'group relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs', 'group relative flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs',
'border border-border/50 shrink-0 min-w-0 max-w-[160px]', 'border border-border/50 shrink-0 min-w-0 max-w-[160px]',
'transition-all duration-150', 'transition-all duration-150 select-none',
isActive isActive
? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm' ? 'bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 shadow-sm'
: 'bg-muted/30 hover:bg-muted/50 border-border/30', : 'bg-muted/30 hover:bg-muted/50 border-border/30',
tab.isPinned && 'border-amber-500/50' tab.isPinned && 'border-amber-500/50',
isDragging && 'opacity-50 cursor-grabbing',
isDragOver && 'border-primary border-dashed bg-primary/10',
!tab.isPinned && 'cursor-grab'
)} )}
title={tab.title} title={tab.title}
> >
@@ -111,7 +201,7 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
</button> </button>
)} )}
</div> </div>
</button> </div>
); );
} }
@@ -125,10 +215,12 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
* - Active tab highlighting * - Active tab highlighting
* - Close button on hover * - Close button on hover
* - Pin/unpin functionality * - Pin/unpin functionality
* - Drag-and-drop tab reordering and moving between panes
* - Pane actions dropdown * - Pane actions dropdown
*/ */
export function TabBar({ paneId, className }: TabBarProps) { export function TabBar({ paneId, className }: TabBarProps) {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const [isDragOver, setIsDragOver] = useState(false);
const panes = useViewerPanes(); const panes = useViewerPanes();
const pane = panes[paneId]; const pane = panes[paneId];
const setActiveTab = useViewerStore((state) => state.setActiveTab); const setActiveTab = useViewerStore((state) => state.setActiveTab);
@@ -136,6 +228,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
const togglePinTab = useViewerStore((state) => state.togglePinTab); const togglePinTab = useViewerStore((state) => state.togglePinTab);
const addPane = useViewerStore((state) => state.addPane); const addPane = useViewerStore((state) => state.addPane);
const removePane = useViewerStore((state) => state.removePane); const removePane = useViewerStore((state) => state.removePane);
const moveTab = useViewerStore((state) => state.moveTab);
const handleTabSelect = useCallback( const handleTabSelect = useCallback(
(tabId: string) => { (tabId: string) => {
@@ -172,6 +265,43 @@ export function TabBar({ paneId, className }: TabBarProps) {
removePane(paneId); removePane(paneId);
}, [paneId, removePane]); }, [paneId, removePane]);
// Drag over handler for tab bar container (allows dropping to end of list)
const handleContainerDragOver = useCallback((e: React.DragEvent) => {
if (e.dataTransfer.types.includes(TAB_DRAG_DATA_TYPE)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}
}, []);
// Drag leave handler for container
const handleContainerDragLeave = useCallback((e: React.DragEvent) => {
// Only set false if leaving the container entirely, not just moving to a child
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragOver(false);
}
}, []);
// Drop handler for tab bar container (drops to end of list)
const handleContainerDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const rawData = e.dataTransfer.getData(TAB_DRAG_DATA_TYPE);
if (!rawData) return;
try {
const dragData: TabDragData = JSON.parse(rawData);
const { tabId: sourceTabId, sourcePaneId } = dragData;
// Move the tab to the end of this pane
const targetIndex = pane?.tabs.length || 0;
moveTab(sourcePaneId, sourceTabId, paneId, targetIndex);
} catch (err) {
console.error('[TabBar] Failed to parse drag data:', err);
}
}, [paneId, pane, moveTab]);
// Sort tabs: pinned first, then by order // Sort tabs: pinned first, then by order
const sortedTabs = useMemo(() => { const sortedTabs = useMemo(() => {
if (!pane) return []; if (!pane) return [];
@@ -197,7 +327,15 @@ export function TabBar({ paneId, className }: TabBarProps) {
)} )}
> >
{/* Tabs */} {/* Tabs */}
<div className="flex items-center gap-1 flex-1 min-w-0 overflow-x-auto"> <div
onDragOver={handleContainerDragOver}
onDragLeave={handleContainerDragLeave}
onDrop={handleContainerDrop}
className={cn(
'flex items-center gap-1 flex-1 min-w-0 overflow-x-auto',
isDragOver && 'bg-primary/5 border border-primary border-dashed rounded'
)}
>
{sortedTabs.length === 0 ? ( {sortedTabs.length === 0 ? (
<span className="text-xs text-muted-foreground px-2"> <span className="text-xs text-muted-foreground px-2">
{formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })} {formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })}
@@ -207,6 +345,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
<TabItem <TabItem
key={tab.id} key={tab.id}
tab={tab} tab={tab}
paneId={paneId}
isActive={pane.activeTabId === tab.id} isActive={pane.activeTabId === tab.id}
onSelect={() => handleTabSelect(tab.id)} onSelect={() => handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)} onClose={(e) => handleTabClose(e, tab.id)}

View File

@@ -32,8 +32,8 @@ export function CoordinatorEmptyState({
className className
)} )}
> >
{/* Animated Background - Using theme colors */} {/* Animated Background - Using theme colors with gradient utilities */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background"> <div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background animate-slow-gradient">
{/* Grid Pattern */} {/* Grid Pattern */}
<div <div
className="absolute inset-0 opacity-10" className="absolute inset-0 opacity-10"
@@ -46,29 +46,16 @@ export function CoordinatorEmptyState({
}} }}
/> />
{/* Animated Gradient Orbs - Using primary color */} {/* Animated Gradient Orbs - Using gradient utility classes */}
<div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse" <div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-15" />
style={{
background: 'radial-gradient(circle, hsl(var(--primary)) 0%, transparent 70%)',
opacity: 0.15,
}}
/>
<div <div
className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse" className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse opacity-15"
style={{ style={{
background: 'radial-gradient(circle, hsl(var(--secondary)) 0%, transparent 70%)', background: 'radial-gradient(circle, hsl(var(--secondary)) 0%, transparent 70%)',
animationDelay: '1s', animationDelay: '1s',
opacity: 0.15,
}}
/>
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse"
style={{
background: 'radial-gradient(circle, hsl(var(--accent)) 0%, transparent 70%)',
animationDelay: '2s',
opacity: 0.1,
}} }}
/> />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-10" style={{ animationDelay: '2s' }} />
</div> </div>
{/* Main Content */} {/* Main Content */}

View File

@@ -130,6 +130,7 @@ export function AppShell({
}; };
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// Cleanup: Remove event listener on unmount to prevent memory leak
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);

View File

@@ -1,32 +1,72 @@
import React from 'react'; import React, { useState, useEffect } 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 { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme'; import type { ColorScheme, ThemeMode } from '@/lib/theme';
import { generateThemeFromHue } from '@/lib/colorGenerator';
/** /**
* Theme Selector Component * Theme Selector Component
* Allows users to select from 4 color schemes (blue/green/orange/purple) * Allows users to select from 4 color schemes (blue/green/orange/purple)
* and 2 theme modes (light/dark) * and 2 theme modes (light/dark), plus custom hue customization
* *
* Features: * Features:
* - 8 total theme combinations * - 8 preset theme combinations + custom hue support
* - Keyboard navigation support (Arrow keys) * - Keyboard navigation support (Arrow keys)
* - ARIA labels for accessibility * - ARIA labels for accessibility
* - Visual feedback for selected theme * - Visual feedback for selected theme
* - System dark mode detection * - System dark mode detection
* - Custom hue slider (0-360) with real-time preview
*/ */
export function ThemeSelector() { export function ThemeSelector() {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const { colorScheme, resolvedTheme, setColorScheme, setTheme } = useTheme(); const { colorScheme, resolvedTheme, customHue, isCustomTheme, setColorScheme, setTheme, setCustomHue } = useTheme();
// Local state for preview hue (uncommitted changes)
const [previewHue, setPreviewHue] = useState<number | null>(customHue);
// Sync preview with customHue from store
useEffect(() => {
setPreviewHue(customHue);
}, [customHue]);
// Resolved mode is either 'light' or 'dark' // Resolved mode is either 'light' or 'dark'
const mode: ThemeMode = resolvedTheme; const mode: ThemeMode = resolvedTheme;
// Get preview colors for the custom theme swatches
const getPreviewColor = (variable: string) => {
const hue = previewHue ?? 180; // Default to cyan if null
const colors = generateThemeFromHue(hue, mode);
const hslValue = colors[variable];
return hslValue ? `hsl(${hslValue})` : '#888';
};
const handleSchemeSelect = (scheme: ColorScheme) => { const handleSchemeSelect = (scheme: ColorScheme) => {
// When selecting a preset scheme, reset custom hue
if (isCustomTheme) {
setCustomHue(null);
}
setColorScheme(scheme); setColorScheme(scheme);
}; };
const handleCustomSelect = () => {
// Set custom hue to a default value if null
if (customHue === null) {
setCustomHue(180); // Default cyan
}
};
const handleHueSave = () => {
if (previewHue !== null) {
setCustomHue(previewHue);
}
};
const handleHueReset = () => {
setCustomHue(null);
setPreviewHue(null);
};
const handleModeSelect = (newMode: ThemeMode) => { const handleModeSelect = (newMode: ThemeMode) => {
setTheme(newMode); setTheme(newMode);
}; };
@@ -53,7 +93,7 @@ export function ThemeSelector() {
{formatMessage({ id: 'theme.title.colorScheme' })} {formatMessage({ id: 'theme.title.colorScheme' })}
</h3> </h3>
<div <div
className="grid grid-cols-4 gap-3" className="grid grid-cols-5 gap-3"
role="group" role="group"
aria-label="Color scheme selection" aria-label="Color scheme selection"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -63,12 +103,12 @@ export function ThemeSelector() {
key={scheme.id} key={scheme.id}
onClick={() => handleSchemeSelect(scheme.id)} onClick={() => handleSchemeSelect(scheme.id)}
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: `theme.colorScheme.${scheme.id}` }) })} aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: `theme.colorScheme.${scheme.id}` }) })}
aria-selected={colorScheme === scheme.id} aria-selected={colorScheme === scheme.id && !isCustomTheme}
role="radio" role="radio"
className={` className={`
flex flex-col items-center gap-2 p-3 rounded-lg flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2 transition-all duration-200 border-2
${colorScheme === scheme.id ${colorScheme === scheme.id && !isCustomTheme
? 'border-accent bg-surface shadow-md' ? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface' : 'border-border bg-bg hover:bg-surface'
} }
@@ -87,9 +127,124 @@ export function ThemeSelector() {
</span> </span>
</button> </button>
))} ))}
{/* Custom Color Option */}
<button
onClick={handleCustomSelect}
aria-label={formatMessage({ id: 'theme.select.colorScheme' }, { name: formatMessage({ id: 'theme.colorScheme.custom' }) })}
aria-selected={isCustomTheme}
role="radio"
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${isCustomTheme
? '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
`}
>
{/* Gradient swatch showing current custom hue */}
<div
className="w-8 h-8 rounded-full border-2 border-border shadow-sm"
style={{
background: `linear-gradient(135deg, ${getPreviewColor('--accent')}, ${getPreviewColor('--primary')})`
}}
aria-hidden="true"
/>
{/* Label */}
<span className="text-xs font-medium text-text text-center">
{formatMessage({ id: 'theme.colorScheme.custom' })}
</span>
</button>
</div> </div>
</div> </div>
{/* Custom Hue Selection - Only shown when custom theme is active */}
{isCustomTheme && (
<div className="space-y-4">
<h3 className="text-sm font-medium text-text mb-3">
{formatMessage({ id: 'theme.title.customHue' })}
</h3>
{/* Hue Slider */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<label htmlFor="hue-slider" className="text-xs text-text-secondary">
{formatMessage({ id: 'theme.hueValue' }, { value: previewHue ?? 180 })}
</label>
</div>
<input
id="hue-slider"
type="range"
min="0"
max="360"
step="1"
value={previewHue ?? 180}
onChange={(e) => setPreviewHue(Number(e.target.value))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right,
hsl(0, 70%, 60%), hsl(60, 70%, 60%), hsl(120, 70%, 60%),
hsl(180, 70%, 60%), hsl(240, 70%, 60%), hsl(300, 70%, 60%), hsl(360, 70%, 60%))`
}}
aria-label={formatMessage({ id: 'theme.title.customHue' })}
/>
{/* Preview Swatches */}
<div className="flex gap-2 items-center">
<span className="text-xs text-text-secondary mr-2">
{formatMessage({ id: 'theme.preview' })}:
</span>
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: getPreviewColor('--bg') }}
title="Background"
/>
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: getPreviewColor('--surface') }}
title="Surface"
/>
<div
className="w-10 h-10 rounded border-2 border-border shadow-sm"
style={{ backgroundColor: getPreviewColor('--accent') }}
title="Accent"
/>
</div>
{/* Save and Reset Buttons */}
<div className="flex gap-2 pt-2">
<button
onClick={handleHueSave}
disabled={previewHue === customHue}
className={`
flex-1 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${previewHue === customHue
? 'bg-muted text-muted-text cursor-not-allowed'
: 'bg-accent text-white hover:bg-accent-hover focus:ring-2 focus:ring-accent focus:ring-offset-2'
}
`}
>
{formatMessage({ id: 'theme.save' })}
</button>
<button
onClick={handleHueReset}
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.reset' })}
</button>
</div>
</div>
</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">

View File

@@ -4,7 +4,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 } from 'react';
import { useAppStore, selectTheme, selectResolvedTheme } from '../stores/appStore'; import { useAppStore, selectTheme, selectResolvedTheme, selectCustomHue, selectIsCustomTheme } from '../stores/appStore';
import type { Theme, ColorScheme } from '../types/store'; import type { Theme, ColorScheme } from '../types/store';
export interface UseThemeReturn { export interface UseThemeReturn {
@@ -16,10 +16,16 @@ export interface UseThemeReturn {
isDark: boolean; isDark: boolean;
/** Current color scheme ('blue', 'green', 'orange', 'purple') */ /** Current color scheme ('blue', 'green', 'orange', 'purple') */
colorScheme: ColorScheme; colorScheme: ColorScheme;
/** Custom hue value (0-360) for theme customization, null when using preset themes */
customHue: number | null;
/** Whether the current theme is a custom theme */
isCustomTheme: boolean;
/** Set theme preference */ /** Set theme preference */
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
/** Set color scheme */ /** Set color scheme */
setColorScheme: (scheme: ColorScheme) => void; setColorScheme: (scheme: ColorScheme) => void;
/** Set custom hue value (0-360) or null to reset to preset theme */
setCustomHue: (hue: number | null) => void;
/** Toggle between light and dark (ignores system) */ /** Toggle between light and dark (ignores system) */
toggleTheme: () => void; toggleTheme: () => void;
} }
@@ -46,8 +52,11 @@ export function useTheme(): UseThemeReturn {
const theme = useAppStore(selectTheme); const theme = useAppStore(selectTheme);
const resolvedTheme = useAppStore(selectResolvedTheme); const resolvedTheme = useAppStore(selectResolvedTheme);
const colorScheme = useAppStore((state) => state.colorScheme); const colorScheme = useAppStore((state) => state.colorScheme);
const customHue = useAppStore(selectCustomHue);
const isCustomTheme = useAppStore(selectIsCustomTheme);
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 toggleThemeAction = useAppStore((state) => state.toggleTheme); const toggleThemeAction = useAppStore((state) => state.toggleTheme);
const setTheme = useCallback( const setTheme = useCallback(
@@ -64,6 +73,13 @@ export function useTheme(): UseThemeReturn {
[setColorSchemeAction] [setColorSchemeAction]
); );
const setCustomHue = useCallback(
(hue: number | null) => {
setCustomHueAction(hue);
},
[setCustomHueAction]
);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
toggleThemeAction(); toggleThemeAction();
}, [toggleThemeAction]); }, [toggleThemeAction]);
@@ -73,8 +89,11 @@ export function useTheme(): UseThemeReturn {
resolvedTheme, resolvedTheme,
isDark: resolvedTheme === 'dark', isDark: resolvedTheme === 'dark',
colorScheme, colorScheme,
customHue,
isCustomTheme,
setTheme, setTheme,
setColorScheme, setColorScheme,
setCustomHue,
toggleTheme, toggleTheme,
}; };
} }

View File

@@ -0,0 +1,202 @@
/**
* Unit tests for colorGenerator module
* Tests HSL theme generation algorithm and output validation
*/
import { describe, it, expect } from 'vitest';
import { generateThemeFromHue, getVariableCount, isValidHue } from './colorGenerator';
describe('colorGenerator', () => {
describe('generateThemeFromHue', () => {
it('should generate object with 40+ keys for light mode', () => {
const result = generateThemeFromHue(180, 'light');
const keys = Object.keys(result);
expect(keys.length).toBeGreaterThanOrEqual(40);
});
it('should generate object with 40+ keys for dark mode', () => {
const result = generateThemeFromHue(180, 'dark');
const keys = Object.keys(result);
expect(keys.length).toBeGreaterThanOrEqual(40);
});
it('should return values in "H S% L%" format', () => {
const result = generateThemeFromHue(180, 'light');
const hslPattern = /^\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%$/;
Object.values(result).forEach(value => {
expect(value).toMatch(hslPattern);
});
});
it('should generate high lightness values for light mode backgrounds', () => {
const result = generateThemeFromHue(180, 'light');
// Parse lightness from --bg variable
const bgLightness = parseInt(result['--bg'].split(' ')[2]);
expect(bgLightness).toBeGreaterThan(85);
// Parse lightness from --surface variable
const surfaceLightness = parseInt(result['--surface'].split(' ')[2]);
expect(surfaceLightness).toBeGreaterThan(85);
});
it('should generate low lightness values for dark mode backgrounds', () => {
const result = generateThemeFromHue(180, 'dark');
// Parse lightness from --bg variable
const bgLightness = parseInt(result['--bg'].split(' ')[2]);
expect(bgLightness).toBeLessThan(22);
// Parse lightness from --surface variable
const surfaceLightness = parseInt(result['--surface'].split(' ')[2]);
expect(surfaceLightness).toBeLessThan(22);
});
it('should use provided hue in generated variables', () => {
const testHue = 240;
const result = generateThemeFromHue(testHue, 'light');
// Check primary hue-based variables
const accentHue = parseInt(result['--accent'].split(' ')[0]);
expect(accentHue).toBe(testHue);
const primaryHue = parseInt(result['--primary'].split(' ')[0]);
expect(primaryHue).toBe(testHue);
});
it('should handle edge case: hue = 0', () => {
const result = generateThemeFromHue(0, 'light');
expect(Object.keys(result).length).toBeGreaterThanOrEqual(40);
expect(result['--accent']).toMatch(/^0\s+\d+%\s+\d+%$/);
});
it('should handle edge case: hue = 360', () => {
const result = generateThemeFromHue(360, 'light');
expect(Object.keys(result).length).toBeGreaterThanOrEqual(40);
// 360 should normalize to 0
expect(result['--accent']).toMatch(/^0\s+\d+%\s+\d+%$/);
});
it('should handle negative hue values by normalizing', () => {
const result = generateThemeFromHue(-60, 'light');
// -60 should normalize to 300
const accentHue = parseInt(result['--accent'].split(' ')[0]);
expect(accentHue).toBe(300);
});
it('should handle hue values > 360 by normalizing', () => {
const result = generateThemeFromHue(450, 'light');
// 450 should normalize to 90
const accentHue = parseInt(result['--accent'].split(' ')[0]);
expect(accentHue).toBe(90);
});
it('should generate different themes for light and dark modes', () => {
const lightTheme = generateThemeFromHue(180, 'light');
const darkTheme = generateThemeFromHue(180, 'dark');
// Background should be different
expect(lightTheme['--bg']).not.toBe(darkTheme['--bg']);
// Text should be different
expect(lightTheme['--text']).not.toBe(darkTheme['--text']);
});
it('should include all essential CSS variables', () => {
const result = generateThemeFromHue(180, 'light');
const essentialVars = [
'--bg',
'--surface',
'--border',
'--text',
'--text-secondary',
'--accent',
'--primary',
'--secondary',
'--muted',
'--success',
'--warning',
'--error',
'--info',
'--destructive',
'--hover'
];
essentialVars.forEach(varName => {
expect(result).toHaveProperty(varName);
});
});
it('should generate complementary secondary colors', () => {
const testHue = 180;
const result = generateThemeFromHue(testHue, 'light');
// Secondary should use complementary hue (180 degrees offset)
const secondaryHue = parseInt(result['--secondary'].split(' ')[0]);
const expectedComplementary = (testHue + 180) % 360;
expect(secondaryHue).toBe(expectedComplementary);
});
it('should have consistent variable count across different hues', () => {
const hue1 = generateThemeFromHue(0, 'light');
const hue2 = generateThemeFromHue(120, 'light');
const hue3 = generateThemeFromHue(240, 'light');
expect(Object.keys(hue1).length).toBe(Object.keys(hue2).length);
expect(Object.keys(hue2).length).toBe(Object.keys(hue3).length);
});
it('should have consistent variable count across modes', () => {
const lightCount = Object.keys(generateThemeFromHue(180, 'light')).length;
const darkCount = Object.keys(generateThemeFromHue(180, 'dark')).length;
expect(lightCount).toBe(darkCount);
});
});
describe('getVariableCount', () => {
it('should return count of generated variables', () => {
const count = getVariableCount();
expect(count).toBeGreaterThanOrEqual(40);
});
it('should return consistent count', () => {
const count1 = getVariableCount();
const count2 = getVariableCount();
expect(count1).toBe(count2);
});
});
describe('isValidHue', () => {
it('should return true for valid hue values', () => {
expect(isValidHue(0)).toBe(true);
expect(isValidHue(180)).toBe(true);
expect(isValidHue(360)).toBe(true);
expect(isValidHue(450)).toBe(true);
expect(isValidHue(-60)).toBe(true);
});
it('should return false for NaN', () => {
expect(isValidHue(NaN)).toBe(false);
});
it('should return false for Infinity', () => {
expect(isValidHue(Infinity)).toBe(false);
expect(isValidHue(-Infinity)).toBe(false);
});
it('should return false for non-number types', () => {
expect(isValidHue('180' as any)).toBe(false);
expect(isValidHue(null as any)).toBe(false);
expect(isValidHue(undefined as any)).toBe(false);
});
});
});

View File

@@ -0,0 +1,188 @@
/**
* Color Generator Module
* Generates complete CSS variable sets from a single Hue value
*
* Algorithm based on HSL color space with mode-specific saturation/lightness rules:
* - Light mode: Low saturation backgrounds (5-20%), high lightness (85-98%)
* - Dark mode: Medium saturation backgrounds (15-30%), low lightness (10-22%)
* - Accents: High saturation (60-90%), medium lightness (55-65%) for both modes
*
* @module colorGenerator
*/
/**
* Generate a complete theme from a single hue value
*
* @param hue - Hue value from 0 to 360 degrees
* @param mode - Theme mode ('light' or 'dark')
* @returns Record of CSS variable names to HSL values in 'H S% L%' format
*
* @example
* ```typescript
* const theme = generateThemeFromHue(180, 'light');
* // Returns: { '--bg': '180 5% 98%', '--accent': '180 70% 60%', ... }
* ```
*/
export function generateThemeFromHue(
hue: number,
mode: 'light' | 'dark'
): Record<string, string> {
// Normalize hue to 0-360 range
const normalizedHue = ((hue % 360) + 360) % 360;
const vars: Record<string, string> = {};
if (mode === 'light') {
// Light mode: Low saturation, high lightness backgrounds
vars['--bg'] = `${normalizedHue} 5% 98%`;
vars['--bg-secondary'] = `${normalizedHue} 8% 96%`;
vars['--surface'] = `${normalizedHue} 10% 99%`;
vars['--surface-hover'] = `${normalizedHue} 12% 97%`;
vars['--border'] = `${normalizedHue} 15% 88%`;
vars['--border-hover'] = `${normalizedHue} 18% 82%`;
// Text colors: Low saturation, very low lightness
vars['--text'] = `${normalizedHue} 20% 15%`;
vars['--text-secondary'] = `${normalizedHue} 10% 45%`;
vars['--text-tertiary'] = `${normalizedHue} 8% 60%`;
vars['--text-disabled'] = `${normalizedHue} 5% 70%`;
// Accent colors: High saturation, medium lightness
vars['--accent'] = `${normalizedHue} 70% 60%`;
vars['--accent-hover'] = `${normalizedHue} 75% 55%`;
vars['--accent-active'] = `${normalizedHue} 80% 50%`;
vars['--accent-light'] = `${normalizedHue} 65% 90%`;
vars['--accent-lighter'] = `${normalizedHue} 60% 95%`;
// Primary colors
vars['--primary'] = `${normalizedHue} 70% 60%`;
vars['--primary-hover'] = `${normalizedHue} 75% 55%`;
vars['--primary-light'] = `${normalizedHue} 65% 90%`;
vars['--primary-lighter'] = `${normalizedHue} 60% 95%`;
// Secondary colors (complementary hue)
const secondaryHue = (normalizedHue + 180) % 360;
vars['--secondary'] = `${secondaryHue} 60% 65%`;
vars['--secondary-hover'] = `${secondaryHue} 65% 60%`;
vars['--secondary-light'] = `${secondaryHue} 55% 90%`;
// Muted colors
vars['--muted'] = `${normalizedHue} 12% 92%`;
vars['--muted-hover'] = `${normalizedHue} 15% 88%`;
vars['--muted-text'] = `${normalizedHue} 10% 45%`;
// Semantic colors (success, warning, error, info)
vars['--success'] = `120 60% 50%`;
vars['--success-light'] = `120 55% 92%`;
vars['--success-text'] = `120 70% 35%`;
vars['--warning'] = `38 90% 55%`;
vars['--warning-light'] = `38 85% 92%`;
vars['--warning-text'] = `38 95% 40%`;
vars['--error'] = `0 70% 55%`;
vars['--error-light'] = `0 65% 92%`;
vars['--error-text'] = `0 75% 40%`;
vars['--info'] = `200 70% 55%`;
vars['--info-light'] = `200 65% 92%`;
vars['--info-text'] = `200 75% 40%`;
// Destructive (danger)
vars['--destructive'] = `0 70% 55%`;
vars['--destructive-hover'] = `0 75% 50%`;
vars['--destructive-light'] = `0 65% 92%`;
// Interactive states
vars['--hover'] = `${normalizedHue} 12% 94%`;
vars['--active'] = `${normalizedHue} 15% 90%`;
vars['--focus'] = `${normalizedHue} 70% 60%`;
} else {
// Dark mode: Medium saturation, low lightness backgrounds
vars['--bg'] = `${normalizedHue} 20% 10%`;
vars['--bg-secondary'] = `${normalizedHue} 18% 12%`;
vars['--surface'] = `${normalizedHue} 15% 14%`;
vars['--surface-hover'] = `${normalizedHue} 18% 16%`;
vars['--border'] = `${normalizedHue} 10% 22%`;
vars['--border-hover'] = `${normalizedHue} 12% 28%`;
// Text colors: Low saturation, very high lightness
vars['--text'] = `${normalizedHue} 10% 90%`;
vars['--text-secondary'] = `${normalizedHue} 8% 65%`;
vars['--text-tertiary'] = `${normalizedHue} 6% 50%`;
vars['--text-disabled'] = `${normalizedHue} 5% 40%`;
// Accent colors: High saturation, medium lightness
vars['--accent'] = `${normalizedHue} 70% 60%`;
vars['--accent-hover'] = `${normalizedHue} 75% 65%`;
vars['--accent-active'] = `${normalizedHue} 80% 70%`;
vars['--accent-light'] = `${normalizedHue} 60% 25%`;
vars['--accent-lighter'] = `${normalizedHue} 55% 20%`;
// Primary colors
vars['--primary'] = `${normalizedHue} 70% 60%`;
vars['--primary-hover'] = `${normalizedHue} 75% 65%`;
vars['--primary-light'] = `${normalizedHue} 60% 25%`;
vars['--primary-lighter'] = `${normalizedHue} 55% 20%`;
// Secondary colors (complementary hue)
const secondaryHue = (normalizedHue + 180) % 360;
vars['--secondary'] = `${secondaryHue} 60% 65%`;
vars['--secondary-hover'] = `${secondaryHue} 65% 70%`;
vars['--secondary-light'] = `${secondaryHue} 55% 25%`;
// Muted colors
vars['--muted'] = `${normalizedHue} 15% 18%`;
vars['--muted-hover'] = `${normalizedHue} 18% 22%`;
vars['--muted-text'] = `${normalizedHue} 8% 65%`;
// Semantic colors (success, warning, error, info)
vars['--success'] = `120 60% 55%`;
vars['--success-light'] = `120 50% 20%`;
vars['--success-text'] = `120 65% 65%`;
vars['--warning'] = `38 90% 60%`;
vars['--warning-light'] = `38 80% 22%`;
vars['--warning-text'] = `38 95% 70%`;
vars['--error'] = `0 70% 60%`;
vars['--error-light'] = `0 60% 20%`;
vars['--error-text'] = `0 75% 70%`;
vars['--info'] = `200 70% 60%`;
vars['--info-light'] = `200 60% 20%`;
vars['--info-text'] = `200 75% 70%`;
// Destructive (danger)
vars['--destructive'] = `0 70% 60%`;
vars['--destructive-hover'] = `0 75% 65%`;
vars['--destructive-light'] = `0 60% 20%`;
// Interactive states
vars['--hover'] = `${normalizedHue} 18% 16%`;
vars['--active'] = `${normalizedHue} 20% 20%`;
vars['--focus'] = `${normalizedHue} 70% 60%`;
}
return vars;
}
/**
* Get the total count of CSS variables generated
* @returns Number of variables in the generated theme
*/
export function getVariableCount(): number {
// Generate a sample theme to count variables
const sample = generateThemeFromHue(0, 'light');
return Object.keys(sample).length;
}
/**
* Validate hue value is within acceptable range
* @param hue - Hue value to validate
* @returns true if valid, false otherwise
*/
export function isValidHue(hue: number): boolean {
return typeof hue === 'number' && !isNaN(hue) && isFinite(hue);
}

View File

@@ -23,7 +23,9 @@
"multiKeySettings": "Multi-Key Settings", "multiKeySettings": "Multi-Key Settings",
"syncToCodexLens": "Sync to CodexLens", "syncToCodexLens": "Sync to CodexLens",
"manageModels": "Manage Models", "manageModels": "Manage Models",
"addModel": "Add Model" "addModel": "Add Model",
"showDisabled": "Show Disabled",
"hideDisabled": "Hide Disabled"
}, },
"deleteConfirm": "Are you sure you want to delete the provider \"{name}\"?", "deleteConfirm": "Are you sure you want to delete the provider \"{name}\"?",
"emptyState": { "emptyState": {
@@ -129,8 +131,6 @@
"basicInfo": "Basic Information", "basicInfo": "Basic Information",
"endpointSettings": "Endpoint Settings", "endpointSettings": "Endpoint Settings",
"apiBaseUpdated": "Base URL updated", "apiBaseUpdated": "Base URL updated",
"showDisabled": "Show Disabled",
"hideDisabled": "Hide Disabled",
"showAll": "Show All", "showAll": "Show All",
"saveError": "Failed to save provider", "saveError": "Failed to save provider",
"deleteError": "Failed to delete provider", "deleteError": "Failed to delete provider",

View File

@@ -47,5 +47,10 @@
"splitHorizontal": "Split Horizontal", "splitHorizontal": "Split Horizontal",
"splitVertical": "Split Vertical", "splitVertical": "Split Vertical",
"closePane": "Close Pane" "closePane": "Close Pane"
} },
"noActiveTab": "No active tab",
"selectOrCreate": "Select a tab or start a new CLI execution",
"executionNotFound": "Execution not found",
"waitingForOutput": "Waiting for output...",
"noOutput": "No output"
} }

View File

@@ -1,13 +1,15 @@
{ {
"title": { "title": {
"colorScheme": "Color Scheme", "colorScheme": "Color Scheme",
"themeMode": "Theme Mode" "themeMode": "Theme Mode",
"customHue": "Custom Hue"
}, },
"colorScheme": { "colorScheme": {
"blue": "Classic Blue", "blue": "Classic Blue",
"green": "Deep Green", "green": "Deep Green",
"orange": "Vibrant Orange", "orange": "Vibrant Orange",
"purple": "Elegant Purple" "purple": "Elegant Purple",
"custom": "Custom"
}, },
"themeMode": { "themeMode": {
"light": "Light", "light": "Light",
@@ -17,5 +19,9 @@
"colorScheme": "Select {name} theme", "colorScheme": "Select {name} theme",
"themeMode": "Select {name} mode" "themeMode": "Select {name} mode"
}, },
"current": "Current theme: {name}" "current": "Current theme: {name}",
"hueValue": "Hue: {value}°",
"preview": "Preview",
"save": "Save Custom Theme",
"reset": "Reset to Preset"
} }

View File

@@ -23,7 +23,9 @@
"multiKeySettings": "多密钥设置", "multiKeySettings": "多密钥设置",
"syncToCodexLens": "同步到 CodexLens", "syncToCodexLens": "同步到 CodexLens",
"manageModels": "管理模型", "manageModels": "管理模型",
"addModel": "添加模型" "addModel": "添加模型",
"showDisabled": "显示已禁用",
"hideDisabled": "隐藏已禁用"
}, },
"deleteConfirm": "确定要删除提供商 \"{name}\" 吗?", "deleteConfirm": "确定要删除提供商 \"{name}\" 吗?",
"emptyState": { "emptyState": {
@@ -129,8 +131,6 @@
"basicInfo": "基本信息", "basicInfo": "基本信息",
"endpointSettings": "端点设置", "endpointSettings": "端点设置",
"apiBaseUpdated": "基础 URL 已更新", "apiBaseUpdated": "基础 URL 已更新",
"showDisabled": "显示已禁用",
"hideDisabled": "隐藏已禁用",
"showAll": "显示全部", "showAll": "显示全部",
"saveError": "保存提供商失败", "saveError": "保存提供商失败",
"deleteError": "删除提供商失败", "deleteError": "删除提供商失败",

View File

@@ -47,5 +47,10 @@
"splitHorizontal": "水平分割", "splitHorizontal": "水平分割",
"splitVertical": "垂直分割", "splitVertical": "垂直分割",
"closePane": "关闭窗格" "closePane": "关闭窗格"
} },
"noActiveTab": "暂无活动标签页",
"selectOrCreate": "选择一个标签页或启动新的 CLI 执行",
"executionNotFound": "未找到执行",
"waitingForOutput": "等待输出...",
"noOutput": "暂无输出"
} }

View File

@@ -1,13 +1,15 @@
{ {
"title": { "title": {
"colorScheme": "颜色主题", "colorScheme": "颜色主题",
"themeMode": "明暗模式" "themeMode": "明暗模式",
"customHue": "自定义色调"
}, },
"colorScheme": { "colorScheme": {
"blue": "经典蓝", "blue": "经典蓝",
"green": "深邃绿", "green": "深邃绿",
"orange": "活力橙", "orange": "活力橙",
"purple": "优雅紫" "purple": "优雅紫",
"custom": "自定义"
}, },
"themeMode": { "themeMode": {
"light": "浅色", "light": "浅色",
@@ -17,5 +19,9 @@
"colorScheme": "选择{name}主题", "colorScheme": "选择{name}主题",
"themeMode": "选择{name}模式" "themeMode": "选择{name}模式"
}, },
"current": "当前主题: {name}" "current": "当前主题: {name}",
"hueValue": "色调: {value}°",
"preview": "预览",
"save": "保存自定义主题",
"reset": "重置为预设"
} }

View File

@@ -296,19 +296,35 @@ export function CliViewerPage() {
} }
}, [lastMessage, invalidateActive]); }, [lastMessage, invalidateActive]);
// Auto-add new executions as tabs when they appear // Auto-add new executions as tabs, distributing across available panes
// Uses round-robin distribution to spread executions across panes side-by-side
const addedExecutionsRef = useRef<Set<string>>(new Set()); const addedExecutionsRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
if (!focusedPaneId) return; // Get all pane IDs from the current layout
for (const executionId of Object.keys(executions)) { const paneIds = Object.keys(panes);
if (!addedExecutionsRef.current.has(executionId)) { if (paneIds.length === 0) return;
// Get addTab from store directly to avoid dependency on reactive function
// This prevents infinite loop when addTab updates store state
const storeAddTab = useViewerStore.getState().addTab;
// Get new executions that haven't been added yet
const newExecutionIds = Object.keys(executions).filter(
(id) => !addedExecutionsRef.current.has(id)
);
if (newExecutionIds.length === 0) return;
// Distribute new executions across panes round-robin
newExecutionIds.forEach((executionId, index) => {
addedExecutionsRef.current.add(executionId); addedExecutionsRef.current.add(executionId);
const exec = executions[executionId]; const exec = executions[executionId];
const toolShort = exec.tool.split('-')[0]; const toolShort = exec.tool.split('-')[0];
addTab(focusedPaneId, executionId, `${toolShort} (${exec.mode})`); // Round-robin pane selection
} const targetPaneId = paneIds[index % paneIds.length];
} storeAddTab(targetPaneId, executionId, `${toolShort} (${exec.mode})`);
}, [executions, focusedPaneId, addTab]); });
}, [executions, panes]);
// Initialize layout if empty // Initialize layout if empty
useEffect(() => { useEffect(() => {

View File

@@ -8,19 +8,21 @@ import { useIntl } from 'react-intl';
import { import {
Terminal, Terminal,
Search, Search,
Plus,
RefreshCw, RefreshCw,
Eye, Eye,
EyeOff, EyeOff,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Folder,
User,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
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 { Badge } from '@/components/ui/Badge';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useCommands, useCommandMutations } from '@/hooks'; import { useCommands, useCommandMutations } from '@/hooks';
import { CommandGroupAccordion } from '@/components/commands/CommandGroupAccordion'; import { CommandGroupAccordion } from '@/components/commands/CommandGroupAccordion';
import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// ========== Main Page Component ========== // ========== Main Page Component ==========
@@ -113,28 +115,36 @@ export function CommandsManagerPage() {
{formatMessage({ id: 'commands.description' })} {formatMessage({ id: 'commands.description' })}
</p> </p>
</div> </div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}> <Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} /> <RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })} {formatMessage({ id: 'common.actions.refresh' })}
</Button> </Button>
<Button>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'commands.actions.create' })}
</Button>
</div>
</div> </div>
{/* Location and Show Disabled Controls */} {/* Location Tabs - styled like LiteTasksPage */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <TabsNavigation
<LocationSwitcher value={locationFilter}
currentLocation={locationFilter} onValueChange={(v) => setLocationFilter(v as 'project' | 'user')}
onLocationChange={setLocationFilter} tabs={[
projectCount={projectCount} {
userCount={userCount} value: 'project',
disabled={isToggling} label: formatMessage({ id: 'commands.location.project' }),
icon: <Folder className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{projectCount}</Badge>,
disabled: isToggling,
},
{
value: 'user',
label: formatMessage({ id: 'commands.location.user' }),
icon: <User className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{userCount}</Badge>,
disabled: isToggling,
},
]}
/> />
<div className="flex items-center gap-2">
{/* Show Disabled Controls */}
<div className="flex items-center justify-end gap-2">
<Button <Button
variant={showDisabledCommands ? 'default' : 'outline'} variant={showDisabledCommands ? 'default' : 'outline'}
size="sm" size="sm"
@@ -153,7 +163,6 @@ export function CommandsManagerPage() {
</Button> </Button>
</div> </div>
</div> </div>
</div>
{/* Summary Stats */} {/* Summary Stats */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">

View File

@@ -28,6 +28,7 @@ import { Card } from '@/components/ui/Card';
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 { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { Checkbox } from '@/components/ui/Checkbox'; import { Checkbox } from '@/components/ui/Checkbox';
import { useMemory, useMemoryMutations } from '@/hooks'; import { useMemory, useMemoryMutations } from '@/hooks';
@@ -527,33 +528,28 @@ export function MemoryPage() {
</div> </div>
</div> </div>
{/* Tab Navigation */} {/* Tab Navigation - styled like LiteTasksPage */}
<div className="flex items-center gap-2 border-b border-border"> <TabsNavigation
<Button value={currentTab}
variant={currentTab === 'memories' ? 'default' : 'ghost'} onValueChange={(v) => setCurrentTab(v as 'memories' | 'favorites' | 'archived')}
size="sm" tabs={[
onClick={() => setCurrentTab('memories')} {
> value: 'memories',
<Brain className="w-4 h-4 mr-2" /> label: formatMessage({ id: 'memory.tabs.memories' }),
{formatMessage({ id: 'memory.tabs.memories' })} icon: <Brain className="h-4 w-4" />,
</Button> },
<Button {
variant={currentTab === 'favorites' ? 'default' : 'ghost'} value: 'favorites',
size="sm" label: formatMessage({ id: 'memory.tabs.favorites' }),
onClick={() => setCurrentTab('favorites')} icon: <Star className="h-4 w-4" />,
> },
<Star className="w-4 h-4 mr-2" /> {
{formatMessage({ id: 'memory.tabs.favorites' })} value: 'archived',
</Button> label: formatMessage({ id: 'memory.tabs.archived' }),
<Button icon: <Archive className="h-4 w-4" />,
variant={currentTab === 'archived' ? 'default' : 'ghost'} },
size="sm" ]}
onClick={() => setCurrentTab('archived')} />
>
<Archive className="w-4 h-4 mr-2" />
{formatMessage({ id: 'memory.tabs.archived' })}
</Button>
</div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">

View File

@@ -235,21 +235,21 @@ 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-base font-semibold text-foreground mb-1"> <h1 className="text-lg font-semibold text-foreground mb-1">
{projectOverview.projectName} {projectOverview.projectName}
</h1> </h1>
<p className="text-xs text-muted-foreground"> <p className="text-sm text-muted-foreground">
{projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })} {projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })}
</p> </p>
</div> </div>
<div className="text-xs text-muted-foreground text-right"> <div className="text-sm text-muted-foreground text-right">
<div> <div>
{formatMessage({ id: 'projectOverview.header.initialized' })}:{' '} {formatMessage({ id: 'projectOverview.header.initialized' })}:{' '}
{formatDate(projectOverview.initializedAt)} {formatDate(projectOverview.initializedAt)}
</div> </div>
{metadata?.analysis_mode && ( {metadata?.analysis_mode && (
<div className="mt-1"> <div className="mt-1">
<span className="font-mono text-[10px] px-1.5 py-0.5 bg-muted rounded"> <span className="font-mono text-xs px-1.5 py-0.5 bg-muted rounded">
{metadata.analysis_mode} {metadata.analysis_mode}
</span> </span>
</div> </div>
@@ -258,7 +258,7 @@ export function ProjectOverviewPage() {
</div> </div>
{/* Technology Stack */} {/* Technology Stack */}
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5"> <h3 className="text-base font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Code2 className="w-4 h-4" /> <Code2 className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.techStack.title' })} {formatMessage({ id: 'projectOverview.techStack.title' })}
</h3> </h3>
@@ -266,7 +266,7 @@ export function ProjectOverviewPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Languages */} {/* Languages */}
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.languages' })} {formatMessage({ id: 'projectOverview.techStack.languages' })}
</h4> </h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@@ -274,21 +274,21 @@ export function ProjectOverviewPage() {
technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => ( technologyStack.languages.map((lang: { name: string; file_count: number; primary?: boolean }) => (
<div <div
key={lang.name} key={lang.name}
className={`flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded text-xs ${ className={`flex items-center gap-1.5 px-2 py-1 bg-background border border-border rounded text-sm ${
lang.primary ? 'ring-1 ring-primary' : '' lang.primary ? 'ring-1 ring-primary' : ''
}`} }`}
> >
<span className="font-medium text-foreground">{lang.name}</span> <span className="font-medium text-foreground">{lang.name}</span>
<span className="text-[10px] text-muted-foreground">{lang.file_count}</span> <span className="text-xs text-muted-foreground">{lang.file_count}</span>
{lang.primary && ( {lang.primary && (
<span className="text-[9px] px-1 py-0.5 bg-primary text-primary-foreground rounded"> <span className="text-[10px] px-1 py-0.5 bg-primary text-primary-foreground rounded">
{formatMessage({ id: 'projectOverview.techStack.primary' })} {formatMessage({ id: 'projectOverview.techStack.primary' })}
</span> </span>
)} )}
</div> </div>
)) ))
) : ( ) : (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })} {formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span> </span>
)} )}
@@ -297,18 +297,18 @@ export function ProjectOverviewPage() {
{/* Frameworks */} {/* Frameworks */}
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.frameworks' })} {formatMessage({ id: 'projectOverview.techStack.frameworks' })}
</h4> </h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? ( {technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
technologyStack.frameworks.map((fw: string) => ( technologyStack.frameworks.map((fw: string) => (
<Badge key={fw} variant="success" className="px-2 py-0.5 text-[10px]"> <Badge key={fw} variant="success" className="px-2 py-0.5 text-xs">
{fw} {fw}
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })} {formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span> </span>
)} )}
@@ -317,36 +317,36 @@ export function ProjectOverviewPage() {
{/* Build Tools */} {/* Build Tools */}
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.buildTools' })} {formatMessage({ id: 'projectOverview.techStack.buildTools' })}
</h4> </h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 ? ( {technologyStack?.build_tools && technologyStack.build_tools.length > 0 ? (
technologyStack.build_tools.map((tool: string) => ( technologyStack.build_tools.map((tool: string) => (
<Badge key={tool} variant="warning" className="px-2 py-0.5 text-[10px]"> <Badge key={tool} variant="warning" className="px-2 py-0.5 text-xs">
{tool} {tool}
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-muted-foreground text-xs">-</span> <span className="text-muted-foreground text-sm">-</span>
)} )}
</div> </div>
</div> </div>
{/* Test Frameworks */} {/* Test Frameworks */}
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.techStack.testFrameworks' })} {formatMessage({ id: 'projectOverview.techStack.testFrameworks' })}
</h4> </h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 ? ( {technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 ? (
technologyStack.test_frameworks.map((fw: string) => ( technologyStack.test_frameworks.map((fw: string) => (
<Badge key={fw} variant="default" className="px-2 py-0.5 text-[10px]"> <Badge key={fw} variant="default" className="px-2 py-0.5 text-xs">
{fw} {fw}
</Badge> </Badge>
)) ))
) : ( ) : (
<span className="text-muted-foreground text-xs">-</span> <span className="text-muted-foreground text-sm">-</span>
)} )}
</div> </div>
</div> </div>
@@ -354,11 +354,14 @@ export function ProjectOverviewPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Architecture */} {/* Architecture & Key Components - Merged */}
{architecture && ( {(architecture || (keyComponents && keyComponents.length > 0)) && (
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5"> {/* Architecture Section */}
{architecture && (
<>
<h3 className="text-base font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Blocks className="w-4 h-4" /> <Blocks className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.architecture.title' })} {formatMessage({ id: 'projectOverview.architecture.title' })}
</h3> </h3>
@@ -366,23 +369,23 @@ export function ProjectOverviewPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Style */} {/* Style */}
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.style' })} {formatMessage({ id: 'projectOverview.architecture.style' })}
</h4> </h4>
<div className="px-2 py-1.5 bg-background border border-border rounded"> <div className="px-2 py-1.5 bg-background border border-border rounded">
<span className="text-foreground font-medium text-xs">{architecture.style}</span> <span className="text-foreground font-medium text-sm">{architecture.style}</span>
</div> </div>
</div> </div>
{/* Layers */} {/* Layers */}
{architecture.layers && architecture.layers.length > 0 && ( {architecture.layers && architecture.layers.length > 0 && (
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.layers' })} {formatMessage({ id: 'projectOverview.architecture.layers' })}
</h4> </h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{architecture.layers.map((layer: string) => ( {architecture.layers.map((layer: string) => (
<span key={layer} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]"> <span key={layer} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-xs">
{layer} {layer}
</span> </span>
))} ))}
@@ -393,12 +396,12 @@ export function ProjectOverviewPage() {
{/* Patterns */} {/* Patterns */}
{architecture.patterns && architecture.patterns.length > 0 && ( {architecture.patterns && architecture.patterns.length > 0 && (
<div> <div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2"> <h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.patterns' })} {formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4> </h4>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{architecture.patterns.map((pattern: string) => ( {architecture.patterns.map((pattern: string) => (
<span key={pattern} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]"> <span key={pattern} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-xs">
{pattern} {pattern}
</span> </span>
))} ))}
@@ -406,15 +409,18 @@ export function ProjectOverviewPage() {
</div> </div>
)} )}
</div> </div>
</CardContent> </>
</Card>
)} )}
{/* Key Components */} {/* Divider between Architecture and Components */}
{architecture && keyComponents && keyComponents.length > 0 && (
<div className="border-t border-border my-4" />
)}
{/* Key Components Section */}
{keyComponents && keyComponents.length > 0 && ( {keyComponents && keyComponents.length > 0 && (
<Card> <>
<CardContent className="p-4"> <h3 className="text-base font-semibold text-foreground mb-3 flex items-center gap-1.5">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Component className="w-4 h-4" /> <Component className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.components.title' })} {formatMessage({ id: 'projectOverview.components.title' })}
</h3> </h3>
@@ -429,17 +435,17 @@ export function ProjectOverviewPage() {
}; };
const importanceBadges: Record<string, React.ReactElement> = { const importanceBadges: Record<string, React.ReactElement> = {
high: ( high: (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0"> <Badge variant="destructive" className="text-xs px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.high' })} {formatMessage({ id: 'projectOverview.components.importance.high' })}
</Badge> </Badge>
), ),
medium: ( medium: (
<Badge variant="warning" className="text-[10px] px-1.5 py-0"> <Badge variant="warning" className="text-xs px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.medium' })} {formatMessage({ id: 'projectOverview.components.importance.medium' })}
</Badge> </Badge>
), ),
low: ( low: (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0"> <Badge variant="secondary" className="text-xs px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.low' })} {formatMessage({ id: 'projectOverview.components.importance.low' })}
</Badge> </Badge>
), ),
@@ -451,14 +457,14 @@ export function ProjectOverviewPage() {
className={`p-2.5 rounded ${importanceColors[importance] || importanceColors.low}`} className={`p-2.5 rounded ${importanceColors[importance] || importanceColors.low}`}
> >
<div className="flex items-start justify-between mb-1"> <div className="flex items-start justify-between mb-1">
<h4 className="font-medium text-foreground text-xs">{comp.name}</h4> <h4 className="font-medium text-foreground text-sm">{comp.name}</h4>
{importanceBadges[importance]} {importanceBadges[importance]}
</div> </div>
{comp.description && ( {comp.description && (
<p className="text-[10px] text-muted-foreground mb-1">{comp.description}</p> <p className="text-xs text-muted-foreground mb-1">{comp.description}</p>
)} )}
{comp.responsibility && comp.responsibility.length > 0 && ( {comp.responsibility && comp.responsibility.length > 0 && (
<ul className="text-[10px] text-muted-foreground list-disc list-inside"> <ul className="text-xs text-muted-foreground list-disc list-inside">
{comp.responsibility.map((resp: string, i: number) => ( {comp.responsibility.map((resp: string, i: number) => (
<li key={i}>{resp}</li> <li key={i}>{resp}</li>
))} ))}
@@ -468,6 +474,8 @@ export function ProjectOverviewPage() {
); );
})} })}
</div> </div>
</>
)}
</CardContent> </CardContent>
</Card> </Card>
)} )}
@@ -477,7 +485,7 @@ export function ProjectOverviewPage() {
<Card> <Card>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-foreground flex items-center gap-1.5"> <h3 className="text-base font-semibold text-foreground flex items-center gap-1.5">
<GitBranch className="w-4 h-4" /> <GitBranch className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.devIndex.title' })} {formatMessage({ id: 'projectOverview.devIndex.title' })}
</h3> </h3>
@@ -487,8 +495,8 @@ export function ProjectOverviewPage() {
if (count === 0) return null; if (count === 0) return null;
const Icon = cat.icon; const Icon = cat.icon;
return ( return (
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0"> <Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'} className="text-xs px-1.5 py-0">
<Icon className="w-2.5 h-2.5 mr-0.5" /> <Icon className="w-3 h-3 mr-0.5" />
{count} {count}
</Badge> </Badge>
); );
@@ -498,13 +506,13 @@ export function ProjectOverviewPage() {
<Tabs value={devIndexView} onValueChange={(v) => setDevIndexView(v as DevIndexView)}> <Tabs value={devIndexView} onValueChange={(v) => setDevIndexView(v as DevIndexView)}>
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<TabsList className="h-7"> <TabsList className="h-8">
<TabsTrigger value="category" className="text-xs px-2 py-1 h-6"> <TabsTrigger value="category" className="text-sm px-3 py-1 h-7">
<LayoutGrid className="w-3 h-3 mr-1" /> <LayoutGrid className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.categories' })} {formatMessage({ id: 'projectOverview.devIndex.categories' })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="timeline" className="text-xs px-2 py-1 h-6"> <TabsTrigger value="timeline" className="text-sm px-3 py-1 h-7">
<GitCommitHorizontal className="w-3 h-3 mr-1" /> <GitCommitHorizontal className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.timeline' })} {formatMessage({ id: 'projectOverview.devIndex.timeline' })}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@@ -519,10 +527,10 @@ export function ProjectOverviewPage() {
return ( return (
<div key={cat.key}> <div key={cat.key}>
<h4 className="text-xs font-semibold text-foreground mb-2 flex items-center gap-1.5"> <h4 className="text-sm font-semibold text-foreground mb-2 flex items-center gap-1.5">
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
<span>{formatMessage({ id: cat.i18nKey })}</span> <span>{formatMessage({ id: cat.i18nKey })}</span>
<Badge variant="secondary" className="text-[10px] px-1 py-0">{entries.length}</Badge> <Badge variant="secondary" className="text-xs px-1 py-0">{entries.length}</Badge>
</h4> </h4>
<div className="space-y-1.5"> <div className="space-y-1.5">
{entries.slice(0, 5).map((entry: DevelopmentIndexEntry & { type?: string; typeLabel?: string; typeIcon?: React.ElementType; typeColor?: string; date?: string }, i: number) => ( {entries.slice(0, 5).map((entry: DevelopmentIndexEntry & { type?: string; typeLabel?: string; typeIcon?: React.ElementType; typeColor?: string; date?: string }, i: number) => (
@@ -531,15 +539,15 @@ export function ProjectOverviewPage() {
className="p-2 bg-background border border-border rounded hover:shadow-sm transition-shadow" className="p-2 bg-background border border-border rounded hover:shadow-sm transition-shadow"
> >
<div className="flex items-start justify-between mb-0.5"> <div className="flex items-start justify-between mb-0.5">
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5> <h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<span className="text-[10px] text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatDate(entry.archivedAt || entry.date || entry.implemented_at)} {formatDate(entry.archivedAt || entry.date || entry.implemented_at)}
</span> </span>
</div> </div>
{entry.description && ( {entry.description && (
<p className="text-[10px] text-muted-foreground mb-1">{entry.description}</p> <p className="text-xs text-muted-foreground mb-1">{entry.description}</p>
)} )}
<div className="flex items-center gap-1.5 text-[10px] flex-wrap"> <div className="flex items-center gap-1.5 text-xs flex-wrap">
{entry.sessionId && ( {entry.sessionId && (
<span className="px-1.5 py-0.5 bg-primary-light text-primary rounded font-mono"> <span className="px-1.5 py-0.5 bg-primary-light text-primary rounded font-mono">
{entry.sessionId} {entry.sessionId}
@@ -563,7 +571,7 @@ export function ProjectOverviewPage() {
</div> </div>
))} ))}
{entries.length > 5 && ( {entries.length > 5 && (
<div className="text-xs text-muted-foreground text-center py-1"> <div className="text-sm text-muted-foreground text-center py-1">
... and {entries.length - 5} more ... and {entries.length - 5} more
</div> </div>
)} )}
@@ -601,20 +609,20 @@ export function ProjectOverviewPage() {
? 'destructive' ? 'destructive'
: 'secondary' : 'secondary'
} }
className="text-[10px] px-1.5 py-0" className="text-xs px-1.5 py-0"
> >
{entry.typeLabel} {entry.typeLabel}
</Badge> </Badge>
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5> <h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
</div> </div>
<span className="text-[10px] text-muted-foreground whitespace-nowrap"> <span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(entry.date)} {formatDate(entry.date)}
</span> </span>
</div> </div>
{entry.description && ( {entry.description && (
<p className="text-[10px] text-muted-foreground mb-1">{entry.description}</p> <p className="text-xs text-muted-foreground mb-1">{entry.description}</p>
)} )}
<div className="flex items-center gap-1.5 text-[10px]"> <div className="flex items-center gap-1.5 text-xs">
{entry.sessionId && ( {entry.sessionId && (
<span className="px-1.5 py-0.5 bg-muted rounded font-mono"> <span className="px-1.5 py-0.5 bg-muted rounded font-mono">
{entry.sessionId} {entry.sessionId}
@@ -635,7 +643,7 @@ export function ProjectOverviewPage() {
); );
})} })}
{allDevEntries.length > 20 && ( {allDevEntries.length > 20 && (
<div className="text-xs text-muted-foreground text-center py-2"> <div className="text-sm text-muted-foreground text-center py-2">
... and {allDevEntries.length - 20} more entries ... and {allDevEntries.length - 20} more entries
</div> </div>
)} )}

View File

@@ -13,6 +13,9 @@ import {
AlertCircle, AlertCircle,
FileCode, FileCode,
X, X,
Folder,
User,
Globe,
} from 'lucide-react'; } from 'lucide-react';
import { import {
useRules, useRules,
@@ -25,6 +28,7 @@ import { RuleDialog } from '@/components/shared/RuleDialog';
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 { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/Badge';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -130,6 +134,12 @@ export function RulesManagerPage() {
return Array.from(cats).sort(); return Array.from(cats).sort();
}, [rules]); }, [rules]);
// Count rules by location
const projectRulesCount = React.useMemo(() =>
rules.filter((r) => r.location === 'project').length, [rules]);
const userRulesCount = React.useMemo(() =>
rules.filter((r) => r.location === 'user').length, [rules]);
// Handlers // Handlers
const handleEditClick = (rule: Rule) => { const handleEditClick = (rule: Rule) => {
setSelectedRule(rule); setSelectedRule(rule);
@@ -223,6 +233,35 @@ export function RulesManagerPage() {
</div> </div>
)} )}
{/* Location Tabs - styled like LiteTasksPage */}
<TabsNavigation
value={locationFilter}
onValueChange={(v) => setLocationFilter(v as LocationFilter)}
tabs={[
{
value: 'all',
label: formatMessage({ id: 'rules.filters.all' }),
icon: <Globe className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{rules.length}</Badge>,
disabled: isMutating,
},
{
value: 'project',
label: formatMessage({ id: 'rules.location.project' }),
icon: <Folder className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{projectRulesCount}</Badge>,
disabled: isMutating,
},
{
value: 'user',
label: formatMessage({ id: 'rules.location.user' }),
icon: <User className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{userRulesCount}</Badge>,
disabled: isMutating,
},
]}
/>
{/* Filters */} {/* Filters */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Status tabs */} {/* Status tabs */}
@@ -253,37 +292,6 @@ export function RulesManagerPage() {
)} )}
</div> </div>
{/* Location filter dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Filter className="h-4 w-4" />
{formatMessage({ id: 'rules.filters.location' })}
{locationFilter !== 'all' && (
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
{locationFilter === 'project' ? formatMessage({ id: 'rules.location.project' }) : formatMessage({ id: 'rules.location.user' })}
</Badge>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuLabel>{formatMessage({ id: 'rules.filters.location' })}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setLocationFilter('all')}>
{formatMessage({ id: 'rules.filters.all' })}
{locationFilter === 'all' && <span className="ml-auto text-primary">&#10003;</span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocationFilter('project')}>
{formatMessage({ id: 'rules.location.project' })}
{locationFilter === 'project' && <span className="ml-auto text-primary">&#10003;</span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocationFilter('user')}>
{formatMessage({ id: 'rules.location.user' })}
{locationFilter === 'user' && <span className="ml-auto text-primary">&#10003;</span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Category filter dropdown */} {/* Category filter dropdown */}
{categories.length > 0 && ( {categories.length > 0 && (
<DropdownMenu> <DropdownMenu>

View File

@@ -585,7 +585,7 @@ export function SettingsPage() {
</Card> </Card>
{/* Display Settings */} {/* Display Settings */}
<Card className="p-6"> <div className="py-4">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4"> <h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
<Settings className="w-5 h-5" /> <Settings className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.display' })} {formatMessage({ id: 'settings.sections.display' })}
@@ -607,7 +607,7 @@ export function SettingsPage() {
</Button> </Button>
</div> </div>
</div> </div>
</Card> </div>
{/* Reset Settings */} {/* Reset Settings */}
<Card className="p-6 border-destructive/50"> <Card className="p-6 border-destructive/50">

View File

@@ -18,10 +18,14 @@ import {
EyeOff, EyeOff,
List, List,
Grid3x3, Grid3x3,
Folder,
User,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
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 { Badge } from '@/components/ui/Badge';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select'; import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
import { import {
AlertDialog, AlertDialog,
@@ -34,7 +38,6 @@ import {
AlertDialogCancel, AlertDialogCancel,
} from '@/components/ui'; } from '@/components/ui';
import { SkillCard, SkillDetailPanel } from '@/components/shared'; import { SkillCard, SkillDetailPanel } from '@/components/shared';
import { LocationSwitcher } from '@/components/commands/LocationSwitcher';
import { useSkills, useSkillMutations } from '@/hooks'; import { useSkills, useSkillMutations } from '@/hooks';
import { fetchSkillDetail } from '@/lib/api'; import { fetchSkillDetail } from '@/lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -245,14 +248,26 @@ export function SkillsManagerPage() {
</div> </div>
</div> </div>
{/* Location Switcher */} {/* Location Tabs - styled like LiteTasksPage */}
<LocationSwitcher <TabsNavigation
currentLocation={locationFilter} value={locationFilter}
onLocationChange={setLocationFilter} onValueChange={(v) => setLocationFilter(v as 'project' | 'user')}
projectCount={projectSkills.length} tabs={[
userCount={userSkills.length} {
disabled={isToggling} value: 'project',
translationPrefix="skills" label: formatMessage({ id: 'skills.location.project' }),
icon: <Folder className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{projectSkills.length}</Badge>,
disabled: isToggling,
},
{
value: 'user',
label: formatMessage({ id: 'skills.location.user' }),
icon: <User className="h-4 w-4" />,
badge: <Badge variant="secondary" className="ml-2">{userSkills.length}</Badge>,
disabled: isToggling,
},
]}
/> />
</div> </div>

View File

@@ -9,6 +9,7 @@ import type { AppStore, Theme, ColorScheme, Locale, ViewMode, SessionFilter, Lit
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 } from '../lib/theme';
import { generateThemeFromHue } from '../lib/colorGenerator';
// Helper to resolve system theme // Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => { const getSystemTheme = (): 'light' | 'dark' => {
@@ -24,12 +25,87 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => {
return theme; return theme;
}; };
/**
* DOM Theme Application Helper
*
* ARCHITECTURAL NOTE: This function contains DOM manipulation logic that ideally
* belongs in a React component/hook rather than a store. However, it's placed
* here for pragmatic reasons:
* - Immediate theme application without React render cycle
* - SSR compatibility (checks for document/window)
* - Backward compatibility with existing codebase
*
* FUTURE IMPROVEMENT: Move theme application to a ThemeProvider component using
* useEffect to listen for store changes. This would properly separate concerns.
*/
const applyThemeToDocument = (
resolvedTheme: 'light' | 'dark',
colorScheme: ColorScheme,
customHue: number | null
): void => {
if (typeof document === 'undefined') return;
// Define the actual DOM update logic
const performThemeUpdate = () => {
// Update document classes
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolvedTheme);
// Clear custom CSS variables list
const customVars = [
'--bg', '--bg-secondary', '--surface', '--surface-hover',
'--border', '--border-hover', '--text', '--text-secondary',
'--text-tertiary', '--text-disabled', '--accent', '--accent-hover',
'--accent-active', '--accent-light', '--accent-lighter', '--primary',
'--primary-hover', '--primary-light', '--primary-lighter', '--secondary',
'--secondary-hover', '--secondary-light', '--muted', '--muted-hover',
'--muted-text', '--success', '--success-light', '--success-text',
'--warning', '--warning-light', '--warning-text', '--error',
'--error-light', '--error-text', '--info', '--info-light',
'--info-text', '--destructive', '--destructive-hover', '--destructive-light',
'--hover', '--active', '--focus'
];
// Apply custom theme or preset theme
if (customHue !== null) {
const cssVars = generateThemeFromHue(customHue, resolvedTheme);
Object.entries(cssVars).forEach(([varName, varValue]) => {
document.documentElement.style.setProperty(varName, varValue);
});
document.documentElement.setAttribute('data-theme', `custom-${resolvedTheme}`);
} else {
// Clear custom CSS variables
customVars.forEach(varName => {
document.documentElement.style.removeProperty(varName);
});
// Apply preset theme
const themeId = getThemeId(colorScheme, resolvedTheme);
document.documentElement.setAttribute('data-theme', themeId);
}
// Set color scheme attribute
document.documentElement.setAttribute('data-color-scheme', colorScheme);
};
// Use View Transition API for smooth transitions (progressive enhancement)
// @ts-expect-error - View Transition API not yet in TypeScript DOM types
if (document.startViewTransition) {
// @ts-expect-error - View Transition API not yet in TypeScript DOM types
document.startViewTransition(performThemeUpdate);
} else {
// Fallback: apply immediately without transition
performThemeUpdate();
}
};
// Initial state // Initial state
const initialState = { const initialState = {
// Theme // Theme
theme: 'system' as Theme, theme: 'system' as Theme,
resolvedTheme: 'light' as 'light' | 'dark', resolvedTheme: 'light' as 'light' | 'dark',
colorScheme: 'blue' as ColorScheme, // New: default to blue scheme colorScheme: 'blue' as ColorScheme, // New: default to blue scheme
customHue: null as number | null,
isCustomTheme: false,
// Locale // Locale
locale: getInitialLocale() as Locale, locale: getInitialLocale() as Locale,
@@ -66,26 +142,32 @@ export const useAppStore = create<AppStore>()(
const resolved = resolveTheme(theme); const resolved = resolveTheme(theme);
set({ theme, resolvedTheme: resolved }, false, 'setTheme'); set({ theme, resolvedTheme: resolved }, false, 'setTheme');
// Apply theme to document // Apply theme using helper (encapsulates DOM manipulation)
if (typeof document !== 'undefined') { const { colorScheme, customHue } = get();
const { colorScheme } = get(); applyThemeToDocument(resolved, colorScheme, customHue);
const themeId = getThemeId(colorScheme, resolved);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
}, },
setColorScheme: (colorScheme: ColorScheme) => { setColorScheme: (colorScheme: ColorScheme) => {
set({ colorScheme }, false, 'setColorScheme'); set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme');
// Apply color scheme to document // Apply color scheme using helper (encapsulates DOM manipulation)
if (typeof document !== 'undefined') {
const { resolvedTheme } = get(); const { resolvedTheme } = get();
const themeId = getThemeId(colorScheme, resolvedTheme); applyThemeToDocument(resolvedTheme, colorScheme, null);
document.documentElement.setAttribute('data-theme', themeId); },
document.documentElement.setAttribute('data-color-scheme', colorScheme);
setCustomHue: (hue: number | null) => {
if (hue === null) {
// Reset to preset theme
const { colorScheme, resolvedTheme } = get();
set({ customHue: null, isCustomTheme: false }, false, 'setCustomHue');
applyThemeToDocument(resolvedTheme, colorScheme, null);
return;
} }
// Apply custom hue
set({ customHue: hue, isCustomTheme: true }, false, 'setCustomHue');
const { resolvedTheme, colorScheme } = get();
applyThemeToDocument(resolvedTheme, colorScheme, hue);
}, },
toggleTheme: () => { toggleTheme: () => {
@@ -189,6 +271,7 @@ export const useAppStore = create<AppStore>()(
partialize: (state) => ({ partialize: (state) => ({
theme: state.theme, theme: state.theme,
colorScheme: state.colorScheme, colorScheme: state.colorScheme,
customHue: state.customHue,
locale: state.locale, locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed, sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups, expandedNavGroups: state.expandedNavGroups,
@@ -199,12 +282,9 @@ export const useAppStore = create<AppStore>()(
if (state) { if (state) {
const resolved = resolveTheme(state.theme); const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved; state.resolvedTheme = resolved;
const themeId = getThemeId(state.colorScheme, resolved); state.isCustomTheme = state.customHue !== null;
if (typeof document !== 'undefined') { // Apply theme using helper (encapsulates DOM manipulation)
document.documentElement.classList.remove('light', 'dark'); applyThemeToDocument(resolved, state.colorScheme, state.customHue);
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
} }
// Apply locale on rehydration // Apply locale on rehydration
if (state) { if (state) {
@@ -225,10 +305,8 @@ if (typeof window !== 'undefined') {
if (state.theme === 'system') { if (state.theme === 'system') {
const resolved = getSystemTheme(); const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved }); useAppStore.setState({ resolvedTheme: resolved });
const themeId = getThemeId(state.colorScheme, resolved); // Apply theme using helper (encapsulates DOM manipulation)
document.documentElement.classList.remove('light', 'dark'); applyThemeToDocument(resolved, state.colorScheme, state.customHue);
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', themeId);
} }
}); });
} }
@@ -236,6 +314,9 @@ if (typeof window !== 'undefined') {
// Selectors for common access patterns // Selectors for common access patterns
export const selectTheme = (state: AppStore) => state.theme; export const selectTheme = (state: AppStore) => state.theme;
export const selectResolvedTheme = (state: AppStore) => state.resolvedTheme; export const selectResolvedTheme = (state: AppStore) => state.resolvedTheme;
export const selectColorScheme = (state: AppStore) => state.colorScheme;
export const selectCustomHue = (state: AppStore) => state.customHue;
export const selectIsCustomTheme = (state: AppStore) => state.isCustomTheme;
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;

View File

@@ -653,14 +653,14 @@ export const useCoordinatorStore = create<CoordinatorState>()(
{ {
name: LOG_STORAGE_KEY, name: LOG_STORAGE_KEY,
version: COORDINATOR_STORAGE_VERSION, version: COORDINATOR_STORAGE_VERSION,
// Only persist metadata and basic pipeline info (not full nodes/logs) // Only persist basic pipeline info (not full nodes/logs or metadata which may contain sensitive data)
partialize: (state) => ({ partialize: (state) => ({
currentExecutionId: state.currentExecutionId, currentExecutionId: state.currentExecutionId,
status: state.status, status: state.status,
startedAt: state.startedAt, startedAt: state.startedAt,
completedAt: state.completedAt, completedAt: state.completedAt,
totalElapsedMs: state.totalElapsedMs, totalElapsedMs: state.totalElapsedMs,
metadata: state.metadata, // Exclude metadata from persistence - it may contain sensitive data (Record<string, unknown>)
isLogPanelExpanded: state.isLogPanelExpanded, isLogPanelExpanded: state.isLogPanelExpanded,
autoScrollLogs: state.autoScrollLogs, autoScrollLogs: state.autoScrollLogs,
// Only persist basic pipeline info, not full nodes // Only persist basic pipeline info, not full nodes

View File

@@ -21,9 +21,52 @@ const NOTIFICATION_STORAGE_KEY = 'ccw_notifications';
const NOTIFICATION_MAX_STORED = 100; const NOTIFICATION_MAX_STORED = 100;
const NOTIFICATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days const NOTIFICATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
// Patterns that should not be stored in localStorage (potential sensitive data)
const SENSITIVE_PATTERNS = [
// API keys and tokens (common formats)
/\b[A-Za-z0-9_-]{20,}\b/g,
// UUIDs (might be session tokens)
/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
// Base64 encoded strings (might be tokens)
/\b[A-Za-z0-9+/=]{32,}={0,2}\b/g,
];
/**
* Sanitize notification content before persisting to localStorage
* Removes potentially sensitive patterns and limits content length
*/
const sanitizeNotification = (toast: Toast): Toast => {
const sanitizeText = (text: string | null | undefined): string | null => {
if (!text) return null;
let sanitized = text;
// Remove potentially sensitive patterns
for (const pattern of SENSITIVE_PATTERNS) {
sanitized = sanitized.replace(pattern, '[REDACTED]');
}
// Limit length to prevent localStorage bloat
const MAX_LENGTH = 500;
if (sanitized.length > MAX_LENGTH) {
sanitized = sanitized.substring(0, MAX_LENGTH) + '...';
}
return sanitized;
};
return {
...toast,
title: sanitizeText(toast.title) || toast.title,
message: sanitizeText(toast.message) || toast.message,
// Don't persist a2uiSurface or a2uiState as they may contain sensitive runtime data
a2uiSurface: undefined,
a2uiState: undefined,
};
};
// Helper to generate unique ID // Helper to generate unique ID
const generateId = (): string => { const generateId = (): string => {
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; return `toast-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}; };
// Helper to load notifications from localStorage // Helper to load notifications from localStorage
@@ -51,7 +94,9 @@ const saveToStorage = (notifications: Toast[]): void => {
try { try {
// Keep only the last N notifications // Keep only the last N notifications
const toSave = notifications.slice(0, NOTIFICATION_MAX_STORED); const toSave = notifications.slice(0, NOTIFICATION_MAX_STORED);
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(toSave)); // Sanitize notification content before persisting to localStorage
const sanitized = toSave.map(sanitizeNotification);
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(sanitized));
} catch (e) { } catch (e) {
console.error('[NotificationStore] Failed to save to storage:', e); console.error('[NotificationStore] Failed to save to storage:', e);
} }

View File

@@ -39,6 +39,8 @@ export interface AppState {
theme: Theme; theme: Theme;
resolvedTheme: 'light' | 'dark'; resolvedTheme: 'light' | 'dark';
colorScheme: ColorScheme; // New: 4 color scheme options (blue/green/orange/purple) colorScheme: ColorScheme; // New: 4 color scheme options (blue/green/orange/purple)
customHue: number | null; // Custom hue value (0-360) for theme customization
isCustomTheme: boolean; // Indicates if custom theme is active
// Locale // Locale
locale: Locale; locale: Locale;
@@ -68,6 +70,7 @@ export interface AppActions {
setTheme: (theme: Theme) => void; setTheme: (theme: Theme) => void;
toggleTheme: () => void; toggleTheme: () => void;
setColorScheme: (scheme: ColorScheme) => void; // New: set color scheme setColorScheme: (scheme: ColorScheme) => void; // New: set color scheme
setCustomHue: (hue: number | null) => void; // Set custom hue for theme customization
// Locale actions // Locale actions
setLocale: (locale: Locale) => void; setLocale: (locale: Locale) => void;

View File

@@ -1,4 +1,46 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
import plugin from 'tailwindcss/plugin';
// Gradient utilities plugin
const gradientPlugin = plugin(function({ addUtilities, addComponents }) {
// 1. Background gradient utilities
addUtilities({
'.bg-gradient-primary': {
backgroundImage: 'radial-gradient(circle, hsl(var(--accent)) 0%, transparent 70%)',
},
'.bg-gradient-brand': {
backgroundImage: 'linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)))',
},
'.bg-gradient-radial': {
backgroundImage: 'radial-gradient(var(--tw-gradient-stops))',
},
'.bg-gradient-conic': {
backgroundImage: 'conic-gradient(var(--tw-gradient-stops))',
},
});
// 2. Gradient border component
addComponents({
'.border-gradient-brand': {
position: 'relative',
zIndex: '0',
'&::before': {
content: '""',
position: 'absolute',
inset: '0',
zIndex: '-1',
borderRadius: 'inherit',
padding: '1px',
background: 'linear-gradient(to right, hsl(var(--primary)), hsl(var(--secondary)))',
'-webkit-mask': 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
'mask': 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
'-webkit-mask-composite': 'xor',
'mask-composite': 'exclude',
},
},
});
});
export default { export default {
darkMode: ['class', '[data-theme="dark"]'], darkMode: ['class', '[data-theme="dark"]'],
content: [ content: [
@@ -88,6 +130,7 @@ export default {
DEFAULT: "0 2px 8px rgb(0 0 0 / 0.08)", DEFAULT: "0 2px 8px rgb(0 0 0 / 0.08)",
md: "0 4px 12px rgb(0 0 0 / 0.1)", md: "0 4px 12px rgb(0 0 0 / 0.1)",
lg: "0 8px 24px rgb(0 0 0 / 0.12)", lg: "0 8px 24px rgb(0 0 0 / 0.12)",
"glow-accent": "0 0 40px 10px hsl(var(--accent) / 0.7)",
}, },
borderRadius: { borderRadius: {
@@ -109,14 +152,22 @@ export default {
"0%": { transform: "translateX(0)" }, "0%": { transform: "translateX(0)" },
"100%": { transform: "translateX(-50%)" }, "100%": { transform: "translateX(-50%)" },
}, },
"slow-gradient-shift": {
"0%": { backgroundImage: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)" },
"25%": { backgroundImage: "linear-gradient(135deg, hsl(var(--secondary)) 0%, hsl(var(--accent)) 100%)" },
"50%": { backgroundImage: "linear-gradient(135deg, hsl(var(--accent)) 0%, hsl(var(--primary)) 100%)" },
"75%": { backgroundImage: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)" },
"100%": { backgroundImage: "linear-gradient(135deg, hsl(var(--primary)) 0%, hsl(var(--secondary)) 100%)" },
},
}, },
animation: { animation: {
"accordion-down": "accordion-down 0.2s ease-out", "accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out", "accordion-up": "accordion-up 0.2s ease-out",
marquee: "marquee 30s linear infinite", marquee: "marquee 30s linear infinite",
"slow-gradient": "slow-gradient-shift 60s ease-in-out infinite alternate",
}, },
}, },
}, },
plugins: [require("tailwindcss-animate")], plugins: [require("tailwindcss-animate"), gradientPlugin],
} }

View File

@@ -54,7 +54,7 @@ async function killProcess(pid: string): Promise<boolean> {
* @param {Object} options - Command options * @param {Object} options - Command options
*/ */
export async function stopCommand(options: StopOptions): Promise<void> { export async function stopCommand(options: StopOptions): Promise<void> {
const port = options.port || 3456; const port = Number(options.port) || 3456;
const reactPort = port + 1; // React frontend runs on port + 1 const reactPort = port + 1; // React frontend runs on port + 1
const force = options.force || false; const force = options.force || false;

View File

@@ -381,16 +381,29 @@ export class CoreMemoryStore {
* Get all memories * Get all memories
*/ */
getMemories(options: { archived?: boolean; limit?: number; offset?: number } = {}): CoreMemory[] { getMemories(options: { archived?: boolean; limit?: number; offset?: number } = {}): CoreMemory[] {
const { archived = false, limit = 50, offset = 0 } = options; const { archived, limit = 50, offset = 0 } = options;
const stmt = this.db.prepare(` let stmt;
let rows;
if (archived === undefined) {
// Fetch all memories regardless of archived status
stmt = this.db.prepare(`
SELECT * FROM memories
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`);
rows = stmt.all(limit, offset) as any[];
} else {
// Fetch memories filtered by archived status
stmt = this.db.prepare(`
SELECT * FROM memories SELECT * FROM memories
WHERE archived = ? WHERE archived = ?
ORDER BY updated_at DESC ORDER BY updated_at DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`); `);
rows = stmt.all(archived ? 1 : 0, limit, offset) as any[];
const rows = stmt.all(archived ? 1 : 0, limit, offset) as any[]; }
return rows.map(row => ({ return rows.map(row => ({
id: row.id, id: row.id,
content: row.content, content: row.content,

View File

@@ -31,8 +31,10 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
// API: Core Memory - Get all memories // API: Core Memory - Get all memories
if (pathname === '/api/core-memory/memories' && req.method === 'GET') { if (pathname === '/api/core-memory/memories' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath; const projectPath = url.searchParams.get('path') || initialPath;
const archived = url.searchParams.get('archived') === 'true'; const archivedParam = url.searchParams.get('archived');
const limit = parseInt(url.searchParams.get('limit') || '50', 10); // undefined means fetch all, 'true' means only archived, 'false' means only non-archived
const archived = archivedParam === null ? undefined : archivedParam === 'true';
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
const offset = parseInt(url.searchParams.get('offset') || '0', 10); const offset = parseInt(url.searchParams.get('offset') || '0', 10);
try { try {

View File