mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
202
ccw/frontend/src/lib/colorGenerator.test.ts
Normal file
202
ccw/frontend/src/lib/colorGenerator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
188
ccw/frontend/src/lib/colorGenerator.ts
Normal file
188
ccw/frontend/src/lib/colorGenerator.ts
Normal 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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "删除提供商失败",
|
||||
|
||||
@@ -47,5 +47,10 @@
|
||||
"splitHorizontal": "水平分割",
|
||||
"splitVertical": "垂直分割",
|
||||
"closePane": "关闭窗格"
|
||||
}
|
||||
},
|
||||
"noActiveTab": "暂无活动标签页",
|
||||
"selectOrCreate": "选择一个标签页或启动新的 CLI 执行",
|
||||
"executionNotFound": "未找到执行",
|
||||
"waitingForOutput": "等待输出...",
|
||||
"noOutput": "暂无输出"
|
||||
}
|
||||
|
||||
@@ -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": "重置为预设"
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLocationFilter('project')}>
|
||||
{formatMessage({ id: 'rules.location.project' })}
|
||||
{locationFilter === 'project' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLocationFilter('user')}>
|
||||
{formatMessage({ id: 'rules.location.user' })}
|
||||
{locationFilter === 'user' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Category filter dropdown */}
|
||||
{categories.length > 0 && (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user