mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -81,6 +81,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||
{ path: '/terminal-dashboard', labelKey: 'navigation.main.terminalDashboard', icon: Terminal },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
129
ccw/frontend/src/components/terminal-dashboard/AgentList.tsx
Normal file
129
ccw/frontend/src/components/terminal-dashboard/AgentList.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// ========================================
|
||||
// AgentList Component
|
||||
// ========================================
|
||||
// Compact list of active orchestration plans from orchestratorStore.
|
||||
// Shows plan name, current step progress, and status badge.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Bot, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useOrchestratorStore, selectActivePlans } from '@/stores';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { OrchestrationRunState } from '@/stores/orchestratorStore';
|
||||
import type { OrchestrationStatus } from '@/types/orchestrator';
|
||||
|
||||
// ========== Status Badge Config ==========
|
||||
|
||||
const STATUS_CONFIG: Record<
|
||||
OrchestrationStatus,
|
||||
{ variant: 'default' | 'info' | 'success' | 'destructive' | 'secondary' | 'warning'; messageId: string }
|
||||
> = {
|
||||
running: { variant: 'info', messageId: 'terminalDashboard.agentList.statusRunning' },
|
||||
completed: { variant: 'success', messageId: 'terminalDashboard.agentList.statusCompleted' },
|
||||
failed: { variant: 'destructive', messageId: 'terminalDashboard.agentList.statusFailed' },
|
||||
paused: { variant: 'warning', messageId: 'terminalDashboard.agentList.statusPaused' },
|
||||
pending: { variant: 'secondary', messageId: 'terminalDashboard.agentList.statusPending' },
|
||||
cancelled: { variant: 'secondary', messageId: 'terminalDashboard.agentList.statusPending' },
|
||||
};
|
||||
|
||||
// ========== AgentListItem ==========
|
||||
|
||||
function AgentListItem({
|
||||
runState,
|
||||
}: {
|
||||
runState: OrchestrationRunState;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { plan, status, stepStatuses } = runState;
|
||||
|
||||
const totalSteps = plan.steps.length;
|
||||
const completedSteps = useMemo(
|
||||
() =>
|
||||
Object.values(stepStatuses).filter(
|
||||
(s) => s.status === 'completed' || s.status === 'skipped'
|
||||
).length,
|
||||
[stepStatuses]
|
||||
);
|
||||
|
||||
const config = STATUS_CONFIG[status] ?? STATUS_CONFIG.pending;
|
||||
const isRunning = status === 'running';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2',
|
||||
'border-b border-border/30 last:border-b-0',
|
||||
'hover:bg-muted/30 transition-colors'
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{isRunning ? (
|
||||
<Loader2 className="w-3.5 h-3.5 text-blue-500 animate-spin" />
|
||||
) : (
|
||||
<Bot className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium truncate">{plan.name}</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'terminalDashboard.agentList.stepLabel' },
|
||||
{ current: completedSteps, total: totalSteps }
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Badge variant={config.variant} className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
{formatMessage({ id: config.messageId })}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== AgentList Component ==========
|
||||
|
||||
export function AgentList() {
|
||||
const { formatMessage } = useIntl();
|
||||
const activePlans = useOrchestratorStore(selectActivePlans);
|
||||
|
||||
const planEntries = useMemo(
|
||||
() => Object.entries(activePlans),
|
||||
[activePlans]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border shrink-0">
|
||||
<Bot className="w-4 h-4 text-muted-foreground" />
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{formatMessage({ id: 'terminalDashboard.agentList.title' })}
|
||||
</h3>
|
||||
{planEntries.length > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-auto">
|
||||
{planEntries.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan list or empty state */}
|
||||
{planEntries.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 px-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.agentList.noAgents' })}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto max-h-[200px]">
|
||||
{planEntries.map(([planId, runState]) => (
|
||||
<AgentListItem key={planId} runState={runState} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentList;
|
||||
@@ -0,0 +1,90 @@
|
||||
// ========================================
|
||||
// AssociationHighlight Context
|
||||
// ========================================
|
||||
// React context provider for cross-panel association chain highlighting.
|
||||
// Provides ephemeral UI state for linked-chain highlights shared across
|
||||
// left/middle/right panels. The highlighted chain indicates which
|
||||
// Issue, QueueItem, and Session are visually linked.
|
||||
//
|
||||
// Design rationale: React context chosen over Zustand store because
|
||||
// highlight state is ephemeral UI state that does not need persistence
|
||||
// or cross-page sharing.
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { AssociationChain } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== Context Type ==========
|
||||
|
||||
interface AssociationHighlightContextType {
|
||||
/** Currently highlighted association chain, or null if nothing is highlighted */
|
||||
chain: AssociationChain | null;
|
||||
/** Set the highlighted chain (pass null to clear) */
|
||||
setChain: (chain: AssociationChain | null) => void;
|
||||
/** Check if a specific entity is part of the current highlighted chain */
|
||||
isHighlighted: (entityId: string, entityType: 'issue' | 'queue' | 'session') => boolean;
|
||||
}
|
||||
|
||||
// ========== Context ==========
|
||||
|
||||
const AssociationHighlightContext = createContext<AssociationHighlightContextType | null>(null);
|
||||
|
||||
// ========== Provider ==========
|
||||
|
||||
export function AssociationHighlightProvider({ children }: { children: ReactNode }) {
|
||||
const [chain, setChainState] = useState<AssociationChain | null>(null);
|
||||
|
||||
const setChain = useCallback((nextChain: AssociationChain | null) => {
|
||||
setChainState(nextChain);
|
||||
}, []);
|
||||
|
||||
const isHighlighted = useCallback(
|
||||
(entityId: string, entityType: 'issue' | 'queue' | 'session'): boolean => {
|
||||
if (!chain) return false;
|
||||
switch (entityType) {
|
||||
case 'issue':
|
||||
return chain.issueId === entityId;
|
||||
case 'queue':
|
||||
return chain.queueItemId === entityId;
|
||||
case 'session':
|
||||
return chain.sessionId === entityId;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[chain]
|
||||
);
|
||||
|
||||
const value = useMemo<AssociationHighlightContextType>(
|
||||
() => ({ chain, setChain, isHighlighted }),
|
||||
[chain, setChain, isHighlighted]
|
||||
);
|
||||
|
||||
return (
|
||||
<AssociationHighlightContext.Provider value={value}>
|
||||
{children}
|
||||
</AssociationHighlightContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Consumer Hook ==========
|
||||
|
||||
/**
|
||||
* Hook to access the association highlight context.
|
||||
* Must be used within an AssociationHighlightProvider.
|
||||
*/
|
||||
export function useAssociationHighlight(): AssociationHighlightContextType {
|
||||
const ctx = useContext(AssociationHighlightContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'useAssociationHighlight must be used within an AssociationHighlightProvider'
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
// ========================================
|
||||
// BottomInspector Component
|
||||
// ========================================
|
||||
// Collapsible bottom panel showing the full association chain
|
||||
// (Issue -> Queue -> Session) for the currently selected entity.
|
||||
// Consumes issueQueueIntegrationStore for association chain data
|
||||
// and useAssociationHighlight context for the highlighted chain.
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
Terminal,
|
||||
ArrowRight,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import { useQueueExecutionStore } from '@/stores/queueExecutionStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useAssociationHighlight } from './AssociationHighlight';
|
||||
|
||||
// ========== Chain Node ==========
|
||||
|
||||
function ChainNode({
|
||||
icon: Icon,
|
||||
label,
|
||||
entityId,
|
||||
status,
|
||||
timestamp,
|
||||
isLast = false,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
entityId: string | null;
|
||||
status?: string;
|
||||
timestamp?: string;
|
||||
isLast?: boolean;
|
||||
}) {
|
||||
if (!entityId) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 opacity-40">
|
||||
<Icon className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs text-muted-foreground italic">--</span>
|
||||
{!isLast && <ArrowRight className="w-3 h-3 text-muted-foreground mx-1" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-3.5 h-3.5 text-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-mono font-semibold text-foreground px-1.5 py-0.5 rounded bg-muted">
|
||||
{entityId}
|
||||
</span>
|
||||
{status && (
|
||||
<span className="text-[10px] text-muted-foreground px-1 py-0.5 rounded border border-border">
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
{timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTimestamp(timestamp)}
|
||||
</span>
|
||||
)}
|
||||
{!isLast && <ArrowRight className="w-3 h-3 text-muted-foreground mx-1" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Format ISO timestamp to short readable form */
|
||||
function formatTimestamp(ts: string): string {
|
||||
try {
|
||||
const date = new Date(ts);
|
||||
return date.toLocaleTimeString(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function BottomInspector() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const { chain: highlightedChain } = useAssociationHighlight();
|
||||
|
||||
// Use highlighted chain from context, fall back to store association chain
|
||||
const activeChain = highlightedChain ?? associationChain;
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Resolve additional details from stores
|
||||
const chainDetails = useMemo(() => {
|
||||
if (!activeChain) return null;
|
||||
|
||||
const executions = Object.values(useQueueExecutionStore.getState().executions);
|
||||
const sessions = useCliSessionStore.getState().sessions;
|
||||
|
||||
// Find matching execution for queue status
|
||||
let queueStatus: string | undefined;
|
||||
let executionTimestamp: string | undefined;
|
||||
if (activeChain.queueItemId) {
|
||||
const exec = executions.find((e) => e.queueItemId === activeChain.queueItemId);
|
||||
if (exec) {
|
||||
queueStatus = exec.status;
|
||||
executionTimestamp = exec.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Find session metadata
|
||||
let sessionStatus: string | undefined;
|
||||
let sessionTimestamp: string | undefined;
|
||||
if (activeChain.sessionId) {
|
||||
const session = sessions[activeChain.sessionId];
|
||||
if (session) {
|
||||
sessionStatus = 'active';
|
||||
sessionTimestamp = session.createdAt;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
queueStatus,
|
||||
executionTimestamp,
|
||||
sessionStatus,
|
||||
sessionTimestamp,
|
||||
};
|
||||
}, [activeChain]);
|
||||
|
||||
const hasChain = activeChain !== null;
|
||||
|
||||
return (
|
||||
<div className={cn('border-t border-border bg-muted/30 shrink-0 transition-all duration-200')}>
|
||||
{/* Toggle button */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="flex items-center gap-2 w-full px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
<span className="font-medium">
|
||||
{formatMessage({ id: 'terminalDashboard.inspector.title' })}
|
||||
</span>
|
||||
{hasChain && (
|
||||
<span className="ml-1 w-2 h-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-4 h-4 ml-auto" />
|
||||
) : (
|
||||
<ChevronUp className="w-4 h-4 ml-auto" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapsible content */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
isOpen ? 'max-h-40 opacity-100' : 'max-h-0 opacity-0'
|
||||
)}
|
||||
>
|
||||
<div className="px-4 pb-3">
|
||||
{hasChain ? (
|
||||
<div className="space-y-2">
|
||||
{/* Chain label */}
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.inspector.associationChain' })}
|
||||
</p>
|
||||
{/* Chain visualization: Issue -> Queue -> Session */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<ChainNode
|
||||
icon={AlertCircle}
|
||||
label="Issue"
|
||||
entityId={activeChain.issueId}
|
||||
/>
|
||||
<ChainNode
|
||||
icon={ListChecks}
|
||||
label="Queue"
|
||||
entityId={activeChain.queueItemId}
|
||||
status={chainDetails?.queueStatus}
|
||||
timestamp={chainDetails?.executionTimestamp}
|
||||
/>
|
||||
<ChainNode
|
||||
icon={Terminal}
|
||||
label="Session"
|
||||
entityId={activeChain.sessionId}
|
||||
status={chainDetails?.sessionStatus}
|
||||
timestamp={chainDetails?.sessionTimestamp}
|
||||
isLast
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.inspector.noSelection' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
ccw/frontend/src/components/terminal-dashboard/GlobalKpiBar.tsx
Normal file
138
ccw/frontend/src/components/terminal-dashboard/GlobalKpiBar.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
// ========================================
|
||||
// GlobalKpiBar Component
|
||||
// ========================================
|
||||
// Top bar showing 3 KPI metrics spanning the full page width.
|
||||
// Metrics:
|
||||
// 1. Active Sessions - count from sessionManagerStore (wraps cliSessionStore)
|
||||
// 2. Queue Size - pending/ready items count from useIssueQueue React Query hook
|
||||
// 3. Alert Count - total alerts from all terminalMetas
|
||||
//
|
||||
// Per design spec (V-001): consumes sessionManagerStore, NOT cliSessionStore directly.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Activity, ListChecks, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import { useIssueQueue } from '@/hooks/useIssues';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== KPI Item ==========
|
||||
|
||||
function KpiItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
variant = 'default',
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: number;
|
||||
variant?: 'default' | 'primary' | 'warning' | 'destructive';
|
||||
}) {
|
||||
const variantStyles = {
|
||||
default: 'text-muted-foreground',
|
||||
primary: 'text-primary',
|
||||
warning: 'text-warning',
|
||||
destructive: 'text-destructive',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn('w-4 h-4', variantStyles[variant])} />
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className={cn('text-sm font-semibold tabular-nums', variantStyles[variant])}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function GlobalKpiBar() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const queueQuery = useIssueQueue();
|
||||
|
||||
// Derive active session count from sessionManagerStore groups
|
||||
const sessionCount = useMemo(() => {
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
// Count sessions that have 'active' status in terminalMetas
|
||||
let activeCount = 0;
|
||||
for (const sid of allSessionIds) {
|
||||
const meta = terminalMetas[sid];
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
if (status === 'active') {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
// If no sessions are managed in groups, return total unique session IDs
|
||||
// This ensures the KPI shows meaningful data even before grouping
|
||||
return activeCount > 0 ? activeCount : allSessionIds.length;
|
||||
}, [groups, terminalMetas]);
|
||||
|
||||
// Derive queue pending count from useIssueQueue data
|
||||
const queuePendingCount = useMemo(() => {
|
||||
const queue = queueQuery.data;
|
||||
if (!queue) return 0;
|
||||
// Count all items across grouped_items
|
||||
let count = 0;
|
||||
if (queue.grouped_items) {
|
||||
for (const items of Object.values(queue.grouped_items)) {
|
||||
count += items.length;
|
||||
}
|
||||
}
|
||||
// Also count ungrouped tasks and solutions
|
||||
if (queue.tasks) count += queue.tasks.length;
|
||||
if (queue.solutions) count += queue.solutions.length;
|
||||
return count;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
// Derive total alert count from all terminalMetas
|
||||
const totalAlerts = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const meta of Object.values(terminalMetas)) {
|
||||
count += meta.alertCount;
|
||||
}
|
||||
return count;
|
||||
}, [terminalMetas]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-6 px-4 py-2 border-b border-border bg-muted/30 shrink-0">
|
||||
<KpiItem
|
||||
icon={Activity}
|
||||
label={formatMessage({ id: 'terminalDashboard.kpi.activeSessions' })}
|
||||
value={sessionCount}
|
||||
variant="primary"
|
||||
/>
|
||||
|
||||
<div className="w-px h-4 bg-border" />
|
||||
|
||||
<KpiItem
|
||||
icon={ListChecks}
|
||||
label={formatMessage({ id: 'terminalDashboard.kpi.queueSize' })}
|
||||
value={queuePendingCount}
|
||||
variant={queuePendingCount > 0 ? 'warning' : 'default'}
|
||||
/>
|
||||
|
||||
<div className="w-px h-4 bg-border" />
|
||||
|
||||
<KpiItem
|
||||
icon={AlertTriangle}
|
||||
label={formatMessage({ id: 'terminalDashboard.kpi.alertCount' })}
|
||||
value={totalAlerts}
|
||||
variant={totalAlerts > 0 ? 'destructive' : 'default'}
|
||||
/>
|
||||
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatMessage({ id: 'terminalDashboard.page.title' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
ccw/frontend/src/components/terminal-dashboard/IssuePanel.tsx
Normal file
289
ccw/frontend/src/components/terminal-dashboard/IssuePanel.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
// ========================================
|
||||
// IssuePanel Component
|
||||
// ========================================
|
||||
// Issue list panel for the terminal dashboard middle column.
|
||||
// Consumes existing useIssues() React Query hook for data fetching.
|
||||
// Integrates with issueQueueIntegrationStore for selection state
|
||||
// and association chain highlighting.
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRightToLine,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
CircleDot,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIssues } from '@/hooks/useIssues';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectSelectedIssueId,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import type { Issue } from '@/lib/api';
|
||||
|
||||
// ========== Priority Badge ==========
|
||||
|
||||
const PRIORITY_STYLES: Record<Issue['priority'], { variant: 'destructive' | 'warning' | 'info' | 'secondary'; label: string }> = {
|
||||
critical: { variant: 'destructive', label: 'Critical' },
|
||||
high: { variant: 'warning', label: 'High' },
|
||||
medium: { variant: 'info', label: 'Medium' },
|
||||
low: { variant: 'secondary', label: 'Low' },
|
||||
};
|
||||
|
||||
function PriorityBadge({ priority }: { priority: Issue['priority'] }) {
|
||||
const style = PRIORITY_STYLES[priority] ?? PRIORITY_STYLES.medium;
|
||||
return (
|
||||
<Badge variant={style.variant} className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
{style.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Status Indicator ==========
|
||||
|
||||
function StatusDot({ status }: { status: Issue['status'] }) {
|
||||
const colorMap: Record<Issue['status'], string> = {
|
||||
open: 'text-info',
|
||||
in_progress: 'text-warning',
|
||||
resolved: 'text-success',
|
||||
closed: 'text-muted-foreground',
|
||||
completed: 'text-success',
|
||||
};
|
||||
return <CircleDot className={cn('w-3 h-3 shrink-0', colorMap[status] ?? 'text-muted-foreground')} />;
|
||||
}
|
||||
|
||||
// ========== Issue Item ==========
|
||||
|
||||
function IssueItem({
|
||||
issue,
|
||||
isSelected,
|
||||
isHighlighted,
|
||||
onSelect,
|
||||
onSendToQueue,
|
||||
}: {
|
||||
issue: Issue;
|
||||
isSelected: boolean;
|
||||
isHighlighted: boolean;
|
||||
onSelect: () => void;
|
||||
onSendToQueue: () => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const handleSendToQueue = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onSendToQueue();
|
||||
},
|
||||
[onSendToQueue]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md transition-colors',
|
||||
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
|
||||
isSelected && 'bg-primary/10 ring-1 ring-primary/30',
|
||||
isHighlighted && !isSelected && 'bg-accent/50'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={issue.status} />
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{issue.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<PriorityBadge priority={issue.priority} />
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'p-1 rounded hover:bg-primary/20 transition-colors',
|
||||
'text-muted-foreground hover:text-primary',
|
||||
'focus:outline-none focus:ring-1 focus:ring-primary/30'
|
||||
)}
|
||||
onClick={handleSendToQueue}
|
||||
title={formatMessage({ id: 'terminalDashboard.issuePanel.sendToQueue' })}
|
||||
>
|
||||
<ArrowRightToLine className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{issue.context && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground truncate pl-5">
|
||||
{issue.context}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground pl-5">
|
||||
<span className="font-mono">{issue.id}</span>
|
||||
{issue.labels && issue.labels.length > 0 && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span className="truncate">{issue.labels.slice(0, 2).join(', ')}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Empty State ==========
|
||||
|
||||
function IssueEmptyState() {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground p-4">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.issuePanel.noIssues' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.issuePanel.noIssuesDesc' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Error State ==========
|
||||
|
||||
function IssueErrorState({ error }: { error: Error }) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-destructive p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-10 w-10 mx-auto mb-3 opacity-60" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.issuePanel.error' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function IssuePanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { issues, isLoading, error, openCount } = useIssues();
|
||||
|
||||
const selectedIssueId = useIssueQueueIntegrationStore(selectSelectedIssueId);
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const setSelectedIssue = useIssueQueueIntegrationStore((s) => s.setSelectedIssue);
|
||||
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
|
||||
|
||||
// Sort: open/in_progress first, then by priority (critical > high > medium > low)
|
||||
const sortedIssues = useMemo(() => {
|
||||
const priorityOrder: Record<string, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
medium: 2,
|
||||
low: 3,
|
||||
};
|
||||
const statusOrder: Record<string, number> = {
|
||||
in_progress: 0,
|
||||
open: 1,
|
||||
resolved: 2,
|
||||
completed: 3,
|
||||
closed: 4,
|
||||
};
|
||||
return [...issues].sort((a, b) => {
|
||||
const sa = statusOrder[a.status] ?? 5;
|
||||
const sb = statusOrder[b.status] ?? 5;
|
||||
if (sa !== sb) return sa - sb;
|
||||
const pa = priorityOrder[a.priority] ?? 3;
|
||||
const pb = priorityOrder[b.priority] ?? 3;
|
||||
return pa - pb;
|
||||
});
|
||||
}, [issues]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(issueId: string) => {
|
||||
if (selectedIssueId === issueId) {
|
||||
setSelectedIssue(null);
|
||||
} else {
|
||||
buildAssociationChain(issueId, 'issue');
|
||||
}
|
||||
},
|
||||
[selectedIssueId, setSelectedIssue, buildAssociationChain]
|
||||
);
|
||||
|
||||
const handleSendToQueue = useCallback(
|
||||
(issueId: string) => {
|
||||
// Select the issue and build chain - queue creation is handled elsewhere
|
||||
buildAssociationChain(issueId, 'issue');
|
||||
},
|
||||
[buildAssociationChain]
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.issuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.issuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
<IssueErrorState error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.issuePanel.title' })}
|
||||
</h3>
|
||||
{openCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{openCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issue List */}
|
||||
{sortedIssues.length === 0 ? (
|
||||
<IssueEmptyState />
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{sortedIssues.map((issue) => (
|
||||
<IssueItem
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
isSelected={selectedIssueId === issue.id}
|
||||
isHighlighted={associationChain?.issueId === issue.id}
|
||||
onSelect={() => handleSelect(issue.id)}
|
||||
onSendToQueue={() => handleSendToQueue(issue.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
264
ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx
Normal file
264
ccw/frontend/src/components/terminal-dashboard/QueuePanel.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
// ========================================
|
||||
// QueuePanel Component
|
||||
// ========================================
|
||||
// Queue list panel for the terminal dashboard middle column.
|
||||
// Consumes existing useIssueQueue() React Query hook for queue data
|
||||
// and bridges queueExecutionStore for execution status per item.
|
||||
// Integrates with issueQueueIntegrationStore for association chain
|
||||
// highlighting and selection state.
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ListChecks,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
ArrowDownToLine,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Zap,
|
||||
Ban,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useIssueQueue } from '@/hooks/useIssues';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import {
|
||||
useQueueExecutionStore,
|
||||
selectByQueueItem,
|
||||
} from '@/stores/queueExecutionStore';
|
||||
import type { QueueItem } from '@/lib/api';
|
||||
|
||||
// ========== Status Config ==========
|
||||
|
||||
type QueueItemStatus = QueueItem['status'];
|
||||
|
||||
const STATUS_CONFIG: Record<QueueItemStatus, {
|
||||
variant: 'info' | 'success' | 'destructive' | 'secondary' | 'warning' | 'outline';
|
||||
icon: typeof Clock;
|
||||
label: string;
|
||||
}> = {
|
||||
pending: { variant: 'secondary', icon: Clock, label: 'Pending' },
|
||||
ready: { variant: 'info', icon: Zap, label: 'Ready' },
|
||||
executing: { variant: 'warning', icon: Loader2, label: 'Executing' },
|
||||
completed: { variant: 'success', icon: CheckCircle, label: 'Completed' },
|
||||
failed: { variant: 'destructive', icon: XCircle, label: 'Failed' },
|
||||
blocked: { variant: 'outline', icon: Ban, label: 'Blocked' },
|
||||
};
|
||||
|
||||
// ========== Queue Item Row ==========
|
||||
|
||||
function QueueItemRow({
|
||||
item,
|
||||
isHighlighted,
|
||||
onSelect,
|
||||
}: {
|
||||
item: QueueItem;
|
||||
isHighlighted: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = STATUS_CONFIG[item.status] ?? STATUS_CONFIG.pending;
|
||||
const StatusIcon = config.icon;
|
||||
|
||||
// Bridge to queueExecutionStore for execution status
|
||||
const executions = useQueueExecutionStore(selectByQueueItem(item.item_id));
|
||||
const activeExec = executions.find((e) => e.status === 'running') ?? executions[0];
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 rounded-md transition-colors',
|
||||
'hover:bg-muted/60 focus:outline-none focus:ring-1 focus:ring-primary/30',
|
||||
isHighlighted && 'bg-accent/50 ring-1 ring-accent/30'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 shrink-0',
|
||||
item.status === 'executing' && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
<span className="text-sm font-medium text-foreground truncate font-mono">
|
||||
{item.item_id}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={config.variant} className="text-[10px] px-1.5 py-0 shrink-0">
|
||||
{formatMessage({ id: `terminalDashboard.queuePanel.status.${item.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground pl-5">
|
||||
<span className="font-mono">{item.issue_id}</span>
|
||||
<span className="text-border">|</span>
|
||||
<span>
|
||||
{formatMessage(
|
||||
{ id: 'terminalDashboard.queuePanel.order' },
|
||||
{ order: item.execution_order }
|
||||
)}
|
||||
</span>
|
||||
<span className="text-border">|</span>
|
||||
<span>{item.execution_group}</span>
|
||||
{activeExec?.sessionKey && (
|
||||
<>
|
||||
<span className="text-border">|</span>
|
||||
<span className="flex items-center gap-0.5">
|
||||
<Terminal className="w-3 h-3" />
|
||||
{activeExec.sessionKey}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{item.depends_on.length > 0 && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground/70 pl-5 truncate">
|
||||
{formatMessage(
|
||||
{ id: 'terminalDashboard.queuePanel.dependsOn' },
|
||||
{ deps: item.depends_on.join(', ') }
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Empty State ==========
|
||||
|
||||
function QueueEmptyState() {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground p-4">
|
||||
<div className="text-center">
|
||||
<ListChecks className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.noItems' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.noItemsDesc' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Error State ==========
|
||||
|
||||
function QueueErrorState({ error }: { error: Error }) {
|
||||
const { formatMessage } = useIntl();
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-destructive p-4">
|
||||
<div className="text-center">
|
||||
<AlertTriangle className="h-10 w-10 mx-auto mb-3 opacity-60" />
|
||||
<p className="text-sm">{formatMessage({ id: 'terminalDashboard.queuePanel.error' })}</p>
|
||||
<p className="text-xs mt-1 opacity-70">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function QueuePanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const queueQuery = useIssueQueue();
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const buildAssociationChain = useIssueQueueIntegrationStore((s) => s.buildAssociationChain);
|
||||
|
||||
// Flatten all queue items from grouped_items
|
||||
const allItems = useMemo(() => {
|
||||
if (!queueQuery.data) return [];
|
||||
const grouped = queueQuery.data.grouped_items ?? {};
|
||||
const items: QueueItem[] = [];
|
||||
for (const group of Object.values(grouped)) {
|
||||
items.push(...group);
|
||||
}
|
||||
// Sort by execution_order
|
||||
items.sort((a, b) => a.execution_order - b.execution_order);
|
||||
return items;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
// Count active items (pending + ready + executing)
|
||||
const activeCount = useMemo(() => {
|
||||
return allItems.filter(
|
||||
(item) => item.status === 'pending' || item.status === 'ready' || item.status === 'executing'
|
||||
).length;
|
||||
}, [allItems]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(queueItemId: string) => {
|
||||
buildAssociationChain(queueItemId, 'queue');
|
||||
},
|
||||
[buildAssociationChain]
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (queueQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (queueQuery.error) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
</div>
|
||||
<QueueErrorState error={queueQuery.error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with flow indicator */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<ArrowDownToLine className="w-4 h-4 text-muted-foreground" />
|
||||
<ListChecks className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.queuePanel.title' })}
|
||||
</h3>
|
||||
{activeCount > 0 && (
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Queue Item List */}
|
||||
{allItems.length === 0 ? (
|
||||
<QueueEmptyState />
|
||||
) : (
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-1.5 space-y-0.5">
|
||||
{allItems.map((item) => (
|
||||
<QueueItemRow
|
||||
key={item.item_id}
|
||||
item={item}
|
||||
isHighlighted={associationChain?.queueItemId === item.item_id}
|
||||
onSelect={() => handleSelect(item.item_id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// ========================================
|
||||
// SessionGroupTree Component
|
||||
// ========================================
|
||||
// Tree view for session groups with drag-and-drop support.
|
||||
// Sessions can be dragged between groups. Groups are expandable sections.
|
||||
// Uses @hello-pangea/dnd for drag-and-drop, sessionManagerStore for state.
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
type DropResult,
|
||||
} from '@hello-pangea/dnd';
|
||||
import {
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
Folder,
|
||||
Plus,
|
||||
Terminal,
|
||||
GripVertical,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSessionManagerStore, selectGroups, selectSessionManagerActiveTerminalId } from '@/stores';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
// ========== SessionGroupTree Component ==========
|
||||
|
||||
export function SessionGroupTree() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||
const createGroup = useSessionManagerStore((s) => s.createGroup);
|
||||
const moveSessionToGroup = useSessionManagerStore((s) => s.moveSessionToGroup);
|
||||
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = useCallback((groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreateGroup = useCallback(() => {
|
||||
const name = formatMessage({ id: 'terminalDashboard.sessionTree.defaultGroupName' });
|
||||
createGroup(name);
|
||||
}, [createGroup, formatMessage]);
|
||||
|
||||
const handleSessionClick = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveTerminal(sessionId);
|
||||
},
|
||||
[setActiveTerminal]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
const { draggableId, destination } = result;
|
||||
if (!destination) return;
|
||||
|
||||
// destination.droppableId is the target group ID
|
||||
const targetGroupId = destination.droppableId;
|
||||
moveSessionToGroup(draggableId, targetGroupId);
|
||||
},
|
||||
[moveSessionToGroup]
|
||||
);
|
||||
|
||||
// Build a lookup for session display names
|
||||
const sessionNames = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const [key, meta] of Object.entries(sessions)) {
|
||||
map[key] = meta.tool ? `${meta.tool} - ${meta.shellKind}` : meta.shellKind;
|
||||
}
|
||||
return map;
|
||||
}, [sessions]);
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 text-muted-foreground p-4">
|
||||
<Folder className="w-8 h-8 opacity-50" />
|
||||
<p className="text-xs text-center">
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Create group button */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Groups with drag-and-drop */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
{groups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.id);
|
||||
return (
|
||||
<div key={group.id} className="border-b border-border/50 last:border-b-0">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
|
||||
'hover:bg-muted/50 transition-colors text-sm'
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate font-medium">{group.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{group.sessionIds.length}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Expanded: droppable session list */}
|
||||
{isExpanded && (
|
||||
<Droppable droppableId={group.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={cn(
|
||||
'min-h-[32px] pb-1',
|
||||
snapshot.isDraggingOver && 'bg-primary/5'
|
||||
)}
|
||||
>
|
||||
{group.sessionIds.length === 0 ? (
|
||||
<p className="px-8 py-2 text-xs text-muted-foreground italic">
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.emptyGroup' })}
|
||||
</p>
|
||||
) : (
|
||||
group.sessionIds.map((sessionId, index) => (
|
||||
<Draggable
|
||||
key={sessionId}
|
||||
draggableId={sessionId}
|
||||
index={index}
|
||||
>
|
||||
{(dragProvided, dragSnapshot) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
|
||||
'hover:bg-muted/50 transition-colors text-sm',
|
||||
activeTerminalId === sessionId && 'bg-primary/10 text-primary',
|
||||
dragSnapshot.isDragging && 'bg-muted shadow-md'
|
||||
)}
|
||||
onClick={() => handleSessionClick(sessionId)}
|
||||
>
|
||||
<span
|
||||
{...dragProvided.dragHandleProps}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</span>
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{sessionNames[sessionId] ?? sessionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DragDropContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionGroupTree;
|
||||
@@ -0,0 +1,211 @@
|
||||
// ========================================
|
||||
// TerminalInstance Component
|
||||
// ========================================
|
||||
// xterm.js terminal wrapper for the Terminal Dashboard.
|
||||
// Reuses exact integration pattern from TerminalMainArea:
|
||||
// XTerm instance in ref, FitAddon, ResizeObserver, batched PTY input (30ms),
|
||||
// output chunk streaming from cliSessionStore.
|
||||
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import {
|
||||
fetchCliSessionBuffer,
|
||||
sendCliSessionText,
|
||||
resizeCliSession,
|
||||
} from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface TerminalInstanceProps {
|
||||
/** Session key to render terminal for */
|
||||
sessionId: string;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalInstance({ sessionId, className }: TerminalInstanceProps) {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// cliSessionStore selectors
|
||||
const outputChunks = useCliSessionStore((s) => s.outputChunks);
|
||||
const setBuffer = useCliSessionStore((s) => s.setBuffer);
|
||||
const clearOutput = useCliSessionStore((s) => s.clearOutput);
|
||||
|
||||
// ========== 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);
|
||||
|
||||
// PTY input batching (30ms, matching TerminalMainArea)
|
||||
const pendingInputRef = useRef<string>('');
|
||||
const flushTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Track sessionId in a ref so xterm onData callback always has latest value
|
||||
const sessionIdRef = useRef<string>(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
const projectPathRef = useRef<string | null>(projectPath);
|
||||
projectPathRef.current = projectPath;
|
||||
|
||||
// ========== PTY Input Batching ==========
|
||||
|
||||
const flushInput = useCallback(async () => {
|
||||
const key = sessionIdRef.current;
|
||||
if (!key) return;
|
||||
const pending = pendingInputRef.current;
|
||||
pendingInputRef.current = '';
|
||||
if (!pending) return;
|
||||
try {
|
||||
await sendCliSessionText(
|
||||
key,
|
||||
{ text: pending, appendNewline: false },
|
||||
projectPathRef.current || undefined
|
||||
);
|
||||
} catch {
|
||||
// Ignore transient failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleFlush = useCallback(() => {
|
||||
if (flushTimerRef.current !== null) return;
|
||||
flushTimerRef.current = window.setTimeout(async () => {
|
||||
flushTimerRef.current = null;
|
||||
await flushInput();
|
||||
}, 30);
|
||||
}, [flushInput]);
|
||||
|
||||
// ========== xterm Lifecycle ==========
|
||||
|
||||
// Initialize xterm instance (once per mount)
|
||||
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 30ms)
|
||||
term.onData((data) => {
|
||||
if (!sessionIdRef.current) return;
|
||||
pendingInputRef.current += data;
|
||||
scheduleFlush();
|
||||
});
|
||||
|
||||
xtermRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
return () => {
|
||||
// Flush any pending input before cleanup
|
||||
if (flushTimerRef.current !== null) {
|
||||
window.clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
try {
|
||||
term.dispose();
|
||||
} finally {
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Attach to session: clear terminal and load buffer
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!term || !fitAddon) return;
|
||||
|
||||
lastChunkIndexRef.current = 0;
|
||||
term.reset();
|
||||
term.clear();
|
||||
|
||||
if (!sessionId) return;
|
||||
clearOutput(sessionId);
|
||||
|
||||
fetchCliSessionBuffer(sessionId, projectPath || undefined)
|
||||
.then(({ buffer }) => {
|
||||
setBuffer(sessionId, buffer || '');
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore
|
||||
})
|
||||
.finally(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
}, [sessionId, projectPath, setBuffer, clearOutput]);
|
||||
|
||||
// Stream new output chunks into xterm and forward to monitor worker
|
||||
useEffect(() => {
|
||||
const term = xtermRef.current;
|
||||
if (!term || !sessionId) return;
|
||||
|
||||
const chunks = outputChunks[sessionId] ?? [];
|
||||
const start = lastChunkIndexRef.current;
|
||||
if (start >= chunks.length) return;
|
||||
|
||||
const { feedMonitor } = useSessionManagerStore.getState();
|
||||
for (let i = start; i < chunks.length; i++) {
|
||||
term.write(chunks[i].data);
|
||||
feedMonitor(sessionId, chunks[i].data);
|
||||
}
|
||||
lastChunkIndexRef.current = chunks.length;
|
||||
}, [outputChunks, sessionId]);
|
||||
|
||||
// ResizeObserver -> fit + resize backend
|
||||
useEffect(() => {
|
||||
const host = terminalHostRef.current;
|
||||
const term = xtermRef.current;
|
||||
const fitAddon = fitAddonRef.current;
|
||||
if (!host || !term || !fitAddon) return;
|
||||
|
||||
const resize = () => {
|
||||
fitAddon.fit();
|
||||
if (sessionIdRef.current) {
|
||||
void (async () => {
|
||||
try {
|
||||
await resizeCliSession(
|
||||
sessionIdRef.current,
|
||||
{ cols: term.cols, rows: term.rows },
|
||||
projectPathRef.current || undefined
|
||||
);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
}
|
||||
};
|
||||
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, [sessionId, projectPath]);
|
||||
|
||||
// ========== Render ==========
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalHostRef}
|
||||
className={cn('h-full w-full bg-black/90', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// ========================================
|
||||
// TerminalTabBar Component
|
||||
// ========================================
|
||||
// Horizontal tab strip for terminal sessions in the Terminal Dashboard.
|
||||
// Renders tabs from sessionManagerStore groups with status indicators and alert badges.
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectSessionManagerActiveTerminalId,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== Status Styles ==========
|
||||
|
||||
const statusDotStyles: Record<TerminalStatus, string> = {
|
||||
active: 'bg-green-500',
|
||||
idle: 'bg-gray-400',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalTabBar() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
|
||||
|
||||
// Flatten all sessionIds from all groups
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
|
||||
if (allSessionIds.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-muted/30 min-h-[40px]">
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.tabBar.noTabs' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-border bg-muted/30 overflow-x-auto shrink-0">
|
||||
{allSessionIds.map((sessionId) => {
|
||||
const meta = terminalMetas[sessionId];
|
||||
const title = meta?.title ?? sessionId;
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
const alertCount = meta?.alertCount ?? 0;
|
||||
const isActive = activeTerminalId === sessionId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sessionId}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 text-xs border-r border-border',
|
||||
'whitespace-nowrap transition-colors hover:bg-accent/50',
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-2 border-b-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setActiveTerminal(sessionId)}
|
||||
title={title}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full shrink-0',
|
||||
statusDotStyles[status]
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<span className="truncate max-w-[120px]">{title}</span>
|
||||
|
||||
{/* Alert badge */}
|
||||
{alertCount > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-[10px] font-medium leading-none rounded-full bg-destructive text-destructive-foreground shrink-0">
|
||||
{alertCount > 99 ? '99+' : alertCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// ========================================
|
||||
// TerminalWorkbench Component
|
||||
// ========================================
|
||||
// Container for the right panel of the Terminal Dashboard.
|
||||
// Combines TerminalTabBar (tab switching) and TerminalInstance (xterm.js)
|
||||
// in a flex-col layout. MVP scope: single terminal view (1x1 grid).
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectSessionManagerActiveTerminalId,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import { TerminalTabBar } from './TerminalTabBar';
|
||||
import { TerminalInstance } from './TerminalInstance';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalWorkbench() {
|
||||
const { formatMessage } = useIntl();
|
||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tab strip (fixed height) */}
|
||||
<TerminalTabBar />
|
||||
|
||||
{/* Terminal content (flex-1, takes remaining space) */}
|
||||
{activeTerminalId ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalInstance sessionId={activeTerminalId} />
|
||||
</div>
|
||||
) : (
|
||||
/* Empty state when no terminal is selected */
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'terminalDashboard.workbench.noTerminal' })}
|
||||
</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.workbench.noTerminalHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user