mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: add CLI config preview API for Codex and Gemini
- Implemented `fetchCodexConfigPreview` and `fetchGeminiConfigPreview` functions in the API layer to retrieve masked configuration files. - Added new interfaces `CodexConfigPreviewResponse` and `GeminiConfigPreviewResponse` to define the structure of the API responses. - Created utility functions to read and mask sensitive values from `config.toml` and `auth.json` for Codex, and `settings.json` for Gemini. - Updated CLI settings routes to handle new preview endpoints. - Enhanced session content parser to support Claude JSONL format. - Updated UI components to reflect changes in history page and navigation, including new tabs for observability. - Localized changes for English and Chinese languages to reflect "CLI History" terminology.
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Check, Eye, EyeOff, X, Plus } from 'lucide-react';
|
||||
import { Check, Eye, EyeOff, X, Plus, Loader2, Download } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { fetchCodexConfigPreview, fetchGeminiConfigPreview } from '@/lib/api';
|
||||
import type { CliSettingsEndpoint, CliProvider } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -101,6 +102,8 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
// Gemini specific
|
||||
const [geminiApiKey, setGeminiApiKey] = useState('');
|
||||
const [showGeminiKey, setShowGeminiKey] = useState(false);
|
||||
const [geminiSettingsJson, setGeminiSettingsJson] = useState('');
|
||||
const [isLoadingGeminiConfig, setIsLoadingGeminiConfig] = useState(false);
|
||||
|
||||
// Shared
|
||||
const [model, setModel] = useState('');
|
||||
@@ -112,6 +115,9 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
const [showJsonInput, setShowJsonInput] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Codex config preview loading state
|
||||
const [isLoadingCodexConfig, setIsLoadingCodexConfig] = useState(false);
|
||||
|
||||
// Initialize form
|
||||
useEffect(() => {
|
||||
if (cliSettings) {
|
||||
@@ -171,6 +177,7 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
setWriteCommonConfig(false);
|
||||
setGeminiApiKey('');
|
||||
setShowGeminiKey(false);
|
||||
setGeminiSettingsJson('');
|
||||
setAvailableModels([]);
|
||||
setModelInput('');
|
||||
setTags([]);
|
||||
@@ -224,6 +231,47 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle load Codex config preview
|
||||
const handleLoadCodexConfig = async () => {
|
||||
setIsLoadingCodexConfig(true);
|
||||
try {
|
||||
const result = await fetchCodexConfigPreview();
|
||||
if (result.success) {
|
||||
if (result.configToml) {
|
||||
setConfigToml(result.configToml);
|
||||
}
|
||||
if (result.authJson) {
|
||||
setAuthJson(result.authJson);
|
||||
}
|
||||
} else {
|
||||
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||
}
|
||||
} catch (err) {
|
||||
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||
} finally {
|
||||
setIsLoadingCodexConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle load Gemini config preview
|
||||
const handleLoadGeminiConfig = async () => {
|
||||
setIsLoadingGeminiConfig(true);
|
||||
try {
|
||||
const result = await fetchGeminiConfigPreview();
|
||||
if (result.success) {
|
||||
if (result.settingsJson) {
|
||||
setGeminiSettingsJson(result.settingsJson);
|
||||
}
|
||||
} else {
|
||||
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||
}
|
||||
} catch (err) {
|
||||
error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config');
|
||||
} finally {
|
||||
setIsLoadingGeminiConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
@@ -521,6 +569,35 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
{/* ========== Codex Settings ========== */}
|
||||
{cliProvider === 'codex' && (
|
||||
<div className="space-y-4">
|
||||
{/* Load Global Config Button */}
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Load Global Config</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Load config.toml and auth.json from global Codex config directory
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLoadCodexConfig}
|
||||
disabled={isLoadingCodexConfig}
|
||||
>
|
||||
{isLoadingCodexConfig ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Load Config
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="codex-apikey">API Key</Label>
|
||||
@@ -645,6 +722,35 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
{/* ========== Gemini Settings ========== */}
|
||||
{cliProvider === 'gemini' && (
|
||||
<div className="space-y-4">
|
||||
{/* Load Global Config Button */}
|
||||
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Load Global Config</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Load settings.json from global Gemini config directory
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLoadGeminiConfig}
|
||||
disabled={isLoadingGeminiConfig}
|
||||
>
|
||||
{isLoadingGeminiConfig ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Load Config
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gemini-apikey">API Key</Label>
|
||||
<div className="relative">
|
||||
@@ -675,6 +781,38 @@ export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }
|
||||
placeholder="gemini-2.5-flash"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* settings.json Editor */}
|
||||
{geminiSettingsJson && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="gemini-settingsjson">settings.json (Preview)</Label>
|
||||
<Button
|
||||
type="button" variant="ghost" size="sm"
|
||||
onClick={() => {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(geminiSettingsJson), null, 2);
|
||||
setGeminiSettingsJson(formatted);
|
||||
} catch { /* skip */ }
|
||||
}}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
</div>
|
||||
<Textarea
|
||||
id="gemini-settingsjson"
|
||||
value={geminiSettingsJson}
|
||||
onChange={(e) => setGeminiSettingsJson(e.target.value)}
|
||||
placeholder='{"model": "gemini-2.5-flash", ...}'
|
||||
className="font-mono text-sm"
|
||||
rows={8}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Gemini settings.json content (read-only preview)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
// Dynamic header component for IssueHub
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { AlertCircle, Radar, ListTodo, LayoutGrid, Activity, Terminal } from 'lucide-react';
|
||||
|
||||
type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability' | 'executions';
|
||||
import { AlertCircle, Radar, ListTodo, LayoutGrid, Play } from 'lucide-react';
|
||||
import type { IssueTab } from './IssueHubTabs';
|
||||
|
||||
interface IssueHubHeaderProps {
|
||||
currentTab: IssueTab;
|
||||
@@ -37,19 +36,14 @@ export function IssueHubHeader({ currentTab }: IssueHubHeaderProps) {
|
||||
title: formatMessage({ id: 'issues.discovery.pageTitle' }),
|
||||
description: formatMessage({ id: 'issues.discovery.description' }),
|
||||
},
|
||||
observability: {
|
||||
icon: <Activity className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.observability.pageTitle' }),
|
||||
description: formatMessage({ id: 'issues.observability.description' }),
|
||||
},
|
||||
executions: {
|
||||
icon: <Terminal className="w-6 h-6 text-primary" />,
|
||||
icon: <Play className="w-6 h-6 text-primary" />,
|
||||
title: formatMessage({ id: 'issues.executions.pageTitle' }),
|
||||
description: formatMessage({ id: 'issues.executions.description' }),
|
||||
},
|
||||
};
|
||||
|
||||
const config = tabConfig[currentTab];
|
||||
const config = tabConfig[currentTab] || tabConfig.issues;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Keep in sync with IssueHubHeader/IssueHubPage
|
||||
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'observability' | 'executions';
|
||||
export type IssueTab = 'issues' | 'board' | 'queue' | 'discovery' | 'executions';
|
||||
|
||||
interface IssueHubTabsProps {
|
||||
currentTab: IssueTab;
|
||||
@@ -23,7 +23,6 @@ export function IssueHubTabs({ currentTab, onTabChange }: IssueHubTabsProps) {
|
||||
{ value: 'board', label: formatMessage({ id: 'issues.hub.tabs.board' }) },
|
||||
{ value: 'queue', label: formatMessage({ id: 'issues.hub.tabs.queue' }) },
|
||||
{ value: 'discovery', label: formatMessage({ id: 'issues.hub.tabs.discovery' }) },
|
||||
{ value: 'observability', label: formatMessage({ id: 'issues.hub.tabs.observability' }) },
|
||||
{ value: 'executions', label: formatMessage({ id: 'issues.hub.tabs.executions' }) },
|
||||
];
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
LayoutDashboard,
|
||||
Clock,
|
||||
Zap,
|
||||
GitFork,
|
||||
Shield,
|
||||
@@ -78,7 +77,6 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
|
||||
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
|
||||
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||
{ path: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal },
|
||||
|
||||
@@ -6150,6 +6150,54 @@ export async function getCliSettingsPath(endpointId: string): Promise<{ endpoint
|
||||
return fetchApi(`/api/cli/settings/${encodeURIComponent(endpointId)}/path`);
|
||||
}
|
||||
|
||||
// ========== CLI Config Preview API ==========
|
||||
|
||||
/**
|
||||
* Codex config preview response
|
||||
*/
|
||||
export interface CodexConfigPreviewResponse {
|
||||
/** Whether preview was successful */
|
||||
success: boolean;
|
||||
/** Path to config.toml */
|
||||
configPath: string;
|
||||
/** Path to auth.json */
|
||||
authPath: string;
|
||||
/** config.toml content with sensitive values masked */
|
||||
configToml: string | null;
|
||||
/** auth.json content with API keys masked */
|
||||
authJson: string | null;
|
||||
/** Error messages if any files could not be read */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini config preview response
|
||||
*/
|
||||
export interface GeminiConfigPreviewResponse {
|
||||
/** Whether preview was successful */
|
||||
success: boolean;
|
||||
/** Path to settings.json */
|
||||
settingsPath: string;
|
||||
/** settings.json content with sensitive values masked */
|
||||
settingsJson: string | null;
|
||||
/** Error messages if file could not be read */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Codex config files preview (config.toml and auth.json)
|
||||
*/
|
||||
export async function fetchCodexConfigPreview(): Promise<CodexConfigPreviewResponse> {
|
||||
return fetchApi('/api/cli/settings/codex/preview');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Gemini settings file preview (settings.json)
|
||||
*/
|
||||
export async function fetchGeminiConfigPreview(): Promise<GeminiConfigPreviewResponse> {
|
||||
return fetchApi('/api/cli/settings/gemini/preview');
|
||||
}
|
||||
|
||||
// ========== Orchestrator Execution Monitoring API ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
"coordinator": "Coordinator",
|
||||
"executions": "Executions",
|
||||
"loops": "Loops",
|
||||
"history": "History",
|
||||
"history": "CLI History",
|
||||
"memory": "Memory",
|
||||
"prompts": "Prompts",
|
||||
"skills": "Skills",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"sessions": "Sessions",
|
||||
"liteTasks": "Lite Tasks",
|
||||
"project": "Project",
|
||||
"history": "History",
|
||||
"history": "CLI History",
|
||||
"orchestrator": "Orchestrator",
|
||||
"coordinator": "Coordinator",
|
||||
"loops": "Loop Monitor",
|
||||
|
||||
@@ -245,7 +245,7 @@
|
||||
"coordinator": "协调器",
|
||||
"executions": "执行监控",
|
||||
"loops": "循环",
|
||||
"history": "历史",
|
||||
"history": "CLI执行历史",
|
||||
"memory": "记忆",
|
||||
"prompts": "提示词",
|
||||
"skills": "技能",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"sessions": "会话",
|
||||
"liteTasks": "轻量任务",
|
||||
"project": "项目",
|
||||
"history": "历史",
|
||||
"history": "CLI执行历史",
|
||||
"orchestrator": "编排器",
|
||||
"coordinator": "协调器",
|
||||
"loops": "循环监控",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// HistoryPage Component
|
||||
// ========================================
|
||||
// CLI execution history page with filtering and bulk actions
|
||||
// Includes tabs: Executions + Session Audit (Observability)
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
@@ -22,6 +23,7 @@ import { useHistory } from '@/hooks/useHistory';
|
||||
import { ConversationCard } from '@/components/shared/ConversationCard';
|
||||
import { CliStreamPanel } from '@/components/shared/CliStreamPanel';
|
||||
import { NativeSessionPanel } from '@/components/shared/NativeSessionPanel';
|
||||
import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
@@ -42,11 +44,14 @@ import {
|
||||
} from '@/components/ui/Dropdown';
|
||||
import type { CliExecution } from '@/lib/api';
|
||||
|
||||
type HistoryTab = 'executions' | 'observability';
|
||||
|
||||
/**
|
||||
* HistoryPage component - Display CLI execution history
|
||||
*/
|
||||
export function HistoryPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [currentTab, setCurrentTab] = React.useState<HistoryTab>('executions');
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [toolFilter, setToolFilter] = React.useState<string | undefined>(undefined);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
@@ -163,148 +168,189 @@ export function HistoryPage() {
|
||||
>
|
||||
{isImmersiveMode ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'history.deleteOptions' })}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'history.deleteBy' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{tools.map((tool) => (
|
||||
<DropdownMenuItem key={tool} onClick={() => handleDeleteByTool(tool)}>
|
||||
{formatMessage({ id: 'history.deleteAllTool' }, { tool })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteAll}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'history.deleteAll' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||
<Terminal className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
{/* Tabs - matching IssueHubTabs style */}
|
||||
<div className="flex gap-2 border-b border-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"border-b-2 rounded-none h-11 px-4",
|
||||
currentTab === 'executions'
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setCurrentTab('executions')}
|
||||
>
|
||||
{formatMessage({ id: 'history.tabs.executions' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"border-b-2 rounded-none h-11 px-4",
|
||||
currentTab === 'observability'
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={() => setCurrentTab('observability')}
|
||||
>
|
||||
{formatMessage({ id: 'history.tabs.observability' })}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{currentTab === 'executions' && (
|
||||
<div className="space-y-4">
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||
<Terminal className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{formatMessage({ id: 'common.actions.retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and Actions on same row */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* Search input */}
|
||||
<div className="flex-1 max-w-sm relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'history.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 min-w-[160px] justify-between">
|
||||
{toolFilter || formatMessage({ id: 'history.filterAllTools' })}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => setToolFilter(undefined)}>
|
||||
{formatMessage({ id: 'history.filterAllTools' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{tools.map((tool) => (
|
||||
<DropdownMenuItem
|
||||
key={tool}
|
||||
onClick={() => setToolFilter(tool)}
|
||||
className={toolFilter === tool ? 'bg-accent' : ''}
|
||||
>
|
||||
{tool}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'history.deleteOptions' })}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'history.deleteBy' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{tools.map((tool) => (
|
||||
<DropdownMenuItem key={tool} onClick={() => handleDeleteByTool(tool)}>
|
||||
{formatMessage({ id: 'history.deleteAllTool' }, { tool })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDeleteAll}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<AlertTriangle className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'history.deleteAll' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{formatMessage({ id: 'common.actions.retry' })}
|
||||
</Button>
|
||||
|
||||
{/* Executions list */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : executions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
<SearchX className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'history.empty.filtered' })
|
||||
: formatMessage({ id: 'history.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'history.empty.filteredMessage' })
|
||||
: formatMessage({ id: 'history.empty.message' })}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outline" onClick={handleClearFilters} className="mt-4">
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{executions.map((execution) => (
|
||||
<ConversationCard
|
||||
key={execution.id}
|
||||
execution={execution}
|
||||
onClick={handleCardClick}
|
||||
onViewNative={handleViewNative}
|
||||
onDelete={handleDeleteClick}
|
||||
actionsDisabled={isDeleting}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* Search input */}
|
||||
<div className="flex-1 max-w-sm relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'history.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2 min-w-[160px] justify-between">
|
||||
{toolFilter || formatMessage({ id: 'history.filterAllTools' })}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-48">
|
||||
<DropdownMenuItem onClick={() => setToolFilter(undefined)}>
|
||||
{formatMessage({ id: 'history.filterAllTools' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{tools.map((tool) => (
|
||||
<DropdownMenuItem
|
||||
key={tool}
|
||||
onClick={() => setToolFilter(tool)}
|
||||
className={toolFilter === tool ? 'bg-accent' : ''}
|
||||
>
|
||||
{tool}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Clear filters */}
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilters}>
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Executions list */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-28 rounded-lg bg-muted animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : executions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4">
|
||||
<SearchX className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'history.empty.filtered' })
|
||||
: formatMessage({ id: 'history.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'history.empty.filteredMessage' })
|
||||
: formatMessage({ id: 'history.empty.message' })}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outline" onClick={handleClearFilters} className="mt-4">
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{executions.map((execution) => (
|
||||
<ConversationCard
|
||||
key={execution.id}
|
||||
execution={execution}
|
||||
onClick={handleCardClick}
|
||||
onViewNative={handleViewNative}
|
||||
onDelete={handleDeleteClick}
|
||||
actionsDisabled={isDeleting}
|
||||
/>
|
||||
))}
|
||||
{currentTab === 'observability' && (
|
||||
<div className="mt-4">
|
||||
<ObservabilityPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Unified page for issues, queue, and discovery with tab navigation
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
@@ -16,11 +16,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { IssueHubHeader } from '@/components/issue/hub/IssueHubHeader';
|
||||
import { IssueHubTabs, type IssueTab } from '@/components/issue/hub/IssueHubTabs';
|
||||
|
||||
const VALID_TABS: IssueTab[] = ['issues', 'board', 'queue', 'discovery', 'executions'];
|
||||
import { IssuesPanel } from '@/components/issue/hub/IssuesPanel';
|
||||
import { IssueBoardPanel } from '@/components/issue/hub/IssueBoardPanel';
|
||||
import { QueuePanel } from '@/components/issue/hub/QueuePanel';
|
||||
import { DiscoveryPanel } from '@/components/issue/hub/DiscoveryPanel';
|
||||
import { ObservabilityPanel } from '@/components/issue/hub/ObservabilityPanel';
|
||||
import { ExecutionPanel } from '@/components/issue/hub/ExecutionPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
@@ -285,7 +286,16 @@ function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: {
|
||||
export function IssueHubPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const currentTab = (searchParams.get('tab') as IssueTab) || 'issues';
|
||||
const rawTab = searchParams.get('tab') as IssueTab;
|
||||
const currentTab = VALID_TABS.includes(rawTab) ? rawTab : 'issues';
|
||||
|
||||
// Redirect invalid tabs to 'issues'
|
||||
useEffect(() => {
|
||||
if (!VALID_TABS.includes(rawTab)) {
|
||||
setSearchParams({ tab: 'issues' });
|
||||
}
|
||||
}, [rawTab, setSearchParams]);
|
||||
|
||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||
const [isGithubSyncing, setIsGithubSyncing] = useState(false);
|
||||
|
||||
@@ -387,11 +397,8 @@ export function IssueHubPage() {
|
||||
case 'discovery':
|
||||
return null; // Discovery panel has its own controls
|
||||
|
||||
case 'observability':
|
||||
return null; // Observability panel has its own controls
|
||||
|
||||
case 'executions':
|
||||
return null; // Execution panel has its own controls
|
||||
return null; // Executions panel has its own controls
|
||||
|
||||
default:
|
||||
return null;
|
||||
@@ -436,7 +443,6 @@ export function IssueHubPage() {
|
||||
{currentTab === 'board' && <IssueBoardPanel />}
|
||||
{currentTab === 'queue' && <QueuePanel />}
|
||||
{currentTab === 'discovery' && <DiscoveryPanel />}
|
||||
{currentTab === 'observability' && <ObservabilityPanel />}
|
||||
{currentTab === 'executions' && <ExecutionPanel />}
|
||||
|
||||
<NewIssueDialog open={isNewIssueOpen} onOpenChange={setIsNewIssueOpen} onSubmit={handleCreateIssue} isCreating={isCreating} />
|
||||
|
||||
Reference in New Issue
Block a user