Add comprehensive tests for ast-grep and tree-sitter relationship extraction

- Introduced test suite for AstGrepPythonProcessor covering pattern definitions, parsing, and relationship extraction.
- Added comparison tests between tree-sitter and ast-grep for consistency in relationship extraction.
- Implemented tests for ast-grep binding module to verify functionality and availability.
- Ensured tests cover various scenarios including inheritance, function calls, and imports.
This commit is contained in:
catlog22
2026-02-15 21:14:14 +08:00
parent 126a357aa2
commit 48a6a1f2aa
56 changed files with 10622 additions and 374 deletions

View File

@@ -704,7 +704,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1;
return (
<div
key={task.task_id}
key={`${currentSession.session_id}-${task.task_id}`}
className={cn(
'flex items-center gap-2 p-2 rounded hover:bg-background/50 transition-colors',
isLastOdd && 'col-span-2'

View File

@@ -0,0 +1,396 @@
// ========================================
// Platform Configuration Cards
// ========================================
// Individual configuration cards for each notification platform
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
MessageCircle,
Send,
Link,
Check,
X,
ChevronDown,
ChevronUp,
TestTube,
Eye,
EyeOff,
} 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 {
RemoteNotificationConfig,
NotificationPlatform,
DiscordConfig,
TelegramConfig,
WebhookConfig,
} from '@/types/remote-notification';
import { PLATFORM_INFO } from '@/types/remote-notification';
interface PlatformConfigCardsProps {
config: RemoteNotificationConfig;
expandedPlatform: NotificationPlatform | null;
testing: NotificationPlatform | null;
onToggleExpand: (platform: NotificationPlatform | null) => void;
onUpdateConfig: (
platform: NotificationPlatform,
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
) => void;
onTest: (
platform: NotificationPlatform,
config: DiscordConfig | TelegramConfig | WebhookConfig
) => void;
onSave: () => void;
saving: boolean;
}
export function PlatformConfigCards({
config,
expandedPlatform,
testing,
onToggleExpand,
onUpdateConfig,
onTest,
onSave,
saving,
}: PlatformConfigCardsProps) {
const { formatMessage } = useIntl();
const platforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
const getPlatformIcon = (platform: NotificationPlatform) => {
switch (platform) {
case 'discord':
return <MessageCircle className="w-4 h-4" />;
case 'telegram':
return <Send className="w-4 h-4" />;
case 'webhook':
return <Link className="w-4 h-4" />;
}
};
const getPlatformConfig = (
platform: NotificationPlatform
): DiscordConfig | TelegramConfig | WebhookConfig => {
switch (platform) {
case 'discord':
return config.platforms.discord || { enabled: false, webhookUrl: '' };
case 'telegram':
return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' };
case 'webhook':
return config.platforms.webhook || { enabled: false, url: '', method: 'POST' };
}
};
const isConfigured = (platform: NotificationPlatform): boolean => {
const platformConfig = getPlatformConfig(platform);
switch (platform) {
case 'discord':
return !!(platformConfig as DiscordConfig).webhookUrl;
case 'telegram':
return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId;
case 'webhook':
return !!(platformConfig as WebhookConfig).url;
}
};
return (
<div className="grid gap-3">
{platforms.map((platform) => {
const info = PLATFORM_INFO[platform];
const platformConfig = getPlatformConfig(platform);
const configured = isConfigured(platform);
const expanded = expandedPlatform === platform;
return (
<Card key={platform} className="overflow-hidden">
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => onToggleExpand(expanded ? null : platform)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
platformConfig.enabled && configured
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground'
)}>
{getPlatformIcon(platform)}
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{info.name}</span>
{configured && (
<Badge variant="outline" className="text-xs text-green-600 border-green-500/30">
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'settings.remoteNotifications.configured' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">{info.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant={platformConfig.enabled ? 'default' : 'outline'}
size="sm"
className="h-7"
onClick={(e) => {
e.stopPropagation();
onUpdateConfig(platform, { enabled: !platformConfig.enabled });
}}
>
{platformConfig.enabled ? (
<Check className="w-3.5 h-3.5" />
) : (
<X className="w-3.5 h-3.5" />
)}
</Button>
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Expanded Content */}
{expanded && (
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
{platform === 'discord' && (
<DiscordConfigForm
config={platformConfig as DiscordConfig}
onUpdate={(updates) => onUpdateConfig('discord', updates)}
/>
)}
{platform === 'telegram' && (
<TelegramConfigForm
config={platformConfig as TelegramConfig}
onUpdate={(updates) => onUpdateConfig('telegram', updates)}
/>
)}
{platform === 'webhook' && (
<WebhookConfigForm
config={platformConfig as WebhookConfig}
onUpdate={(updates) => onUpdateConfig('webhook', updates)}
/>
)}
{/* Action Buttons */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={() => onTest(platform, platformConfig)}
disabled={testing === platform || !configured}
>
<TestTube className={cn('w-3.5 h-3.5 mr-1', testing === platform && 'animate-pulse')} />
{formatMessage({ id: 'settings.remoteNotifications.testConnection' })}
</Button>
<Button
variant="default"
size="sm"
onClick={onSave}
disabled={saving}
>
{formatMessage({ id: 'settings.remoteNotifications.save' })}
</Button>
</div>
</div>
)}
</Card>
);
})}
</div>
);
}
// ========== Discord Config Form ==========
function DiscordConfigForm({
config,
onUpdate,
}: {
config: DiscordConfig;
onUpdate: (updates: Partial<DiscordConfig>) => void;
}) {
const { formatMessage } = useIntl();
const [showUrl, setShowUrl] = useState(false);
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.discord.webhookUrl' })}
</label>
<div className="flex gap-2 mt-1">
<Input
type={showUrl ? 'text' : 'password'}
value={config.webhookUrl || ''}
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
placeholder="https://discord.com/api/webhooks/..."
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setShowUrl(!showUrl)}
>
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.discord.webhookUrlHint' })}
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.discord.username' })}
</label>
<Input
value={config.username || ''}
onChange={(e) => onUpdate({ username: e.target.value })}
placeholder="CCW Notification"
className="mt-1"
/>
</div>
</div>
);
}
// ========== Telegram Config Form ==========
function TelegramConfigForm({
config,
onUpdate,
}: {
config: TelegramConfig;
onUpdate: (updates: Partial<TelegramConfig>) => void;
}) {
const { formatMessage } = useIntl();
const [showToken, setShowToken] = useState(false);
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.telegram.botToken' })}
</label>
<div className="flex gap-2 mt-1">
<Input
type={showToken ? 'text' : 'password'}
value={config.botToken || ''}
onChange={(e) => onUpdate({ botToken: e.target.value })}
placeholder="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setShowToken(!showToken)}
>
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.telegram.botTokenHint' })}
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.telegram.chatId' })}
</label>
<Input
value={config.chatId || ''}
onChange={(e) => onUpdate({ chatId: e.target.value })}
placeholder="-1001234567890"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.telegram.chatIdHint' })}
</p>
</div>
</div>
);
}
// ========== Webhook Config Form ==========
function WebhookConfigForm({
config,
onUpdate,
}: {
config: WebhookConfig;
onUpdate: (updates: Partial<WebhookConfig>) => void;
}) {
const { formatMessage } = useIntl();
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.webhook.url' })}
</label>
<Input
value={config.url || ''}
onChange={(e) => onUpdate({ url: e.target.value })}
placeholder="https://your-server.com/webhook"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.webhook.method' })}
</label>
<div className="flex gap-2 mt-1">
<Button
variant={config.method === 'POST' ? 'default' : 'outline'}
size="sm"
onClick={() => onUpdate({ method: 'POST' })}
>
POST
</Button>
<Button
variant={config.method === 'PUT' ? 'default' : 'outline'}
size="sm"
onClick={() => onUpdate({ method: 'PUT' })}
>
PUT
</Button>
</div>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.webhook.headers' })}
</label>
<Input
value={config.headers ? JSON.stringify(config.headers) : ''}
onChange={(e) => {
try {
const headers = e.target.value ? JSON.parse(e.target.value) : undefined;
onUpdate({ headers });
} catch {
// Invalid JSON, ignore
}
}}
placeholder='{"Authorization": "Bearer token"}'
className="mt-1 font-mono text-xs"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.webhook.headersHint' })}
</p>
</div>
</div>
);
}
export default PlatformConfigCards;

View File

@@ -0,0 +1,347 @@
// ========================================
// Remote Notification Settings Section
// ========================================
// Configuration UI for remote notification platforms
import { useState, useEffect, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
Bell,
BellOff,
RefreshCw,
Check,
X,
ChevronDown,
ChevronUp,
TestTube,
Save,
AlertTriangle,
} 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 { toast } from 'sonner';
import type {
RemoteNotificationConfig,
NotificationPlatform,
EventConfig,
DiscordConfig,
TelegramConfig,
WebhookConfig,
} from '@/types/remote-notification';
import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification';
import { PlatformConfigCards } from './PlatformConfigCards';
interface RemoteNotificationSectionProps {
className?: string;
}
export function RemoteNotificationSection({ className }: RemoteNotificationSectionProps) {
const { formatMessage } = useIntl();
const [config, setConfig] = useState<RemoteNotificationConfig | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<NotificationPlatform | null>(null);
const [expandedPlatform, setExpandedPlatform] = useState<NotificationPlatform | null>(null);
// Load configuration
const loadConfig = useCallback(async () => {
setLoading(true);
try {
const response = await fetch('/api/notifications/remote/config');
if (response.ok) {
const data = await response.json();
setConfig(data);
} else {
// Use default config if not found
setConfig(getDefaultConfig());
}
} catch (error) {
console.error('Failed to load remote notification config:', error);
setConfig(getDefaultConfig());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadConfig();
}, [loadConfig]);
// Save configuration
const saveConfig = useCallback(async (newConfig: RemoteNotificationConfig) => {
setSaving(true);
try {
const response = await fetch('/api/notifications/remote/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newConfig),
});
if (response.ok) {
const data = await response.json();
setConfig(data.config);
toast.success(formatMessage({ id: 'settings.remoteNotifications.saved' }));
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
toast.error(formatMessage({ id: 'settings.remoteNotifications.saveError' }));
} finally {
setSaving(false);
}
}, [formatMessage]);
// Test platform
const testPlatform = useCallback(async (
platform: NotificationPlatform,
platformConfig: DiscordConfig | TelegramConfig | WebhookConfig
) => {
setTesting(platform);
try {
const response = await fetch('/api/notifications/remote/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platform, config: platformConfig }),
});
const result = await response.json();
if (result.success) {
toast.success(
formatMessage({ id: 'settings.remoteNotifications.testSuccess' }),
{ description: `${result.responseTime}ms` }
);
} else {
toast.error(
formatMessage({ id: 'settings.remoteNotifications.testFailed' }),
{ description: result.error }
);
}
} catch (error) {
toast.error(formatMessage({ id: 'settings.remoteNotifications.testError' }));
} finally {
setTesting(null);
}
}, [formatMessage]);
// Toggle master switch
const toggleEnabled = () => {
if (!config) return;
saveConfig({ ...config, enabled: !config.enabled });
};
// Update platform config
const updatePlatformConfig = (
platform: NotificationPlatform,
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
) => {
if (!config) return;
const newConfig = {
...config,
platforms: {
...config.platforms,
[platform]: {
...config.platforms[platform as keyof typeof config.platforms],
...updates,
},
},
};
setConfig(newConfig);
};
// Update event config
const updateEventConfig = (eventIndex: number, updates: Partial<EventConfig>) => {
if (!config) return;
const newEvents = [...config.events];
newEvents[eventIndex] = { ...newEvents[eventIndex], ...updates };
setConfig({ ...config, events: newEvents });
};
// Reset to defaults
const resetConfig = async () => {
if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) {
return;
}
try {
const response = await fetch('/api/notifications/remote/reset', {
method: 'POST',
});
if (response.ok) {
const data = await response.json();
setConfig(data.config);
toast.success(formatMessage({ id: 'settings.remoteNotifications.resetSuccess' }));
}
} catch {
toast.error(formatMessage({ id: 'settings.remoteNotifications.resetError' }));
}
};
if (loading) {
return (
<Card className={cn('p-6', className)}>
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
</div>
</Card>
);
}
if (!config) {
return null;
}
return (
<Card className={cn('p-6', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
{config.enabled ? (
<Bell className="w-5 h-5 text-primary" />
) : (
<BellOff className="w-5 h-5 text-muted-foreground" />
)}
{formatMessage({ id: 'settings.remoteNotifications.title' })}
</h2>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadConfig()}
disabled={loading}
>
<RefreshCw className={cn('w-3.5 h-3.5', loading && 'animate-spin')} />
</Button>
<Button
variant={config.enabled ? 'default' : 'outline'}
size="sm"
onClick={toggleEnabled}
>
{config.enabled ? (
<>
<Check className="w-4 h-4 mr-1" />
{formatMessage({ id: 'settings.remoteNotifications.enabled' })}
</>
) : (
<>
<X className="w-4 h-4 mr-1" />
{formatMessage({ id: 'settings.remoteNotifications.disabled' })}
</>
)}
</Button>
</div>
</div>
{/* Description */}
<p className="text-sm text-muted-foreground mb-6">
{formatMessage({ id: 'settings.remoteNotifications.description' })}
</p>
{config.enabled && (
<>
{/* Platform Configuration */}
<div className="space-y-4 mb-6">
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.platforms' })}
</h3>
<PlatformConfigCards
config={config}
expandedPlatform={expandedPlatform}
testing={testing}
onToggleExpand={setExpandedPlatform}
onUpdateConfig={updatePlatformConfig}
onTest={testPlatform}
onSave={() => saveConfig(config)}
saving={saving}
/>
</div>
{/* Event Configuration */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.events' })}
</h3>
<div className="grid gap-3">
{config.events.map((eventConfig, index) => {
const info = EVENT_INFO[eventConfig.event];
return (
<div
key={eventConfig.event}
className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/30"
>
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
eventConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}>
<span className="text-sm">{info.icon}</span>
</div>
<div>
<p className="text-sm font-medium">{info.name}</p>
<p className="text-xs text-muted-foreground">{info.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Platform badges */}
<div className="flex gap-1">
{eventConfig.platforms.map((platform) => (
<Badge key={platform} variant="secondary" className="text-xs">
{PLATFORM_INFO[platform].name}
</Badge>
))}
{eventConfig.platforms.length === 0 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
</Badge>
)}
</div>
{/* Toggle */}
<Button
variant={eventConfig.enabled ? 'default' : 'outline'}
size="sm"
className="h-7"
onClick={() => updateEventConfig(index, { enabled: !eventConfig.enabled })}
>
{eventConfig.enabled ? (
<Check className="w-3.5 h-3.5" />
) : (
<X className="w-3.5 h-3.5" />
)}
</Button>
</div>
</div>
);
})}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between mt-6 pt-4 border-t border-border">
<Button
variant="outline"
size="sm"
onClick={resetConfig}
>
{formatMessage({ id: 'settings.remoteNotifications.reset' })}
</Button>
<Button
variant="default"
size="sm"
onClick={() => saveConfig(config)}
disabled={saving}
>
<Save className="w-4 h-4 mr-1" />
{saving
? formatMessage({ id: 'settings.remoteNotifications.saving' })
: formatMessage({ id: 'settings.remoteNotifications.save' })}
</Button>
</div>
</>
)}
</Card>
);
}
export default RemoteNotificationSection;

View File

@@ -0,0 +1,270 @@
// ========================================
// CliConfigModal Component
// ========================================
// Config modal for creating a new CLI session in Terminal Dashboard.
import * as React from 'react';
import { useIntl } from 'react-intl';
import { FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
export type CliTool = 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode';
export type LaunchMode = 'default' | 'yolo';
export type ShellKind = 'bash' | 'pwsh';
export interface CliSessionConfig {
tool: CliTool;
model?: string;
launchMode: LaunchMode;
preferredShell: ShellKind;
workingDir: string;
}
export interface CliConfigModalProps {
isOpen: boolean;
onClose: () => void;
defaultWorkingDir?: string | null;
onCreateSession: (config: CliSessionConfig) => Promise<void>;
}
const CLI_TOOLS: CliTool[] = ['claude', 'gemini', 'qwen', 'codex', 'opencode'];
const MODEL_OPTIONS: Record<CliTool, string[]> = {
claude: ['sonnet', 'haiku'],
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash'],
qwen: ['coder-model'],
codex: ['gpt-5.2'],
opencode: ['opencode/glm-4.7-free'],
};
const AUTO_MODEL_VALUE = '__auto__';
export function CliConfigModal({
isOpen,
onClose,
defaultWorkingDir,
onCreateSession,
}: CliConfigModalProps) {
const { formatMessage } = useIntl();
const [tool, setTool] = React.useState<CliTool>('gemini');
const [model, setModel] = React.useState<string | undefined>(MODEL_OPTIONS.gemini[0]);
const [launchMode, setLaunchMode] = React.useState<LaunchMode>('yolo');
const [preferredShell, setPreferredShell] = React.useState<ShellKind>('bash');
const [workingDir, setWorkingDir] = React.useState<string>(defaultWorkingDir ?? '');
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const modelOptions = React.useMemo(() => MODEL_OPTIONS[tool] ?? [], [tool]);
React.useEffect(() => {
if (!isOpen) return;
// Reset to a safe default each time the modal is opened.
const nextWorkingDir = defaultWorkingDir ?? '';
setWorkingDir(nextWorkingDir);
setError(null);
}, [isOpen, defaultWorkingDir]);
const handleToolChange = (nextTool: string) => {
const next = nextTool as CliTool;
setTool(next);
const nextModels = MODEL_OPTIONS[next] ?? [];
if (!model || !nextModels.includes(model)) {
setModel(nextModels[0]);
}
};
const handleBrowse = () => {
// Reserved for future file-picker integration
console.log('[CliConfigModal] browse working directory - not implemented');
};
const handleCreate = async () => {
const dir = workingDir.trim();
if (!dir) {
setError(formatMessage({ id: 'terminalDashboard.cliConfig.errors.workingDirRequired' }));
return;
}
setIsSubmitting(true);
setError(null);
try {
await onCreateSession({
tool,
model,
launchMode,
preferredShell,
workingDir: dir,
});
onClose();
} catch (err) {
console.error('[CliConfigModal] create session failed:', err);
setError(formatMessage({ id: 'terminalDashboard.cliConfig.errors.createFailed' }));
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-[720px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'terminalDashboard.cliConfig.title' })}</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'terminalDashboard.cliConfig.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Tool */}
<div className="space-y-2">
<Label htmlFor="cli-config-tool">
{formatMessage({ id: 'terminalDashboard.cliConfig.tool' })}
</Label>
<Select value={tool} onValueChange={handleToolChange} disabled={isSubmitting}>
<SelectTrigger id="cli-config-tool">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CLI_TOOLS.map((t) => (
<SelectItem key={t} value={t}>
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Model */}
<div className="space-y-2">
<Label htmlFor="cli-config-model">
{formatMessage({ id: 'terminalDashboard.cliConfig.model' })}
</Label>
<Select
value={model ?? AUTO_MODEL_VALUE}
onValueChange={(v) => setModel(v === AUTO_MODEL_VALUE ? undefined : v)}
disabled={isSubmitting}
>
<SelectTrigger id="cli-config-model">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={AUTO_MODEL_VALUE}>
{formatMessage({ id: 'terminalDashboard.cliConfig.modelAuto' })}
</SelectItem>
{modelOptions.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Mode */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'terminalDashboard.cliConfig.mode' })}</Label>
<RadioGroup
value={launchMode}
onValueChange={(v) => setLaunchMode(v as LaunchMode)}
className="flex items-center gap-4"
>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<RadioGroupItem value="default" />
{formatMessage({ id: 'terminalDashboard.cliConfig.modeDefault' })}
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<RadioGroupItem value="yolo" />
{formatMessage({ id: 'terminalDashboard.cliConfig.modeYolo' })}
</label>
</RadioGroup>
</div>
{/* Shell */}
<div className="space-y-2">
<Label htmlFor="cli-config-shell">
{formatMessage({ id: 'terminalDashboard.cliConfig.shell' })}
</Label>
<Select
value={preferredShell}
onValueChange={(v) => setPreferredShell(v as ShellKind)}
disabled={isSubmitting}
>
<SelectTrigger id="cli-config-shell">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">bash</SelectItem>
<SelectItem value="pwsh">pwsh</SelectItem>
</SelectContent>
</Select>
</div>
{/* Working Directory */}
<div className="space-y-2">
<Label htmlFor="cli-config-workingDir">
{formatMessage({ id: 'terminalDashboard.cliConfig.workingDir' })}
</Label>
<div className="flex gap-2">
<Input
id="cli-config-workingDir"
value={workingDir}
onChange={(e) => {
setWorkingDir(e.target.value);
if (error) setError(null);
}}
placeholder={formatMessage({ id: 'terminalDashboard.cliConfig.workingDirPlaceholder' })}
disabled={isSubmitting}
className={cn(error && 'border-destructive')}
/>
<Button
type="button"
variant="outline"
onClick={handleBrowse}
disabled={isSubmitting}
title={formatMessage({ id: 'terminalDashboard.cliConfig.browse' })}
>
<FolderOpen className="w-4 h-4" />
</Button>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={isSubmitting}>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button onClick={handleCreate} disabled={isSubmitting}>
{formatMessage({ id: 'common.actions.create' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CliConfigModal;

View File

@@ -27,6 +27,11 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from '@/components/ui/Dropdown';
@@ -37,6 +42,8 @@ import {
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/terminalGridStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { sendCliSessionText } from '@/lib/api';
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
// ========== Types ==========
@@ -56,6 +63,19 @@ const LAYOUT_PRESETS = [
{ id: 'grid-2x2' as const, icon: LayoutGrid, labelId: 'terminalDashboard.toolbar.layoutGrid' },
];
type LaunchMode = 'default' | 'yolo';
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
type CliTool = (typeof CLI_TOOLS)[number];
const LAUNCH_COMMANDS: Record<CliTool, Record<LaunchMode, string>> = {
claude: { default: 'claude', yolo: 'claude --permission-mode bypassPermissions' },
gemini: { default: 'gemini', yolo: 'gemini --approval-mode yolo' },
qwen: { default: 'qwen', yolo: 'qwen --approval-mode yolo' },
codex: { default: 'codex', yolo: 'codex --full-auto' },
opencode: { default: 'opencode', yolo: 'opencode' },
};
// ========== Component ==========
export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolbarProps) {
@@ -94,117 +114,216 @@ export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolba
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
const [isCreating, setIsCreating] = useState(false);
const [selectedTool, setSelectedTool] = useState<CliTool>('gemini');
const [launchMode, setLaunchMode] = useState<LaunchMode>('yolo');
const [isConfigOpen, setIsConfigOpen] = useState(false);
const handleQuickCreate = useCallback(async () => {
if (!focusedPaneId || !projectPath) return;
setIsCreating(true);
try {
await createSessionAndAssign(focusedPaneId, {
const created = await createSessionAndAssign(focusedPaneId, {
workingDir: projectPath,
preferredShell: 'bash',
tool: selectedTool,
}, projectPath);
if (created?.session?.sessionKey) {
const command = LAUNCH_COMMANDS[selectedTool]?.[launchMode] ?? selectedTool;
setTimeout(() => {
sendCliSessionText(
created.session.sessionKey,
{ text: command, appendNewline: true },
projectPath
).catch((err) => console.error('[DashboardToolbar] auto-launch failed:', err));
}, 300);
}
} finally {
setIsCreating(false);
}
}, [focusedPaneId, projectPath, createSessionAndAssign, selectedTool, launchMode]);
const handleConfigure = useCallback(() => {
setIsConfigOpen(true);
}, []);
const handleCreateConfiguredSession = useCallback(async (config: CliSessionConfig) => {
if (!focusedPaneId || !projectPath) throw new Error('No focused pane or project path');
setIsCreating(true);
try {
const created = await createSessionAndAssign(
focusedPaneId,
{
workingDir: config.workingDir || projectPath,
preferredShell: config.preferredShell,
tool: config.tool,
model: config.model,
},
projectPath
);
if (!created?.session?.sessionKey) throw new Error('createSessionAndAssign failed');
const tool = config.tool as CliTool;
const mode = config.launchMode as LaunchMode;
const command = LAUNCH_COMMANDS[tool]?.[mode] ?? tool;
setTimeout(() => {
sendCliSessionText(
created.session.sessionKey,
{ text: command, appendNewline: true },
projectPath
).catch((err) => console.error('[DashboardToolbar] auto-launch failed:', err));
}, 300);
} finally {
setIsCreating(false);
}
}, [focusedPaneId, projectPath, createSessionAndAssign]);
const handleConfigure = useCallback(() => {
// TODO: Open configuration modal (future implementation)
console.log('Configure CLI session - modal to be implemented');
}, []);
return (
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
{/* Launch CLI dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<>
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
{/* Launch CLI dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted',
isCreating && 'opacity-50 cursor-wait'
)}
disabled={isCreating || !projectPath}
>
{isCreating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Terminal className="w-3.5 h-3.5" />
)}
<span>{formatMessage({ id: 'terminalDashboard.toolbar.launchCli' })}</span>
<ChevronDown className="w-3 h-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<span>{formatMessage({ id: 'terminalDashboard.toolbar.tool' })}</span>
<span className="text-xs text-muted-foreground">({selectedTool})</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={selectedTool}
onValueChange={(v) => setSelectedTool(v as CliTool)}
>
{CLI_TOOLS.map((tool) => (
<DropdownMenuRadioItem key={tool} value={tool}>
{tool}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<span>{formatMessage({ id: 'terminalDashboard.toolbar.mode' })}</span>
<span className="text-xs text-muted-foreground">
{launchMode === 'default'
? formatMessage({ id: 'terminalDashboard.toolbar.modeDefault' })
: formatMessage({ id: 'terminalDashboard.toolbar.modeYolo' })}
</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
value={launchMode}
onValueChange={(v) => setLaunchMode(v as LaunchMode)}
>
<DropdownMenuRadioItem value="default">
{formatMessage({ id: 'terminalDashboard.toolbar.modeDefault' })}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="yolo">
{formatMessage({ id: 'terminalDashboard.toolbar.modeYolo' })}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleQuickCreate}
disabled={isCreating || !projectPath || !focusedPaneId}
className="gap-2"
>
<Zap className="w-4 h-4" />
<span>{formatMessage({ id: 'terminalDashboard.toolbar.quickCreate' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleConfigure}
disabled={isCreating || !projectPath || !focusedPaneId}
className="gap-2"
>
<Settings className="w-4 h-4" />
<span>{formatMessage({ id: 'terminalDashboard.toolbar.configure' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Panel toggle buttons */}
<ToolbarButton
icon={AlertCircle}
label={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
isActive={activePanel === 'issues'}
onClick={() => onTogglePanel('issues')}
badge={openCount > 0 ? openCount : undefined}
/>
<ToolbarButton
icon={ListChecks}
label={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
isActive={activePanel === 'queue'}
onClick={() => onTogglePanel('queue')}
badge={queueCount > 0 ? queueCount : undefined}
/>
<ToolbarButton
icon={Info}
label={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
isActive={activePanel === 'inspector'}
onClick={() => onTogglePanel('inspector')}
dot={hasChain}
/>
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Layout presets */}
{LAYOUT_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handlePreset(preset.id)}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted',
isCreating && 'opacity-50 cursor-wait'
'p-1.5 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
disabled={isCreating || !projectPath}
title={formatMessage({ id: preset.labelId })}
>
{isCreating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Terminal className="w-3.5 h-3.5" />
)}
<span>{formatMessage({ id: 'terminalDashboard.toolbar.launchCli' })}</span>
<ChevronDown className="w-3 h-3" />
<preset.icon className="w-3.5 h-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={4}>
<DropdownMenuItem
onClick={handleQuickCreate}
disabled={isCreating || !projectPath || !focusedPaneId}
className="gap-2"
>
<Zap className="w-4 h-4" />
<span>{formatMessage({ id: 'terminalDashboard.toolbar.quickCreate' })}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleConfigure}
disabled={isCreating}
className="gap-2"
>
<Settings className="w-4 h-4" />
<span>{formatMessage({ id: 'terminalDashboard.toolbar.configure' })}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
))}
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Right-aligned title */}
<span className="ml-auto text-xs text-muted-foreground font-medium">
{formatMessage({ id: 'terminalDashboard.page.title' })}
</span>
</div>
{/* Panel toggle buttons */}
<ToolbarButton
icon={AlertCircle}
label={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
isActive={activePanel === 'issues'}
onClick={() => onTogglePanel('issues')}
badge={openCount > 0 ? openCount : undefined}
<CliConfigModal
isOpen={isConfigOpen}
onClose={() => setIsConfigOpen(false)}
defaultWorkingDir={projectPath}
onCreateSession={handleCreateConfiguredSession}
/>
<ToolbarButton
icon={ListChecks}
label={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
isActive={activePanel === 'queue'}
onClick={() => onTogglePanel('queue')}
badge={queueCount > 0 ? queueCount : undefined}
/>
<ToolbarButton
icon={Info}
label={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
isActive={activePanel === 'inspector'}
onClick={() => onTogglePanel('inspector')}
dot={hasChain}
/>
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Layout presets */}
{LAYOUT_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handlePreset(preset.id)}
className={cn(
'p-1.5 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title={formatMessage({ id: preset.labelId })}
>
<preset.icon className="w-3.5 h-3.5" />
</button>
))}
{/* Right-aligned title */}
<span className="ml-auto text-xs text-muted-foreground font-medium">
{formatMessage({ id: 'terminalDashboard.page.title' })}
</span>
</div>
</>
);
}