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:
catlog22
2026-02-12 23:53:11 +08:00
parent e44a97e812
commit ddbe12b7af
72 changed files with 1055 additions and 254 deletions

View File

@@ -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 } {

View File

@@ -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
});
});

View File

@@ -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,

View File

@@ -127,10 +127,6 @@ export function AppShell({
return () => window.removeEventListener('resize', handleResize);
}, []);
const handleMenuClick = useCallback(() => {
setMobileOpen((prev) => !prev);
}, []);
const handleMobileClose = useCallback(() => {
setMobileOpen(false);
}, []);

View File

@@ -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();

View File

@@ -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 ==========

View File

@@ -30,8 +30,6 @@ interface ServerCheckboxItem {
selected: boolean;
}
type CopyDirection = 'to-codex' | 'from-codex';
// ========== Component ==========
export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelProps) {

View File

@@ -10,7 +10,6 @@ import {
Globe,
Sparkles,
Download,
Check,
Settings,
Key,
Zap,

View File

@@ -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);

View File

@@ -15,6 +15,7 @@ import {
Terminal,
Wrench,
FileEdit,
FileText,
Brain,
Search,
} from 'lucide-react';

View File

@@ -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';

View File

@@ -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);

View File

@@ -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);

View File

@@ -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';

View File

@@ -91,7 +91,6 @@ export function AssistantMessage({
onCopy,
className
}: AssistantMessageProps) {
const { formatMessage } = useIntl();
const [isExpanded, setIsExpanded] = useState(true);
const [copied, setCopied] = useState(false);

View File

@@ -19,7 +19,6 @@ export interface ErrorMessageProps {
export function ErrorMessage({
title,
message,
timestamp,
onRetry,
onDismiss,
className

View File

@@ -18,7 +18,6 @@ export interface UserMessageProps {
export function UserMessage({
content,
timestamp,
onCopy,
onViewRaw,
className

View File

@@ -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

View File

@@ -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}` });

View File

@@ -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';

View File

@@ -2,7 +2,7 @@
// LogBlock Component
// ========================================
import React, { memo } from 'react';
import { memo } from 'react';
import {
ChevronDown,
ChevronUp,

View File

@@ -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';

View File

@@ -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';

View File

@@ -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,

View File

@@ -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';

View File

@@ -3,7 +3,7 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
} from '../ui/Dialog';
interface VersionCheckModalProps {
currentVersion: string;

View File

@@ -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';

View File

@@ -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[];

View File

@@ -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);

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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';

View File

@@ -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 {

View File

@@ -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);

View File

@@ -108,7 +108,6 @@ export function useActiveCliExecutions(
const removeExecution = useCliStreamStore(state => state.removeExecution);
const executions = useCliStreamStore(state => state.executions);
const setCurrentExecution = useCliStreamStore(state => state.setCurrentExecution);
const markExecutionClosedByUser = useCliStreamStore(state => state.markExecutionClosedByUser);
const isExecutionClosedByUser = useCliStreamStore(state => state.isExecutionClosedByUser);
const cleanupUserClosedExecutions = useCliStreamStore(state => state.cleanupUserClosedExecutions);

View File

@@ -44,10 +44,8 @@ import {
type ProviderCredential,
type CustomEndpoint,
type CacheStats,
type GlobalCacheSettings,
type ModelPoolConfig,
type ModelPoolType,
type DiscoveredProvider,
type CliSettingsEndpoint,
type SaveCliSettingsRequest,
} from '../lib/api';

View File

@@ -9,15 +9,10 @@ import {
fetchGraphImpact,
type GraphDependenciesRequest,
type GraphDependenciesResponse,
type GraphImpactRequest,
type GraphImpactResponse,
} from '../lib/api';
import type {
GraphData,
GraphNode,
GraphEdge,
GraphFilters,
GraphMetadata,
NodeType,
EdgeType,
} from '../types/graph-explorer';
@@ -132,7 +127,7 @@ function filterGraphData(
// Filter by minimum complexity
if (filters.minComplexity !== undefined) {
filteredNodes = filteredNodes.filter(node => {
filteredNodes = filteredNodes.filter(_node => {
// This would require complexity data to be available
// For now, we'll skip this filter
return true;
@@ -239,7 +234,6 @@ export function useGraphData(options: UseGraphDataOptions = {}): UseGraphDataRet
rootPath,
maxDepth,
nodeTypes,
edgeTypes,
} = options;
const queryClient = useQueryClient();

View File

@@ -8,7 +8,6 @@ import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
useIssueQueue,
useIssueMutations,
useQueueMutations,
useIssueDiscovery,
} from './useIssues';

View File

@@ -25,7 +25,6 @@ import {
exportDiscoveryFindingsAsIssues,
type Issue,
type IssueQueue,
type IssuesResponse,
type QueueHistoryIndex,
type DiscoverySession,
type Finding,

View File

@@ -24,7 +24,6 @@ import {
type McpProjectConfigType,
type McpTemplate,
type McpTemplateInstallRequest,
type AllProjectsResponse,
type OtherProjectsServersResponse,
type CrossCliCopyRequest,
type CrossCliCopyResponse,
@@ -439,7 +438,7 @@ export function useCodexMutations(): UseCodexMutationsReturn {
// Optimistic update could be added here if needed
return { serverName, enabled };
},
onError: (_error, _vars, context) => {
onError: (_error, _vars, _context) => {
// Rollback on error
console.error('Failed to toggle Codex MCP server:', _error);
},

View File

@@ -168,7 +168,7 @@ export function useCreateSession(): UseCreateSessionReturn {
const mutation = useMutation({
mutationFn: createSession,
onSuccess: (newSession) => {
onSuccess: () => {
// Invalidate sessions cache to trigger refetch
queryClient.invalidateQueries({ queryKey: ['workspace'] });
// Invalidate dashboard stats

View File

@@ -9,7 +9,6 @@ import {
enableSkill,
disableSkill,
type Skill,
type SkillsResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';

View File

@@ -15,7 +15,7 @@ import {
type ExecutionLog,
} from '../types/execution';
import { SurfaceUpdateSchema } from '../packages/a2ui-runtime/core/A2UITypes';
import type { ToolCallKind } from '../types/toolCall';
import type { ToolCallKind, ToolCallExecution } from '../types/toolCall';
// Constants
const RECONNECT_DELAY_BASE = 1000; // 1 second
@@ -242,7 +242,7 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
const currentNodeId = stores.currentExecution?.currentNodeId;
if (currentNodeId && (unitType === 'stdout' || unitType === 'stderr')) {
const toolCalls = stores.getToolCallsForNode?.(currentNodeId);
const activeCall = toolCalls?.find(c => c.status === 'executing');
const activeCall = toolCalls?.find((c: ToolCallExecution) => c.status === 'executing');
if (activeCall) {
stores.updateToolCall(currentNodeId, activeCall.callId, {

View File

@@ -3,9 +3,8 @@
// ========================================
// TanStack Query hook for fetching workflow status distribution
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { workspaceQueryKeys } from '@/lib/queryKeys';
/**
* Workflow status count data structure

View File

@@ -74,7 +74,7 @@ describe('MCP API (frontend ↔ backend contract)', () => {
it('toggleMcpServer uses /api/mcp-toggle with { projectPath, serverName, enable }', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(async (input, init) => {
.mockImplementation(async (input, _init) => {
if (input === '/api/mcp-toggle') {
return jsonResponse({ success: true, serverName: 'global1', enabled: false });
}

View File

@@ -261,7 +261,7 @@ describe('Component Renderer Interface', () => {
});
it('should support async action handlers', async () => {
const asyncAction: ActionHandler = async (actionId, params) => {
const asyncAction: ActionHandler = async (_actionId, _params) => {
await Promise.resolve();
return;
};

View File

@@ -5,8 +5,7 @@
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { A2UIParser, a2uiParser, A2UIParseError } from '../core/A2UIParser';
import type { SurfaceUpdate, A2UIComponent } from '../core/A2UITypes';
import { a2uiParser, A2UIParseError } from '../core/A2UIParser';
// Import component renderers to trigger auto-registration
import '../renderer/components';

View File

@@ -4,7 +4,7 @@
// Tests for all A2UI component renderers
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, within } from '@testing-library/react';
import { render, screen, cleanup } from '@testing-library/react';
import type { A2UIComponent } from '../core/A2UITypes';
import type { A2UIState, ActionHandler, BindingResolver } from '../core/A2UIComponentRegistry';
import type { TextComponent, ButtonComponent, DropdownComponent, CLIOutputComponent, DateTimeInputComponent } from '../core/A2UITypes';
@@ -653,7 +653,7 @@ describe('A2UI Component Integration', () => {
});
it('should handle async action handlers', async () => {
const asyncOnAction: ActionHandler = async (actionId, params) => {
const asyncOnAction: ActionHandler = async (_actionId, _params) => {
await new Promise((resolve) => setTimeout(resolve, 10));
};

View File

@@ -3,8 +3,8 @@
// ========================================
// React component that renders A2UI surfaces
import React, { useState, useCallback, useMemo } from 'react';
import type { SurfaceUpdate, SurfaceComponent, A2UIComponent, LiteralString, Binding } from '../core/A2UITypes';
import { useState, useCallback } from 'react';
import type { SurfaceUpdate, A2UIComponent, LiteralString, Binding } from '../core/A2UITypes';
import { a2uiRegistry, type A2UIState, type ActionHandler, type BindingResolver } from '../core/A2UIComponentRegistry';
// ========== Renderer Props ==========
@@ -26,7 +26,7 @@ interface A2UIRendererProps {
*/
export function A2UIRenderer({ surface, onAction, className = '' }: A2UIRendererProps) {
// Local state initialized with surface's initial state
const [localState, setLocalState] = useState<A2UIState>(surface.initialState || {});
const [localState] = useState<A2UIState>(surface.initialState || {});
// Handle action from components
const handleAction = useCallback<ActionHandler>(
@@ -57,21 +57,6 @@ export function A2UIRenderer({ surface, onAction, className = '' }: A2UIRenderer
[localState]
);
// Update state from external source
const updateState = useCallback((updates: Partial<A2UIState>) => {
setLocalState((prev) => ({ ...prev, ...updates }));
}, []);
// Memoize context for components
const contextValue = useMemo(
() => ({
state: localState,
resolveBinding,
updateState,
}),
[localState, resolveBinding, updateState]
);
return (
<div className={`a2ui-surface ${className}`} data-surface-id={surface.surfaceId}>
{surface.components.map((comp) => (

View File

@@ -3,25 +3,16 @@
// ========================================
// Maps A2UI Button component to shadcn/ui Button
import React from 'react';
import { Button } from '@/components/ui/Button';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import type { A2UIState, ActionHandler, BindingResolver } from '../../core/A2UIComponentRegistry';
import type { ButtonComponent, A2UIComponent } from '../../core/A2UITypes';
import type { ButtonComponent } from '../../core/A2UITypes';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
interface A2UIButtonRendererProps {
component: A2UIComponent;
state: A2UIState;
onAction: ActionHandler;
resolveBinding: BindingResolver;
}
/**
* A2UI Button Component Renderer
* Maps A2UI variants (primary/secondary/destructive) to shadcn/ui variants (default/secondary/destructive/ghost)
*/
export const A2UIButton: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UIButton: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const buttonComp = component as ButtonComponent;
const { Button: buttonConfig } = buttonComp;

View File

@@ -101,7 +101,7 @@ function StreamingIndicator() {
* A2UI CLIOutput Component Renderer
* Displays CLI output with optional syntax highlighting and streaming indicator
*/
export const A2UICLIOutput: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UICLIOutput: ComponentRenderer = ({ component, resolveBinding }) => {
const cliOutputComp = component as CLIOutputComponent;
const { CLIOutput: config } = cliOutputComp;

View File

@@ -3,24 +3,16 @@
// ========================================
// Maps A2UI Card component to shadcn/ui Card
import React from 'react';
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/Card';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { CardComponent } from '../../core/A2UITypes';
interface A2UICardProps {
component: CardComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Card Component Renderer
* Container component with optional title and description
*/
export const A2UICard: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UICard: ComponentRenderer = ({ component, resolveBinding }) => {
const cardComp = component as CardComponent;
const { Card: cardConfig } = cardComp;

View File

@@ -3,25 +3,18 @@
// ========================================
// Maps A2UI Checkbox component to shadcn/ui Checkbox
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { Checkbox } from '@/components/ui/Checkbox';
import { Label } from '@/components/ui/Label';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
import type { CheckboxComponent } from '../../core/A2UITypes';
interface A2UICheckboxProps {
component: CheckboxComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Checkbox Component Renderer
* Boolean state binding with onChange handler
*/
export const A2UICheckbox: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UICheckbox: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const checkboxComp = component as CheckboxComponent;
const { Checkbox: checkboxConfig } = checkboxComp;

View File

@@ -3,9 +3,9 @@
// ========================================
// Date/time picker with ISO string format support
import React, { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect } from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
import { resolveTextContent } from '../A2UIRenderer';
import type { DateTimeInputComponent } from '../../core/A2UITypes';
/**
@@ -30,7 +30,7 @@ function isoToDateTimeLocal(isoString: string): string {
/**
* Convert datetime-local input format to ISO string
*/
function dateTimeLocalToIso(dateTimeLocal: string, includeTime: boolean): string {
function dateTimeLocalToIso(dateTimeLocal: string, _includeTime: boolean): string {
if (!dateTimeLocal) return '';
const date = new Date(dateTimeLocal);
@@ -43,7 +43,7 @@ function dateTimeLocalToIso(dateTimeLocal: string, includeTime: boolean): string
* A2UI DateTimeInput Component Renderer
* Uses native input[type="datetime-local"] or input[type="date"] based on includeTime
*/
export const A2UIDateTimeInput: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UIDateTimeInput: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const dateTimeComp = component as DateTimeInputComponent;
const { DateTimeInput: config } = dateTimeComp;
const includeTime = config.includeTime ?? true;

View File

@@ -3,24 +3,16 @@
// ========================================
// Maps A2UI Progress component to shadcn/ui Progress
import React from 'react';
import { Progress } from '@/components/ui/Progress';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { ProgressComponent } from '../../core/A2UITypes';
interface A2UIProgressProps {
component: ProgressComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Progress Component Renderer
* For CLI output progress display
*/
export const A2UIProgress: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UIProgress: ComponentRenderer = ({ component, resolveBinding }) => {
const progressComp = component as ProgressComponent;
const { Progress: progressConfig } = progressComp;

View File

@@ -4,25 +4,18 @@
// Maps A2UI RadioGroup component to shadcn/ui RadioGroup
// Used for single-select questions with visible options
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Label } from '@/components/ui/Label';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding, resolveTextContent } from '../A2UIRenderer';
import type { RadioGroupComponent } from '../../core/A2UITypes';
interface A2UIRadioGroupProps {
component: RadioGroupComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI RadioGroup Component Renderer
* Single selection from visible options with onChange handler
*/
export const A2UIRadioGroup: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UIRadioGroup: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const radioGroupComp = component as RadioGroupComponent;
const { RadioGroup: radioConfig } = radioGroupComp;

View File

@@ -6,20 +6,12 @@
import React from 'react';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveTextContent } from '../A2UIRenderer';
import type { TextComponent } from '../../core/A2UITypes';
interface A2UITextProps {
component: TextComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI Text Component Renderer
* Maps A2UI Text usageHint to HTML elements (h1, h2, h3, p, span, code)
*/
export const A2UIText: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UIText: ComponentRenderer = ({ component, resolveBinding }) => {
const { Text } = component as { Text: { text: unknown; usageHint?: string } };
// Resolve text content

View File

@@ -3,24 +3,17 @@
// ========================================
// Maps A2UI TextArea component to shadcn/ui Textarea
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { Textarea } from '@/components/ui/Textarea';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { TextAreaComponent } from '../../core/A2UITypes';
interface A2UITextAreaProps {
component: TextAreaComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI TextArea Component Renderer
* Two-way binding via onChange updates to local state
*/
export const A2UITextArea: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UITextArea: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const areaComp = component as TextAreaComponent;
const { TextArea: areaConfig } = areaComp;

View File

@@ -3,24 +3,17 @@
// ========================================
// Maps A2UI TextField component to shadcn/ui Input
import React, { useState, useCallback } from 'react';
import { useState, useCallback } from 'react';
import { Input } from '@/components/ui/Input';
import type { ComponentRenderer } from '../../core/A2UIComponentRegistry';
import { resolveLiteralOrBinding } from '../A2UIRenderer';
import type { TextFieldComponent } from '../../core/A2UITypes';
interface A2UITextFieldProps {
component: TextFieldComponent;
state: Record<string, unknown>;
onAction: (actionId: string, params: Record<string, unknown>) => void | Promise<void>;
resolveBinding: (binding: { path: string }) => unknown;
}
/**
* A2UI TextField Component Renderer
* Two-way binding via onChange updates to local state
*/
export const A2UITextField: ComponentRenderer = ({ component, state, onAction, resolveBinding }) => {
export const A2UITextField: ComponentRenderer = ({ component, onAction, resolveBinding }) => {
const fieldComp = component as TextFieldComponent;
const { TextField: fieldConfig } = fieldComp;

View File

@@ -188,7 +188,7 @@ export function CliViewerPage() {
const lastMessage = useNotificationStore(selectWsLastMessage);
// Active execution sync from server
const { isLoading: isSyncing } = useActiveCliExecutions(true); // Always sync when page is open
const { isLoading: _isSyncing } = useActiveCliExecutions(true); // Always sync when page is open
const invalidateActive = useInvalidateActiveCliExecutions();
// Detect current layout type from store

View File

@@ -7,7 +7,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { CodexLensManagerPage } from './CodexLensManagerPage';
import * as api from '@/lib/api';
// Mock api module
vi.mock('@/lib/api', () => ({

View File

@@ -15,7 +15,7 @@ import {
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import {
AlertDialog,
AlertDialogTrigger,
@@ -30,7 +30,6 @@ import {
import { OverviewTab } from '@/components/codexlens/OverviewTab';
import { SettingsTab } from '@/components/codexlens/SettingsTab';
import { AdvancedTab } from '@/components/codexlens/AdvancedTab';
import { GpuSelector } from '@/components/codexlens/GpuSelector';
import { ModelsTab } from '@/components/codexlens/ModelsTab';
import { SearchTab } from '@/components/codexlens/SearchTab';
import { SemanticInstallDialog } from '@/components/codexlens/SemanticInstallDialog';

View File

@@ -4,7 +4,7 @@
// Tests for the issue discovery page with i18n
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import { render, screen } from '@/test/i18n';
import { DiscoveryPage } from './DiscoveryPage';
import { useWorkflowStore } from '@/stores/workflowStore';
import type { DiscoverySession } from '@/lib/api';

View File

@@ -13,14 +13,13 @@ import {
XCircle,
BarChart3,
Calendar,
Filter,
ListTree,
History,
List,
Monitor,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { TabsNavigation, type TabItem } from '@/components/ui/TabsNavigation';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { Badge } from '@/components/ui/Badge';
import { Button } from '@/components/ui/Button';
import { ExecutionMonitor } from './orchestrator/ExecutionMonitor';

View File

@@ -3,7 +3,7 @@
// ========================================
// Unified page for issues, queue, and discovery with tab navigation
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useIntl } from 'react-intl';
import {

View File

@@ -386,7 +386,7 @@ export function IssueManagerPage() {
try {
const result = await pullIssuesFromGitHub({ state: 'open', limit: 100 });
await refetch();
toast.success(formatMessage({ id: 'issues.messages.githubSyncSuccess' }, result));
toast.success(formatMessage({ id: 'issues.messages.githubSyncSuccess' }, { ...result }));
} catch (err) {
console.error('GitHub sync failed:', err);
toast.error(formatMessage({ id: 'issues.messages.githubSyncError' }));

View File

@@ -15,11 +15,8 @@ import {
ArrowLeft,
FileEdit,
Wrench,
Calendar,
Loader2,
XCircle,
CheckCircle,
Clock,
Code,
Zap,
ListTodo,
@@ -31,7 +28,6 @@ import {
Folder,
MessageSquare,
FileText,
ChevronDown,
ChevronRight,
Ruler,
Stethoscope,
@@ -41,10 +37,8 @@ import { Flowchart } from '@/components/shared/Flowchart';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
import { Tabs, TabsContent } from '@/components/ui/Tabs';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/Collapsible';
import type { LiteTask } from '@/lib/api';
// ========================================
// Type Definitions
@@ -64,43 +58,6 @@ interface Exploration {
content?: string;
}
interface ExplorationAngle {
findings: string[];
recommendations: string[];
patterns: string[];
risks: string[];
}
interface ImplementationTask {
id: string;
title: string;
description?: string;
status?: string;
assignee?: string;
}
interface Milestone {
id: string;
name: string;
description?: string;
target_date?: string;
}
interface DiscussionSolution {
id: string;
name: string;
summary: string | { en: string; zh: string };
feasibility: number;
effort: 'low' | 'medium' | 'high';
risk: 'low' | 'medium' | 'high';
source_cli: string[];
implementation_plan: {
approach: string;
tasks: ImplementationTask[];
milestones: Milestone[];
};
}
// ========================================
// Main Component
// ========================================

View File

@@ -91,6 +91,24 @@ export {
selectActiveTab,
} from './viewerStore';
// Terminal Panel Store
export {
useTerminalPanelStore,
selectIsPanelOpen as selectIsTerminalPanelOpen,
selectActiveTerminalId,
selectPanelView,
selectTerminalOrder,
selectTerminalCount,
} from './terminalPanelStore';
// Terminal Panel Store Types
export type {
PanelView,
TerminalPanelState,
TerminalPanelActions,
TerminalPanelStore,
} from './terminalPanelStore';
// Re-export types for convenience
export type {
// App Store Types

View File

@@ -0,0 +1,147 @@
// ========================================
// Terminal Panel Store
// ========================================
// Zustand store for terminal panel UI state management.
// Manages panel visibility, active terminal, view mode, and terminal ordering.
// Separated from cliSessionStore to keep UI state independent of data state.
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
// ========== Types ==========
export type PanelView = 'terminal' | 'queue';
export interface TerminalPanelState {
/** Whether the bottom terminal panel is open */
isPanelOpen: boolean;
/** The sessionKey of the currently active terminal */
activeTerminalId: string | null;
/** Current panel view mode */
panelView: PanelView;
/** Ordered list of terminal sessionKeys (tab order) */
terminalOrder: string[];
}
export interface TerminalPanelActions {
/** Open panel and activate the given terminal; adds it to order if new */
openTerminal: (sessionKey: string) => void;
/** Close the terminal panel (keeps terminal order intact) */
closePanel: () => void;
/** Switch active terminal without opening/closing */
setActiveTerminal: (sessionKey: string) => void;
/** Switch panel view between 'terminal' and 'queue' */
setPanelView: (view: PanelView) => void;
/** Add a terminal to the order list (no-op if already present) */
addTerminal: (sessionKey: string) => void;
/** Remove a terminal from the order list and adjust active if needed */
removeTerminal: (sessionKey: string) => void;
}
export type TerminalPanelStore = TerminalPanelState & TerminalPanelActions;
// ========== Initial State ==========
const initialState: TerminalPanelState = {
isPanelOpen: false,
activeTerminalId: null,
panelView: 'terminal',
terminalOrder: [],
};
// ========== Store ==========
export const useTerminalPanelStore = create<TerminalPanelStore>()(
devtools(
(set, get) => ({
...initialState,
// ========== Panel Lifecycle ==========
openTerminal: (sessionKey: string) => {
const { terminalOrder } = get();
const nextOrder = terminalOrder.includes(sessionKey)
? terminalOrder
: [...terminalOrder, sessionKey];
set(
{
isPanelOpen: true,
activeTerminalId: sessionKey,
panelView: 'terminal',
terminalOrder: nextOrder,
},
false,
'openTerminal'
);
},
closePanel: () => {
set({ isPanelOpen: false }, false, 'closePanel');
},
// ========== Terminal Selection ==========
setActiveTerminal: (sessionKey: string) => {
set({ activeTerminalId: sessionKey }, false, 'setActiveTerminal');
},
// ========== View Mode ==========
setPanelView: (view: PanelView) => {
set({ panelView: view }, false, 'setPanelView');
},
// ========== Terminal Order Management ==========
addTerminal: (sessionKey: string) => {
const { terminalOrder } = get();
if (terminalOrder.includes(sessionKey)) return;
set(
{ terminalOrder: [...terminalOrder, sessionKey] },
false,
'addTerminal'
);
},
removeTerminal: (sessionKey: string) => {
const { terminalOrder, activeTerminalId } = get();
const nextOrder = terminalOrder.filter((key) => key !== sessionKey);
// If removed terminal was active, activate the previous or next neighbor
let nextActive = activeTerminalId;
if (activeTerminalId === sessionKey) {
const removedIndex = terminalOrder.indexOf(sessionKey);
if (nextOrder.length === 0) {
nextActive = null;
} else if (removedIndex >= nextOrder.length) {
nextActive = nextOrder[nextOrder.length - 1];
} else {
nextActive = nextOrder[removedIndex];
}
}
set(
{
terminalOrder: nextOrder,
activeTerminalId: nextActive,
// Auto-close panel when no terminals remain
isPanelOpen: nextOrder.length > 0 ? get().isPanelOpen : false,
},
false,
'removeTerminal'
);
},
}),
{ name: 'TerminalPanelStore' }
)
);
// ========== Selectors ==========
export const selectIsPanelOpen = (state: TerminalPanelStore) => state.isPanelOpen;
export const selectActiveTerminalId = (state: TerminalPanelStore) => state.activeTerminalId;
export const selectPanelView = (state: TerminalPanelStore) => state.panelView;
export const selectTerminalOrder = (state: TerminalPanelStore) => state.terminalOrder;
export const selectTerminalCount = (state: TerminalPanelStore) => state.terminalOrder.length;

View File

@@ -206,6 +206,9 @@ export interface ExecutionStoreActions {
completeToolCall: (nodeId: string, callId: string, result: { status: ToolCallExecution['status']; exitCode?: number; error?: string; result?: unknown }) => void;
toggleToolCallExpanded: (nodeId: string, callId: string) => void;
// Tool call getters
getToolCallsForNode: (nodeId: string) => ToolCallExecution[];
// Node selection (new)
selectNode: (nodeId: string | null) => void;