feat: add Terminal Dashboard components and state management

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,8 @@
"rules": "Rules",
"explorer": "File Explorer",
"graph": "Graph Explorer",
"teams": "Team Execution"
"teams": "Team Execution",
"terminalDashboard": "Terminal Dashboard"
},
"sidebar": {
"collapse": "Collapse",

View 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"
}
}

View File

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

View File

@@ -35,7 +35,8 @@
"rules": "规则",
"explorer": "文件浏览器",
"graph": "图浏览器",
"teams": "团队执行"
"teams": "团队执行",
"terminalDashboard": "终端仪表板"
},
"sidebar": {
"collapse": "收起",

View 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": "检查器详情将在此显示"
}
}

View File

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

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

View File

@@ -35,3 +35,4 @@ export { CliViewerPage } from './CliViewerPage';
export { CliSessionSharePage } from './CliSessionSharePage';
export { IssueManagerPage } from './IssueManagerPage';
export { TeamPage } from './TeamPage';
export { TerminalDashboardPage } from './TerminalDashboardPage';

View File

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

View File

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

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

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

View File

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

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

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