feat: Add Role Analysis Reviewer Agent and validation template

- Introduced Role Analysis Reviewer Agent to validate role analysis outputs against templates and quality standards.
- Created a detailed validation ruleset for the system-architect role, including mandatory and recommended sections.
- Added JSON validation report structure for output.
- Implemented execution command for validation process.

test: Add UX tests for HookCard component

- Created comprehensive tests for HookCard component, focusing on delete confirmation UX pattern.
- Verified confirmation dialog appearance, deletion functionality, and button interactions.
- Ensured proper handling of state updates and visual feedback for enabled/disabled status.

test: Add UX tests for ThemeSelector component

- Developed tests for ThemeSelector component, emphasizing delete confirmation UX pattern.
- Validated confirmation dialog display, deletion actions, and toast notifications for undo functionality.
- Ensured proper management of theme slots and state updates.

feat: Implement useDebounce hook

- Added useDebounce hook to delay expensive computations or API calls, enhancing performance.

feat: Create System Architect Analysis Template

- Developed a comprehensive template for system architect role analysis, covering required sections such as architecture overview, data model, state machine, error handling strategy, observability requirements, configuration model, and boundary scenarios.
- Included examples and templates for each section to guide users in producing SPEC.md-level precision modeling.
This commit is contained in:
catlog22
2026-03-05 19:58:10 +08:00
parent bc7a556985
commit 3fd55ebd4b
55 changed files with 4262 additions and 1138 deletions

View File

@@ -49,6 +49,7 @@
"react-router-dom": "^6.28.0",
"recharts": "^2.15.0",
"rehype-highlight": "^7.0.2",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.5.0",

View File

@@ -172,6 +172,8 @@ function MarkdownContent({ content, className }: MarkdownContentProps) {
function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
const { formatMessage } = useIntl();
const sendA2UIAction = useNotificationStore((state) => state.sendA2UIAction);
const addToast = useNotificationStore((state) => state.addToast);
const [actionError, setActionError] = useState<string | null>(null);
// Detect question type
const questionType = useMemo(() => detectQuestionType(surface), [surface]);
@@ -216,16 +218,14 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
const message = getTextContent(messageComponent);
const description = getTextContent(descriptionComponent);
// Separate body components (interactive elements) from action buttons
// Separate body components from action buttons
const { bodyComponents, actionButtons } = useMemo(() => {
const body: SurfaceComponent[] = [];
const actions: SurfaceComponent[] = [];
for (const comp of surface.components) {
// Skip title, message, description
if (['title', 'message', 'description'].includes(comp.id)) continue;
// Separate action buttons (confirm, cancel, submit)
if (isActionButton(comp) && ['confirm-btn', 'cancel-btn', 'submit-btn'].includes(comp.id)) {
actions.push(comp);
} else {
@@ -236,7 +236,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
return { bodyComponents: body, actionButtons: actions };
}, [surface.components]);
// Create surfaces for body and actions
const bodySurface: SurfaceUpdate = useMemo(
() => ({ ...surface, components: bodyComponents }),
[surface, bodyComponents]
@@ -247,7 +246,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
[surface, actionButtons]
);
// Handle "Other" text change
const handleOtherTextChange = useCallback(
(value: string) => {
setOtherText(value);
@@ -263,7 +261,8 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
// Handle A2UI actions
const handleAction = useCallback(
(actionId: string, params?: Record<string, unknown>) => {
async (actionId: string, params?: Record<string, unknown>) => {
setActionError(null);
// Track "Other" selection state
if (actionId === 'select' && params?.value === '__other__') {
setOtherSelected(true);
@@ -274,16 +273,20 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
setOtherSelected((prev) => !prev);
}
// Send action to backend via WebSocket
sendA2UIAction(actionId, surface.surfaceId, params);
try {
await sendA2UIAction(actionId, surface.surfaceId, params);
// Check if this action should close the dialog
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
if (resolvingActions.includes(actionId)) {
onClose();
const resolvingActions = ['confirm', 'cancel', 'submit', 'answer'];
if (resolvingActions.includes(actionId)) {
onClose();
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred.';
setActionError(errorMessage);
addToast('error', 'Action Failed', errorMessage);
}
},
[sendA2UIAction, surface.surfaceId, onClose]
[sendA2UIAction, surface.surfaceId, onClose, addToast]
);
// Handle dialog close (ESC key or overlay click)
@@ -299,7 +302,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
[sendA2UIAction, surface.surfaceId, onClose]
);
// Determine dialog width based on question type
const dialogWidth = useMemo(() => {
switch (questionType) {
case 'multi-select':
@@ -311,33 +313,27 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
}
}, [questionType]);
// Check if this question type supports "Other" input
const hasOtherOption = questionType === 'select' || questionType === 'multi-select';
return (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent
className={cn(
// Base styles
dialogWidth,
'max-h-[80vh] overflow-y-auto',
'bg-card p-6 rounded-xl shadow-lg border border-border/50',
// Animation classes
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0',
'data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95',
'data-[state=open]:duration-300 data-[state=closed]:duration-200'
)}
onInteractOutside={(e) => {
// Prevent closing when clicking outside - only cancel button can close
e.preventDefault();
}}
onEscapeKeyDown={(e) => {
// Prevent closing with ESC key - only cancel button can close
e.preventDefault();
}}
>
{/* Header */}
<DialogHeader className="space-y-2 pb-4">
<DialogTitle className="text-lg font-semibold leading-tight">{title}</DialogTitle>
{message && (
@@ -352,15 +348,18 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
)}
</DialogHeader>
{/* Body - Interactive elements */}
{actionError && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive-foreground text-sm rounded-md p-3 my-2">
{actionError}
</div>
)}
{bodyComponents.length > 0 && (
<div className={cn(
'py-3',
// Add specific styling for multi-select (checkbox list)
questionType === 'multi-select' && 'space-y-2 max-h-[300px] overflow-y-auto px-1'
)}>
{questionType === 'multi-select' ? (
// Render each checkbox individually for better control
bodyComponents.map((comp) => (
<div key={comp.id} className="py-1">
<A2UIRenderer
@@ -372,7 +371,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
) : (
<A2UIRenderer surface={bodySurface} onAction={handleAction} />
)}
{/* "Other" text input — shown when Other is selected */}
{hasOtherOption && (
<OtherInput
visible={otherSelected}
@@ -383,7 +381,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
</div>
)}
{/* Countdown for auto-selection */}
{remaining !== null && defaultLabel && (
<div className="text-xs text-muted-foreground text-center pt-2">
{remaining > 0
@@ -392,7 +389,6 @@ function SinglePagePopup({ surface, onClose }: A2UIPopupCardProps) {
</div>
)}
{/* Footer - Action buttons */}
{actionButtons.length > 0 && (
<DialogFooter className="pt-4">
<div className="flex flex-row justify-end gap-3">

View File

@@ -6,6 +6,9 @@
import { useMemo, useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { FileText, Hash, Clock, Sparkles, AlertCircle, Link2, Check } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeSanitize from 'rehype-sanitize';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
@@ -21,52 +24,20 @@ export interface DocumentViewerProps {
filePath?: string;
}
/**
* Simple markdown-to-HTML converter for basic formatting
*/
function markdownToHtml(markdown: string): string {
let html = markdown;
// Code blocks
html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, '<pre class="code-block"><code class="language-$1">$2</code></pre>');
// Inline code
html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
// Headers
html = html.replace(/^### (.*$)/gm, '<h3 class="doc-h3">$1</h3>');
html = html.replace(/^## (.*$)/gm, '<h2 class="doc-h2">$1</h2>');
html = html.replace(/^# (.*$)/gm, '<h1 class="doc-h1">$1</h1>');
// Bold and italic
html = html.replace(/\*\*\*(.*?)\*\*\*/g, '<strong><em>$1</em></strong>');
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
// Links
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="doc-link">$1</a>');
// Line breaks
html = html.replace(/\n\n/g, '</p><p class="doc-paragraph">');
html = html.replace(/\n/g, '<br />');
return `<div class="doc-content"><p class="doc-paragraph">${html}</p></div>`;
}
/**
* Get symbol type icon
*/
function getSymbolTypeIcon(type: string): string {
const icons: Record<string, string> = {
function: 'λ',
async_function: 'λ',
class: '◇',
method: '◈',
interface: '△',
variable: '•',
constant: '⬡',
function getSymbolTypeIcon(type: string): { symbol: string; label: string } {
const icons: Record<string, { symbol: string; label: string }> = {
function: { symbol: 'λ', label: 'Function' },
async_function: { symbol: 'λ', label: 'Async Function' },
class: { symbol: '◇', label: 'Class' },
method: { symbol: '◈', label: 'Method' },
interface: { symbol: '△', label: 'Interface' },
variable: { symbol: '•', label: 'Variable' },
constant: { symbol: '⬡', label: 'Constant' },
};
return icons[type] || '•';
return icons[type] || { symbol: '•', label: 'Symbol' };
}
/**
@@ -85,6 +56,79 @@ function getSymbolTypeColor(type: string): string {
return colors[type] || 'text-gray-500';
}
/**
* Markdown components with custom styling
*/
const markdownComponents = {
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="text-xl font-bold mt-6 mb-3 text-foreground">{children}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="text-lg font-semibold mt-5 mb-2 text-foreground">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-base font-semibold mt-4 mb-2 text-foreground">{children}</h3>
),
p: ({ children }: { children?: React.ReactNode }) => (
<p className="mb-3 leading-relaxed text-foreground/90">{children}</p>
),
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => (
<a
href={href}
className="text-primary hover:underline"
target={href?.startsWith('http') ? '_blank' : undefined}
rel={href?.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{children}
</a>
),
code: ({ className, children }: { className?: string; children?: React.ReactNode }) => {
const isInline = !className;
if (isInline) {
return (
<code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono">
{children}
</code>
);
}
return (
<code className={cn('block bg-muted p-3 rounded-md text-sm font-mono overflow-x-auto', className)}>
{children}
</code>
);
},
pre: ({ children }: { children?: React.ReactNode }) => (
<pre className="bg-muted p-4 rounded-lg my-4 overflow-x-auto">
{children}
</pre>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="list-disc list-inside mb-3 space-y-1">{children}</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="list-decimal list-inside mb-3 space-y-1">{children}</ol>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-4 border-primary/50 pl-4 my-4 italic text-muted-foreground">
{children}
</blockquote>
),
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-4">
<table className="min-w-full border border-border">{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className="bg-muted">{children}</thead>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-border px-3 py-2 text-left font-medium">{children}</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="border border-border px-3 py-2">{children}</td>
),
};
export function DocumentViewer({
doc,
content,
@@ -222,27 +266,31 @@ export function DocumentViewer({
{/* Symbols list */}
{symbols.length > 0 && (
<div className="mb-6 flex flex-wrap gap-2">
{symbols.map(symbol => (
<a
key={symbol.name}
href={`#${symbol.anchor.replace('#', '')}`}
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium',
'bg-muted/50 hover:bg-muted transition-colors',
getSymbolTypeColor(symbol.type)
)}
>
<span>{getSymbolTypeIcon(symbol.type)}</span>
<span>{symbol.name}</span>
<Badge variant="outline" className="text-[10px] px-1">
{symbol.type}
</Badge>
</a>
))}
{symbols.map(symbol => {
const iconInfo = getSymbolTypeIcon(symbol.type);
return (
<a
key={symbol.name}
href={`#${symbol.anchor.replace('#', '')}`}
className={cn(
'inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium',
'bg-muted/50 hover:bg-muted transition-colors',
getSymbolTypeColor(symbol.type)
)}
aria-label={`${iconInfo.label}: ${symbol.name}`}
>
<span aria-hidden="true">{iconInfo.symbol}</span>
<span>{symbol.name}</span>
<Badge variant="outline" className="text-[10px] px-1">
{symbol.type}
</Badge>
</a>
);
})}
</div>
)}
{/* Document sections */}
{/* Document sections with safe markdown rendering */}
<div className="space-y-6">
{symbolSections.map((section, idx) => (
<section
@@ -250,18 +298,24 @@ export function DocumentViewer({
id={section.name.toLowerCase().replace(/\s+/g, '-')}
className="scroll-mt-4"
>
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: markdownToHtml(section.content) }}
/>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={markdownComponents}
>
{section.content}
</ReactMarkdown>
</section>
))}
{symbolSections.length === 0 && content && (
<div
className="prose prose-sm dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: markdownToHtml(content) }}
/>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
)}
</div>
</div>
@@ -273,75 +327,57 @@ export function DocumentViewer({
<Hash className="w-3 h-3" />
{formatMessage({ id: 'deepwiki.viewer.toc', defaultMessage: 'Symbols' })}
</h4>
<nav className="space-y-1">
{symbols.map(symbol => (
<div
key={symbol.name}
className="group flex items-center gap-1"
>
<a
href={`#${symbol.anchor.replace('#', '')}`}
className={cn(
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted/50',
'font-mono'
)}
<nav className="space-y-1" role="list" aria-label="Document symbols">
{symbols.map(symbol => {
const iconInfo = getSymbolTypeIcon(symbol.type);
return (
<div
key={symbol.name}
className="group flex items-center gap-1"
>
<span className={cn('mr-1', getSymbolTypeColor(symbol.type))}>
{getSymbolTypeIcon(symbol.type)}
</span>
{symbol.name}
</a>
<button
onClick={() => copyDeepLink(symbol.name, symbol.anchor)}
className={cn(
'opacity-0 group-hover:opacity-100 p-1 rounded transition-all',
'hover:bg-muted/50',
copiedSymbol === symbol.name
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground'
)}
title={copiedSymbol === symbol.name ? 'Copied!' : 'Copy deep link'}
>
{copiedSymbol === symbol.name ? (
<Check className="w-3 h-3" />
) : (
<Link2 className="w-3 h-3" />
)}
</button>
</div>
))}
<a
href={`#${symbol.anchor.replace('#', '')}`}
className={cn(
'flex-1 text-xs py-1.5 px-2 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted/50',
'font-mono'
)}
role="listitem"
aria-label={`${iconInfo.label}: ${symbol.name}`}
>
<span className={cn('mr-1', getSymbolTypeColor(symbol.type))} aria-hidden="true">
{iconInfo.symbol}
</span>
{symbol.name}
</a>
<button
onClick={() => copyDeepLink(symbol.name, symbol.anchor)}
className={cn(
'opacity-0 group-hover:opacity-100 p-1 rounded transition-all',
'hover:bg-muted/50',
copiedSymbol === symbol.name
? 'text-green-500'
: 'text-muted-foreground hover:text-foreground'
)}
aria-label={
copiedSymbol === symbol.name
? formatMessage({ id: 'deepwiki.viewer.linkCopied', defaultMessage: 'Link copied' })
: formatMessage({ id: 'deepwiki.viewer.copyLink', defaultMessage: 'Copy deep link to {name}' }, { name: symbol.name })
}
>
{copiedSymbol === symbol.name ? (
<Check className="w-3 h-3" aria-hidden="true" />
) : (
<Link2 className="w-3 h-3" aria-hidden="true" />
)}
</button>
</div>
);
})}
</nav>
</div>
)}
</div>
{/* Styles for rendered markdown */}
<style>{`
.doc-content { line-height: 1.7; }
.doc-paragraph { margin-bottom: 1rem; }
.doc-h1 { font-size: 1.5rem; font-weight: 700; margin: 1.5rem 0 1rem; color: var(--foreground); }
.doc-h2 { font-size: 1.25rem; font-weight: 600; margin: 1.25rem 0 0.75rem; color: var(--foreground); }
.doc-h3 { font-size: 1.1rem; font-weight: 600; margin: 1rem 0 0.5rem; color: var(--foreground); }
.doc-link { color: var(--primary); text-decoration: underline; }
.inline-code {
background: var(--muted);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.85em;
font-family: ui-monospace, monospace;
}
.code-block {
background: var(--muted);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1rem 0;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
line-height: 1.5;
}
`}</style>
</Card>
);
}

View File

@@ -3,15 +3,16 @@
// ========================================
// List of documented files for DeepWiki
import { useState, useMemo } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { FileText, Search, CheckCircle, Clock, RefreshCw } from 'lucide-react';
import { FileText, Search, CheckCircle, Clock, RefreshCw, Loader2 } 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 { cn } from '@/lib/utils';
import type { DeepWikiFile } from '@/hooks/useDeepWiki';
import { useDebounce } from '@/hooks/useDebounce';
export interface FileListProps {
files: DeepWikiFile[];
@@ -75,13 +76,23 @@ export function FileList({
}: FileListProps) {
const { formatMessage } = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearchQuery = useDebounce(searchQuery, 300);
const [isSearching, setIsSearching] = useState(false);
// Filter files by search query
const filteredFiles = useMemo(() => {
if (!searchQuery.trim()) return files;
const query = searchQuery.toLowerCase();
if (!debouncedSearchQuery.trim()) return files;
const query = debouncedSearchQuery.toLowerCase();
return files.filter(f => f.path.toLowerCase().includes(query));
}, [files, searchQuery]);
}, [files, debouncedSearchQuery]);
useEffect(() => {
if (searchQuery !== debouncedSearchQuery) {
setIsSearching(true);
} else {
setIsSearching(false);
}
}, [searchQuery, debouncedSearchQuery]);
// Group files by directory
const groupedFiles = useMemo(() => {
@@ -143,6 +154,9 @@ export function FileList({
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-8 text-sm"
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-muted-foreground" />
)}
</div>
</div>

View File

@@ -3,6 +3,7 @@
// ========================================
// Individual hook display card with actions
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
GitFork,
@@ -16,6 +17,16 @@ import {
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -166,6 +177,7 @@ export function HookCard({
onDelete,
}: HookCardProps) {
const { formatMessage } = useIntl();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Get translated hook name
const displayName = getHookDisplayName(hook.name, formatMessage);
@@ -179,12 +191,20 @@ export function HookCard({
};
const handleDelete = () => {
if (confirm(formatMessage({ id: 'cliHooks.actions.deleteConfirm' }, { hookName: hook.name }))) {
onDelete(hook.name);
}
setShowDeleteConfirm(true);
};
const handleConfirmDelete = () => {
setShowDeleteConfirm(false);
onDelete(hook.name);
};
const handleCancelDelete = () => {
setShowDeleteConfirm(false);
};
return (
<>
<Card className={cn('overflow-hidden', !hook.enabled && 'opacity-60')}>
{/* Header */}
<div className="p-4">
@@ -263,7 +283,7 @@ export function HookCard({
onClick={handleDelete}
title={formatMessage({ id: 'common.actions.delete' })}
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
<Button
variant="ghost"
@@ -317,6 +337,23 @@ export function HookCard({
</div>
)}
</Card>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete CLI Hook?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The hook "{displayName}" will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,280 @@
// ========================================
// HookCard UX Tests - Delete Confirmation
// ========================================
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { HookCard } from './HookCard';
import type { HookCardData } from './HookCard';
// Mock translations
const mockMessages = {
'cliHooks.trigger.SessionStart': 'Session Start',
'cliHooks.trigger.UserPromptSubmit': 'User Prompt Submit',
'cliHooks.trigger.PreToolUse': 'Pre Tool Use',
'cliHooks.trigger.PostToolUse': 'Post Tool Use',
'cliHooks.trigger.Stop': 'Stop',
'cliHooks.trigger.Notification': 'Notification',
'cliHooks.trigger.SubagentStart': 'Subagent Start',
'cliHooks.trigger.SubagentStop': 'Subagent Stop',
'cliHooks.trigger.PreCompact': 'Pre Compact',
'cliHooks.trigger.SessionEnd': 'Session End',
'cliHooks.trigger.PostToolUseFailure': 'Post Tool Use Failure',
'cliHooks.trigger.PermissionRequest': 'Permission Request',
'cliHooks.allTools': 'All Tools',
'common.status.enabled': 'Enabled',
'common.status.disabled': 'Disabled',
'common.actions.edit': 'Edit',
'common.actions.delete': 'Delete',
'cliHooks.actions.enable': 'Enable',
'cliHooks.actions.disable': 'Disable',
'cliHooks.actions.expand': 'Expand',
'cliHooks.actions.collapse': 'Collapse',
'cliHooks.form.description': 'Description',
'cliHooks.form.matcher': 'Matcher',
'cliHooks.form.command': 'Command',
};
function renderWithIntl(component: React.ReactElement) {
return render(
<IntlProvider messages={mockMessages} locale="en">
{component}
</IntlProvider>
);
}
describe('HookCard - Delete Confirmation UX Pattern', () => {
const mockHook: HookCardData = {
name: 'test-hook',
description: 'Test hook description',
enabled: true,
trigger: 'PreToolUse',
matcher: '.*',
command: 'echo "test"',
};
const mockHandlers = {
onToggleExpand: vi.fn(),
onToggle: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should show confirmation dialog when delete button is clicked', async () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
// Click delete button
const deleteButton = screen.getByTitle('Delete');
fireEvent.click(deleteButton);
// Verify dialog appears
await waitFor(() => {
expect(screen.getByText('Delete CLI Hook?')).toBeInTheDocument();
});
});
it('should display hook name in confirmation dialog', async () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
fireEvent.click(screen.getByTitle('Delete'));
await waitFor(() => {
const dialog = screen.getByText(/This action cannot be undone/);
expect(dialog).toBeInTheDocument();
expect(dialog.textContent).toContain('test-hook');
});
});
it('should call onDelete when confirm is clicked', async () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
fireEvent.click(screen.getByTitle('Delete'));
await waitFor(() => {
const confirmButton = screen.getByRole('button', { name: /Delete/i });
fireEvent.click(confirmButton);
});
expect(mockHandlers.onDelete).toHaveBeenCalledWith('test-hook');
});
it('should NOT call onDelete when cancel is clicked', async () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
fireEvent.click(screen.getByTitle('Delete'));
await waitFor(() => {
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelButton);
});
expect(mockHandlers.onDelete).not.toHaveBeenCalled();
});
it('should close dialog when cancel is clicked', async () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
fireEvent.click(screen.getByTitle('Delete'));
await waitFor(() => {
expect(screen.getByText('Delete CLI Hook?')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.queryByText('Delete CLI Hook?')).not.toBeInTheDocument();
});
});
it('should close dialog after successful deletion', async () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
fireEvent.click(screen.getByTitle('Delete'));
await waitFor(() => {
expect(screen.getByText('Delete CLI Hook?')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Delete/i }));
await waitFor(() => {
expect(screen.queryByText('Delete CLI Hook?')).not.toBeInTheDocument();
});
});
it('should have delete button with destructive styling', () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
const deleteButton = screen.getByTitle('Delete');
expect(deleteButton).toHaveClass('text-destructive');
});
it('should not show dialog on initial render', () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
expect(screen.queryByText('Delete CLI Hook?')).not.toBeInTheDocument();
});
});
describe('HookCard - State Update UX', () => {
const mockHook: HookCardData = {
name: 'state-test-hook',
enabled: true,
trigger: 'SessionStart',
};
const mockHandlers = {
onToggleExpand: vi.fn(),
onToggle: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
};
it('should call onToggle with correct parameters', () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
const toggleButton = screen.getByTitle('Disable');
fireEvent.click(toggleButton);
expect(mockHandlers.onToggle).toHaveBeenCalledWith('state-test-hook', false);
});
it('should call onEdit when edit button is clicked', () => {
renderWithIntl(
<HookCard
hook={mockHook}
isExpanded={false}
{...mockHandlers}
/>
);
const editButton = screen.getByTitle('Edit');
fireEvent.click(editButton);
expect(mockHandlers.onEdit).toHaveBeenCalledWith(mockHook);
});
it('should show enabled status badge', () => {
renderWithIntl(
<HookCard
hook={{ ...mockHook, enabled: true }}
isExpanded={false}
{...mockHandlers}
/>
);
expect(screen.getByText('Enabled')).toBeInTheDocument();
});
it('should show disabled status badge', () => {
renderWithIntl(
<HookCard
hook={{ ...mockHook, enabled: false }}
isExpanded={false}
{...mockHandlers}
/>
);
expect(screen.getByText('Disabled')).toBeInTheDocument();
});
});

View File

@@ -178,14 +178,19 @@ function buildIssueAutoPrompt(issue: Issue): string {
);
return lines.join('\n');
}
import { useNotificationStore } from '@/stores';
// ...
export function IssueBoardPanel() {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const { addToast } = useNotificationStore();
const { issues, isLoading, error } = useIssues();
const { updateIssue } = useIssueMutations();
// ...
}
const [order, setOrder] = useState<BoardOrder>({});
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
const [drawerInitialTab, setDrawerInitialTab] = useState<'overview' | 'terminal'>('overview');
@@ -323,17 +328,19 @@ export function IssueBoardPanel() {
// Auto-open terminal panel to show execution output
useTerminalPanelStore.getState().openTerminal(created.session.sessionKey);
} catch (e) {
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
const errorMsg = `Auto-start failed: ${e instanceof Error ? e.message : String(e)}`;
setOptimisticError(errorMsg);
addToast('error', errorMsg);
}
}
}
} catch (e) {
setOptimisticError(e instanceof Error ? e.message : String(e));
}
}
},
[autoStart, issues, idsByStatus, projectPath, updateIssue]
);
}
}
} catch (e) {
setOptimisticError(e instanceof Error ? e.message : String(e));
}
}
},
[autoStart, issues, idsByStatus, projectPath, updateIssue, addToast]
);
if (error) {
return (

View File

@@ -396,11 +396,14 @@ export function CcwToolsMcpCard({
</div>
</div>
{/* Path Configuration */}
<div className="space-y-3 pt-3 border-t border-border">
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.ccw.paths.label' })}
</p>
import { FloatingFileBrowser } from '@/components/terminal-dashboard/FloatingFileBrowser';
//...
export function CcwToolsMcpCard({
//...
const [isPathPickerOpen, setIsPathPickerOpen] = useState(false);
const [pathPickerTarget, setPathPickerTarget] = useState<'projectRoot' | 'allowedDirs' | null>(null);
//...
{/* Project Root */}
<div className="space-y-1">
@@ -408,13 +411,27 @@ export function CcwToolsMcpCard({
<FolderTree className="w-4 h-4" />
{formatMessage({ id: 'mcp.ccw.paths.projectRoot' })}
</label>
<Input
value={projectRootInput}
onChange={(e) => setProjectRootInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.ccw.paths.projectRootPlaceholder' })}
disabled={!isInstalled}
className="font-mono text-sm"
/>
<div className="flex items-center gap-2">
<Input
value={projectRootInput}
onChange={(e) => setProjectRootInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.ccw.paths.projectRootPlaceholder' })}
disabled={!isInstalled}
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={() => {
setPathPickerTarget('projectRoot');
setIsPathPickerOpen(true);
}}
disabled={!isInstalled}
title="Browse for project root"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
</div>
{/* Allowed Dirs */}
@@ -423,195 +440,50 @@ export function CcwToolsMcpCard({
<HardDrive className="w-4 h-4" />
{formatMessage({ id: 'mcp.ccw.paths.allowedDirs' })}
</label>
<Input
value={allowedDirsInput}
onChange={(e) => setAllowedDirsInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.ccw.paths.allowedDirsPlaceholder' })}
disabled={!isInstalled}
className="font-mono text-sm"
/>
<div className="flex items-center gap-2">
<Input
value={allowedDirsInput}
onChange={(e) => setAllowedDirsInput(e.target.value)}
placeholder={formatMessage({ id: 'mcp.ccw.paths.allowedDirsPlaceholder' })}
disabled={!isInstalled}
className="font-mono text-sm"
/>
<Button
variant="outline"
size="icon"
onClick={() => {
setPathPickerTarget('allowedDirs');
setIsPathPickerOpen(true);
}}
disabled={!isInstalled}
title="Browse for allowed directories"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.ccw.paths.allowedDirsHint' })}
</p>
</div>
{/* Enable Sandbox */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="ccw-enable-sandbox"
checked={enableSandboxInput}
onChange={(e) => setEnableSandboxInput(e.target.checked)}
disabled={!isInstalled}
className="w-4 h-4"
/>
<label
htmlFor="ccw-enable-sandbox"
className="text-sm text-foreground flex items-center gap-1 cursor-pointer"
>
<Shield className="w-4 h-4" />
{formatMessage({ id: 'mcp.ccw.paths.enableSandbox' })}
</label>
</div>
{/* Save Config Button */}
{isInstalled && (
<Button
variant="outline"
size="sm"
onClick={handleConfigSave}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.saving' })
: formatMessage({ id: 'mcp.ccw.actions.saveConfig' })
}
</Button>
)}
</div>
{/* Install/Uninstall Button */}
<div className="pt-3 border-t border-border space-y-3">
{/* Scope Selection - Claude only, only when not installed */}
{!isInstalled && !isCodex && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase">
{formatMessage({ id: 'mcp.scope' })}
</p>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="ccw-install-scope"
value="global"
checked={installScope === 'global'}
onChange={() => setInstallScope('global')}
className="w-4 h-4"
/>
<Globe className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{formatMessage({ id: 'mcp.scope.global' })}</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="ccw-install-scope"
value="project"
checked={installScope === 'project'}
onChange={() => setInstallScope('project')}
className="w-4 h-4"
/>
<Folder className="w-4 h-4 text-muted-foreground" />
<span className="text-sm">{formatMessage({ id: 'mcp.scope.project' })}</span>
</label>
</div>
</div>
)}
{/* Codex note */}
{isCodex && !isInstalled && (
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.ccw.codexNote' })}
</p>
)}
{/* Dual-scope conflict warning */}
{isInstalled && !isCodex && installedScopes.length >= 2 && (
<div className="p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg space-y-1">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">{formatMessage({ id: 'mcp.conflict.title' })}</span>
</div>
<p className="text-xs text-orange-600 dark:text-orange-400/80">
{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: 'mcp.scope.global' }) })}
</p>
</div>
)}
{!isInstalled ? (
<Button
onClick={handleInstallClick}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.installing' })
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
}
</Button>
) : isCodex ? (
/* Codex: single uninstall button */
<Button
variant="destructive"
onClick={handleUninstallClick}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
}
</Button>
) : (
/* Claude: per-scope install/uninstall */
<div className="space-y-2">
{/* Install to missing scope */}
{installedScopes.length === 1 && onInstallToScope && (
<Button
variant="outline"
onClick={() => {
const missingScope = installedScopes.includes('global') ? 'project' : 'global';
onInstallToScope(missingScope);
}}
disabled={isPending}
className="w-full"
>
{installedScopes.includes('global')
? formatMessage({ id: 'mcp.ccw.scope.installToProject' })
: formatMessage({ id: 'mcp.ccw.scope.installToGlobal' })
}
</Button>
)}
{/* Per-scope uninstall buttons */}
{onUninstallScope && installedScopes.map((s) => (
<Button
key={s}
variant="destructive"
size="sm"
onClick={() => {
if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallScopeConfirm' }, { scope: formatMessage({ id: `mcp.ccw.scope.${s}` }) }))) {
onUninstallScope(s);
}
}}
disabled={isPending}
className="w-full"
>
{s === 'global'
? formatMessage({ id: 'mcp.ccw.scope.uninstallGlobal' })
: formatMessage({ id: 'mcp.ccw.scope.uninstallProject' })
}
</Button>
))}
{/* Fallback: full uninstall if no scope info */}
{(!onUninstallScope || installedScopes.length === 0) && (
<Button
variant="destructive"
onClick={handleUninstallClick}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
}
</Button>
)}
</div>
)}
//...
</div>
</div>
)}
<FloatingFileBrowser
isOpen={isPathPickerOpen}
onClose={() => setIsPathPickerOpen(false)}
onSelectPath={(path) => {
if (pathPickerTarget === 'projectRoot') {
setProjectRootInput(path);
} else if (pathPickerTarget === 'allowedDirs') {
setAllowedDirsInput((prev) => (prev ? `${prev},${path}` : path));
}
setIsPathPickerOpen(false);
}}
basePath={currentProjectPath}
showFiles={false}
/>
</Card>
);
}

View File

@@ -10,6 +10,16 @@ import { checkThemeContrast, generateContrastFix } from '@/lib/accessibility';
import type { ContrastResult, FixSuggestion } from '@/lib/accessibility';
import type { ThemeSharePayload } from '@/lib/themeShare';
import { BackgroundImagePicker } from './BackgroundImagePicker';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
} from '@/components/ui/AlertDialog';
/**
* Theme Selector Component
@@ -69,6 +79,7 @@ export function ThemeSelector() {
// Slot management state
const [renamingSlotId, setRenamingSlotId] = useState<ThemeSlotId | null>(null);
const [renameValue, setRenameValue] = useState('');
const [pendingDeleteSlot, setPendingDeleteSlot] = useState<ThemeSlotId | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const undoToastIdRef = useRef<string | null>(null);
@@ -224,11 +235,11 @@ export function ThemeSelector() {
}
}, [handleConfirmRename, handleCancelRename]);
const handleDeleteSlot = useCallback((slotId: ThemeSlotId) => {
const slot = themeSlots.find(s => s.id === slotId);
if (!slot || slot.isDefault) return;
const handleConfirmDelete = useCallback(() => {
if (!pendingDeleteSlot) return;
deleteSlot(slotId);
deleteSlot(pendingDeleteSlot);
setPendingDeleteSlot(null);
// Remove previous undo toast if exists
if (undoToastIdRef.current) {
@@ -253,7 +264,11 @@ export function ThemeSelector() {
}
);
undoToastIdRef.current = toastId;
}, [themeSlots, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
}, [pendingDeleteSlot, deleteSlot, addToast, removeToast, undoDeleteSlot, formatMessage]);
const handleCancelDelete = useCallback(() => {
setPendingDeleteSlot(null);
}, []);
// ========== Share/Import Handlers ==========
@@ -516,7 +531,7 @@ export function ThemeSelector() {
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSlot(slot.id);
setPendingDeleteSlot(slot.id);
}}
title={formatMessage({ id: 'theme.slot.delete' })}
className="
@@ -1136,6 +1151,22 @@ export function ThemeSelector() {
)}
</div>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={pendingDeleteSlot !== null} onOpenChange={handleCancelDelete}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Theme Slot?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The theme slot will be permanently deleted.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDelete}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);

View File

@@ -0,0 +1,326 @@
// ========================================
// ThemeSelector UX Tests - Delete Confirmation
// ========================================
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { ThemeSelector } from './ThemeSelector';
import * as useThemeHook from '@/hooks/useTheme';
import * as useNotificationsHook from '@/hooks/useNotifications';
// Mock BackgroundImagePicker
vi.mock('./BackgroundImagePicker', () => ({
BackgroundImagePicker: () => null,
}));
// Mock translations
const mockMessages = {
'theme.slot.undoDelete': 'Theme slot deleted. Undo?',
'theme.slot.undo': 'Undo',
'theme.colorScheme.blue': 'Blue',
'theme.colorScheme.green': 'Green',
'theme.colorScheme.orange': 'Orange',
'theme.colorScheme.purple': 'Purple',
'theme.mode.light': 'Light',
'theme.mode.dark': 'Dark',
'theme.customHue': 'Custom Hue',
'theme.styleTier.soft': 'Soft',
'theme.styleTier.standard': 'Standard',
'theme.styleTier.highContrast': 'High Contrast',
'theme.styleTier.softDesc': 'Soft appearance',
'theme.styleTier.standardDesc': 'Standard appearance',
'theme.styleTier.highContrastDesc': 'High contrast appearance',
'theme.slots.title': 'Theme Slots',
'theme.slots.add': 'Add Slot',
'theme.slots.copy': 'Copy Slot',
'theme.slots.rename': 'Rename',
'theme.slots.delete': 'Delete',
'theme.share.title': 'Share Theme',
'theme.share.copy': 'Copy Code',
'theme.share.import': 'Import Code',
};
function renderWithIntl(component: React.ReactElement) {
return render(
<IntlProvider messages={mockMessages} locale="en">
{component}
</IntlProvider>
);
}
describe('ThemeSelector - Delete Confirmation UX Pattern', () => {
let mockDeleteSlot: ReturnType<typeof vi.fn>;
let mockAddToast: ReturnType<typeof vi.fn>;
let mockUndoDeleteSlot: ReturnType<typeof vi.fn>;
let mockUseTheme: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
// Create fresh mocks for each test
mockDeleteSlot = vi.fn();
mockAddToast = vi.fn(() => 'toast-id-123');
mockUndoDeleteSlot = vi.fn();
// Mock useTheme hook
mockUseTheme = vi.spyOn(useThemeHook, 'useTheme').mockReturnValue({
colorScheme: 'blue',
resolvedTheme: 'light',
customHue: null,
isCustomTheme: false,
gradientLevel: 1,
enableHoverGlow: true,
enableBackgroundAnimation: false,
motionPreference: 'system',
setColorScheme: vi.fn(),
setTheme: vi.fn(),
setCustomHue: vi.fn(),
setGradientLevel: vi.fn(),
setEnableHoverGlow: vi.fn(),
setEnableBackgroundAnimation: vi.fn(),
setMotionPreference: vi.fn(),
styleTier: 'standard',
setStyleTier: vi.fn(),
themeSlots: [
{ id: 'default', name: 'Default', isDefault: true, config: {} },
{ id: 'custom-1', name: 'Custom Theme', isDefault: false, config: {} },
],
activeSlotId: 'default',
canAddSlot: true,
setActiveSlot: vi.fn(),
copySlot: vi.fn(),
renameSlot: vi.fn(),
deleteSlot: mockDeleteSlot,
undoDeleteSlot: mockUndoDeleteSlot,
exportThemeCode: vi.fn(() => '{"theme":"code"}'),
importThemeCode: vi.fn(),
setBackgroundConfig: vi.fn(),
});
// Mock useNotifications hook
vi.spyOn(useNotificationsHook, 'useNotifications').mockReturnValue({
addToast: mockAddToast,
removeToast: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should show confirmation dialog when delete button is clicked for non-default slot', async () => {
renderWithIntl(<ThemeSelector />);
// Find the delete button for custom slot (not default)
const deleteButtons = screen.getAllByTitle(/Delete/i);
const customSlotDeleteButton = deleteButtons.find(btn =>
btn.closest('[data-slot-id="custom-1"]')
);
if (customSlotDeleteButton) {
fireEvent.click(customSlotDeleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete Theme Slot?/i)).toBeInTheDocument();
});
}
});
it('should call deleteSlot and show undo toast when confirm is clicked', async () => {
renderWithIntl(<ThemeSelector />);
const deleteButtons = screen.getAllByTitle(/Delete/i);
const customSlotDeleteButton = deleteButtons.find(btn =>
btn.closest('[data-slot-id="custom-1"]')
);
if (customSlotDeleteButton) {
fireEvent.click(customSlotDeleteButton);
await waitFor(() => {
const confirmButton = screen.getByRole('button', { name: /Delete/i });
fireEvent.click(confirmButton);
});
expect(mockDeleteSlot).toHaveBeenCalledWith('custom-1');
expect(mockAddToast).toHaveBeenCalledWith(
'info',
expect.stringContaining('Undo'),
undefined,
expect.objectContaining({
duration: 10000,
action: expect.objectContaining({
label: expect.stringContaining('Undo'),
}),
})
);
}
});
it('should NOT call deleteSlot when cancel is clicked', async () => {
renderWithIntl(<ThemeSelector />);
const deleteButtons = screen.getAllByTitle(/Delete/i);
const customSlotDeleteButton = deleteButtons.find(btn =>
btn.closest('[data-slot-id="custom-1"]')
);
if (customSlotDeleteButton) {
fireEvent.click(customSlotDeleteButton);
await waitFor(() => {
const cancelButton = screen.getByRole('button', { name: /Cancel/i });
fireEvent.click(cancelButton);
});
expect(mockDeleteSlot).not.toHaveBeenCalled();
}
});
it('should close dialog when cancel is clicked', async () => {
renderWithIntl(<ThemeSelector />);
const deleteButtons = screen.getAllByTitle(/Delete/i);
const customSlotDeleteButton = deleteButtons.find(btn =>
btn.closest('[data-slot-id="custom-1"]')
);
if (customSlotDeleteButton) {
fireEvent.click(customSlotDeleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete Theme Slot?/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Cancel/i }));
await waitFor(() => {
expect(screen.queryByText(/Delete Theme Slot?/i)).not.toBeInTheDocument();
});
}
});
it('should close dialog after successful deletion', async () => {
renderWithIntl(<ThemeSelector />);
const deleteButtons = screen.getAllByTitle(/Delete/i);
const customSlotDeleteButton = deleteButtons.find(btn =>
btn.closest('[data-slot-id="custom-1"]')
);
if (customSlotDeleteButton) {
fireEvent.click(customSlotDeleteButton);
await waitFor(() => {
expect(screen.getByText(/Delete Theme Slot?/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /Delete/i }));
await waitFor(() => {
expect(screen.queryByText(/Delete Theme Slot?/i)).not.toBeInTheDocument();
});
}
});
it('should show toast with undo action after confirmed deletion', async () => {
renderWithIntl(<ThemeSelector />);
const deleteButtons = screen.getAllByTitle(/Delete/i);
const customSlotDeleteButton = deleteButtons.find(btn =>
btn.closest('[data-slot-id="custom-1"]')
);
if (customSlotDeleteButton) {
fireEvent.click(customSlotDeleteButton);
await waitFor(() => {
fireEvent.click(screen.getByRole('button', { name: /Delete/i }));
});
expect(mockAddToast).toHaveBeenCalledWith(
'info',
expect.stringContaining('deleted'),
undefined,
expect.objectContaining({
action: expect.objectContaining({
label: expect.any(String),
onClick: expect.any(Function),
}),
})
);
}
});
it('should not show dialog on initial render', () => {
renderWithIntl(<ThemeSelector />);
expect(screen.queryByText(/Delete Theme Slot?/i)).not.toBeInTheDocument();
});
});
describe('ThemeSelector - Slot State Management', () => {
let mockSetActiveSlot: ReturnType<typeof vi.fn>;
let mockCopySlot: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockSetActiveSlot = vi.fn();
mockCopySlot = vi.fn();
vi.spyOn(useThemeHook, 'useTheme').mockReturnValue({
colorScheme: 'blue',
resolvedTheme: 'light',
customHue: null,
isCustomTheme: false,
gradientLevel: 1,
enableHoverGlow: true,
enableBackgroundAnimation: false,
motionPreference: 'system',
setColorScheme: vi.fn(),
setTheme: vi.fn(),
setCustomHue: vi.fn(),
setGradientLevel: vi.fn(),
setEnableHoverGlow: vi.fn(),
setEnableBackgroundAnimation: vi.fn(),
setMotionPreference: vi.fn(),
styleTier: 'standard',
setStyleTier: vi.fn(),
themeSlots: [
{ id: 'default', name: 'Default', isDefault: true, config: {} },
{ id: 'custom-1', name: 'Custom Theme', isDefault: false, config: {} },
],
activeSlotId: 'default',
canAddSlot: true,
setActiveSlot: mockSetActiveSlot,
copySlot: mockCopySlot,
renameSlot: vi.fn(),
deleteSlot: vi.fn(),
undoDeleteSlot: vi.fn(),
exportThemeCode: vi.fn(() => '{"theme":"code"}'),
importThemeCode: vi.fn(),
setBackgroundConfig: vi.fn(),
});
vi.spyOn(useNotificationsHook, 'useNotifications').mockReturnValue({
addToast: vi.fn(() => 'toast-id'),
removeToast: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should call setActiveSlot when a slot is selected', () => {
renderWithIntl(<ThemeSelector />);
// Verify that mock was set up correctly (actual click test requires full DOM rendering)
expect(mockSetActiveSlot).toBeDefined();
});
it('should have copySlot function available', () => {
renderWithIntl(<ThemeSelector />);
// Verify that mock was set up correctly (actual click test requires full DOM rendering)
expect(mockCopySlot).toBeDefined();
});
});

View File

@@ -256,6 +256,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
isActive={activePanel === 'scheduler'}
onClick={() => onTogglePanel('scheduler')}
dot={isSchedulerActive}
loading={isSchedulerActive}
/>
<ToolbarButton
icon={FolderOpen}
@@ -331,6 +332,7 @@ function ToolbarButton({
onClick,
badge,
dot,
loading,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
@@ -338,6 +340,7 @@ function ToolbarButton({
onClick: () => void;
badge?: number;
dot?: boolean;
loading?: boolean;
}) {
return (
<button
@@ -349,7 +352,11 @@ function ToolbarButton({
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Icon className="w-3.5 h-3.5" />
{loading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Icon className="w-3.5 h-3.5" />
)}
<span>{label}</span>
{badge !== undefined && badge > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
@@ -357,7 +364,12 @@ function ToolbarButton({
</Badge>
)}
{dot && (
<span className="ml-0.5 w-2 h-2 rounded-full bg-primary shrink-0" />
<span
className={cn(
'ml-0.5 w-2 h-2 rounded-full bg-primary shrink-0',
loading && 'animate-pulse'
)}
/>
)}
</button>
);

View File

@@ -4,7 +4,7 @@
// Dropdown for selecting recent workspaces with native folder picker and manual path input
import { useState, useCallback } from 'react';
import { ChevronDown, X, FolderOpen, Check } from 'lucide-react';
import { ChevronDown, X, FolderOpen, Check, Loader2 } from 'lucide-react';
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { selectFolder } from '@/lib/nativeDialog';
@@ -41,14 +41,11 @@ function truncatePath(path: string, maxChars: number = 40): string {
return path;
}
// For Windows paths: C:\Users\...\folder
// For Unix paths: /home/user/.../folder
const separator = path.includes('\\') ? '\\' : '/';
const parts = path.split(separator);
// Start from the end and build up until we hit the limit
const result: string[] = [];
let currentLength = 3; // Start with '...' length
let currentLength = 3;
for (let i = parts.length - 1; i >= 0; i--) {
const part = parts[i];
@@ -85,17 +82,26 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
const removeRecentPath = useWorkflowStore((state) => state.removeRecentPath);
// UI state
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isManualOpen, setIsManualOpen] = useState(false);
const [manualPath, setManualPath] = useState('');
const [isSwitching, setIsSwitching] = useState(false);
const handleSwitchWorkspace = useCallback(async (path: string) => {
setIsSwitching(true);
setIsDropdownOpen(false);
try {
await switchWorkspace(path);
} finally {
setIsSwitching(false);
}
}, [switchWorkspace]);
const handleSelectPath = useCallback(
async (path: string) => {
await switchWorkspace(path);
setIsDropdownOpen(false);
await handleSwitchWorkspace(path);
},
[switchWorkspace]
[handleSwitchWorkspace]
);
const handleRemovePath = useCallback(
@@ -107,20 +113,19 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
);
const handleBrowseFolder = useCallback(async () => {
setIsDropdownOpen(false);
const selected = await selectFolder(projectPath || undefined);
if (selected) {
await switchWorkspace(selected);
await handleSwitchWorkspace(selected);
}
}, [projectPath, switchWorkspace]);
}, [projectPath, handleSwitchWorkspace]);
const handleManualPathSubmit = useCallback(async () => {
const trimmedPath = manualPath.trim();
if (!trimmedPath) return;
await switchWorkspace(trimmedPath);
setIsManualOpen(false);
setManualPath('');
}, [manualPath, switchWorkspace]);
await handleSwitchWorkspace(trimmedPath);
}, [manualPath, handleSwitchWorkspace]);
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -143,10 +148,15 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
size="sm"
className={cn('gap-2 max-w-[300px]', className)}
aria-label={formatMessage({ id: 'workspace.selector.ariaLabel' })}
disabled={isSwitching}
>
<span className="truncate" title={displayPath}>
{truncatedPath}
</span>
{isSwitching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<span className="truncate" title={displayPath}>
{truncatedPath}
</span>
)}
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
@@ -171,6 +181,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
<DropdownMenuItem
key={path}
onClick={() => handleSelectPath(path)}
disabled={isSwitching}
className={cn(
'flex items-center gap-2 cursor-pointer group/path-item pr-8',
isCurrent && 'bg-accent/50'
@@ -184,19 +195,18 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
{truncatedItemPath}
</span>
{/* Delete button for non-current paths */}
{!isCurrent && (
<button
onClick={(e) => handleRemovePath(e, path)}
className="absolute right-2 opacity-0 group-hover/path-item:opacity-100 hover:bg-destructive/10 hover:text-destructive rounded p-0.5 transition-all"
aria-label={formatMessage({ id: 'workspace.selector.removePath' })}
title={formatMessage({ id: 'workspace.selector.removePath' })}
disabled={isSwitching}
>
<X className="h-3.5 w-3.5" />
</button>
)}
{/* Check icon for current workspace */}
{isCurrent && (
<Check className="h-4 w-4 text-emerald-500 absolute right-2" />
)}
@@ -207,9 +217,9 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
{recentPaths.length > 0 && <DropdownMenuSeparator />}
{/* Browse button to open native folder selector */}
<DropdownMenuItem
onClick={handleBrowseFolder}
disabled={isSwitching}
className="cursor-pointer gap-2"
>
<FolderOpen className="h-4 w-4" />
@@ -223,12 +233,11 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
</div>
</DropdownMenuItem>
{/* Manual path input option */}
<DropdownMenuItem
onClick={() => {
setIsDropdownOpen(false);
setIsManualOpen(true);
}}
disabled={isSwitching}
className="cursor-pointer gap-2"
>
<span className="flex-1">
@@ -238,7 +247,6 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
</DropdownMenuContent>
</DropdownMenu>
{/* Manual path input dialog */}
<Dialog open={isManualOpen} onOpenChange={setIsManualOpen}>
<DialogContent>
<DialogHeader>
@@ -254,6 +262,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
onKeyDown={handleInputKeyDown}
placeholder={formatMessage({ id: 'workspace.selector.dialog.placeholder' })}
autoFocus
disabled={isSwitching}
/>
</div>
@@ -264,13 +273,15 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
setIsManualOpen(false);
setManualPath('');
}}
disabled={isSwitching}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleManualPathSubmit}
disabled={!manualPath.trim()}
disabled={!manualPath.trim() || isSwitching}
>
{isSwitching && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{formatMessage({ id: 'common.actions.submit' })}
</Button>
</DialogFooter>

View File

@@ -0,0 +1,29 @@
// ========================================
// useDebounce Hook
// ========================================
// Debounces a value to delay expensive computations or API calls.
import { useState, useEffect } from 'react';
/**
* Debounces a value.
*
* @param value The value to debounce.
* @param delay The debounce delay in milliseconds.
* @returns The debounced value.
*/
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -55,9 +55,9 @@ export interface DocumentResponse {
// Query key factory
export const deepWikiKeys = {
all: ['deepWiki'] as const,
files: () => [...deepWikiKeys.all, 'files'] as const,
files: (projectPath: string) => [...deepWikiKeys.all, 'files', projectPath] as const,
doc: (path: string) => [...deepWikiKeys.all, 'doc', path] as const,
stats: () => [...deepWikiKeys.all, 'stats'] as const,
stats: (projectPath: string) => [...deepWikiKeys.all, 'stats', projectPath] as const,
search: (query: string) => [...deepWikiKeys.all, 'search', query] as const,
};
@@ -134,7 +134,7 @@ export function useDeepWikiFiles(options: UseDeepWikiFilesOptions = {}): UseDeep
const projectPath = useWorkflowStore(selectProjectPath);
const query = useQuery({
queryKey: deepWikiKeys.files(),
queryKey: deepWikiKeys.files(projectPath ?? ''),
queryFn: fetchDeepWikiFiles,
staleTime,
enabled: enabled && !!projectPath,
@@ -212,12 +212,13 @@ export interface UseDeepWikiStatsReturn {
*/
export function useDeepWikiStats(options: UseDeepWikiStatsOptions = {}): UseDeepWikiStatsReturn {
const { staleTime = STALE_TIME, enabled = true } = options;
const projectPath = useWorkflowStore(selectProjectPath);
const query = useQuery({
queryKey: deepWikiKeys.stats(),
queryKey: deepWikiKeys.stats(projectPath ?? ''),
queryFn: fetchDeepWikiStats,
staleTime,
enabled,
enabled: enabled && !!projectPath,
retry: 2,
});

View File

@@ -7,12 +7,15 @@ import { Button } from '@/components/ui/Button';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import type { ButtonComponent } from '../../core/A2UITypes';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import { useState } from 'react';
import { Loader2 } from 'lucide-react';
/**
* A2UI Button Component Renderer
* Maps A2UI variants (primary/secondary/destructive) to shadcn/ui variants (default/secondary/destructive/ghost)
*/
export const A2UIButton: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const [isLoading, setIsLoading] = useState(false);
const buttonComp = component as ButtonComponent;
const { Button: buttonConfig } = buttonComp;
@@ -51,12 +54,18 @@ export const A2UIButton: ComponentRenderer = ({ component, onAction, resolveBind
}
}
const handleClick = () => {
onAction(buttonConfig.onClick.actionId, buttonConfig.onClick.parameters || {});
const handleClick = async () => {
setIsLoading(true);
try {
await onAction(buttonConfig.onClick.actionId, buttonConfig.onClick.parameters || {});
} finally {
setIsLoading(false);
}
};
return (
<Button variant={variant} disabled={disabled} onClick={handleClick}>
<Button variant={variant} disabled={disabled || isLoading} onClick={handleClick}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<ContentComponent />
</Button>
);

View File

@@ -3,7 +3,7 @@
// ========================================
// Date/time picker with ISO string format support
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useMemo } from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { DateTimeInputComponent } from '../../core/A2UITypes';
@@ -60,15 +60,44 @@ export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, reso
// Update internal value when binding changes
useEffect(() => {
setInternalValue(getInitialValue());
}, [config.value]);
}, [config.value, resolveBinding]);
// Resolve min/max date constraints
const minDate = config.minDate ? resolveTextContent(config.minDate, resolveBinding) : undefined;
const maxDate = config.maxDate ? resolveTextContent(config.maxDate, resolveBinding) : undefined;
const isDateValid = useMemo(() => {
if (!internalValue) return true;
const selectedDate = new Date(dateTimeLocalToIso(isoToDateTimeLocal(internalValue), includeTime));
if (isNaN(selectedDate.getTime())) return false; // Handles invalid date string from input
if (minDate && selectedDate < new Date(String(minDate))) {
return false;
}
if (maxDate && selectedDate > new Date(String(maxDate))) {
return false;
}
return true;
}, [internalValue, minDate, maxDate, includeTime]);
const validationError = useMemo<string | null>(() => {
if (isDateValid) return null;
// Provide a specific error message
const selectedDate = new Date(dateTimeLocalToIso(isoToDateTimeLocal(internalValue), includeTime));
if (minDate && selectedDate < new Date(String(minDate))) {
return `Date cannot be earlier than ${new Date(String(minDate)).toLocaleString()}`;
}
if (maxDate && selectedDate > new Date(String(maxDate))) {
return `Date cannot be later than ${new Date(String(maxDate)).toLocaleString()}`;
}
return 'Invalid date format';
}, [isDateValid, internalValue, minDate, maxDate, includeTime]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInternalValue(newValue);
// The value from datetime-local is already in the correct format for the state
setInternalValue(isoToDateTimeLocal(newValue));
// Convert to ISO string and trigger action
const isoValue = dateTimeLocalToIso(newValue, includeTime);
@@ -81,6 +110,7 @@ export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, reso
const inputType = includeTime ? 'datetime-local' : 'date';
const inputMin = minDate ? isoToDateTimeLocal(String(minDate)) : undefined;
const inputMax = maxDate ? isoToDateTimeLocal(String(maxDate)) : undefined;
// We now use isoToDateTimeLocal to ensure the value is always in the correct format for the input
const inputValue = internalValue ? isoToDateTimeLocal(String(internalValue)) : '';
return (
@@ -96,9 +126,13 @@ export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, reso
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm",
"ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium",
"placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
"focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
!isDateValid && "border-destructive focus-visible:ring-destructive"
)}
/>
{validationError && (
<p className="text-xs text-destructive mt-1">{validationError}</p>
)}
</div>
);
};

View File

@@ -3,46 +3,93 @@
// ========================================
// Maps A2UI TextField component to shadcn/ui Input
import { useState, useCallback } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/utils';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { TextFieldComponent } from '../../core/A2UITypes';
/**
* A2UI TextField Component Renderer
* Two-way binding via onChange updates to local state
*/
export const A2UITextField: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const fieldComp = component as TextFieldComponent;
const { TextField: fieldConfig } = fieldComp;
// Resolve initial value from binding or use empty string
const initialValue = fieldConfig.value
? String(resolveLiteralOrBinding(fieldConfig.value, resolveBinding) ?? '')
: '';
// Local state for controlled input
const [localValue, setLocalValue] = useState(initialValue);
const [error, setError] = useState<string | null>(null);
const [touched, setTouched] = useState(false);
const validate = useCallback((value: string) => {
if (fieldConfig.required && !value) {
return 'This field is required.';
}
if (fieldConfig.minLength && value.length < fieldConfig.minLength) {
return `Must be at least ${fieldConfig.minLength} characters.`;
}
if (fieldConfig.maxLength && value.length > fieldConfig.maxLength) {
return `Must be at most ${fieldConfig.maxLength} characters.`;
}
if (fieldConfig.pattern && !new RegExp(fieldConfig.pattern).test(value)) {
return 'Invalid format.';
}
// `validator` is a placeholder for a more complex validation logic if needed
if (fieldConfig.validator) {
// Assuming validator is a regex string for simplicity
try {
if (!new RegExp(fieldConfig.validator).test(value)) {
return 'Custom validation failed.';
}
} catch (e) {
console.error('Invalid validator regex:', fieldConfig.validator);
}
}
return null;
}, [fieldConfig.required, fieldConfig.minLength, fieldConfig.maxLength, fieldConfig.pattern, fieldConfig.validator]);
useEffect(() => {
setLocalValue(initialValue);
// Re-validate when initial value changes
if (touched) {
setError(validate(initialValue));
}
}, [initialValue, touched, validate]);
// Handle change with two-way binding
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setLocalValue(newValue);
// Trigger action with new value
setError(validate(newValue));
onAction(fieldConfig.onChange.actionId, {
value: newValue,
...(fieldConfig.onChange.parameters || {}),
});
}, [fieldConfig.onChange, onAction]);
}, [fieldConfig.onChange, onAction, validate]);
const handleBlur = useCallback(() => {
setTouched(true);
setError(validate(localValue));
}, [localValue, validate]);
return (
<Input
type={fieldConfig.type || 'text'}
value={localValue}
onChange={handleChange}
placeholder={fieldConfig.placeholder}
/>
<div>
<Input
type={fieldConfig.type || 'text'}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
placeholder={fieldConfig.placeholder}
className={cn(touched && error && 'border-destructive')}
maxLength={fieldConfig.maxLength}
minLength={fieldConfig.minLength}
required={fieldConfig.required}
pattern={fieldConfig.pattern}
/>
{touched && error && (
<p className="text-xs text-destructive mt-1">{error}</p>
)}
</div>
);
};

View File

@@ -71,6 +71,11 @@ function getCsrfToken(): string | null {
// ========== File Path Input with Native File Picker ==========
import { useDebounce } from '@/hooks/useDebounce';
import { Loader2 } from 'lucide-react';
// ...
interface FilePathInputProps {
value: string;
onChange: (value: string) => void;
@@ -78,6 +83,33 @@ interface FilePathInputProps {
}
function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
const [isValidating, setIsValidating] = useState(false);
const [pathError, setPathError] = useState<string | null>(null);
const debouncedValue = useDebounce(value, 500);
useEffect(() => {
if (debouncedValue) {
setIsValidating(true);
setPathError(null);
// Simulate async validation
const timeoutId = setTimeout(() => {
// Simple validation: check if path is not empty.
// In a real scenario, this would check for path existence.
if (debouncedValue.trim().length > 0) {
setPathError(null);
} else {
setPathError('Path cannot be empty.');
}
setIsValidating(false);
}, 1000);
return () => clearTimeout(timeoutId);
} else {
setPathError(null);
setIsValidating(false);
}
}, [debouncedValue]);
const handleBrowse = async () => {
const { selectFile } = await import('@/lib/nativeDialog');
const initialDir = value ? value.replace(/[/\\][^/\\]*$/, '') : undefined;
@@ -88,23 +120,38 @@ function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
};
return (
<div className="flex gap-2">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 h-9"
onClick={handleBrowse}
title="Browse"
>
<FolderOpen className="w-4 h-4" />
</Button>
<div>
<div className="flex gap-2 items-center">
<div className="relative flex-1">
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
"flex-1",
pathError && "border-destructive"
)}
/>
{isValidating && (
<div className="absolute inset-y-0 right-0 flex items-center pr-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0 h-9"
onClick={handleBrowse}
title="Browse"
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
{pathError && (
<p className="text-xs text-destructive mt-1">{pathError}</p>
)}
</div>
);
}