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

View File

@@ -1,9 +1,9 @@
// ========================================
// 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 { X, Pin, PinOff, MoreHorizontal, SplitSquareHorizontal, SplitSquareVertical } from 'lucide-react';
import { cn } from '@/lib/utils';
@@ -14,7 +14,7 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/DropdownMenu';
} from '@/components/ui/Dropdown';
import {
useViewerStore,
useViewerPanes,
@@ -32,6 +32,7 @@ export interface TabBarProps {
interface TabItemProps {
tab: TabState;
paneId: PaneId;
isActive: boolean;
onSelect: () => void;
onClose: (e: React.MouseEvent) => void;
@@ -49,10 +50,23 @@ const STATUS_COLORS = {
// ========== 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
const displayTitle = useMemo(() => {
// 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;
}, [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 (
<button
<div
role="tab"
tabIndex={0}
draggable={!tab.isPinned}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
className={cn(
'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]',
'transition-all duration-150',
'transition-all duration-150 select-none',
isActive
? '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',
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}
>
@@ -111,7 +201,7 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
</button>
)}
</div>
</button>
</div>
);
}
@@ -125,10 +215,12 @@ function TabItem({ tab, isActive, onSelect, onClose, onTogglePin }: TabItemProps
* - Active tab highlighting
* - Close button on hover
* - Pin/unpin functionality
* - Drag-and-drop tab reordering and moving between panes
* - Pane actions dropdown
*/
export function TabBar({ paneId, className }: TabBarProps) {
const { formatMessage } = useIntl();
const [isDragOver, setIsDragOver] = useState(false);
const panes = useViewerPanes();
const pane = panes[paneId];
const setActiveTab = useViewerStore((state) => state.setActiveTab);
@@ -136,6 +228,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
const togglePinTab = useViewerStore((state) => state.togglePinTab);
const addPane = useViewerStore((state) => state.addPane);
const removePane = useViewerStore((state) => state.removePane);
const moveTab = useViewerStore((state) => state.moveTab);
const handleTabSelect = useCallback(
(tabId: string) => {
@@ -172,6 +265,43 @@ export function TabBar({ paneId, className }: TabBarProps) {
removePane(paneId);
}, [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
const sortedTabs = useMemo(() => {
if (!pane) return [];
@@ -197,7 +327,15 @@ export function TabBar({ paneId, className }: TabBarProps) {
)}
>
{/* 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 ? (
<span className="text-xs text-muted-foreground px-2">
{formatMessage({ id: 'cliViewer.tabs.noTabs', defaultMessage: 'No tabs open' })}
@@ -207,6 +345,7 @@ export function TabBar({ paneId, className }: TabBarProps) {
<TabItem
key={tab.id}
tab={tab}
paneId={paneId}
isActive={pane.activeTabId === tab.id}
onSelect={() => handleTabSelect(tab.id)}
onClose={(e) => handleTabClose(e, tab.id)}

View File

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

View File

@@ -130,6 +130,7 @@ export function AppShell({
};
window.addEventListener('resize', handleResize);
// Cleanup: Remove event listener on unmount to prevent memory leak
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 { useTheme } from '@/hooks/useTheme';
import { COLOR_SCHEMES, THEME_MODES, getThemeName } from '@/lib/theme';
import type { ColorScheme, ThemeMode } from '@/lib/theme';
import { generateThemeFromHue } from '@/lib/colorGenerator';
/**
* Theme Selector Component
* 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:
* - 8 total theme combinations
* - 8 preset theme combinations + custom hue support
* - Keyboard navigation support (Arrow keys)
* - ARIA labels for accessibility
* - Visual feedback for selected theme
* - System dark mode detection
* - Custom hue slider (0-360) with real-time preview
*/
export function ThemeSelector() {
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'
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) => {
// When selecting a preset scheme, reset custom hue
if (isCustomTheme) {
setCustomHue(null);
}
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) => {
setTheme(newMode);
};
@@ -53,7 +93,7 @@ export function ThemeSelector() {
{formatMessage({ id: 'theme.title.colorScheme' })}
</h3>
<div
className="grid grid-cols-4 gap-3"
className="grid grid-cols-5 gap-3"
role="group"
aria-label="Color scheme selection"
onKeyDown={handleKeyDown}
@@ -63,12 +103,12 @@ export function ThemeSelector() {
key={scheme.id}
onClick={() => handleSchemeSelect(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"
className={`
flex flex-col items-center gap-2 p-3 rounded-lg
transition-all duration-200 border-2
${colorScheme === scheme.id
${colorScheme === scheme.id && !isCustomTheme
? 'border-accent bg-surface shadow-md'
: 'border-border bg-bg hover:bg-surface'
}
@@ -87,9 +127,124 @@ export function ThemeSelector() {
</span>
</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>
{/* 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 */}
<div>
<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
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';
export interface UseThemeReturn {
@@ -16,10 +16,16 @@ export interface UseThemeReturn {
isDark: boolean;
/** Current color scheme ('blue', 'green', 'orange', 'purple') */
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 */
setTheme: (theme: Theme) => void;
/** Set color scheme */
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) */
toggleTheme: () => void;
}
@@ -46,8 +52,11 @@ export function useTheme(): UseThemeReturn {
const theme = useAppStore(selectTheme);
const resolvedTheme = useAppStore(selectResolvedTheme);
const colorScheme = useAppStore((state) => state.colorScheme);
const customHue = useAppStore(selectCustomHue);
const isCustomTheme = useAppStore(selectIsCustomTheme);
const setThemeAction = useAppStore((state) => state.setTheme);
const setColorSchemeAction = useAppStore((state) => state.setColorScheme);
const setCustomHueAction = useAppStore((state) => state.setCustomHue);
const toggleThemeAction = useAppStore((state) => state.toggleTheme);
const setTheme = useCallback(
@@ -64,6 +73,13 @@ export function useTheme(): UseThemeReturn {
[setColorSchemeAction]
);
const setCustomHue = useCallback(
(hue: number | null) => {
setCustomHueAction(hue);
},
[setCustomHueAction]
);
const toggleTheme = useCallback(() => {
toggleThemeAction();
}, [toggleThemeAction]);
@@ -73,8 +89,11 @@ export function useTheme(): UseThemeReturn {
resolvedTheme,
isDark: resolvedTheme === 'dark',
colorScheme,
customHue,
isCustomTheme,
setTheme,
setColorScheme,
setCustomHue,
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",
"syncToCodexLens": "Sync to CodexLens",
"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}\"?",
"emptyState": {
@@ -129,8 +131,6 @@
"basicInfo": "Basic Information",
"endpointSettings": "Endpoint Settings",
"apiBaseUpdated": "Base URL updated",
"showDisabled": "Show Disabled",
"hideDisabled": "Hide Disabled",
"showAll": "Show All",
"saveError": "Failed to save provider",
"deleteError": "Failed to delete provider",

View File

@@ -47,5 +47,10 @@
"splitHorizontal": "Split Horizontal",
"splitVertical": "Split Vertical",
"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": {
"colorScheme": "Color Scheme",
"themeMode": "Theme Mode"
"themeMode": "Theme Mode",
"customHue": "Custom Hue"
},
"colorScheme": {
"blue": "Classic Blue",
"green": "Deep Green",
"orange": "Vibrant Orange",
"purple": "Elegant Purple"
"purple": "Elegant Purple",
"custom": "Custom"
},
"themeMode": {
"light": "Light",
@@ -17,5 +19,9 @@
"colorScheme": "Select {name} theme",
"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": "多密钥设置",
"syncToCodexLens": "同步到 CodexLens",
"manageModels": "管理模型",
"addModel": "添加模型"
"addModel": "添加模型",
"showDisabled": "显示已禁用",
"hideDisabled": "隐藏已禁用"
},
"deleteConfirm": "确定要删除提供商 \"{name}\" 吗?",
"emptyState": {
@@ -129,8 +131,6 @@
"basicInfo": "基本信息",
"endpointSettings": "端点设置",
"apiBaseUpdated": "基础 URL 已更新",
"showDisabled": "显示已禁用",
"hideDisabled": "隐藏已禁用",
"showAll": "显示全部",
"saveError": "保存提供商失败",
"deleteError": "删除提供商失败",

View File

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

View File

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

View File

@@ -296,19 +296,35 @@ export function CliViewerPage() {
}
}, [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());
useEffect(() => {
if (!focusedPaneId) return;
for (const executionId of Object.keys(executions)) {
if (!addedExecutionsRef.current.has(executionId)) {
addedExecutionsRef.current.add(executionId);
const exec = executions[executionId];
const toolShort = exec.tool.split('-')[0];
addTab(focusedPaneId, executionId, `${toolShort} (${exec.mode})`);
}
}
}, [executions, focusedPaneId, addTab]);
// Get all pane IDs from the current layout
const paneIds = Object.keys(panes);
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);
const exec = executions[executionId];
const toolShort = exec.tool.split('-')[0];
// Round-robin pane selection
const targetPaneId = paneIds[index % paneIds.length];
storeAddTab(targetPaneId, executionId, `${toolShort} (${exec.mode})`);
});
}, [executions, panes]);
// Initialize layout if empty
useEffect(() => {

View File

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

View File

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

View File

@@ -235,21 +235,21 @@ export function ProjectOverviewPage() {
{/* Header Row */}
<div className="flex items-start justify-between mb-4 pb-3 border-b border-border">
<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}
</h1>
<p className="text-xs text-muted-foreground">
<p className="text-sm text-muted-foreground">
{projectOverview.description || formatMessage({ id: 'projectOverview.noDescription' })}
</p>
</div>
<div className="text-xs text-muted-foreground text-right">
<div className="text-sm text-muted-foreground text-right">
<div>
{formatMessage({ id: 'projectOverview.header.initialized' })}:{' '}
{formatDate(projectOverview.initializedAt)}
</div>
{metadata?.analysis_mode && (
<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}
</span>
</div>
@@ -258,7 +258,7 @@ export function ProjectOverviewPage() {
</div>
{/* 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" />
{formatMessage({ id: 'projectOverview.techStack.title' })}
</h3>
@@ -266,7 +266,7 @@ export function ProjectOverviewPage() {
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{/* Languages */}
<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' })}
</h4>
<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 }) => (
<div
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' : ''
}`}
>
<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 && (
<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' })}
</span>
)}
</div>
))
) : (
<span className="text-muted-foreground text-xs">
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noLanguages' })}
</span>
)}
@@ -297,18 +297,18 @@ export function ProjectOverviewPage() {
{/* Frameworks */}
<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' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.frameworks && technologyStack.frameworks.length > 0 ? (
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}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">
<span className="text-muted-foreground text-sm">
{formatMessage({ id: 'projectOverview.techStack.noFrameworks' })}
</span>
)}
@@ -317,36 +317,36 @@ export function ProjectOverviewPage() {
{/* Build Tools */}
<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' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.build_tools && technologyStack.build_tools.length > 0 ? (
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}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
<span className="text-muted-foreground text-sm">-</span>
)}
</div>
</div>
{/* Test Frameworks */}
<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' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{technologyStack?.test_frameworks && technologyStack.test_frameworks.length > 0 ? (
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}
</Badge>
))
) : (
<span className="text-muted-foreground text-xs">-</span>
<span className="text-muted-foreground text-sm">-</span>
)}
</div>
</div>
@@ -354,120 +354,128 @@ export function ProjectOverviewPage() {
</CardContent>
</Card>
{/* Architecture */}
{architecture && (
{/* Architecture & Key Components - Merged */}
{(architecture || (keyComponents && keyComponents.length > 0)) && (
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Blocks className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.architecture.title' })}
</h3>
{/* 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" />
{formatMessage({ id: 'projectOverview.architecture.title' })}
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Style */}
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.style' })}
</h4>
<div className="px-2 py-1.5 bg-background border border-border rounded">
<span className="text-foreground font-medium text-xs">{architecture.style}</span>
</div>
</div>
{/* Layers */}
{architecture.layers && architecture.layers.length > 0 && (
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.layers' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{architecture.layers.map((layer: string) => (
<span key={layer} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]">
{layer}
</span>
))}
</div>
</div>
)}
{/* Patterns */}
{architecture.patterns && architecture.patterns.length > 0 && (
<div>
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{architecture.patterns.map((pattern: string) => (
<span key={pattern} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-[10px]">
{pattern}
</span>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Key Components */}
{keyComponents && keyComponents.length > 0 && (
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Component className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.components.title' })}
</h3>
<div className="space-y-2">
{keyComponents.map((comp: KeyComponent) => {
const importance = comp.importance || 'low';
const importanceColors: Record<string, string> = {
high: 'border-l-2 border-l-destructive bg-destructive/5',
medium: 'border-l-2 border-l-warning bg-warning/5',
low: 'border-l-2 border-l-muted-foreground bg-muted',
};
const importanceBadges: Record<string, React.ReactElement> = {
high: (
<Badge variant="destructive" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.high' })}
</Badge>
),
medium: (
<Badge variant="warning" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.medium' })}
</Badge>
),
low: (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.low' })}
</Badge>
),
};
return (
<div
key={comp.name}
className={`p-2.5 rounded ${importanceColors[importance] || importanceColors.low}`}
>
<div className="flex items-start justify-between mb-1">
<h4 className="font-medium text-foreground text-xs">{comp.name}</h4>
{importanceBadges[importance]}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Style */}
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.style' })}
</h4>
<div className="px-2 py-1.5 bg-background border border-border rounded">
<span className="text-foreground font-medium text-sm">{architecture.style}</span>
</div>
{comp.description && (
<p className="text-[10px] text-muted-foreground mb-1">{comp.description}</p>
)}
{comp.responsibility && comp.responsibility.length > 0 && (
<ul className="text-[10px] text-muted-foreground list-disc list-inside">
{comp.responsibility.map((resp: string, i: number) => (
<li key={i}>{resp}</li>
))}
</ul>
)}
</div>
);
})}
</div>
{/* Layers */}
{architecture.layers && architecture.layers.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.layers' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{architecture.layers.map((layer: string) => (
<span key={layer} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-xs">
{layer}
</span>
))}
</div>
</div>
)}
{/* Patterns */}
{architecture.patterns && architecture.patterns.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide mb-2">
{formatMessage({ id: 'projectOverview.architecture.patterns' })}
</h4>
<div className="flex flex-wrap gap-1.5">
{architecture.patterns.map((pattern: string) => (
<span key={pattern} className="px-1.5 py-0.5 bg-muted text-foreground rounded text-xs">
{pattern}
</span>
))}
</div>
</div>
)}
</div>
</>
)}
{/* 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 && (
<>
<h3 className="text-base font-semibold text-foreground mb-3 flex items-center gap-1.5">
<Component className="w-4 h-4" />
{formatMessage({ id: 'projectOverview.components.title' })}
</h3>
<div className="space-y-2">
{keyComponents.map((comp: KeyComponent) => {
const importance = comp.importance || 'low';
const importanceColors: Record<string, string> = {
high: 'border-l-2 border-l-destructive bg-destructive/5',
medium: 'border-l-2 border-l-warning bg-warning/5',
low: 'border-l-2 border-l-muted-foreground bg-muted',
};
const importanceBadges: Record<string, React.ReactElement> = {
high: (
<Badge variant="destructive" className="text-xs px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.high' })}
</Badge>
),
medium: (
<Badge variant="warning" className="text-xs px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.medium' })}
</Badge>
),
low: (
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{formatMessage({ id: 'projectOverview.components.importance.low' })}
</Badge>
),
};
return (
<div
key={comp.name}
className={`p-2.5 rounded ${importanceColors[importance] || importanceColors.low}`}
>
<div className="flex items-start justify-between mb-1">
<h4 className="font-medium text-foreground text-sm">{comp.name}</h4>
{importanceBadges[importance]}
</div>
{comp.description && (
<p className="text-xs text-muted-foreground mb-1">{comp.description}</p>
)}
{comp.responsibility && comp.responsibility.length > 0 && (
<ul className="text-xs text-muted-foreground list-disc list-inside">
{comp.responsibility.map((resp: string, i: number) => (
<li key={i}>{resp}</li>
))}
</ul>
)}
</div>
);
})}
</div>
</>
)}
</CardContent>
</Card>
)}
@@ -477,7 +485,7 @@ export function ProjectOverviewPage() {
<Card>
<CardContent className="p-4">
<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" />
{formatMessage({ id: 'projectOverview.devIndex.title' })}
</h3>
@@ -487,8 +495,8 @@ export function ProjectOverviewPage() {
if (count === 0) return null;
const Icon = cat.icon;
return (
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'} className="text-[10px] px-1.5 py-0">
<Icon className="w-2.5 h-2.5 mr-0.5" />
<Badge key={cat.key} variant={cat.color === 'primary' ? 'default' : 'secondary'} className="text-xs px-1.5 py-0">
<Icon className="w-3 h-3 mr-0.5" />
{count}
</Badge>
);
@@ -498,13 +506,13 @@ export function ProjectOverviewPage() {
<Tabs value={devIndexView} onValueChange={(v) => setDevIndexView(v as DevIndexView)}>
<div className="flex items-center justify-between mb-3">
<TabsList className="h-7">
<TabsTrigger value="category" className="text-xs px-2 py-1 h-6">
<LayoutGrid className="w-3 h-3 mr-1" />
<TabsList className="h-8">
<TabsTrigger value="category" className="text-sm px-3 py-1 h-7">
<LayoutGrid className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.categories' })}
</TabsTrigger>
<TabsTrigger value="timeline" className="text-xs px-2 py-1 h-6">
<GitCommitHorizontal className="w-3 h-3 mr-1" />
<TabsTrigger value="timeline" className="text-sm px-3 py-1 h-7">
<GitCommitHorizontal className="w-3.5 h-3.5 mr-1" />
{formatMessage({ id: 'projectOverview.devIndex.timeline' })}
</TabsTrigger>
</TabsList>
@@ -519,10 +527,10 @@ export function ProjectOverviewPage() {
return (
<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" />
<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>
<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) => (
@@ -531,15 +539,15 @@ export function ProjectOverviewPage() {
className="p-2 bg-background border border-border rounded hover:shadow-sm transition-shadow"
>
<div className="flex items-start justify-between mb-0.5">
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5>
<span className="text-[10px] text-muted-foreground">
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
<span className="text-xs text-muted-foreground">
{formatDate(entry.archivedAt || entry.date || entry.implemented_at)}
</span>
</div>
{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 && (
<span className="px-1.5 py-0.5 bg-primary-light text-primary rounded font-mono">
{entry.sessionId}
@@ -563,7 +571,7 @@ export function ProjectOverviewPage() {
</div>
))}
{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
</div>
)}
@@ -601,20 +609,20 @@ export function ProjectOverviewPage() {
? 'destructive'
: 'secondary'
}
className="text-[10px] px-1.5 py-0"
className="text-xs px-1.5 py-0"
>
{entry.typeLabel}
</Badge>
<h5 className="font-medium text-foreground text-xs">{entry.title}</h5>
<h5 className="font-medium text-foreground text-sm">{entry.title}</h5>
</div>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(entry.date)}
</span>
</div>
{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 && (
<span className="px-1.5 py-0.5 bg-muted rounded font-mono">
{entry.sessionId}
@@ -635,7 +643,7 @@ export function ProjectOverviewPage() {
);
})}
{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
</div>
)}

View File

@@ -13,6 +13,9 @@ import {
AlertCircle,
FileCode,
X,
Folder,
User,
Globe,
} from 'lucide-react';
import {
useRules,
@@ -25,6 +28,7 @@ import { RuleDialog } from '@/components/shared/RuleDialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import {
Dialog,
DialogContent,
@@ -130,6 +134,12 @@ export function RulesManagerPage() {
return Array.from(cats).sort();
}, [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
const handleEditClick = (rule: Rule) => {
setSelectedRule(rule);
@@ -223,6 +233,35 @@ export function RulesManagerPage() {
</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 */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
{/* Status tabs */}
@@ -253,37 +292,6 @@ export function RulesManagerPage() {
)}
</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 */}
{categories.length > 0 && (
<DropdownMenu>

View File

@@ -585,7 +585,7 @@ export function SettingsPage() {
</Card>
{/* 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">
<Settings className="w-5 h-5" />
{formatMessage({ id: 'settings.sections.display' })}
@@ -607,7 +607,7 @@ export function SettingsPage() {
</Button>
</div>
</div>
</Card>
</div>
{/* Reset Settings */}
<Card className="p-6 border-destructive/50">

View File

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

View File

@@ -9,6 +9,7 @@ import type { AppStore, Theme, ColorScheme, Locale, ViewMode, SessionFilter, Lit
import { DEFAULT_DASHBOARD_LAYOUT } from '../components/dashboard/defaultLayouts';
import { getInitialLocale, updateIntl } from '../lib/i18n';
import { getThemeId } from '../lib/theme';
import { generateThemeFromHue } from '../lib/colorGenerator';
// Helper to resolve system theme
const getSystemTheme = (): 'light' | 'dark' => {
@@ -24,12 +25,87 @@ const resolveTheme = (theme: Theme): 'light' | 'dark' => {
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
const initialState = {
// Theme
theme: 'system' as Theme,
resolvedTheme: 'light' as 'light' | 'dark',
colorScheme: 'blue' as ColorScheme, // New: default to blue scheme
customHue: null as number | null,
isCustomTheme: false,
// Locale
locale: getInitialLocale() as Locale,
@@ -66,26 +142,32 @@ export const useAppStore = create<AppStore>()(
const resolved = resolveTheme(theme);
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
// Apply theme to document
if (typeof document !== 'undefined') {
const { colorScheme } = get();
const themeId = getThemeId(colorScheme, resolved);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
// Apply theme using helper (encapsulates DOM manipulation)
const { colorScheme, customHue } = get();
applyThemeToDocument(resolved, colorScheme, customHue);
},
setColorScheme: (colorScheme: ColorScheme) => {
set({ colorScheme }, false, 'setColorScheme');
set({ colorScheme, customHue: null, isCustomTheme: false }, false, 'setColorScheme');
// Apply color scheme to document
if (typeof document !== 'undefined') {
const { resolvedTheme } = get();
const themeId = getThemeId(colorScheme, resolvedTheme);
document.documentElement.setAttribute('data-theme', themeId);
document.documentElement.setAttribute('data-color-scheme', colorScheme);
// Apply color scheme using helper (encapsulates DOM manipulation)
const { resolvedTheme } = get();
applyThemeToDocument(resolvedTheme, colorScheme, null);
},
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: () => {
@@ -189,6 +271,7 @@ export const useAppStore = create<AppStore>()(
partialize: (state) => ({
theme: state.theme,
colorScheme: state.colorScheme,
customHue: state.customHue,
locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups,
@@ -199,12 +282,9 @@ export const useAppStore = create<AppStore>()(
if (state) {
const resolved = resolveTheme(state.theme);
state.resolvedTheme = resolved;
const themeId = getThemeId(state.colorScheme, resolved);
if (typeof document !== 'undefined') {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', themeId);
}
state.isCustomTheme = state.customHue !== null;
// Apply theme using helper (encapsulates DOM manipulation)
applyThemeToDocument(resolved, state.colorScheme, state.customHue);
}
// Apply locale on rehydration
if (state) {
@@ -225,10 +305,8 @@ if (typeof window !== 'undefined') {
if (state.theme === 'system') {
const resolved = getSystemTheme();
useAppStore.setState({ resolvedTheme: resolved });
const themeId = getThemeId(state.colorScheme, resolved);
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(resolved);
document.documentElement.setAttribute('data-theme', themeId);
// Apply theme using helper (encapsulates DOM manipulation)
applyThemeToDocument(resolved, state.colorScheme, state.customHue);
}
});
}
@@ -236,6 +314,9 @@ if (typeof window !== 'undefined') {
// Selectors for common access patterns
export const selectTheme = (state: AppStore) => state.theme;
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 selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
export const selectCurrentView = (state: AppStore) => state.currentView;

View File

@@ -653,14 +653,14 @@ export const useCoordinatorStore = create<CoordinatorState>()(
{
name: LOG_STORAGE_KEY,
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) => ({
currentExecutionId: state.currentExecutionId,
status: state.status,
startedAt: state.startedAt,
completedAt: state.completedAt,
totalElapsedMs: state.totalElapsedMs,
metadata: state.metadata,
// Exclude metadata from persistence - it may contain sensitive data (Record<string, unknown>)
isLogPanelExpanded: state.isLogPanelExpanded,
autoScrollLogs: state.autoScrollLogs,
// 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_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
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
@@ -51,7 +94,9 @@ const saveToStorage = (notifications: Toast[]): void => {
try {
// Keep only the last N notifications
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) {
console.error('[NotificationStore] Failed to save to storage:', e);
}

View File

@@ -39,6 +39,8 @@ export interface AppState {
theme: Theme;
resolvedTheme: 'light' | 'dark';
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;
@@ -68,6 +70,7 @@ export interface AppActions {
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setColorScheme: (scheme: ColorScheme) => void; // New: set color scheme
setCustomHue: (hue: number | null) => void; // Set custom hue for theme customization
// Locale actions
setLocale: (locale: Locale) => void;

View File

@@ -1,4 +1,46 @@
/** @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 {
darkMode: ['class', '[data-theme="dark"]'],
content: [
@@ -88,6 +130,7 @@ export default {
DEFAULT: "0 2px 8px rgb(0 0 0 / 0.08)",
md: "0 4px 12px rgb(0 0 0 / 0.1)",
lg: "0 8px 24px rgb(0 0 0 / 0.12)",
"glow-accent": "0 0 40px 10px hsl(var(--accent) / 0.7)",
},
borderRadius: {
@@ -109,14 +152,22 @@ export default {
"0%": { transform: "translateX(0)" },
"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: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
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
*/
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 force = options.force || false;

View File

@@ -381,16 +381,29 @@ export class CoreMemoryStore {
* Get all memories
*/
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(`
SELECT * FROM memories
WHERE archived = ?
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`);
let stmt;
let rows;
const rows = stmt.all(archived ? 1 : 0, limit, offset) as any[];
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
WHERE archived = ?
ORDER BY updated_at DESC
LIMIT ? OFFSET ?
`);
rows = stmt.all(archived ? 1 : 0, limit, offset) as any[];
}
return rows.map(row => ({
id: row.id,
content: row.content,

View File

@@ -31,8 +31,10 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
// API: Core Memory - Get all memories
if (pathname === '/api/core-memory/memories' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const archived = url.searchParams.get('archived') === 'true';
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const archivedParam = url.searchParams.get('archived');
// 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);
try {

View File