feat: add tests and implementation for issue discovery and queue pages

- Implemented `DiscoveryPage` with session management and findings display.
- Added tests for `DiscoveryPage` to ensure proper rendering and functionality.
- Created `QueuePage` for managing issue execution queues with stats and actions.
- Added tests for `QueuePage` to verify UI elements and translations.
- Introduced `useIssues` hooks for fetching and managing issue data.
- Added loading skeletons and error handling for better user experience.
- Created `vite-env.d.ts` for TypeScript support in Vite environment.
This commit is contained in:
catlog22
2026-01-31 21:20:10 +08:00
parent 6d225948d1
commit 1bd082a725
79 changed files with 5870 additions and 449 deletions

View File

@@ -0,0 +1,260 @@
// ========================================
// LogBlock Component
// ========================================
import React, { memo } from 'react';
import {
ChevronDown,
ChevronUp,
Copy,
RotateCcw,
CheckCircle,
AlertCircle,
Loader2,
Clock,
Brain,
Settings,
Info,
MessageCircle,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import type { LogBlockProps, LogLine } from './types';
// Re-use output line styling helpers from CliStreamMonitor
function getOutputLineIcon(type: LogLine['type']) {
switch (type) {
case 'thought':
return <Brain className="h-3 w-3" />;
case 'system':
return <Settings className="h-3 w-3" />;
case 'stderr':
return <AlertCircle className="h-3 w-3" />;
case 'metadata':
return <Info className="h-3 w-3" />;
case 'tool_call':
return <Wrench className="h-3 w-3" />;
case 'stdout':
default:
return <MessageCircle className="h-3 w-3" />;
}
}
function getOutputLineClass(type: LogLine['type']): string {
switch (type) {
case 'thought':
return 'text-purple-400';
case 'system':
return 'text-blue-400';
case 'stderr':
return 'text-red-400';
case 'metadata':
return 'text-yellow-400';
case 'tool_call':
return 'text-green-400';
case 'stdout':
default:
return 'text-foreground';
}
}
function getBlockBorderClass(status: LogBlockProps['block']['status']): string {
switch (status) {
case 'running':
return 'border-l-4 border-l-blue-500';
case 'completed':
return 'border-l-4 border-l-green-500';
case 'error':
return 'border-l-4 border-l-red-500';
case 'pending':
return 'border-l-4 border-l-yellow-500';
default:
return 'border-l-4 border-l-border';
}
}
function getBlockTypeColor(type: LogBlockProps['block']['type']): string {
switch (type) {
case 'command':
return 'text-blue-400';
case 'tool':
return 'text-green-400';
case 'output':
return 'text-foreground';
case 'error':
return 'text-red-400';
case 'warning':
return 'text-yellow-400';
case 'info':
return 'text-cyan-400';
default:
return 'text-foreground';
}
}
function getStatusBadgeVariant(status: LogBlockProps['block']['status']): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'outline' {
switch (status) {
case 'running':
return 'info';
case 'completed':
return 'success';
case 'error':
return 'destructive';
case 'pending':
return 'warning';
default:
return 'secondary';
}
}
function getStatusIcon(status: LogBlockProps['block']['status']) {
switch (status) {
case 'running':
return <Loader2 className="h-3 w-3 animate-spin" />;
case 'completed':
return <CheckCircle className="h-3 w-3" />;
case 'error':
return <AlertCircle className="h-3 w-3" />;
case 'pending':
return <Clock className="h-3 w-3" />;
default:
return null;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
export const LogBlock = memo(function LogBlock({
block,
isExpanded,
onToggleExpand,
onCopyCommand,
onCopyOutput,
onReRun,
className,
}: LogBlockProps) {
return (
<div className={cn('border border-border rounded-lg overflow-hidden', getBlockBorderClass(block.status), className)}>
{/* Header */}
<div
className={cn(
'flex items-center gap-2 px-3 py-2 bg-card cursor-pointer hover:bg-accent/50 transition-colors',
'group'
)}
onClick={onToggleExpand}
>
{/* Expand/Collapse Icon */}
<div className="shrink-0">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</div>
{/* Status Icon */}
<div className="shrink-0 text-muted-foreground">
{getStatusIcon(block.status)}
</div>
{/* Title with type-specific color */}
<div className={cn('font-medium text-sm truncate', getBlockTypeColor(block.type))}>
{block.title}
</div>
{/* Metadata */}
<div className="flex items-center gap-2 text-xs text-muted-foreground flex-1 min-w-0">
{block.toolName && (
<span className="truncate">{block.toolName}</span>
)}
<span className="shrink-0">{block.lineCount} lines</span>
{block.duration !== undefined && (
<span className="shrink-0">{formatDuration(block.duration)}</span>
)}
</div>
{/* Status Badge */}
<Badge variant={getStatusBadgeVariant(block.status)} className="shrink-0">
{block.status}
</Badge>
{/* Action Buttons (visible on hover) */}
<div
className={cn(
'flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity',
'shrink-0'
)}
onClick={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onCopyCommand}
title="Copy command"
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onCopyOutput}
title="Copy output"
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onReRun}
title="Re-run"
>
<RotateCcw className="h-3 w-3" />
</Button>
</div>
</div>
{/* Expandable Content */}
{isExpanded && (
<div className="px-3 py-2 bg-background border-t border-border">
<div className="font-mono text-xs space-y-1 max-h-96 overflow-y-auto">
{block.lines.map((line, index) => (
<div key={index} className={cn('flex gap-2', getOutputLineClass(line.type))}>
<span className="text-muted-foreground shrink-0">
{getOutputLineIcon(line.type)}
</span>
<span className="break-all">{line.content}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison for performance
return (
prevProps.block.id === nextProps.block.id &&
prevProps.block.status === nextProps.block.status &&
prevProps.block.lineCount === nextProps.block.lineCount &&
prevProps.block.duration === nextProps.block.duration &&
prevProps.isExpanded === nextProps.isExpanded &&
prevProps.className === nextProps.className
);
});
export default LogBlock;

View File

@@ -0,0 +1,331 @@
// ========================================
// LogBlockList Component
// ========================================
// Container component for displaying grouped CLI output blocks
import React, { useState, useMemo, useCallback } from 'react';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { LogBlock } from './LogBlock';
import type { LogBlockData, LogLine } from './types';
import type { CliOutputLine } from '@/stores/cliStreamStore';
/**
* Parse tool call metadata from content
* Expected format: "[Tool] toolName(args)"
*/
function parseToolCallMetadata(content: string): { toolName: string; args: string } | undefined {
const toolCallMatch = content.match(/^\[Tool\]\s+(\w+)\((.*)\)$/);
if (toolCallMatch) {
return {
toolName: toolCallMatch[1],
args: toolCallMatch[2] || '',
};
}
return undefined;
}
/**
* Generate block title based on type and content
*/
function generateBlockTitle(lineType: string, content: string): string {
switch (lineType) {
case 'tool_call':
const metadata = parseToolCallMetadata(content);
if (metadata) {
return metadata.args ? `${metadata.toolName}(${metadata.args})` : metadata.toolName;
}
return 'Tool Call';
case 'thought':
return 'Thought';
case 'system':
return 'System';
case 'stderr':
return 'Error Output';
case 'stdout':
return 'Output';
case 'metadata':
return 'Metadata';
default:
return 'Log';
}
}
/**
* Get block type for a line
*/
function getBlockType(lineType: string): LogBlockData['type'] {
switch (lineType) {
case 'tool_call':
return 'tool';
case 'thought':
return 'info';
case 'system':
return 'info';
case 'stderr':
return 'error';
case 'stdout':
case 'metadata':
default:
return 'output';
}
}
/**
* Check if a line type should start a new block
*/
function shouldStartNewBlock(lineType: string, currentBlockType: string | null): boolean {
// No current block exists
if (!currentBlockType) {
return true;
}
// These types always start new blocks
if (lineType === 'tool_call' || lineType === 'thought' || lineType === 'system') {
return true;
}
// stderr starts a new block if not already in stderr
if (lineType === 'stderr' && currentBlockType !== 'stderr') {
return true;
}
// tool_call block captures all following stdout/stderr until next tool_call
if (currentBlockType === 'tool_call' && (lineType === 'stdout' || lineType === 'stderr')) {
return false;
}
// stderr block captures all stderr until next different type
if (currentBlockType === 'stderr' && lineType === 'stderr') {
return false;
}
// stdout merges into current stdout block
if (currentBlockType === 'stdout' && lineType === 'stdout') {
return false;
}
// Different type - start new block
if (currentBlockType !== lineType) {
return true;
}
return false;
}
/**
* Group CLI output lines into log blocks
*
* Block grouping rules:
* 1. tool_call starts new block, includes following stdout/stderr until next tool_call
* 2. thought becomes independent block
* 3. system becomes independent block
* 4. stderr becomes highlighted block
* 5. Other stdout merges into normal blocks
*/
function groupLinesIntoBlocks(
lines: CliOutputLine[],
executionId: string,
executionStatus: 'running' | 'completed' | 'error'
): LogBlockData[] {
const blocks: LogBlockData[] = [];
let currentLines: LogLine[] = [];
let currentType: string | null = null;
let currentTitle = '';
let currentToolName: string | undefined;
let blockStartTime = 0;
let blockIndex = 0;
for (const line of lines) {
const blockType = getBlockType(line.type);
// Check if we need to start a new block
if (shouldStartNewBlock(line.type, currentType)) {
// Save current block if exists
if (currentLines.length > 0) {
const duration = blockStartTime > 0 ? line.timestamp - blockStartTime : undefined;
blocks.push({
id: `${executionId}-block-${blockIndex}`,
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
type: getBlockType(currentType || ''),
status: executionStatus === 'running' ? 'running' : 'completed',
toolName: currentToolName,
lineCount: currentLines.length,
duration,
lines: currentLines,
timestamp: blockStartTime,
});
blockIndex++;
}
// Start new block
currentType = line.type;
currentTitle = generateBlockTitle(line.type, line.content);
currentLines = [
{
type: line.type,
content: line.content,
timestamp: line.timestamp,
},
];
blockStartTime = line.timestamp;
// Extract tool name for tool_call blocks
if (line.type === 'tool_call') {
const metadata = parseToolCallMetadata(line.content);
currentToolName = metadata?.toolName;
} else {
currentToolName = undefined;
}
} else {
// Add line to current block
currentLines.push({
type: line.type,
content: line.content,
timestamp: line.timestamp,
});
}
}
// Finalize the last block
if (currentLines.length > 0) {
const lastLine = currentLines[currentLines.length - 1];
const duration = blockStartTime > 0 ? lastLine.timestamp - blockStartTime : undefined;
blocks.push({
id: `${executionId}-block-${blockIndex}`,
title: currentTitle || generateBlockTitle(currentType || '', currentLines[0]?.content || ''),
type: getBlockType(currentType || ''),
status: executionStatus === 'running' ? 'running' : 'completed',
toolName: currentToolName,
lineCount: currentLines.length,
duration,
lines: currentLines,
timestamp: blockStartTime,
});
}
return blocks;
}
/**
* Props for LogBlockList component
*/
export interface LogBlockListProps {
/** Execution ID to display logs for */
executionId: string | null;
/** Optional CSS class name */
className?: string;
}
/**
* LogBlockList component
* Displays CLI output grouped into collapsible blocks
*/
export function LogBlockList({ executionId, className }: LogBlockListProps) {
// Get execution data from store
const executions = useCliStreamStore((state) => state.executions);
// Get current execution or execution by ID
const currentExecution = useMemo(() => {
if (!executionId) return null;
return executions[executionId] || null;
}, [executions, executionId]);
// Manage expanded blocks state
const [expandedBlocks, setExpandedBlocks] = useState<Set<string>>(new Set());
// Group output lines into blocks
const blocks = useMemo(() => {
if (!currentExecution?.output || currentExecution.output.length === 0) {
return [];
}
return groupLinesIntoBlocks(currentExecution.output, executionId!, currentExecution.status);
}, [currentExecution, executionId]);
// Toggle block expand/collapse
const toggleBlockExpand = useCallback((blockId: string) => {
setExpandedBlocks((prev) => {
const next = new Set(prev);
if (next.has(blockId)) {
next.delete(blockId);
} else {
next.add(blockId);
}
return next;
});
}, []);
// Copy command to clipboard
const copyCommand = useCallback((block: LogBlockData) => {
const command = block.lines.find((l) => l.type === 'tool_call')?.content || '';
navigator.clipboard.writeText(command).catch((err) => {
console.error('Failed to copy command:', err);
});
}, []);
// Copy output to clipboard
const copyOutput = useCallback((block: LogBlockData) => {
const output = block.lines.map((l) => l.content).join('\n');
navigator.clipboard.writeText(output).catch((err) => {
console.error('Failed to copy output:', err);
});
}, []);
// Re-run block (placeholder for future implementation)
const reRun = useCallback((block: LogBlockData) => {
console.log('Re-run block:', block.id);
// TODO: Implement re-run functionality
}, []);
// Empty states
if (!executionId) {
return (
<div className={className}>
<div className="flex items-center justify-center h-full text-muted-foreground">
No execution selected
</div>
</div>
);
}
if (!currentExecution) {
return (
<div className={className}>
<div className="flex items-center justify-center h-full text-muted-foreground">
Execution not found
</div>
</div>
);
}
if (blocks.length === 0) {
const isRunning = currentExecution.status === 'running';
return (
<div className={className}>
<div className="flex items-center justify-center h-full text-muted-foreground">
{isRunning ? 'Waiting for output...' : 'No output available'}
</div>
</div>
);
}
return (
<div className={className}>
<div className="space-y-2 p-3">
{blocks.map((block) => (
<LogBlock
key={block.id}
block={block}
isExpanded={expandedBlocks.has(block.id)}
onToggleExpand={() => toggleBlockExpand(block.id)}
onCopyCommand={() => copyCommand(block)}
onCopyOutput={() => copyOutput(block)}
onReRun={() => reRun(block)}
/>
))}
</div>
</div>
);
}
export default LogBlockList;

View File

@@ -0,0 +1,7 @@
// ========================================
// LogBlock Component Exports
// ========================================
export { LogBlock, default } from './LogBlock';
export { LogBlockList, type LogBlockListProps } from './LogBlockList';
export type { LogBlockProps, LogBlockData, LogLine } from './types';

View File

@@ -0,0 +1,31 @@
// ========================================
// LogBlock Types
// ========================================
export interface LogBlockProps {
block: LogBlockData;
isExpanded: boolean;
onToggleExpand: () => void;
onCopyCommand: () => void;
onCopyOutput: () => void;
onReRun: () => void;
className?: string;
}
export interface LogBlockData {
id: string;
title: string;
type: 'command' | 'tool' | 'output' | 'error' | 'warning' | 'info';
status: 'running' | 'completed' | 'error' | 'pending';
toolName?: string;
lineCount: number;
duration?: number;
lines: LogLine[];
timestamp: number;
}
export interface LogLine {
type: 'stdout' | 'stderr' | 'thought' | 'system' | 'metadata' | 'tool_call';
content: string;
timestamp: number;
}