mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +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}
|
||||
|
||||
@@ -39,6 +39,7 @@ import workspace from './workspace.json';
|
||||
import help from './help.json';
|
||||
import cliViewer from './cli-viewer.json';
|
||||
import team from './team.json';
|
||||
import terminalDashboard from './terminal-dashboard.json';
|
||||
|
||||
/**
|
||||
* Flattens nested JSON object to dot-separated keys
|
||||
@@ -101,4 +102,5 @@ export default {
|
||||
...flattenMessages(help, 'help'),
|
||||
...flattenMessages(cliViewer, 'cliViewer'),
|
||||
...flattenMessages(team, 'team'),
|
||||
...flattenMessages(terminalDashboard, 'terminalDashboard'),
|
||||
} as Record<string, string>;
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"rules": "Rules",
|
||||
"explorer": "File Explorer",
|
||||
"graph": "Graph Explorer",
|
||||
"teams": "Team Execution"
|
||||
"teams": "Team Execution",
|
||||
"terminalDashboard": "Terminal Dashboard"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Collapse",
|
||||
|
||||
81
ccw/frontend/src/locales/en/terminal-dashboard.json
Normal file
81
ccw/frontend/src/locales/en/terminal-dashboard.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "Terminal Dashboard"
|
||||
},
|
||||
"columns": {
|
||||
"sessions": "Sessions",
|
||||
"workflow": "Workflow",
|
||||
"terminal": "Terminal Workbench"
|
||||
},
|
||||
"kpi": {
|
||||
"title": "Dashboard KPI",
|
||||
"activeSessions": "Active Sessions",
|
||||
"queueSize": "Queue Size",
|
||||
"alertCount": "Alerts",
|
||||
"errorCount": "Errors",
|
||||
"idleCount": "Idle"
|
||||
},
|
||||
"inspector": {
|
||||
"title": "Inspector",
|
||||
"noSelection": "Select an item to view details",
|
||||
"associationChain": "Association Chain"
|
||||
},
|
||||
"sessionTree": {
|
||||
"createGroup": "New Group",
|
||||
"groupNamePrompt": "Enter group name",
|
||||
"defaultGroupName": "Untitled Group",
|
||||
"emptyGroup": "No sessions in this group",
|
||||
"noGroups": "No session groups yet",
|
||||
"sessionCount": "{count} sessions",
|
||||
"dragHint": "Drag sessions between groups"
|
||||
},
|
||||
"agentList": {
|
||||
"title": "Agents",
|
||||
"noAgents": "No active agents",
|
||||
"stepLabel": "Step {current}/{total}",
|
||||
"statusRunning": "Running",
|
||||
"statusCompleted": "Completed",
|
||||
"statusFailed": "Failed",
|
||||
"statusPaused": "Paused",
|
||||
"statusPending": "Pending"
|
||||
},
|
||||
"issuePanel": {
|
||||
"title": "Issues",
|
||||
"sendToQueue": "Send to Queue",
|
||||
"noIssues": "No issues found",
|
||||
"noIssuesDesc": "Issues will appear here when discovered",
|
||||
"error": "Failed to load issues"
|
||||
},
|
||||
"queuePanel": {
|
||||
"title": "Queue",
|
||||
"noItems": "Queue is empty",
|
||||
"noItemsDesc": "Send issues to queue to start workflow",
|
||||
"error": "Failed to load queue",
|
||||
"order": "#{order}",
|
||||
"dependsOn": "Depends on: {deps}",
|
||||
"status": {
|
||||
"pending": "Pending",
|
||||
"ready": "Ready",
|
||||
"executing": "Executing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed",
|
||||
"blocked": "Blocked"
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"noTabs": "No terminal sessions"
|
||||
},
|
||||
"workbench": {
|
||||
"noTerminal": "No terminal selected",
|
||||
"noTerminalHint": "Select a session from the tab bar or create a new one"
|
||||
},
|
||||
"placeholder": {
|
||||
"sessionTree": "Session groups will appear here",
|
||||
"agentList": "Agent list will appear here",
|
||||
"issuePanel": "Issue panel will appear here",
|
||||
"queuePanel": "Queue panel will appear here",
|
||||
"terminalWorkbench": "Terminal workbench will appear here",
|
||||
"kpiBar": "KPI metrics will appear here",
|
||||
"inspector": "Inspector details will appear here"
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import workspace from './workspace.json';
|
||||
import help from './help.json';
|
||||
import cliViewer from './cli-viewer.json';
|
||||
import team from './team.json';
|
||||
import terminalDashboard from './terminal-dashboard.json';
|
||||
|
||||
/**
|
||||
* Flattens nested JSON object to dot-separated keys
|
||||
@@ -101,4 +102,5 @@ export default {
|
||||
...flattenMessages(help, 'help'),
|
||||
...flattenMessages(cliViewer, 'cliViewer'),
|
||||
...flattenMessages(team, 'team'),
|
||||
...flattenMessages(terminalDashboard, 'terminalDashboard'),
|
||||
} as Record<string, string>;
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"rules": "规则",
|
||||
"explorer": "文件浏览器",
|
||||
"graph": "图浏览器",
|
||||
"teams": "团队执行"
|
||||
"teams": "团队执行",
|
||||
"terminalDashboard": "终端仪表板"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收起",
|
||||
|
||||
81
ccw/frontend/src/locales/zh/terminal-dashboard.json
Normal file
81
ccw/frontend/src/locales/zh/terminal-dashboard.json
Normal file
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"page": {
|
||||
"title": "终端仪表板"
|
||||
},
|
||||
"columns": {
|
||||
"sessions": "会话",
|
||||
"workflow": "工作流",
|
||||
"terminal": "终端工作台"
|
||||
},
|
||||
"kpi": {
|
||||
"title": "仪表板 KPI",
|
||||
"activeSessions": "活跃会话",
|
||||
"queueSize": "队列大小",
|
||||
"alertCount": "告警数",
|
||||
"errorCount": "错误数",
|
||||
"idleCount": "空闲数"
|
||||
},
|
||||
"inspector": {
|
||||
"title": "检查器",
|
||||
"noSelection": "选择一个项目以查看详情",
|
||||
"associationChain": "关联链路"
|
||||
},
|
||||
"sessionTree": {
|
||||
"createGroup": "新建分组",
|
||||
"groupNamePrompt": "输入分组名称",
|
||||
"defaultGroupName": "未命名分组",
|
||||
"emptyGroup": "此分组暂无会话",
|
||||
"noGroups": "暂无会话分组",
|
||||
"sessionCount": "{count} 个会话",
|
||||
"dragHint": "拖拽会话至其他分组"
|
||||
},
|
||||
"agentList": {
|
||||
"title": "代理",
|
||||
"noAgents": "暂无活跃代理",
|
||||
"stepLabel": "步骤 {current}/{total}",
|
||||
"statusRunning": "运行中",
|
||||
"statusCompleted": "已完成",
|
||||
"statusFailed": "已失败",
|
||||
"statusPaused": "已暂停",
|
||||
"statusPending": "等待中"
|
||||
},
|
||||
"issuePanel": {
|
||||
"title": "问题",
|
||||
"sendToQueue": "发送到队列",
|
||||
"noIssues": "暂无问题",
|
||||
"noIssuesDesc": "发现问题时将在此显示",
|
||||
"error": "加载问题失败"
|
||||
},
|
||||
"queuePanel": {
|
||||
"title": "队列",
|
||||
"noItems": "队列为空",
|
||||
"noItemsDesc": "将问题发送到队列以启动工作流",
|
||||
"error": "加载队列失败",
|
||||
"order": "#{order}",
|
||||
"dependsOn": "依赖: {deps}",
|
||||
"status": {
|
||||
"pending": "等待中",
|
||||
"ready": "就绪",
|
||||
"executing": "执行中",
|
||||
"completed": "已完成",
|
||||
"failed": "已失败",
|
||||
"blocked": "已阻塞"
|
||||
}
|
||||
},
|
||||
"tabBar": {
|
||||
"noTabs": "暂无终端会话"
|
||||
},
|
||||
"workbench": {
|
||||
"noTerminal": "未选择终端",
|
||||
"noTerminalHint": "从标签栏选择会话或创建新会话"
|
||||
},
|
||||
"placeholder": {
|
||||
"sessionTree": "会话分组将在此显示",
|
||||
"agentList": "Agent 列表将在此显示",
|
||||
"issuePanel": "问题面板将在此显示",
|
||||
"queuePanel": "队列面板将在此显示",
|
||||
"terminalWorkbench": "终端工作台将在此显示",
|
||||
"kpiBar": "KPI 指标将在此显示",
|
||||
"inspector": "检查器详情将在此显示"
|
||||
}
|
||||
}
|
||||
@@ -607,8 +607,9 @@ export function ReviewSessionPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Fix Progress Carousel */}
|
||||
{sessionId && <FixProgressCarousel sessionId={sessionId} />}
|
||||
{/* Fix Progress Carousel — disabled: backend /api/fix-progress not implemented yet
|
||||
See FRONTEND_BACKEND_ALIGNMENT_AUDIT.md for details */}
|
||||
{/* {sessionId && <FixProgressCarousel sessionId={sessionId} />} */}
|
||||
|
||||
{/* Unified Filter Card with Dimension Tabs */}
|
||||
<Card>
|
||||
|
||||
96
ccw/frontend/src/pages/TerminalDashboardPage.tsx
Normal file
96
ccw/frontend/src/pages/TerminalDashboardPage.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
// ========================================
|
||||
// Terminal Dashboard Page
|
||||
// ========================================
|
||||
// Three-column Allotment layout for terminal execution management.
|
||||
// Left: session groups + agent list
|
||||
// Middle: issue + queue workflow panels
|
||||
// Right: terminal workbench
|
||||
// Top: GlobalKpiBar (real component)
|
||||
// Bottom: BottomInspector (collapsible, real component)
|
||||
// Cross-cutting: AssociationHighlightProvider wraps the layout
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Allotment } from 'allotment';
|
||||
import 'allotment/dist/style.css';
|
||||
import { FolderTree } from 'lucide-react';
|
||||
import { SessionGroupTree } from '@/components/terminal-dashboard/SessionGroupTree';
|
||||
import { AgentList } from '@/components/terminal-dashboard/AgentList';
|
||||
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
||||
import { TerminalWorkbench } from '@/components/terminal-dashboard/TerminalWorkbench';
|
||||
import { GlobalKpiBar } from '@/components/terminal-dashboard/GlobalKpiBar';
|
||||
import { BottomInspector } from '@/components/terminal-dashboard/BottomInspector';
|
||||
import { AssociationHighlightProvider } from '@/components/terminal-dashboard/AssociationHighlight';
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function TerminalDashboardPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-56px)] overflow-hidden">
|
||||
{/* GlobalKpiBar at top (flex-shrink-0) */}
|
||||
<GlobalKpiBar />
|
||||
|
||||
{/* AssociationHighlightProvider wraps the three-column layout + bottom inspector */}
|
||||
<AssociationHighlightProvider>
|
||||
{/* Three-column Allotment layout (flex-1) */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Allotment proportionalLayout={true}>
|
||||
{/* Left column: Sessions */}
|
||||
<Allotment.Pane preferredSize={250} minSize={180} maxSize={400}>
|
||||
<div className="h-full border-r border-border bg-background flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<FolderTree className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.columns.sessions' })}
|
||||
</h2>
|
||||
</div>
|
||||
{/* SessionGroupTree takes remaining space */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionGroupTree />
|
||||
</div>
|
||||
{/* AgentList at bottom with max height */}
|
||||
<div className="shrink-0">
|
||||
<AgentList />
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* Middle column: Workflow (IssuePanel + QueuePanel vertical split) */}
|
||||
<Allotment.Pane minSize={300}>
|
||||
<div className="h-full border-r border-border bg-background overflow-hidden">
|
||||
<Allotment vertical proportionalLayout={true}>
|
||||
{/* Top: IssuePanel */}
|
||||
<Allotment.Pane minSize={120}>
|
||||
<div className="h-full overflow-hidden">
|
||||
<IssuePanel />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
{/* Bottom: QueuePanel */}
|
||||
<Allotment.Pane minSize={120}>
|
||||
<div className="h-full overflow-hidden border-t border-border">
|
||||
<QueuePanel />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* Right column: Terminal Workbench */}
|
||||
<Allotment.Pane minSize={300}>
|
||||
<div className="h-full bg-background overflow-hidden">
|
||||
<TerminalWorkbench />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
</div>
|
||||
|
||||
{/* BottomInspector at bottom (flex-shrink-0) */}
|
||||
<BottomInspector />
|
||||
</AssociationHighlightProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TerminalDashboardPage;
|
||||
@@ -35,3 +35,4 @@ export { CliViewerPage } from './CliViewerPage';
|
||||
export { CliSessionSharePage } from './CliSessionSharePage';
|
||||
export { IssueManagerPage } from './IssueManagerPage';
|
||||
export { TeamPage } from './TeamPage';
|
||||
export { TerminalDashboardPage } from './TerminalDashboardPage';
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
CliViewerPage,
|
||||
CliSessionSharePage,
|
||||
TeamPage,
|
||||
TerminalDashboardPage,
|
||||
} from '@/pages';
|
||||
|
||||
/**
|
||||
@@ -169,6 +170,10 @@ const routes: RouteObject[] = [
|
||||
path: 'teams',
|
||||
element: <TeamPage />,
|
||||
},
|
||||
{
|
||||
path: 'terminal-dashboard',
|
||||
element: <TerminalDashboardPage />,
|
||||
},
|
||||
// Catch-all route for 404
|
||||
{
|
||||
path: '*',
|
||||
@@ -223,6 +228,7 @@ export const ROUTES = {
|
||||
EXPLORER: '/explorer',
|
||||
GRAPH: '/graph',
|
||||
TEAMS: '/teams',
|
||||
TERMINAL_DASHBOARD: '/terminal-dashboard',
|
||||
} as const;
|
||||
|
||||
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||
|
||||
@@ -123,6 +123,25 @@ export {
|
||||
selectPlanStepByExecutionId,
|
||||
} from './orchestratorStore';
|
||||
|
||||
// Session Manager Store
|
||||
export {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectLayout,
|
||||
selectSessionManagerActiveTerminalId,
|
||||
selectTerminalMetas,
|
||||
selectTerminalMeta,
|
||||
} from './sessionManagerStore';
|
||||
|
||||
// Issue Queue Integration Store
|
||||
export {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectSelectedIssueId,
|
||||
selectAssociationChain,
|
||||
selectQueueByIssue,
|
||||
selectIssueById,
|
||||
} from './issueQueueIntegrationStore';
|
||||
|
||||
// Terminal Panel Store Types
|
||||
export type {
|
||||
PanelView,
|
||||
@@ -241,3 +260,25 @@ export type {
|
||||
} from '../types/flow';
|
||||
|
||||
export { NODE_TYPE_CONFIGS } from '../types/flow';
|
||||
|
||||
// Session Manager Store Types
|
||||
export type {
|
||||
SessionGridLayout,
|
||||
SessionLayout,
|
||||
TerminalStatus,
|
||||
TerminalMeta,
|
||||
SessionGroup,
|
||||
SessionManagerState,
|
||||
SessionManagerActions,
|
||||
SessionManagerStore,
|
||||
AlertSeverity,
|
||||
MonitorAlert,
|
||||
} from '../types/terminal-dashboard';
|
||||
|
||||
// Issue Queue Integration Store Types
|
||||
export type {
|
||||
AssociationChain,
|
||||
IssueQueueIntegrationState,
|
||||
IssueQueueIntegrationActions,
|
||||
IssueQueueIntegrationStore,
|
||||
} from '../types/terminal-dashboard';
|
||||
|
||||
232
ccw/frontend/src/stores/issueQueueIntegrationStore.ts
Normal file
232
ccw/frontend/src/stores/issueQueueIntegrationStore.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// ========================================
|
||||
// Issue Queue Integration Store
|
||||
// ========================================
|
||||
// Zustand store bridging issue/queue data with execution tracking.
|
||||
// Manages association chain state for highlight linkage across
|
||||
// Issue <-> QueueItem <-> Terminal Session.
|
||||
//
|
||||
// Design principles:
|
||||
// - Does NOT duplicate issues[]/queue[] arrays (use React Query hooks for data)
|
||||
// - Bridges queueExecutionStore via getState() for execution status
|
||||
// - Manages UI-specific integration state (selection, association chain)
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
AssociationChain,
|
||||
IssueQueueIntegrationState,
|
||||
IssueQueueIntegrationStore,
|
||||
} from '../types/terminal-dashboard';
|
||||
import { useQueueExecutionStore } from './queueExecutionStore';
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: IssueQueueIntegrationState = {
|
||||
selectedIssueId: null,
|
||||
associationChain: null,
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useIssueQueueIntegrationStore = create<IssueQueueIntegrationStore>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Issue Selection ==========
|
||||
|
||||
setSelectedIssue: (issueId: string | null) => {
|
||||
if (issueId === null) {
|
||||
set(
|
||||
{ selectedIssueId: null, associationChain: null },
|
||||
false,
|
||||
'setSelectedIssue/clear'
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Resolve association chain from issue ID
|
||||
const chain = resolveChainFromIssue(issueId);
|
||||
set(
|
||||
{ selectedIssueId: issueId, associationChain: chain },
|
||||
false,
|
||||
'setSelectedIssue'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Association Chain ==========
|
||||
|
||||
buildAssociationChain: (
|
||||
entityId: string,
|
||||
entityType: 'issue' | 'queue' | 'session'
|
||||
) => {
|
||||
let chain: AssociationChain;
|
||||
|
||||
switch (entityType) {
|
||||
case 'issue':
|
||||
chain = resolveChainFromIssue(entityId);
|
||||
break;
|
||||
case 'queue':
|
||||
chain = resolveChainFromQueueItem(entityId);
|
||||
break;
|
||||
case 'session':
|
||||
chain = resolveChainFromSession(entityId);
|
||||
break;
|
||||
default:
|
||||
chain = { issueId: null, queueItemId: null, sessionId: null };
|
||||
}
|
||||
|
||||
set(
|
||||
{
|
||||
associationChain: chain,
|
||||
selectedIssueId: chain.issueId,
|
||||
},
|
||||
false,
|
||||
'buildAssociationChain'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Queue Status Bridge ==========
|
||||
|
||||
_updateQueueItemStatus: (
|
||||
queueItemId: string,
|
||||
status: string,
|
||||
sessionId?: string
|
||||
) => {
|
||||
// Bridge to queueExecutionStore for execution tracking
|
||||
const execStore = useQueueExecutionStore.getState();
|
||||
const executions = Object.values(execStore.executions);
|
||||
const matchedExec = executions.find(
|
||||
(e) => e.queueItemId === queueItemId
|
||||
);
|
||||
|
||||
if (matchedExec) {
|
||||
// Update status in the execution store
|
||||
const validStatuses = ['pending', 'running', 'completed', 'failed'] as const;
|
||||
type ValidStatus = (typeof validStatuses)[number];
|
||||
if (validStatuses.includes(status as ValidStatus)) {
|
||||
execStore.updateStatus(matchedExec.id, status as ValidStatus);
|
||||
}
|
||||
}
|
||||
|
||||
// If a sessionId is provided, update the association chain
|
||||
if (sessionId) {
|
||||
set(
|
||||
(state) => {
|
||||
if (
|
||||
state.associationChain &&
|
||||
state.associationChain.queueItemId === queueItemId
|
||||
) {
|
||||
return {
|
||||
associationChain: {
|
||||
...state.associationChain,
|
||||
sessionId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return state;
|
||||
},
|
||||
false,
|
||||
'_updateQueueItemStatus'
|
||||
);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'IssueQueueIntegrationStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Chain Resolution Helpers ==========
|
||||
|
||||
/**
|
||||
* Resolve association chain starting from an issue ID.
|
||||
* Looks up queueExecutionStore to find linked queue items and sessions.
|
||||
*/
|
||||
function resolveChainFromIssue(issueId: string): AssociationChain {
|
||||
const executions = Object.values(
|
||||
useQueueExecutionStore.getState().executions
|
||||
);
|
||||
// Find the first execution matching this issue
|
||||
const matched = executions.find((e) => e.issueId === issueId);
|
||||
if (matched) {
|
||||
return {
|
||||
issueId,
|
||||
queueItemId: matched.queueItemId,
|
||||
sessionId: matched.sessionKey ?? null,
|
||||
};
|
||||
}
|
||||
return { issueId, queueItemId: null, sessionId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve association chain starting from a queue item ID.
|
||||
* Looks up queueExecutionStore to find linked issue and session.
|
||||
*/
|
||||
function resolveChainFromQueueItem(queueItemId: string): AssociationChain {
|
||||
const executions = Object.values(
|
||||
useQueueExecutionStore.getState().executions
|
||||
);
|
||||
const matched = executions.find((e) => e.queueItemId === queueItemId);
|
||||
if (matched) {
|
||||
return {
|
||||
issueId: matched.issueId,
|
||||
queueItemId,
|
||||
sessionId: matched.sessionKey ?? null,
|
||||
};
|
||||
}
|
||||
return { issueId: null, queueItemId, sessionId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve association chain starting from a session key.
|
||||
* Looks up queueExecutionStore to find linked issue and queue item.
|
||||
*/
|
||||
function resolveChainFromSession(sessionId: string): AssociationChain {
|
||||
const executions = Object.values(
|
||||
useQueueExecutionStore.getState().executions
|
||||
);
|
||||
const matched = executions.find((e) => e.sessionKey === sessionId);
|
||||
if (matched) {
|
||||
return {
|
||||
issueId: matched.issueId,
|
||||
queueItemId: matched.queueItemId,
|
||||
sessionId,
|
||||
};
|
||||
}
|
||||
return { issueId: null, queueItemId: null, sessionId };
|
||||
}
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Select currently selected issue ID */
|
||||
export const selectSelectedIssueId = (state: IssueQueueIntegrationStore) =>
|
||||
state.selectedIssueId;
|
||||
|
||||
/** Select current association chain */
|
||||
export const selectAssociationChain = (state: IssueQueueIntegrationStore) =>
|
||||
state.associationChain;
|
||||
|
||||
/**
|
||||
* Select queue executions for a specific issue.
|
||||
* WARNING: Returns new array each call - use with useMemo in components.
|
||||
*/
|
||||
export const selectQueueByIssue =
|
||||
(issueId: string) =>
|
||||
(): import('./queueExecutionStore').QueueExecution[] => {
|
||||
const executions = Object.values(
|
||||
useQueueExecutionStore.getState().executions
|
||||
);
|
||||
return executions.filter((e) => e.issueId === issueId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Select an issue's execution by issue ID (first matched).
|
||||
* Returns the execution record or undefined.
|
||||
*/
|
||||
export const selectIssueById =
|
||||
(issueId: string) =>
|
||||
(): import('./queueExecutionStore').QueueExecution | undefined => {
|
||||
const executions = Object.values(
|
||||
useQueueExecutionStore.getState().executions
|
||||
);
|
||||
return executions.find((e) => e.issueId === issueId);
|
||||
};
|
||||
205
ccw/frontend/src/stores/sessionManagerStore.ts
Normal file
205
ccw/frontend/src/stores/sessionManagerStore.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
// ========================================
|
||||
// Session Manager Store
|
||||
// ========================================
|
||||
// Zustand store for terminal dashboard session management.
|
||||
// Manages session groups, layout, active terminal, terminal metadata,
|
||||
// and monitor Web Worker lifecycle.
|
||||
// Consumes cliSessionStore data via getState() pattern (no data duplication).
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
MonitorAlert,
|
||||
SessionGroup,
|
||||
SessionLayout,
|
||||
SessionManagerState,
|
||||
SessionManagerStore,
|
||||
TerminalMeta,
|
||||
TerminalStatus,
|
||||
} from '../types/terminal-dashboard';
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: SessionManagerState = {
|
||||
groups: [],
|
||||
layout: { grid: '1x1', splits: [1] },
|
||||
activeTerminalId: null,
|
||||
terminalMetas: {},
|
||||
};
|
||||
|
||||
// ========== Worker Ref (non-reactive, outside Zustand) ==========
|
||||
|
||||
/** Module-level worker reference. Worker objects are not serializable. */
|
||||
let _workerRef: Worker | null = null;
|
||||
|
||||
// ========== Worker Message Handler ==========
|
||||
|
||||
function _handleWorkerMessage(event: MessageEvent<MonitorAlert>): void {
|
||||
const msg = event.data;
|
||||
if (msg.type !== 'alert') return;
|
||||
|
||||
const { sessionId, severity, message } = msg;
|
||||
|
||||
// Map severity to terminal status
|
||||
const statusMap: Record<string, TerminalStatus> = {
|
||||
critical: 'error',
|
||||
warning: 'idle',
|
||||
};
|
||||
|
||||
const store = useSessionManagerStore.getState();
|
||||
const existing = store.terminalMetas[sessionId];
|
||||
const currentAlertCount = existing?.alertCount ?? 0;
|
||||
|
||||
store.updateTerminalMeta(sessionId, {
|
||||
status: statusMap[severity] ?? 'idle',
|
||||
alertCount: currentAlertCount + 1,
|
||||
});
|
||||
|
||||
// Log for debugging (non-intrusive)
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug(`[MonitorWorker] ${severity}: ${message} (session=${sessionId})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useSessionManagerStore = create<SessionManagerStore>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Group Management ==========
|
||||
|
||||
createGroup: (name: string) => {
|
||||
const id = `group-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
const newGroup: SessionGroup = { id, name, sessionIds: [] };
|
||||
set(
|
||||
(state) => ({ groups: [...state.groups, newGroup] }),
|
||||
false,
|
||||
'createGroup'
|
||||
);
|
||||
},
|
||||
|
||||
removeGroup: (groupId: string) => {
|
||||
set(
|
||||
(state) => ({ groups: state.groups.filter((g) => g.id !== groupId) }),
|
||||
false,
|
||||
'removeGroup'
|
||||
);
|
||||
},
|
||||
|
||||
moveSessionToGroup: (sessionId: string, groupId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const nextGroups = state.groups.map((group) => {
|
||||
// Remove session from its current group
|
||||
const filtered = group.sessionIds.filter((id) => id !== sessionId);
|
||||
// Add to target group
|
||||
if (group.id === groupId) {
|
||||
return { ...group, sessionIds: [...filtered, sessionId] };
|
||||
}
|
||||
return { ...group, sessionIds: filtered };
|
||||
});
|
||||
return { groups: nextGroups };
|
||||
},
|
||||
false,
|
||||
'moveSessionToGroup'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Terminal Selection ==========
|
||||
|
||||
setActiveTerminal: (sessionId: string | null) => {
|
||||
set({ activeTerminalId: sessionId }, false, 'setActiveTerminal');
|
||||
},
|
||||
|
||||
// ========== Terminal Metadata ==========
|
||||
|
||||
updateTerminalMeta: (sessionId: string, meta: Partial<TerminalMeta>) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.terminalMetas[sessionId] ?? {
|
||||
title: sessionId,
|
||||
status: 'idle' as const,
|
||||
alertCount: 0,
|
||||
};
|
||||
return {
|
||||
terminalMetas: {
|
||||
...state.terminalMetas,
|
||||
[sessionId]: { ...existing, ...meta },
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateTerminalMeta'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Layout Management ==========
|
||||
|
||||
setGroupLayout: (layout: SessionLayout) => {
|
||||
set({ layout }, false, 'setGroupLayout');
|
||||
},
|
||||
|
||||
// ========== Monitor Worker Lifecycle ==========
|
||||
|
||||
spawnMonitor: () => {
|
||||
// Idempotent: only create if not already running
|
||||
if (_workerRef) return;
|
||||
try {
|
||||
_workerRef = new Worker(
|
||||
new URL('../workers/monitor.worker.ts', import.meta.url),
|
||||
{ type: 'module' }
|
||||
);
|
||||
_workerRef.onmessage = _handleWorkerMessage;
|
||||
_workerRef.onerror = (err) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[MonitorWorker] error:', err);
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// Worker creation can fail in environments without worker support
|
||||
_workerRef = null;
|
||||
}
|
||||
},
|
||||
|
||||
terminateMonitor: () => {
|
||||
if (!_workerRef) return;
|
||||
_workerRef.terminate();
|
||||
_workerRef = null;
|
||||
},
|
||||
|
||||
feedMonitor: (sessionId: string, text: string) => {
|
||||
// Lazily spawn worker on first feed call
|
||||
if (!_workerRef) {
|
||||
useSessionManagerStore.getState().spawnMonitor();
|
||||
}
|
||||
if (_workerRef) {
|
||||
_workerRef.postMessage({ type: 'output', sessionId, text });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ name: 'SessionManagerStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Select all session groups */
|
||||
export const selectGroups = (state: SessionManagerStore) => state.groups;
|
||||
|
||||
/** Select current terminal layout */
|
||||
export const selectLayout = (state: SessionManagerStore) => state.layout;
|
||||
|
||||
/** Select active terminal session key */
|
||||
export const selectSessionManagerActiveTerminalId = (state: SessionManagerStore) =>
|
||||
state.activeTerminalId;
|
||||
|
||||
/** Select all terminal metadata records */
|
||||
export const selectTerminalMetas = (state: SessionManagerStore) => state.terminalMetas;
|
||||
|
||||
/** Select terminal metadata for a specific session */
|
||||
export const selectTerminalMeta =
|
||||
(sessionId: string) =>
|
||||
(state: SessionManagerStore): TerminalMeta | undefined =>
|
||||
state.terminalMetas[sessionId];
|
||||
@@ -183,3 +183,21 @@ export type {
|
||||
NodeComplexity,
|
||||
GraphAnalysis,
|
||||
} from './graph-explorer';
|
||||
|
||||
// ========== Terminal Dashboard Types ==========
|
||||
export type {
|
||||
// Session Manager
|
||||
SessionGridLayout,
|
||||
SessionLayout,
|
||||
TerminalStatus,
|
||||
TerminalMeta,
|
||||
SessionGroup,
|
||||
SessionManagerState,
|
||||
SessionManagerActions,
|
||||
SessionManagerStore,
|
||||
// Issue Queue Integration
|
||||
AssociationChain,
|
||||
IssueQueueIntegrationState,
|
||||
IssueQueueIntegrationActions,
|
||||
IssueQueueIntegrationStore,
|
||||
} from './terminal-dashboard';
|
||||
|
||||
120
ccw/frontend/src/types/terminal-dashboard.ts
Normal file
120
ccw/frontend/src/types/terminal-dashboard.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// ========================================
|
||||
// Terminal Dashboard Types
|
||||
// ========================================
|
||||
// TypeScript interfaces for sessionManagerStore and issueQueueIntegrationStore.
|
||||
// Domain types for the terminal execution management dashboard.
|
||||
|
||||
// ========== Session Manager Types ==========
|
||||
|
||||
/** Grid layout preset for terminal workbench */
|
||||
export type SessionGridLayout = '1x1' | '1x2' | '2x1' | '2x2';
|
||||
|
||||
/** Terminal session layout configuration */
|
||||
export interface SessionLayout {
|
||||
/** Grid preset */
|
||||
grid: SessionGridLayout;
|
||||
/** Split ratios for each pane (normalized 0-1) */
|
||||
splits: number[];
|
||||
}
|
||||
|
||||
/** Terminal status indicator */
|
||||
export type TerminalStatus = 'active' | 'idle' | 'error';
|
||||
|
||||
/** Metadata for a terminal instance in the dashboard */
|
||||
export interface TerminalMeta {
|
||||
/** Display title for the terminal tab */
|
||||
title: string;
|
||||
/** Current terminal status */
|
||||
status: TerminalStatus;
|
||||
/** Number of unread alerts (errors, warnings) */
|
||||
alertCount: number;
|
||||
}
|
||||
|
||||
/** Group of terminal sessions */
|
||||
export interface SessionGroup {
|
||||
/** Unique group identifier */
|
||||
id: string;
|
||||
/** Display name */
|
||||
name: string;
|
||||
/** Ordered list of session keys belonging to this group */
|
||||
sessionIds: string[];
|
||||
}
|
||||
|
||||
/** Session Manager store state (data only) */
|
||||
export interface SessionManagerState {
|
||||
/** All session groups */
|
||||
groups: SessionGroup[];
|
||||
/** Current terminal layout configuration */
|
||||
layout: SessionLayout;
|
||||
/** Currently active terminal session key */
|
||||
activeTerminalId: string | null;
|
||||
/** Per-terminal metadata keyed by session key */
|
||||
terminalMetas: Record<string, TerminalMeta>;
|
||||
}
|
||||
|
||||
/** Alert severity from the monitor worker */
|
||||
export type AlertSeverity = 'critical' | 'warning';
|
||||
|
||||
/** Alert message posted from the monitor worker */
|
||||
export interface MonitorAlert {
|
||||
type: 'alert';
|
||||
sessionId: string;
|
||||
severity: AlertSeverity;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Session Manager store actions */
|
||||
export interface SessionManagerActions {
|
||||
/** Create a new session group */
|
||||
createGroup: (name: string) => void;
|
||||
/** Remove a session group by ID */
|
||||
removeGroup: (groupId: string) => void;
|
||||
/** Move a session to a different group */
|
||||
moveSessionToGroup: (sessionId: string, groupId: string) => void;
|
||||
/** Set the active terminal by session key */
|
||||
setActiveTerminal: (sessionId: string | null) => void;
|
||||
/** Update metadata for a specific terminal */
|
||||
updateTerminalMeta: (sessionId: string, meta: Partial<TerminalMeta>) => void;
|
||||
/** Set the terminal grid layout */
|
||||
setGroupLayout: (layout: SessionLayout) => void;
|
||||
/** Spawn the monitor Web Worker (idempotent) */
|
||||
spawnMonitor: () => void;
|
||||
/** Terminate the monitor Web Worker */
|
||||
terminateMonitor: () => void;
|
||||
/** Forward a terminal output chunk to the monitor worker */
|
||||
feedMonitor: (sessionId: string, text: string) => void;
|
||||
}
|
||||
|
||||
export type SessionManagerStore = SessionManagerState & SessionManagerActions;
|
||||
|
||||
// ========== Issue Queue Integration Types ==========
|
||||
|
||||
/** Association chain linking an issue, queue item, and terminal session */
|
||||
export interface AssociationChain {
|
||||
/** Issue identifier (e.g., 'GH-123') */
|
||||
issueId: string | null;
|
||||
/** Queue item identifier (e.g., 'Q-456') */
|
||||
queueItemId: string | null;
|
||||
/** Terminal session key (e.g., 'T-789') */
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
/** Issue Queue Integration store state (data only) */
|
||||
export interface IssueQueueIntegrationState {
|
||||
/** Currently selected issue ID for highlight linkage */
|
||||
selectedIssueId: string | null;
|
||||
/** Current association chain resolved from any selected entity */
|
||||
associationChain: AssociationChain | null;
|
||||
}
|
||||
|
||||
/** Issue Queue Integration store actions */
|
||||
export interface IssueQueueIntegrationActions {
|
||||
/** Set the selected issue ID and trigger association chain resolution */
|
||||
setSelectedIssue: (issueId: string | null) => void;
|
||||
/** Build a full association chain from any entity ID (issue, queue item, or session) */
|
||||
buildAssociationChain: (entityId: string, entityType: 'issue' | 'queue' | 'session') => void;
|
||||
/** Internal: update queue item status bridging to queueExecutionStore */
|
||||
_updateQueueItemStatus: (queueItemId: string, status: string, sessionId?: string) => void;
|
||||
}
|
||||
|
||||
export type IssueQueueIntegrationStore = IssueQueueIntegrationState & IssueQueueIntegrationActions;
|
||||
154
ccw/frontend/src/workers/monitor.worker.ts
Normal file
154
ccw/frontend/src/workers/monitor.worker.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// ========================================
|
||||
// Monitor Web Worker
|
||||
// ========================================
|
||||
// Off-main-thread rule-based output analysis for terminal sessions.
|
||||
// MVP rules:
|
||||
// 1. Keyword matching: /error|failed|exception/i -> critical alert
|
||||
// 2. Stall detection: no output for > 60s -> warning alert
|
||||
//
|
||||
// Message protocol:
|
||||
// IN: { type: 'output', sessionId: string, text: string }
|
||||
// IN: { type: 'reset', sessionId: string } -- reset session tracking
|
||||
// OUT: { type: 'alert', sessionId: string, severity: string, message: string }
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface OutputMessage {
|
||||
type: 'output';
|
||||
sessionId: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ResetMessage {
|
||||
type: 'reset';
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type IncomingMessage = OutputMessage | ResetMessage;
|
||||
|
||||
interface AlertMessage {
|
||||
type: 'alert';
|
||||
sessionId: string;
|
||||
severity: 'critical' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface KeywordRule {
|
||||
pattern: RegExp;
|
||||
severity: 'critical' | 'warning';
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastActivity: number;
|
||||
alertCount: number;
|
||||
/** Track stall alert to avoid repeated notifications per stall period */
|
||||
stallAlerted: boolean;
|
||||
}
|
||||
|
||||
// ========== Rules ==========
|
||||
|
||||
const KEYWORD_RULES: KeywordRule[] = [
|
||||
{
|
||||
pattern: /error|failed|exception/i,
|
||||
severity: 'critical',
|
||||
label: 'error keyword',
|
||||
},
|
||||
];
|
||||
|
||||
/** Stall threshold in milliseconds (60 seconds) */
|
||||
const STALL_THRESHOLD_MS = 60_000;
|
||||
|
||||
/** Stall check interval in milliseconds (15 seconds) */
|
||||
const STALL_CHECK_INTERVAL_MS = 15_000;
|
||||
|
||||
// ========== State ==========
|
||||
|
||||
const sessions = new Map<string, SessionState>();
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
function getOrCreateSession(sessionId: string): SessionState {
|
||||
let state = sessions.get(sessionId);
|
||||
if (!state) {
|
||||
state = {
|
||||
lastActivity: Date.now(),
|
||||
alertCount: 0,
|
||||
stallAlerted: false,
|
||||
};
|
||||
sessions.set(sessionId, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function postAlert(alert: AlertMessage): void {
|
||||
self.postMessage(alert);
|
||||
}
|
||||
|
||||
// ========== Output Processing ==========
|
||||
|
||||
function processOutput(sessionId: string, text: string): void {
|
||||
const state = getOrCreateSession(sessionId);
|
||||
state.lastActivity = Date.now();
|
||||
// Reset stall alert flag on new output
|
||||
state.stallAlerted = false;
|
||||
|
||||
// Run keyword rules against text
|
||||
for (const rule of KEYWORD_RULES) {
|
||||
if (rule.pattern.test(text)) {
|
||||
state.alertCount++;
|
||||
postAlert({
|
||||
type: 'alert',
|
||||
sessionId,
|
||||
severity: rule.severity,
|
||||
message: `Detected ${rule.label} in output`,
|
||||
});
|
||||
// Only report first matching rule per chunk to avoid alert flood
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Stall Detection ==========
|
||||
|
||||
function checkStalls(): void {
|
||||
const now = Date.now();
|
||||
sessions.forEach((state, sessionId) => {
|
||||
if (state.stallAlerted) return;
|
||||
const elapsed = now - state.lastActivity;
|
||||
if (elapsed > STALL_THRESHOLD_MS) {
|
||||
state.stallAlerted = true;
|
||||
state.alertCount++;
|
||||
const seconds = Math.floor(elapsed / 1000);
|
||||
postAlert({
|
||||
type: 'alert',
|
||||
sessionId,
|
||||
severity: 'warning',
|
||||
message: `Session stalled: no output for ${seconds}s`,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Message Handler ==========
|
||||
|
||||
self.onmessage = (event: MessageEvent<IncomingMessage>) => {
|
||||
const msg = event.data;
|
||||
switch (msg.type) {
|
||||
case 'output':
|
||||
processOutput(msg.sessionId, msg.text);
|
||||
break;
|
||||
case 'reset':
|
||||
sessions.delete(msg.sessionId);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// ========== Periodic Stall Check ==========
|
||||
|
||||
const _stallInterval = setInterval(checkStalls, STALL_CHECK_INTERVAL_MS);
|
||||
|
||||
// Cleanup on worker termination (best-effort)
|
||||
self.addEventListener('close', () => {
|
||||
clearInterval(_stallInterval);
|
||||
});
|
||||
Reference in New Issue
Block a user