mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: add CliStreamMonitor and related components for CLI output streaming
- Implemented CliStreamMonitor component for real-time CLI output monitoring with multi-execution support. - Created JsonFormatter component for displaying JSON content in various formats (text, card, inline). - Added utility functions for JSON detection and formatting in jsonUtils.ts. - Introduced LogBlock utility functions for styling CLI output lines. - Developed a new Collapsible component for better UI interactions. - Created IssueHubPage for managing issues, queue, and discovery with tab navigation.
This commit is contained in:
353
ccw/frontend/src/components/shared/LogBlock/JsonFormatter.tsx
Normal file
353
ccw/frontend/src/components/shared/LogBlock/JsonFormatter.tsx
Normal file
@@ -0,0 +1,353 @@
|
||||
// ========================================
|
||||
// JsonFormatter Component
|
||||
// ========================================
|
||||
// Displays JSON content in formatted text or card view
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { ChevronDown, ChevronRight, Copy, Check, Braces } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
detectJsonContent,
|
||||
formatJson,
|
||||
getJsonSummary,
|
||||
getJsonValueTypeColor,
|
||||
type JsonDisplayMode,
|
||||
} from './jsonUtils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface JsonFormatterProps {
|
||||
/** Content to format */
|
||||
content: string;
|
||||
/** Display mode */
|
||||
displayMode?: JsonDisplayMode;
|
||||
/** CSS className */
|
||||
className?: string;
|
||||
/** Maximum lines for text mode (default: 20) */
|
||||
maxLines?: number;
|
||||
/** Whether to show type labels in card mode (default: true) */
|
||||
showTypeLabels?: boolean;
|
||||
}
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
/**
|
||||
* Copy button with feedback
|
||||
*/
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCopy}
|
||||
className="h-6 px-2 text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON value renderer with syntax highlighting
|
||||
*/
|
||||
function JsonValue({ value, depth = 0 }: { value: unknown; depth?: number }) {
|
||||
const indent = ' '.repeat(depth);
|
||||
const colorClass = getJsonValueTypeColor(value);
|
||||
|
||||
if (value === null) {
|
||||
return <span className={colorClass}>null</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return <span className={colorClass}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return <span className={colorClass}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return <span className={colorClass}>"{value}"</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return <span className="text-blue-400">[]</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-blue-400">
|
||||
<span>[</span>
|
||||
<div className="pl-4">
|
||||
{value.map((item, index) => (
|
||||
<div key={index} className="hover:bg-muted/50 rounded px-1">
|
||||
<JsonValue value={item} depth={depth + 1} />
|
||||
{index < value.length - 1 && <span className="text-foreground">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span>{indent}]</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
return <span className="text-yellow-400">{`{}`}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-yellow-400">
|
||||
<span>{`{`}</span>
|
||||
<div className="pl-4">
|
||||
{entries.map(([key, val], index) => (
|
||||
<div key={key} className="hover:bg-muted/50 rounded px-1">
|
||||
<span className="text-cyan-400">"{key}"</span>
|
||||
<span className="text-foreground">: </span>
|
||||
<JsonValue value={val} depth={depth + 1} />
|
||||
{index < entries.length - 1 && <span className="text-foreground">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span>{indent}{`}`}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact JSON view for inline display
|
||||
*/
|
||||
function JsonCompact({ data }: { data: unknown }) {
|
||||
return (
|
||||
<code className="text-xs font-mono">
|
||||
<JsonValue value={data} />
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card view for structured JSON display
|
||||
*/
|
||||
function JsonCard({ data, showTypeLabels = true }: { data: unknown; showTypeLabels?: boolean }) {
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleKey = useCallback((key: string) => {
|
||||
setExpandedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
||||
// Primitive or array - use inline view
|
||||
return (
|
||||
<div className="p-3 bg-muted/30 rounded border border-border">
|
||||
<JsonCompact data={data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const entries = Object.entries(data as Record<string, unknown>);
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<Braces className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">JSON Data</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({entries.length} {entries.length === 1 ? 'property' : 'properties'})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Properties */}
|
||||
<div className="divide-y divide-border">
|
||||
{entries.map(([key, value]) => {
|
||||
const isObject = value !== null && typeof value === 'object';
|
||||
const isExpanded = expandedKeys.has(key);
|
||||
const isArray = Array.isArray(value);
|
||||
const summary = getJsonSummary(value);
|
||||
|
||||
return (
|
||||
<div key={key} className="group">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-2 px-3 py-2 hover:bg-muted/30 transition-colors',
|
||||
isObject && 'cursor-pointer'
|
||||
)}
|
||||
onClick={() => isObject && toggleKey(key)}
|
||||
>
|
||||
{/* Expand/collapse icon */}
|
||||
{isObject && (
|
||||
<div className="shrink-0 mt-0.5">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key */}
|
||||
<span className="font-mono text-sm text-cyan-400 shrink-0">"{key}"</span>
|
||||
<span className="text-muted-foreground shrink-0">:</span>
|
||||
|
||||
{/* Value summary or full value */}
|
||||
<div className={cn('flex-1 min-w-0', getJsonValueTypeColor(value))}>
|
||||
{showTypeLabels && (
|
||||
<span className="text-xs text-muted-foreground mr-1">
|
||||
{isArray ? 'array' : isObject ? 'object' : typeof value}
|
||||
</span>
|
||||
)}
|
||||
{!isObject ? (
|
||||
<span className="text-sm font-mono break-all">{summary}</span>
|
||||
) : (
|
||||
<span className="text-sm">{summary}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded nested object */}
|
||||
{isObject && isExpanded && (
|
||||
<div className="pl-8 pr-3 pb-2">
|
||||
<div className="p-3 bg-muted/30 rounded border border-border">
|
||||
<JsonCard data={value} showTypeLabels={showTypeLabels} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text view for formatted JSON
|
||||
*/
|
||||
function JsonText({ data, maxLines = 20 }: { data: unknown; maxLines?: number }) {
|
||||
const formatted = useMemo(() => formatJson(data), [data]);
|
||||
const lines = formatted.split('\n');
|
||||
|
||||
const showTruncated = maxLines && lines.length > maxLines;
|
||||
const displayLines = showTruncated ? lines.slice(0, maxLines) : lines;
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<pre className="text-xs font-mono bg-muted/30 p-3 rounded border border-border overflow-x-auto">
|
||||
<code className="text-foreground">
|
||||
{displayLines.map((line, i) => (
|
||||
<div key={i} className="hover:bg-muted/50 px-1 -mx-1">
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
{showTruncated && (
|
||||
<div className="text-muted-foreground italic">
|
||||
// ... {lines.length - maxLines} more lines
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
{/* Copy button */}
|
||||
<div className="absolute top-2 right-2">
|
||||
<CopyButton text={formatted} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
/**
|
||||
* JsonFormatter Component
|
||||
*
|
||||
* Displays JSON content in various formats:
|
||||
* - `text`: Formatted JSON text with syntax highlighting
|
||||
* - `card`: Structured card view with collapsible properties
|
||||
* - `inline`: Compact inline display
|
||||
*
|
||||
* Auto-detects JSON from mixed content and validates it.
|
||||
*/
|
||||
export function JsonFormatter({
|
||||
content,
|
||||
displayMode = 'text',
|
||||
className,
|
||||
maxLines = 20,
|
||||
showTypeLabels = true,
|
||||
}: JsonFormatterProps) {
|
||||
// Detect JSON content
|
||||
const detection = useMemo(() => detectJsonContent(content), [content]);
|
||||
|
||||
// Not JSON or invalid - show as plain text
|
||||
if (!detection.isJson) {
|
||||
return (
|
||||
<div className={cn('text-xs font-mono text-foreground', className)}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Valid JSON - render based on display mode
|
||||
switch (displayMode) {
|
||||
case 'card':
|
||||
return (
|
||||
<div className={className}>
|
||||
<JsonCard data={detection.parsed} showTypeLabels={showTypeLabels} />
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'inline':
|
||||
return (
|
||||
<div className={cn('inline-flex items-center gap-1 px-2 py-1 bg-muted/50 rounded border border-border', className)}>
|
||||
<Braces className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
<span className="text-xs font-mono">
|
||||
<JsonCompact data={detection.parsed} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'text':
|
||||
default:
|
||||
return (
|
||||
<div className={className}>
|
||||
<JsonText data={detection.parsed} maxLines={maxLines} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default JsonFormatter;
|
||||
@@ -22,8 +22,9 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { LogBlockProps, LogLine } from './types';
|
||||
import { getOutputLineClass } from './utils';
|
||||
|
||||
// Re-use output line styling helpers from CliStreamMonitor
|
||||
// Local function for icon rendering (uses JSX, must stay in .tsx file)
|
||||
function getOutputLineIcon(type: LogLine['type']) {
|
||||
switch (type) {
|
||||
case 'thought':
|
||||
@@ -42,24 +43,6 @@ function getOutputLineIcon(type: LogLine['type']) {
|
||||
}
|
||||
}
|
||||
|
||||
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':
|
||||
@@ -247,13 +230,22 @@ export const LogBlock = memo(function LogBlock({
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison for performance
|
||||
// Compare all relevant block fields to detect changes
|
||||
const prevBlock = prevProps.block;
|
||||
const nextBlock = nextProps.block;
|
||||
|
||||
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
|
||||
prevProps.className === nextProps.className &&
|
||||
prevBlock.id === nextBlock.id &&
|
||||
prevBlock.status === nextBlock.status &&
|
||||
prevBlock.title === nextBlock.title &&
|
||||
prevBlock.toolName === nextBlock.toolName &&
|
||||
prevBlock.lineCount === nextBlock.lineCount &&
|
||||
prevBlock.duration === nextBlock.duration
|
||||
// Note: We don't compare block.lines deeply for performance reasons.
|
||||
// The store's getBlocks method returns cached arrays, so if lines change
|
||||
// significantly, a new block object will be created and the id will change.
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -3,208 +3,9 @@
|
||||
// ========================================
|
||||
// Container component for displaying grouped CLI output blocks
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useCliStreamStore, type LogBlockData } 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
|
||||
@@ -219,29 +20,26 @@ export interface LogBlockListProps {
|
||||
/**
|
||||
* LogBlockList component
|
||||
* Displays CLI output grouped into collapsible blocks
|
||||
*
|
||||
* Uses the store's getBlocks method to retrieve pre-computed blocks,
|
||||
* avoiding duplicate logic and ensuring consistent block grouping.
|
||||
*/
|
||||
export function LogBlockList({ executionId, className }: LogBlockListProps) {
|
||||
// Get execution data from store
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
// Get blocks directly from store using the getBlocks selector
|
||||
// This avoids duplicate logic and leverages store-side caching
|
||||
const blocks = useCliStreamStore(
|
||||
(state) => executionId ? state.getBlocks(executionId) : [],
|
||||
(a, b) => a === b // Shallow comparison - arrays are cached in store
|
||||
);
|
||||
|
||||
// Get current execution or execution by ID
|
||||
const currentExecution = useMemo(() => {
|
||||
if (!executionId) return null;
|
||||
return executions[executionId] || null;
|
||||
}, [executions, executionId]);
|
||||
// Get execution status for empty state display
|
||||
const currentExecution = useCliStreamStore((state) =>
|
||||
executionId ? state.executions[executionId] : null
|
||||
);
|
||||
|
||||
// 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) => {
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
|
||||
export { LogBlock, default } from './LogBlock';
|
||||
export { LogBlockList, type LogBlockListProps } from './LogBlockList';
|
||||
export { getOutputLineClass } from './utils';
|
||||
export type { LogBlockProps, LogBlockData, LogLine } from './types';
|
||||
|
||||
187
ccw/frontend/src/components/shared/LogBlock/jsonUtils.ts
Normal file
187
ccw/frontend/src/components/shared/LogBlock/jsonUtils.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
// ========================================
|
||||
// LogBlock JSON Utilities
|
||||
// ========================================
|
||||
// JSON content detection and formatting utilities
|
||||
|
||||
/**
|
||||
* JSON content type detection result
|
||||
*/
|
||||
export interface JsonDetectionResult {
|
||||
isJson: boolean;
|
||||
parsed?: unknown;
|
||||
error?: string;
|
||||
format: 'object' | 'array' | 'primitive' | 'invalid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display mode for JSON content
|
||||
*/
|
||||
export type JsonDisplayMode = 'text' | 'card' | 'inline';
|
||||
|
||||
/**
|
||||
* Detect if content is valid JSON
|
||||
*
|
||||
* @param content - Content string to check
|
||||
* @returns Detection result with parsed data if valid
|
||||
*/
|
||||
export function detectJson(content: string): JsonDetectionResult {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Quick check for JSON patterns
|
||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
||||
return { isJson: false, format: 'invalid' };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
|
||||
// Determine format type
|
||||
let format: JsonDetectionResult['format'] = 'primitive';
|
||||
if (Array.isArray(parsed)) {
|
||||
format = 'array';
|
||||
} else if (parsed !== null && typeof parsed === 'object') {
|
||||
format = 'object';
|
||||
}
|
||||
|
||||
return { isJson: true, parsed, format };
|
||||
} catch (error) {
|
||||
return {
|
||||
isJson: false,
|
||||
format: 'invalid',
|
||||
error: error instanceof Error ? error.message : 'Unknown parse error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from mixed content
|
||||
* Handles cases where JSON is embedded in text output
|
||||
*
|
||||
* @param content - Content that may contain JSON
|
||||
* @returns Extracted JSON string or null if not found
|
||||
*/
|
||||
export function extractJson(content: string): string | null {
|
||||
const trimmed = content.trim();
|
||||
|
||||
// Direct JSON
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
||||
// Find the matching bracket
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
let end = -1;
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const char = trimmed[i];
|
||||
|
||||
if (escape) {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === '{' || char === '[') {
|
||||
depth++;
|
||||
} else if (char === '}' || char === ']') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (end > 0) {
|
||||
return trimmed.substring(0, end);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find JSON in code blocks
|
||||
const codeBlockMatch = content.match(/```(?:json)?\s*\n([\s\S]*?)\n```/);
|
||||
if (codeBlockMatch) {
|
||||
return codeBlockMatch[1].trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if content should be displayed as JSON
|
||||
* Combines extraction and validation
|
||||
*
|
||||
* @param content - Content to check
|
||||
* @returns Detection result
|
||||
*/
|
||||
export function detectJsonContent(content: string): JsonDetectionResult & { extracted: string | null } {
|
||||
const extracted = extractJson(content);
|
||||
|
||||
if (!extracted) {
|
||||
return { isJson: false, format: 'invalid', extracted: null };
|
||||
}
|
||||
|
||||
const result = detectJson(extracted);
|
||||
return { ...result, extracted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format JSON for display
|
||||
*
|
||||
* @param data - Parsed JSON data
|
||||
* @param indent - Indentation spaces (default: 2)
|
||||
* @returns Formatted JSON string
|
||||
*/
|
||||
export function formatJson(data: unknown, indent: number = 2): string {
|
||||
return JSON.stringify(data, null, indent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary string for JSON data
|
||||
*
|
||||
* @param data - Parsed JSON data
|
||||
* @returns Summary description
|
||||
*/
|
||||
export function getJsonSummary(data: unknown): string {
|
||||
if (data === null) return 'null';
|
||||
if (typeof data === 'boolean') return data ? 'true' : 'false';
|
||||
if (typeof data === 'number') return String(data);
|
||||
if (typeof data === 'string') return `"${data.length > 30 ? data.substring(0, 30) + '...' : data}"`;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
const length = data.length;
|
||||
return `Array[${length}]${length > 0 ? ` (${getJsonSummary(data[0])}, ...)` : ''}`;
|
||||
}
|
||||
|
||||
if (typeof data === 'object') {
|
||||
const keys = Object.keys(data);
|
||||
return `Object{${keys.length}}${keys.length > 0 ? ` (${keys.slice(0, 3).join(', ')}${keys.length > 3 ? ', ...' : ''})` : ''}`;
|
||||
}
|
||||
|
||||
return String(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color class for JSON value type
|
||||
*
|
||||
* @param value - JSON value
|
||||
* @returns Tailwind color class
|
||||
*/
|
||||
export function getJsonValueTypeColor(value: unknown): string {
|
||||
if (value === null) return 'text-muted-foreground';
|
||||
if (typeof value === 'boolean') return 'text-purple-400';
|
||||
if (typeof value === 'number') return 'text-orange-400';
|
||||
if (typeof value === 'string') return 'text-green-400';
|
||||
if (Array.isArray(value)) return 'text-blue-400';
|
||||
if (typeof value === 'object') return 'text-yellow-400';
|
||||
return 'text-foreground';
|
||||
}
|
||||
30
ccw/frontend/src/components/shared/LogBlock/utils.ts
Normal file
30
ccw/frontend/src/components/shared/LogBlock/utils.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// ========================================
|
||||
// LogBlock Utility Functions
|
||||
// ========================================
|
||||
// Shared helper functions for LogBlock components
|
||||
|
||||
import type { CliOutputLine } from '@/stores/cliStreamStore';
|
||||
|
||||
/**
|
||||
* Get the CSS class name for a given output line type
|
||||
*
|
||||
* @param type - The output line type
|
||||
* @returns The CSS class name for styling the line
|
||||
*/
|
||||
export function getOutputLineClass(type: CliOutputLine['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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user