mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
feat: add terminal panel components and Zustand store for state management
- Created a barrel export file for terminal panel components. - Implemented Zustand store for managing terminal panel UI state, including visibility, active terminal, view mode, and terminal ordering. - Added actions for opening/closing the terminal panel, setting the active terminal, changing view modes, and managing terminal order. - Introduced selectors for accessing terminal panel state properties.
This commit is contained in:
@@ -37,14 +37,6 @@ type ModeType = 'provider-based' | 'direct';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function safeStringifyConfig(config: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(config ?? {}, null, 2);
|
||||
} catch {
|
||||
return '{}';
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfigJson(
|
||||
configJson: string
|
||||
): { ok: true; value: Record<string, unknown> } | { ok: false; errorKey: string } {
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('ExecutionGroup', () => {
|
||||
render(<ExecutionGroup {...defaultProps} type="parallel" />, { locale: 'en' });
|
||||
|
||||
// Parallel items should not have numbers in the numbering position
|
||||
const numberElements = document.querySelectorAll('.text-muted-foreground.text-xs');
|
||||
document.querySelectorAll('.text-muted-foreground.text-xs');
|
||||
// In parallel mode, the numbering position should be empty
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,13 @@ describe('QueueCard', () => {
|
||||
};
|
||||
|
||||
const mockQueue: IssueQueue = {
|
||||
tasks: ['task1', 'task2'],
|
||||
solutions: ['solution1'],
|
||||
tasks: [
|
||||
{ item_id: 'task1', issue_id: 'issue-1', solution_id: 'sol-1', status: 'pending', execution_order: 1, execution_group: 'group-1', depends_on: [], semantic_priority: 1 },
|
||||
{ item_id: 'task2', issue_id: 'issue-1', solution_id: 'sol-1', status: 'pending', execution_order: 2, execution_group: 'group-1', depends_on: [], semantic_priority: 1 },
|
||||
],
|
||||
solutions: [
|
||||
{ item_id: 'solution1', issue_id: 'issue-1', solution_id: 'sol-1', status: 'pending', execution_order: 1, execution_group: 'group-1', depends_on: [], semantic_priority: 1 },
|
||||
],
|
||||
conflicts: [],
|
||||
execution_groups: ['group-1'],
|
||||
grouped_items: mockQueueItems,
|
||||
|
||||
@@ -127,10 +127,6 @@ export function AppShell({
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = useCallback(() => {
|
||||
setMobileOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleMobileClose = useCallback(() => {
|
||||
setMobileOpen(false);
|
||||
}, []);
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Header Component - i18n Tests', () => {
|
||||
|
||||
describe('translated aria-labels', () => {
|
||||
it('should have translated aria-label for menu toggle', () => {
|
||||
render(<Header onMenuClick={vi.fn()} />);
|
||||
render(<Header />);
|
||||
|
||||
const menuButton = screen.getByRole('button', { name: /toggle navigation/i });
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -30,8 +30,6 @@ interface ServerCheckboxItem {
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
type CopyDirection = 'to-codex' | 'from-codex';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelProps) {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Globe,
|
||||
Sparkles,
|
||||
Download,
|
||||
Check,
|
||||
Settings,
|
||||
Key,
|
||||
Zap,
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
Code,
|
||||
Image as ImageIcon,
|
||||
Database,
|
||||
Mail,
|
||||
MailOpen,
|
||||
@@ -67,15 +66,7 @@ function formatTimeAgo(timestamp: string, formatMessage: (message: { id: string;
|
||||
return new Date(timestamp).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatDetails(details: unknown): string {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
if (typeof details === 'string') return details;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
if (typeof details === 'object' && details !== null) {
|
||||
return JSON.stringify(details, null, 2);
|
||||
}
|
||||
return String(details);
|
||||
}
|
||||
// ========== Main Types ==========
|
||||
|
||||
function getNotificationIcon(type: Toast['type']) {
|
||||
const iconClassName = 'h-4 w-4 shrink-0';
|
||||
@@ -718,7 +709,6 @@ export interface NotificationPanelProps {
|
||||
}
|
||||
|
||||
export function NotificationPanel({ isOpen, onClose }: NotificationPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Store state
|
||||
const persistentNotifications = useNotificationStore(selectPersistentNotifications);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Terminal,
|
||||
Wrench,
|
||||
FileEdit,
|
||||
FileText,
|
||||
Brain,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Vertical timeline displaying tool calls in chronological order
|
||||
|
||||
import React, { memo, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { memo, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Wrench, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
|
||||
@@ -10,12 +10,9 @@ import {
|
||||
Info,
|
||||
Code,
|
||||
Copy,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
Brain,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { JsonField } from './JsonField';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
@@ -94,8 +91,6 @@ const TYPE_CONFIGS: Record<string, TypeConfig> = {
|
||||
export function JsonCard({
|
||||
data,
|
||||
type,
|
||||
timestamp,
|
||||
onCopy,
|
||||
}: JsonCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface JsonFieldProps {
|
||||
|
||||
export function JsonField({ fieldName, value }: JsonFieldProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [, setCopied] = useState(false);
|
||||
|
||||
const isObject = value !== null && typeof value === 'object';
|
||||
const isNested = isObject && (Array.isArray(value) || Object.keys(value).length > 0);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Brain, Settings, AlertCircle, Info, MessageCircle, Wrench } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { JsonCard } from './JsonCard';
|
||||
import { detectJsonInLine } from '../utils/jsonDetector';
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ export function AssistantMessage({
|
||||
onCopy,
|
||||
className
|
||||
}: AssistantMessageProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ export interface ErrorMessageProps {
|
||||
export function ErrorMessage({
|
||||
title,
|
||||
message,
|
||||
timestamp,
|
||||
onRetry,
|
||||
onDismiss,
|
||||
className
|
||||
|
||||
@@ -18,7 +18,6 @@ export interface UserMessageProps {
|
||||
|
||||
export function UserMessage({
|
||||
content,
|
||||
timestamp,
|
||||
onCopy,
|
||||
onViewRaw,
|
||||
className
|
||||
|
||||
@@ -154,7 +154,7 @@ interface OutputLineCardProps {
|
||||
onCopy?: (content: string) => void;
|
||||
}
|
||||
|
||||
function OutputLineCard({ group, onCopy }: OutputLineCardProps) {
|
||||
function OutputLineCard({ group }: OutputLineCardProps) {
|
||||
const borderColor = getBorderColorForType(group.type);
|
||||
|
||||
// Extract content from all lines in the group
|
||||
@@ -357,12 +357,6 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
}, 50); // 50ms debounce
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setIsUserScrolling(false);
|
||||
}, []);
|
||||
|
||||
// Handle closing an execution tab
|
||||
const handleCloseExecution = useCallback((executionId: string) => {
|
||||
// Mark as closed by user so it won't be re-added by server sync
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// ========================================
|
||||
// Display detailed view of a single insight with patterns, suggestions, and metadata
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
@@ -133,8 +132,7 @@ function formatRelativeTime(timestamp: string, locale: string): string {
|
||||
/**
|
||||
* PatternItem component for displaying a single pattern
|
||||
*/
|
||||
function PatternItem({ pattern, locale }: { pattern: Pattern; locale: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
function PatternItem({ pattern }: { pattern: Pattern; locale: string }) {
|
||||
const severity = pattern.severity ?? 'info';
|
||||
const config = severityConfig[severity] ?? severityConfig.default;
|
||||
|
||||
@@ -177,7 +175,7 @@ function PatternItem({ pattern, locale }: { pattern: Pattern; locale: string })
|
||||
/**
|
||||
* SuggestionItem component for displaying a single suggestion
|
||||
*/
|
||||
function SuggestionItem({ suggestion, locale }: { suggestion: Suggestion; locale: string }) {
|
||||
function SuggestionItem({ suggestion }: { suggestion: Suggestion; locale: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = suggestionTypeConfig[suggestion.type] ?? suggestionTypeConfig.refactor;
|
||||
const typeLabel = formatMessage({ id: `prompts.suggestions.types.${suggestion.type}` });
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// ========================================
|
||||
// AI insights panel for prompt history analysis
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// LogBlock Component
|
||||
// ========================================
|
||||
|
||||
import React, { memo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Container component for displaying grouped CLI output blocks
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useCliStreamStore, type LogBlockData } from '@/stores/cliStreamStore';
|
||||
import { LogBlock } from './LogBlock';
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// ========================================
|
||||
// Statistics display for prompt history
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
|
||||
import { MessageSquare, FileType, Hash, Star } from 'lucide-react';
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
// ========================================
|
||||
// Real-time scrolling ticker with CSS marquee animation and WebSocket messages
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRealtimeUpdates, type TickerMessage } from '@/hooks/useRealtimeUpdates';
|
||||
import {
|
||||
Play,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Workflow,
|
||||
Activity,
|
||||
WifiOff,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Badge } from '../ui/Badge';
|
||||
import { toast } from 'sonner';
|
||||
import { VersionCheckModal } from './VersionCheckModal';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
} from '../ui/Dialog';
|
||||
|
||||
interface VersionCheckModalProps {
|
||||
currentVersion: string;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Team selector, stats chips, and controls
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users, MessageSquare, Clock, RefreshCw } from 'lucide-react';
|
||||
import { Users, MessageSquare, RefreshCw } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ChevronDown, ChevronUp, FileText, Filter, X } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Select,
|
||||
@@ -17,7 +16,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage, TeamMessageType, TeamMessageFilter } from '@/types/team';
|
||||
import type { TeamMessage, TeamMessageFilter } from '@/types/team';
|
||||
|
||||
interface TeamMessageFeedProps {
|
||||
messages: TeamMessage[];
|
||||
|
||||
@@ -104,18 +104,6 @@ function Arrow() {
|
||||
);
|
||||
}
|
||||
|
||||
function ForkArrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="w-4 h-0.5 bg-border" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="w-3 h-0.5 bg-border -rotate-20" />
|
||||
<div className="w-3 h-0.5 bg-border rotate-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamPipeline({ messages }: TeamPipelineProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const stageStatus = derivePipelineStatus(messages);
|
||||
|
||||
323
ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx
Normal file
323
ccw/frontend/src/components/terminal-panel/TerminalMainArea.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
// ========================================
|
||||
// TerminalMainArea Component
|
||||
// ========================================
|
||||
// Main display area for the terminal panel.
|
||||
// Shows header with session info, tab switcher (terminal/queue), and
|
||||
// embedded xterm.js terminal with command input. Reuses the xterm rendering
|
||||
// pattern from IssueTerminalTab (init, FitAddon, output streaming, PTY input).
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { X, Send } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import type { PanelView } from '@/stores/terminalPanelStore';
|
||||
import {
|
||||
fetchCliSessionBuffer,
|
||||
sendCliSessionText,
|
||||
resizeCliSession,
|
||||
executeInCliSession,
|
||||
} from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface TerminalMainAreaProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
|
||||
const panelView = useTerminalPanelStore((s) => s.panelView);
|
||||
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
|
||||
|
||||
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
||||
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
||||
|
||||
const activeSession = activeTerminalId ? sessionsByKey[activeTerminalId] : null;
|
||||
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// xterm refs
|
||||
const terminalHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const xtermRef = useRef<XTerm | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const lastChunkIndexRef = useRef<number>(0);
|
||||
|
||||
// Input batching refs (same pattern as IssueTerminalTab)
|
||||
const pendingInputRef = useRef<string>('');
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
const activeSessionKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Keep ref in sync with activeTerminalId for closures
|
||||
useEffect(() => {
|
||||
activeSessionKeyRef.current = activeTerminalId;
|
||||
}, [activeTerminalId]);
|
||||
|
||||
const flushInput = async () => {
|
||||
const sessionKey = activeSessionKeyRef.current;
|
||||
if (!sessionKey) return;
|
||||
const pending = pendingInputRef.current;
|
||||
pendingInputRef.current = '';
|
||||
if (!pending) return;
|
||||
try {
|
||||
await sendCliSessionText(sessionKey, { text: pending, appendNewline: false }, projectPath || undefined);
|
||||
} catch {
|
||||
// Ignore transient failures
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushTimerRef.current !== null) return;
|
||||
flushTimerRef.current = window.setTimeout(async () => {
|
||||
flushTimerRef.current = null;
|
||||
await flushInput();
|
||||
}, 30);
|
||||
};
|
||||
|
||||
// ========== xterm Initialization ==========
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalHostRef.current) return;
|
||||
if (xtermRef.current) return;
|
||||
|
||||
const term = new XTerm({
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
fontSize: 12,
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(terminalHostRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
// Forward keystrokes to backend (batched)
|
||||
term.onData((data) => {
|
||||
if (!activeSessionKeyRef.current) return;
|
||||
pendingInputRef.current += data;
|
||||
scheduleFlush();
|
||||
});
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
return () => {
|
||||
try {
|
||||
term.dispose();
|
||||
} finally {
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ========== Attach to Active Session ==========
|
||||
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!term || !fitAddon) return;
|
||||
|
||||
lastChunkIndexRef.current = 0;
|
||||
term.reset();
|
||||
term.clear();
|
||||
|
||||
if (!activeTerminalId) return;
|
||||
clearOutput(activeTerminalId);
|
||||
|
||||
fetchCliSessionBuffer(activeTerminalId, projectPath || undefined)
|
||||
.then(({ buffer }) => {
|
||||
setBuffer(activeTerminalId, buffer || '');
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
})
|
||||
.finally(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
}, [activeTerminalId, projectPath, setBuffer, clearOutput]);
|
||||
|
||||
// ========== Stream Output Chunks ==========
|
||||
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
if (!term) return;
|
||||
if (!activeTerminalId) return;
|
||||
|
||||
const chunks = outputChunks[activeTerminalId] ?? [];
|
||||
const start = lastChunkIndexRef.current;
|
||||
if (start >= chunks.length) return;
|
||||
|
||||
for (let i = start; i < chunks.length; i++) {
|
||||
term.write(chunks[i].data);
|
||||
}
|
||||
lastChunkIndexRef.current = chunks.length;
|
||||
}, [outputChunks, activeTerminalId]);
|
||||
|
||||
// ========== Resize Observer ==========
|
||||
|
||||
useEffect(() => {
|
||||
const host = terminalHostRef.current;
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!host || !term || !fitAddon) return;
|
||||
|
||||
const resize = () => {
|
||||
fitAddon.fit();
|
||||
const sessionKey = activeSessionKeyRef.current;
|
||||
if (sessionKey) {
|
||||
void (async () => {
|
||||
try {
|
||||
await resizeCliSession(sessionKey, { cols: term.cols, rows: term.rows }, projectPath || undefined);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, [projectPath]);
|
||||
|
||||
// ========== Execute Command ==========
|
||||
|
||||
const handleExecute = async () => {
|
||||
if (!activeTerminalId) return;
|
||||
if (!prompt.trim()) return;
|
||||
setIsExecuting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await executeInCliSession(activeTerminalId, {
|
||||
tool: activeSession?.tool || 'claude',
|
||||
prompt: prompt.trim(),
|
||||
mode: 'analysis',
|
||||
category: 'user',
|
||||
}, projectPath || undefined);
|
||||
setPrompt('');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Ctrl+Enter or Cmd+Enter to execute
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void handleExecute();
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Render ==========
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-card">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate">
|
||||
{activeSession
|
||||
? `${activeSession.tool || 'cli'} - ${activeSession.sessionKey}`
|
||||
: 'Terminal Panel'}
|
||||
</h3>
|
||||
{activeSession?.tool && (
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0">
|
||||
{activeSession.workingDir}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="flex-shrink-0 hover:bg-secondary"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={panelView}
|
||||
onValueChange={(v) => setPanelView(v as PanelView)}
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
>
|
||||
<div className="px-4 pt-2 bg-card">
|
||||
<TabsList>
|
||||
<TabsTrigger value="terminal">Terminal</TabsTrigger>
|
||||
<TabsTrigger value="queue">Queue</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Terminal View */}
|
||||
<TabsContent value="terminal" className="flex-1 flex flex-col min-h-0 mt-0 p-0">
|
||||
{activeTerminalId ? (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* xterm container */}
|
||||
<div className="flex-1 min-h-0 bg-black/90">
|
||||
<div ref={terminalHostRef} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
{/* Command input area */}
|
||||
<div className="border-t border-border p-3 bg-card">
|
||||
{error && (
|
||||
<div className="text-xs text-destructive mb-2">{error}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter command... (Ctrl+Enter to execute)"
|
||||
className={cn(
|
||||
'flex-1 min-h-[60px] max-h-[120px] p-2 bg-background border border-input rounded-md text-sm resize-none',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary'
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
disabled={!activeTerminalId || isExecuting || !prompt.trim()}
|
||||
className="self-end"
|
||||
>
|
||||
<Send className="w-4 h-4 mr-1" />
|
||||
Execute
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<p className="text-sm">No terminal selected</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Queue View (placeholder for Phase 2) */}
|
||||
<TabsContent value="queue" className="flex-1 flex items-center justify-center mt-0 p-0">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<p className="text-sm">Execution Queue Management</p>
|
||||
<p className="text-xs mt-1">Coming in Phase 2</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
ccw/frontend/src/components/terminal-panel/TerminalNavBar.tsx
Normal file
142
ccw/frontend/src/components/terminal-panel/TerminalNavBar.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// ========================================
|
||||
// TerminalNavBar Component
|
||||
// ========================================
|
||||
// Left navigation bar for the terminal panel.
|
||||
// Shows queue entry icon at top, separator, and dynamic terminal session icons
|
||||
// with status badges. Reads session data from cliSessionStore and panel state
|
||||
// from terminalPanelStore.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
ClipboardList,
|
||||
Terminal,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Circle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
|
||||
// ========== Status Badge Configuration ==========
|
||||
|
||||
type SessionStatus = 'running' | 'completed' | 'failed' | 'idle';
|
||||
|
||||
interface StatusBadgeConfig {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
colorClass: string;
|
||||
}
|
||||
|
||||
const statusBadgeMap: Record<SessionStatus, StatusBadgeConfig> = {
|
||||
running: { icon: Loader2, colorClass: 'bg-blue-500' },
|
||||
completed: { icon: CheckCircle, colorClass: 'bg-green-500' },
|
||||
failed: { icon: XCircle, colorClass: 'bg-red-500' },
|
||||
idle: { icon: Circle, colorClass: 'bg-gray-500' },
|
||||
};
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Derive a simple session status from the session metadata.
|
||||
* This is a heuristic based on available data - the shellKind and updatedAt fields
|
||||
* provide indirect clues about activity. A more precise status would require
|
||||
* backend support for explicit session state tracking.
|
||||
*/
|
||||
function deriveSessionStatus(_sessionKey: string, _shellKind: string): SessionStatus {
|
||||
// For now, default to idle. In Phase 2 we can refine this
|
||||
// based on active execution tracking from the backend.
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalNavBar() {
|
||||
const panelView = useTerminalPanelStore((s) => s.panelView);
|
||||
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
|
||||
const terminalOrder = useTerminalPanelStore((s) => s.terminalOrder);
|
||||
const setActiveTerminal = useTerminalPanelStore((s) => s.setActiveTerminal);
|
||||
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
|
||||
|
||||
const sessionsByKey = useCliSessionStore((s) => s.sessions);
|
||||
|
||||
// Build ordered list of sessions that exist in the store
|
||||
const orderedSessions = useMemo(() => {
|
||||
return terminalOrder
|
||||
.map((key) => sessionsByKey[key])
|
||||
.filter(Boolean);
|
||||
}, [terminalOrder, sessionsByKey]);
|
||||
|
||||
const handleQueueClick = () => {
|
||||
setPanelView('queue');
|
||||
};
|
||||
|
||||
const handleTerminalClick = (sessionKey: string) => {
|
||||
setPanelView('terminal');
|
||||
setActiveTerminal(sessionKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-16 flex-shrink-0 flex flex-col border-r border-border bg-muted/30">
|
||||
{/* Queue entry icon */}
|
||||
<div className="flex items-center justify-center py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQueueClick}
|
||||
className={cn(
|
||||
'w-10 h-10 flex items-center justify-center rounded-md transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
panelView === 'queue' && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
title="Execution Queue"
|
||||
>
|
||||
<ClipboardList className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-3 border-t border-border" />
|
||||
|
||||
{/* Terminal session icons (scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-1">
|
||||
{orderedSessions.map((session) => {
|
||||
const isActive = activeTerminalId === session.sessionKey && panelView === 'terminal';
|
||||
const status = deriveSessionStatus(session.sessionKey, session.shellKind);
|
||||
const badge = statusBadgeMap[status];
|
||||
const BadgeIcon = badge.icon;
|
||||
|
||||
return (
|
||||
<div key={session.sessionKey} className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTerminalClick(session.sessionKey)}
|
||||
className={cn(
|
||||
'relative w-10 h-10 flex items-center justify-center rounded-md transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
isActive && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
title={`${session.tool || 'cli'} - ${session.sessionKey}`}
|
||||
>
|
||||
<Terminal className="w-5 h-5" />
|
||||
{/* Status badge overlay */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute bottom-0.5 right-0.5 w-3.5 h-3.5 rounded-full flex items-center justify-center',
|
||||
badge.colorClass
|
||||
)}
|
||||
>
|
||||
<BadgeIcon
|
||||
className={cn(
|
||||
'w-2 h-2 text-white',
|
||||
status === 'running' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
ccw/frontend/src/components/terminal-panel/TerminalPanel.tsx
Normal file
71
ccw/frontend/src/components/terminal-panel/TerminalPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
// ========================================
|
||||
// TerminalPanel Component
|
||||
// ========================================
|
||||
// Right-side overlay panel for terminal monitoring.
|
||||
// Follows the IssueDrawer pattern: fixed overlay + translate-x slide animation.
|
||||
// Contains TerminalNavBar (left icon strip) and TerminalMainArea (main content).
|
||||
// All state is read from terminalPanelStore - no props needed.
|
||||
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { TerminalNavBar } from './TerminalNavBar';
|
||||
import { TerminalMainArea } from './TerminalMainArea';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalPanel() {
|
||||
const isPanelOpen = useTerminalPanelStore((s) => s.isPanelOpen);
|
||||
const closePanel = useTerminalPanelStore((s) => s.closePanel);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closePanel();
|
||||
}, [closePanel]);
|
||||
|
||||
// ESC key to close
|
||||
useEffect(() => {
|
||||
if (!isPanelOpen) return;
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [isPanelOpen, handleClose]);
|
||||
|
||||
if (!isPanelOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 bg-black/40 transition-opacity z-40',
|
||||
isPanelOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={handleClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 right-0 h-full w-1/2 bg-background border-l border-border shadow-2xl z-50',
|
||||
'flex flex-row transition-transform duration-300 ease-in-out',
|
||||
isPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Terminal Panel"
|
||||
style={{ minWidth: '400px', maxWidth: '800px' }}
|
||||
>
|
||||
{/* Left navigation bar */}
|
||||
<TerminalNavBar />
|
||||
|
||||
{/* Main display area */}
|
||||
<TerminalMainArea onClose={handleClose} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
ccw/frontend/src/components/terminal-panel/index.ts
Normal file
8
ccw/frontend/src/components/terminal-panel/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ========================================
|
||||
// Terminal Panel - Barrel Exports
|
||||
// ========================================
|
||||
// Re-exports all terminal panel components for convenient imports.
|
||||
|
||||
export { TerminalPanel } from './TerminalPanel';
|
||||
export { TerminalNavBar } from './TerminalNavBar';
|
||||
export { TerminalMainArea } from './TerminalMainArea';
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Info, Plus, Trash2 } from "lucide-react";
|
||||
import { Info, Trash2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ContextRule {
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface MultiNodeSelectorProps {
|
||||
}
|
||||
|
||||
const MultiNodeSelector = React.forwardRef<HTMLDivElement, MultiNodeSelectorProps>(
|
||||
({ availableNodes, selectedNodes, onChange, placeholder, emptyMessage, className }, ref) => {
|
||||
({ availableNodes, selectedNodes, onChange, emptyMessage, className }, ref) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const isSelected = (nodeId: string) => selectedNodes.includes(nodeId);
|
||||
|
||||
Reference in New Issue
Block a user