feat: add Terminal Dashboard components and state management

- Implement TerminalTabBar for session tab management with status indicators and alert badges.
- Create TerminalWorkbench to combine TerminalTabBar and TerminalInstance for terminal session display.
- Add localization support for terminal dashboard in English and Chinese.
- Develop TerminalDashboardPage for the main layout of the terminal dashboard with a three-column structure.
- Introduce Zustand stores for session management and issue/queue integration, handling session groups, terminal metadata, and alert management.
- Create a monitor web worker for off-main-thread output analysis, detecting errors and stalls in terminal sessions.
- Define TypeScript types for terminal dashboard state management and integration.
This commit is contained in:
catlog22
2026-02-14 20:54:05 +08:00
parent 4d22ae4b2f
commit e4b898f401
37 changed files with 2810 additions and 5438 deletions

View File

@@ -9,7 +9,6 @@ import { useIntl } from 'react-intl';
import {
X,
Terminal as TerminalIcon,
Plus,
Trash2,
RotateCcw,
Loader2,
@@ -22,7 +21,6 @@ import { useCliSessionStore, type CliSessionMeta } from '@/stores/cliSessionStor
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { QueueExecutionListView } from './QueueExecutionListView';
import {
createCliSession,
fetchCliSessionBuffer,
sendCliSessionText,
resizeCliSession,
@@ -41,14 +39,12 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const { formatMessage } = useIntl();
const panelView = useTerminalPanelStore((s) => s.panelView);
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
const openTerminal = useTerminalPanelStore((s) => s.openTerminal);
const removeTerminal = useTerminalPanelStore((s) => s.removeTerminal);
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 upsertSession = useCliSessionStore((s) => s.upsertSession);
const removeSessionFromStore = useCliSessionStore((s) => s.removeSession);
const projectPath = useWorkflowStore(selectProjectPath);
@@ -69,12 +65,8 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
const flushTimerRef = useRef<number | null>(null);
// Toolbar state
const [isCreating, setIsCreating] = useState(false);
const [isClosing, setIsClosing] = useState(false);
// Available CLI tools
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
const flushInput = useCallback(async () => {
const sessionKey = activeTerminalId;
if (!sessionKey) return;
@@ -204,23 +196,6 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
// ========== CLI Session Actions ==========
const handleCreateSession = useCallback(async (tool: string) => {
if (!projectPath || isCreating) return;
setIsCreating(true);
try {
const created = await createCliSession(
{ workingDir: projectPath, tool },
projectPath
);
upsertSession(created.session);
openTerminal(created.session.sessionKey);
} catch (err) {
console.error('[TerminalMainArea] createCliSession failed:', err);
} finally {
setIsCreating(false);
}
}, [projectPath, isCreating, upsertSession, openTerminal]);
const handleCloseSession = useCallback(async () => {
if (!activeTerminalId || isClosing) return;
setIsClosing(true);
@@ -268,50 +243,30 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
</div>
{/* Toolbar */}
{panelView === 'terminal' && (
{panelView === 'terminal' && activeTerminalId && (
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-border bg-muted/30">
{/* New CLI session buttons */}
{CLI_TOOLS.map((tool) => (
<Button
key={tool}
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1"
disabled={isCreating || !projectPath}
onClick={() => handleCreateSession(tool)}
title={`New ${tool} session`}
>
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
{tool}
</Button>
))}
<div className="flex-1" />
{/* Terminal actions */}
{activeTerminalId && (
<>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleClearTerminal}
title="Clear terminal"
>
<RotateCcw className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
disabled={isClosing}
onClick={handleCloseSession}
title="Close session"
>
{isClosing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={handleClearTerminal}
title="Clear terminal"
>
<RotateCcw className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-destructive hover:text-destructive"
disabled={isClosing}
onClick={handleCloseSession}
title="Close session"
>
{isClosing ? <Loader2 className="h-3 w-3 animate-spin" /> : <Trash2 className="h-3 w-3" />}
</Button>
</div>
)}
@@ -328,29 +283,12 @@ export function TerminalMainArea({ onClose }: TerminalMainAreaProps) {
/>
</div>
) : (
/* Empty State - with quick launch */
/* 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 mb-4">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
{projectPath && (
<div className="flex items-center justify-center gap-2">
{CLI_TOOLS.map((tool) => (
<Button
key={tool}
variant="outline"
size="sm"
className="gap-1"
disabled={isCreating}
onClick={() => handleCreateSession(tool)}
>
{isCreating ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
{tool}
</Button>
))}
</div>
)}
<p className="text-xs mt-1">{formatMessage({ id: 'home.terminalPanel.selectTerminalHint' })}</p>
</div>
</div>
)}

View File

@@ -11,7 +11,7 @@ import { cn } from '@/lib/utils';
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
import { useCliSessionStore, type CliSessionMeta, type CliSessionOutputChunk } from '@/stores/cliSessionStore';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { createCliSession } from '@/lib/api';
import { createCliSession, sendCliSessionText } from '@/lib/api';
// ========== Status Badge Mapping ==========
@@ -45,6 +45,16 @@ const StatusIcon: Record<SessionStatus, React.ComponentType<{ className?: string
idle: Circle,
};
type LaunchMode = 'default' | 'yolo';
const LAUNCH_COMMANDS: Record<string, Record<LaunchMode, string>> = {
claude: { default: 'claude', yolo: 'claude --permission-mode bypassPermissions' },
gemini: { default: 'gemini', yolo: 'gemini --approval-mode yolo' },
qwen: { default: 'qwen', yolo: 'qwen --approval-mode yolo' },
codex: { default: 'codex', yolo: 'codex --full-auto' },
opencode: { default: 'opencode', yolo: 'opencode' },
};
export function TerminalNavBar() {
const panelView = useTerminalPanelStore((s) => s.panelView);
const activeTerminalId = useTerminalPanelStore((s) => s.activeTerminalId);
@@ -61,6 +71,7 @@ export function TerminalNavBar() {
const projectPath = useWorkflowStore(selectProjectPath);
const [isCreating, setIsCreating] = useState(false);
const [showToolMenu, setShowToolMenu] = useState(false);
const [launchMode, setLaunchMode] = useState<LaunchMode>('yolo');
const CLI_TOOLS = ['claude', 'gemini', 'qwen', 'codex', 'opencode'] as const;
@@ -75,12 +86,24 @@ export function TerminalNavBar() {
);
upsertSession(created.session);
openTerminal(created.session.sessionKey);
// Auto-launch CLI tool after PTY is ready
const command = LAUNCH_COMMANDS[tool]?.[launchMode] ?? tool;
setTimeout(() => {
sendCliSessionText(
created.session.sessionKey,
{ text: command, appendNewline: true },
projectPath
).catch((err) =>
console.error('[TerminalNavBar] auto-launch failed:', err)
);
}, 300);
} catch (err) {
console.error('[TerminalNavBar] createCliSession failed:', err);
} finally {
setIsCreating(false);
}
}, [projectPath, isCreating, upsertSession, openTerminal]);
}, [projectPath, isCreating, launchMode, upsertSession, openTerminal]);
const handleQueueClick = () => {
setPanelView('queue');
@@ -173,7 +196,25 @@ export function TerminalNavBar() {
{showToolMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowToolMenu(false)} />
<div className="absolute left-full bottom-0 ml-1 z-50 bg-card border border-border rounded-md shadow-lg py-1 min-w-[120px]">
<div className="absolute left-full bottom-0 ml-1 z-50 bg-card border border-border rounded-md shadow-lg min-w-[140px]">
{/* Mode Toggle */}
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border">
{(['default', 'yolo'] as const).map((mode) => (
<button
key={mode}
className={cn(
'flex-1 text-xs px-2 py-1 rounded transition-colors',
launchMode === mode
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent'
)}
onClick={() => setLaunchMode(mode)}
>
{mode === 'default' ? 'Default' : 'Yolo'}
</button>
))}
</div>
{/* Tool List */}
{CLI_TOOLS.map((tool) => (
<button
key={tool}