feat: Implement terminal panel for command execution and monitoring

- Added TerminalPanel component with navigation and main area for terminal interactions.
- Integrated terminal session management with CLI execution output display.
- Enhanced SolutionDrawer to open terminal panel on command execution.
- Updated localization files for terminal panel strings in English and Chinese.
- Introduced hooks for terminal panel state management.
- Created JSON schemas for plan overview and fix plan types.
This commit is contained in:
catlog22
2026-02-13 00:22:16 +08:00
parent ddbe12b7af
commit a77c965e89
17 changed files with 744 additions and 254 deletions

View File

@@ -18,6 +18,7 @@ import { useIssues, useIssueMutations } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { createCliSession, executeInCliSession } from '@/lib/api';
import type { Issue } from '@/lib/api';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
type IssueBoardStatus = Issue['status'];
type ToolName = 'claude' | 'codex' | 'gemini';
@@ -318,6 +319,8 @@ export function IssueBoardPanel() {
resumeKey: issueId,
resumeStrategy: autoStart.resumeStrategy,
}, projectPath);
// Auto-open terminal panel to show execution output
useTerminalPanelStore.getState().openTerminal(created.session.sessionKey);
} catch (e) {
setOptimisticError(`Auto-start failed: ${e instanceof Error ? e.message : String(e)}`);
}

View File

@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { cn } from '@/lib/utils';
import type { Issue } from '@/lib/api';
import { IssueTerminalTab } from './IssueTerminalTab';
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
// ========== Types ==========
export interface IssueDrawerProps {
@@ -43,6 +43,7 @@ const priorityConfig: Record<string, { label: string; variant: 'default' | 'seco
export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }: IssueDrawerProps) {
const { formatMessage } = useIntl();
const openTerminal = useOpenTerminalPanel();
const [activeTab, setActiveTab] = useState<TabValue>(initialTab);
// Reset to initial tab when opening/switching issues
@@ -224,9 +225,21 @@ export function IssueDrawer({ issue, isOpen, onClose, initialTab = 'overview' }:
)}
</TabsContent>
{/* Terminal Tab */}
{/* Terminal Tab - Link to Terminal Panel */}
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
<IssueTerminalTab issueId={issue.id} />
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Terminal className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm mb-4">{formatMessage({ id: 'home.terminalPanel.openInPanel' })}</p>
<Button
onClick={() => {
openTerminal(issue.id);
onClose();
}}
>
<Terminal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
</Button>
</div>
</TabsContent>
{/* History Tab */}

View File

@@ -20,6 +20,7 @@ import {
type QueueItem,
} from '@/lib/api';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
type ToolName = 'claude' | 'codex' | 'gemini';
type ResumeStrategy = 'nativeResume' | 'promptConcat';
@@ -170,6 +171,8 @@ export function QueueExecuteInSession({ item, className }: { item: QueueItem; cl
resumeStrategy,
}, projectPath);
setLastExecution({ executionId: result.executionId, command: result.command });
// Auto-open terminal panel to show execution output
useTerminalPanelStore.getState().openTerminal(sessionKey);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {

View File

@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/Button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
import { QueueExecuteInSession } from '@/components/issue/queue/QueueExecuteInSession';
import { QueueSendToOrchestrator } from '@/components/issue/queue/QueueSendToOrchestrator';
import { IssueTerminalTab } from '@/components/issue/hub/IssueTerminalTab';
import { useOpenTerminalPanel } from '@/stores/terminalPanelStore';
import { useIssueQueue } from '@/hooks';
import { cn } from '@/lib/utils';
import type { QueueItem } from '@/lib/api';
@@ -39,6 +39,7 @@ const statusConfig: Record<string, { label: string; variant: 'default' | 'second
export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
const { formatMessage } = useIntl();
const openTerminal = useOpenTerminalPanel();
const [activeTab, setActiveTab] = useState<TabValue>('overview');
const { data: queue } = useIssueQueue();
const itemId = item?.item_id;
@@ -257,9 +258,21 @@ export function SolutionDrawer({ item, isOpen, onClose }: SolutionDrawerProps) {
)}
</TabsContent>
{/* Terminal Tab */}
{/* Terminal Tab - Link to Terminal Panel */}
<TabsContent value="terminal" className="mt-4 pb-6 focus-visible:outline-none">
<IssueTerminalTab issueId={issueId} />
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Terminal className="h-12 w-12 mb-4 opacity-50" />
<p className="text-sm mb-4">{formatMessage({ id: 'home.terminalPanel.openInPanel' })}</p>
<Button
onClick={() => {
openTerminal(issueId);
onClose();
}}
>
<Terminal className="h-4 w-4 mr-2" />
{formatMessage({ id: 'home.terminalPanel.openInPanel' })}
</Button>
</div>
</TabsContent>
{/* JSON Tab */}

View File

@@ -11,6 +11,7 @@ import { Sidebar } from './Sidebar';
import { MainContent } from './MainContent';
import { CliStreamMonitor } from '@/components/shared/CliStreamMonitor';
import { NotificationPanel } from '@/components/notification';
import { TerminalPanel } from '@/components/terminal-panel';
import { AskQuestionDialog, A2UIPopupCard } from '@/components/a2ui';
import { BackgroundImage } from '@/components/shared/BackgroundImage';
import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } from '@/stores';
@@ -200,6 +201,9 @@ export function AppShell({
onClose={handleNotificationPanelClose}
/>
{/* Terminal Panel - Global Drawer */}
<TerminalPanel />
{/* Ask Question Dialog - For ask_question MCP tool (legacy) */}
{currentQuestion && (
<AskQuestionDialog

View File

@@ -1,22 +1,19 @@
// ========================================
// 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).
// Main content area inside TerminalPanel.
// Renders terminal output (xterm.js) or queue view based on panelView.
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { X, Terminal as TerminalIcon } from 'lucide-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 { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
fetchCliSessionBuffer,
sendCliSessionText,
@@ -26,48 +23,45 @@ import {
// ========== Types ==========
export interface TerminalMainAreaProps {
interface TerminalMainAreaProps {
onClose: () => void;
}
// ========== Component ==========
export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const projectPath = useWorkflowStore(selectProjectPath);
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
const { formatMessage } = useIntl();
const panelView = useTerminalPanelStore((s) => s.panelView);
const setPanelView = useTerminalPanelStore((s) => s.setPanelView);
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
const sessionsByKey = useCliSessionStore((s) => s.sessions);
const sessions = 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 projectPath = useWorkflowStore(selectProjectPath);
const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeSession: CliSessionMeta | undefined = activeTerminalId
? sessions[activeTerminalId]
: undefined;
// ========== xterm State ==========
// 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)
// PTY input batching
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]);
// Command execution
const [prompt, setPrompt] = useState('');
const [isExecuting, setIsExecuting] = useState(false);
const flushInput = async () => {
const sessionKey = activeSessionKeyRef.current;
const flushInput = useCallback(async () => {
const sessionKey = activeTerminalId;
if (!sessionKey) return;
const pending = pendingInputRef.current;
pendingInputRef.current = '';
@@ -77,18 +71,19 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
} catch {
// Ignore transient failures
}
};
}, [activeTerminalId, projectPath]);
const scheduleFlush = () => {
const scheduleFlush = useCallback(() => {
if (flushTimerRef.current !== null) return;
flushTimerRef.current = window.setTimeout(async () => {
flushTimerRef.current = null;
await flushInput();
}, 30);
};
}, [flushInput]);
// ========== xterm Initialization ==========
// ========== xterm Lifecycle ==========
// Init xterm instance
useEffect(() => {
if (!terminalHostRef.current) return;
if (xtermRef.current) return;
@@ -107,7 +102,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
// Forward keystrokes to backend (batched)
term.onData((data) => {
if (!activeSessionKeyRef.current) return;
if (!activeTerminalId) return;
pendingInputRef.current += data;
scheduleFlush();
});
@@ -126,8 +121,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ========== Attach to Active Session ==========
// Attach to selected session: clear terminal and load buffer
useEffect(() => {
const term = xtermRef.current;
const fitAddon = fitAddonRef.current;
@@ -152,8 +146,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
});
}, [activeTerminalId, projectPath, setBuffer, clearOutput]);
// ========== Stream Output Chunks ==========
// Stream new output chunks into xterm
useEffect(() => {
const term = xtermRef.current;
if (!term) return;
@@ -169,8 +162,7 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
lastChunkIndexRef.current = chunks.length;
}, [outputChunks, activeTerminalId]);
// ========== Resize Observer ==========
// Resize observer -> fit + resize backend
useEffect(() => {
const host = terminalHostRef.current;
const term = xtermRef.current;
@@ -179,11 +171,10 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const resize = () => {
fitAddon.fit();
const sessionKey = activeSessionKeyRef.current;
if (sessionKey) {
if (activeTerminalId) {
void (async () => {
try {
await resizeCliSession(sessionKey, { cols: term.cols, rows: term.rows }, projectPath || undefined);
await resizeCliSession(activeTerminalId, { cols: term.cols, rows: term.rows }, projectPath || undefined);
} catch {
// ignore
}
@@ -194,32 +185,30 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const ro = new ResizeObserver(resize);
ro.observe(host);
return () => ro.disconnect();
}, [projectPath]);
}, [activeTerminalId, projectPath]);
// ========== Execute Command ==========
// ========== Command Execution ==========
const handleExecute = async () => {
if (!activeTerminalId) return;
if (!prompt.trim()) return;
if (!activeTerminalId || !prompt.trim()) return;
setIsExecuting(true);
setError(null);
const sessionTool = (activeSession?.tool || 'claude') as 'claude' | 'codex' | 'gemini';
try {
await executeInCliSession(activeTerminalId, {
tool: activeSession?.tool || 'claude',
tool: sessionTool,
prompt: prompt.trim(),
mode: 'analysis',
category: 'user',
}, projectPath || undefined);
setPrompt('');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} catch {
// Error shown in terminal output
} 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();
@@ -232,92 +221,82 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
<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">
<div className="flex items-center gap-2 min-w-0">
<TerminalIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-semibold text-foreground truncate">
{panelView === 'queue'
? formatMessage({ id: 'home.terminalPanel.executionQueue' })
: activeSession
? `${activeSession.tool || 'cli'} - ${activeSession.sessionKey}`
: formatMessage({ id: 'home.terminalPanel.title' })}
</span>
{activeSession?.workingDir && panelView === 'terminal' && (
<span className="text-xs text-muted-foreground truncate hidden sm:inline">
{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 variant="ghost" size="icon" onClick={onClose} className="flex-shrink-0 hover:bg-secondary">
<X className="h-5 w-5" />
</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>
{/* Content */}
{panelView === 'queue' ? (
/* Queue View - Placeholder */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.executionQueueDesc' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.executionQueuePhase2' })}</p>
</div>
</TabsContent>
</Tabs>
</div>
) : activeTerminalId ? (
/* Terminal View */
<div className="flex-1 flex flex-col min-h-0">
{/* xterm container */}
<div className="flex-1 min-h-0">
<div
ref={terminalHostRef}
className="h-full w-full bg-black/90 rounded-none"
/>
</div>
{/* Command Input */}
<div className="border-t border-border p-3 bg-card">
<div className="space-y-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'home.terminalPanel.commandPlaceholder' })}
className={cn(
'w-full min-h-[60px] p-2 bg-background border border-input rounded-md text-sm resize-none',
'focus:outline-none focus:ring-2 focus:ring-primary'
)}
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleExecute}
disabled={!activeTerminalId || isExecuting || !prompt.trim()}
>
{formatMessage({ id: 'home.terminalPanel.execute' })}
</Button>
</div>
</div>
</div>
</div>
) : (
/* Empty State */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<TerminalIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-sm">{formatMessage({ id: 'home.terminalPanel.noTerminalSelected' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,139 +1,122 @@
// ========================================
// 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.
// Left-side icon navigation bar (w-16) inside TerminalPanel.
// Shows fixed queue entry icon + dynamic terminal icons with status badges.
import { useMemo } from 'react';
import {
ClipboardList,
Terminal,
Loader2,
CheckCircle,
XCircle,
Circle,
} from 'lucide-react';
import { useIntl } from 'react-intl';
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';
import { useCliSessionStore, type CliSessionMeta, type CliSessionOutputChunk } from '@/stores/cliSessionStore';
// ========== Status Badge Configuration ==========
// ========== Status Badge Mapping ==========
type SessionStatus = 'running' | 'completed' | 'failed' | 'idle';
interface StatusBadgeConfig {
icon: React.ComponentType<{ className?: string }>;
colorClass: string;
}
/** Activity detection threshold in milliseconds */
const ACTIVITY_THRESHOLD_MS = 10_000;
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.
function getSessionStatus(
session: CliSessionMeta | undefined,
chunks: CliSessionOutputChunk[] | undefined,
): SessionStatus {
if (!session) return 'idle';
if (!chunks || chunks.length === 0) return 'idle';
const lastChunk = chunks[chunks.length - 1];
if (Date.now() - lastChunk.timestamp < ACTIVITY_THRESHOLD_MS) return 'running';
return 'idle';
}
// ========== Component ==========
const statusStyles: Record<SessionStatus, string> = {
running: 'bg-blue-500',
completed: 'bg-green-500',
failed: 'bg-red-500',
idle: 'bg-gray-500',
};
const StatusIcon: Record<SessionStatus, React.ComponentType<{ className?: string }>> = {
running: Loader2,
completed: CheckCircle,
failed: XCircle,
idle: Circle,
};
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 setActiveTerminal = useTerminalPanelStore((s) => s.setActiveTerminal);
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 sessions = useCliSessionStore((s) => s.sessions);
const outputChunks = useCliSessionStore((s) => s.outputChunks);
const { formatMessage } = useIntl();
const handleQueueClick = () => {
setPanelView('queue');
};
const handleTerminalClick = (sessionKey: string) => {
setPanelView('terminal');
setActiveTerminal(sessionKey);
setPanelView('terminal');
};
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>
<div className="w-16 flex-shrink-0 border-r border-border bg-card flex flex-col items-center py-2">
{/* Queue Entry - Fixed at top */}
<button
className={cn(
'w-10 h-10 rounded-md flex items-center justify-center transition-colors hover:bg-accent',
panelView === 'queue' && 'bg-accent'
)}
onClick={handleQueueClick}
title={formatMessage({ id: 'home.terminalPanel.executionQueue' })}
>
<ClipboardList className="h-5 w-5 text-muted-foreground" />
</button>
{/* Separator */}
<div className="mx-3 border-t border-border" />
<div className="w-8 border-t border-border my-2" />
{/* 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;
{/* Dynamic Terminal Icons */}
<div className="flex-1 overflow-y-auto flex flex-col items-center gap-1 w-full px-1">
{terminalOrder.map((sessionKey) => {
const session = sessions[sessionKey];
const status = getSessionStatus(session, outputChunks[sessionKey]);
const StatusIconComp = StatusIcon[status];
const isActive = activeTerminalId === sessionKey && panelView === 'terminal';
const label = session
? `${session.tool || 'cli'} - ${session.sessionKey}`
: sessionKey;
return (
<div key={session.sessionKey} className="flex items-center justify-center">
<button
type="button"
onClick={() => handleTerminalClick(session.sessionKey)}
<button
key={sessionKey}
className={cn(
'relative w-10 h-10 rounded-md flex items-center justify-center transition-colors hover:bg-accent',
isActive && 'bg-accent'
)}
onClick={() => handleTerminalClick(sessionKey)}
title={label}
>
<Terminal className="h-5 w-5 text-muted-foreground" />
{/* Status Badge - bottom-right overlay */}
<span
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'
'absolute bottom-0.5 right-0.5 w-3.5 h-3.5 rounded-full flex items-center justify-center',
statusStyles[status]
)}
title={`${session.tool || 'cli'} - ${session.sessionKey}`}
>
<Terminal className="w-5 h-5" />
{/* Status badge overlay */}
<span
<StatusIconComp
className={cn(
'absolute bottom-0.5 right-0.5 w-3.5 h-3.5 rounded-full flex items-center justify-center',
badge.colorClass
'h-2 w-2 text-white',
status === 'running' && 'animate-spin'
)}
>
<BadgeIcon
className={cn(
'w-2 h-2 text-white',
status === 'running' && 'animate-spin'
)}
/>
</span>
</button>
</div>
/>
</span>
</button>
);
})}
</div>

View File

@@ -1,10 +1,8 @@
// ========================================
// 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.
// Right-side sliding panel for terminal monitoring.
// Follows IssueDrawer pattern: overlay + fixed panel + translate-x animation.
import { useEffect, useCallback } from 'react';
import { cn } from '@/lib/utils';
@@ -12,8 +10,6 @@ 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);
@@ -32,10 +28,6 @@ export function TerminalPanel() {
return () => window.removeEventListener('keydown', handleEsc);
}, [isPanelOpen, handleClose]);
if (!isPanelOpen) {
return null;
}
return (
<>
{/* Overlay */}
@@ -51,19 +43,17 @@ export function TerminalPanel() {
{/* 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',
'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 */}
{/* Left: Icon Navigation */}
<TerminalNavBar />
{/* Main display area */}
{/* Right: Main Content */}
<TerminalMainArea onClose={handleClose} />
</div>
</>

View File

@@ -1,7 +1,6 @@
// ========================================
// Terminal Panel - Barrel Exports
// Terminal Panel - Barrel Export
// ========================================
// Re-exports all terminal panel components for convenient imports.
export { TerminalPanel } from './TerminalPanel';
export { TerminalNavBar } from './TerminalNavBar';

View File

@@ -105,5 +105,22 @@
},
"project": {
"features": "features"
},
"terminalPanel": {
"title": "Terminal Monitor",
"executionQueue": "Execution Queue",
"executionQueueDesc": "Execution Queue Management",
"executionQueuePhase2": "Coming in Phase 2",
"noTerminalSelected": "No terminal selected",
"selectTerminalHint": "Select a terminal from the sidebar",
"commandPlaceholder": "Enter command... (Ctrl+Enter to execute)",
"execute": "Execute",
"openInPanel": "Open in Terminal Panel",
"status": {
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"idle": "Idle"
}
}
}

View File

@@ -105,5 +105,22 @@
},
"project": {
"features": "个功能"
},
"terminalPanel": {
"title": "终端监控",
"executionQueue": "执行队列",
"executionQueueDesc": "执行队列管理",
"executionQueuePhase2": "将在 Phase 2 实现",
"noTerminalSelected": "未选择终端",
"selectTerminalHint": "从侧边栏选择一个终端",
"commandPlaceholder": "输入命令... (Ctrl+Enter 执行)",
"execute": "执行",
"openInPanel": "在终端面板中查看",
"status": {
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"idle": "空闲"
}
}
}

View File

@@ -145,3 +145,11 @@ export const selectActiveTerminalId = (state: TerminalPanelStore) => state.activ
export const selectPanelView = (state: TerminalPanelStore) => state.panelView;
export const selectTerminalOrder = (state: TerminalPanelStore) => state.terminalOrder;
export const selectTerminalCount = (state: TerminalPanelStore) => state.terminalOrder.length;
// ========== Convenience Hooks ==========
/** Hook that returns the openTerminal action for use in event handlers */
export function useOpenTerminalPanel() {
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
return openTerminal;
}