@@ -1,13 +1,12 @@
// ========================================
// QueuePanel Component
// ========================================
// Queue list panel for the terminal dashboard middle column .
// Consumes existing useIssueQueue() React Query hook for queue data
// and bridges queueExecutionStor e f or execution status per item .
// Integrates with issueQueueIntegrationStore for association chain
// highlighting and selection state.
// Queue list panel for the terminal dashboard with tab switching .
// Tab 1 (Queue): Issue queue items from useIssueQueue() hook.
// Tab 2 (Orchestrator): Activ e orchestration plans from orchestratorStore .
// Integrates with issueQueueIntegrationStore for association chain.
import { useMemo , useCallback } from 'react' ;
import { useState , useMemo, useCallback , memo } from 'react' ;
import { useIntl } from 'react-intl' ;
import {
ListChecks ,
@@ -20,8 +19,18 @@ import {
Zap ,
Ban ,
Terminal ,
Workflow ,
Circle ,
CheckCircle2 ,
SkipForward ,
Pause ,
Play ,
Square ,
RotateCcw ,
AlertCircle ,
} from 'lucide-react' ;
import { Badge } from '@/components/ui/Badge' ;
import { Button } from '@/components/ui/Button' ;
import { cn } from '@/lib/utils' ;
import { useIssueQueue } from '@/hooks/useIssues' ;
import {
@@ -32,9 +41,20 @@ import {
useQueueExecutionStore ,
selectByQueueItem ,
} from '@/stores/queueExecutionStore' ;
import {
useOrchestratorStore ,
selectActivePlans ,
selectActivePlanCount ,
type OrchestrationRunState ,
} from '@/stores/orchestratorStore' ;
import type { StepStatus , OrchestrationStatus } from '@/types/orchestrator' ;
import type { QueueItem } from '@/lib/api' ;
// ========== Status Config ==========
// ========== Tab Type ==========
type QueueTab = 'queue' | 'orchestrator' ;
// ========== Queue Tab: Status Config ==========
type QueueItemStatus = QueueItem [ 'status' ] ;
@@ -51,7 +71,7 @@ const STATUS_CONFIG: Record<QueueItemStatus, {
blocked : { variant : 'outline' , icon : Ban , label : 'Blocked' } ,
} ;
// ========== Queue Item Row ==========
// ========== Queue Tab: Item Row ==========
function QueueItemRow ( {
item ,
@@ -66,7 +86,6 @@ function QueueItemRow({
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 ] ;
@@ -129,58 +148,14 @@ function QueueItemRow({
) ;
}
// ========== Empty State ==========
// ========== Queue Tab: Content ==========
function QueueEmptyState ( { compact = false } : { compact ? : boolean } ) {
const { formatMessage } = useIntl ( ) ;
if ( compact ) {
return (
< div className = "flex items-center gap-2 text-muted-foreground px-3 py-2" >
< ListChecks className = "h-4 w-4 opacity-30 shrink-0" / >
< span className = "text-xs" > { formatMessage ( { id : 'terminalDashboard.queuePanel.noItems' } ) } < / span >
< span className = "text-[10px] opacity-70" > { formatMessage ( { id : 'terminalDashboard.queuePanel.noItemsDesc' } ) } < / span >
< / div >
) ;
}
return (
< div className = "flex-1 flex items-center justify-center text-muted-foreground p-4" >
< div className = "text-center" >
< ListChecks className = "h-6 w-6 mx-auto mb-1.5 opacity-30" / >
< 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-6 w-6 mx-auto mb-1.5 opacity-30" / >
< 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 ( { embedded = false } : { embedded? : boolean } ) {
function QueueTabContent ( { embedded = false } : { embedded ? : boolean } ) {
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 ? ? { } ;
@@ -188,18 +163,10 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
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' ) ;
@@ -207,74 +174,341 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
[ buildAssociationChain ]
) ;
// Loading state
if ( queueQuery . isLoading ) {
return (
< div className = "flex flex-col h-full " >
{ ! embedded && (
< 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 className = "flex-1 flex items-center justify-center " >
< Loader2 className = "w-5 h-5 animate-spin text-muted-foreground" / >
< / div >
) ;
}
if ( queueQuery . error ) {
return (
< div className = "flex-1 flex items-center justify-center text-destructive p-4" >
< div className = "text -center" >
< AlertTriangle className = "h-6 w-6 mx-auto mb-1.5 opacity-30 " / >
< p className = "text-sm" > { formatMessage ( { id : 'terminalDashboard.queuePanel.error' } ) } < / p >
< p className = "text-xs mt-1 opacity-70" > { queueQuery . error . message } < / p >
< / div >
< / div >
) ;
}
// Error state
if ( queueQuery . error ) {
if ( allItems . length === 0 ) {
return (
< div className = "flex flex-col h-full " >
{ ! embedded && (
< 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 className = "flex-1 flex items-center justify-center text-muted-foreground p-4 " >
< div className = "text-center" >
< ListChecks className = "h-6 w-6 mx-auto mb-1.5 opacity-30" / >
< 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 >
) ;
}
return (
< div className = "flex flex-col h-full " >
{ /* Header with flow indicator (hidden when embedded) */ }
{ ! embedded && (
< 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 >
) }
< 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 >
) ;
}
{ /* Queue Item List */ }
{ allItems . length === 0 ? (
< QueueEmptyState compact = { embedded } / >
) : (
< 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 >
// ========== Orchestrator Tab: Status Badge ==========
const orchestratorStatusClass : Record < OrchestrationStatus , string > = {
pending : 'bg-muted text-muted-foreground border-border' ,
running : 'bg-primary/10 text-primary border-primary/50' ,
paused : 'bg-amber-500/10 text-amber-500 border-amber-500/50' ,
completed : 'bg-green-500/10 text-green-500 border-green-500/50' ,
failed : 'bg-destructive/10 text-destructive border-destructive/50' ,
cancelled : 'bg-muted text-muted-foreground border-border' ,
};
function OrchestratorStatusBadge ( { status } : { status : OrchestrationStatus } ) {
const { formatMessage } = useIntl ( ) ;
return (
< span className = { cn ( 'px-2 py-0.5 rounded text-[10px] font-medium border' , orchestratorStatusClass [ status ] ) } >
{ formatMessage ( { id : ` orchestrator.status. ${ status } ` } ) }
< / span >
) ;
}
// ========== Orchestrator Tab: Step Icon ==========
function StepIcon ( { status } : { status : StepStatus } ) {
switch ( status ) {
case 'running' :
return < Loader2 className = "w-3.5 h-3.5 text-primary animate-spin" / > ;
case 'completed' :
return < CheckCircle2 className = "w-3.5 h-3.5 text-green-500" / > ;
case 'failed' :
return < XCircle className = "w-3.5 h-3.5 text-destructive" / > ;
case 'skipped' :
return < SkipForward className = "w-3.5 h-3.5 text-muted-foreground" / > ;
case 'paused' :
return < Pause className = "w-3.5 h-3.5 text-amber-500" / > ;
case 'cancelled' :
return < Square className = "w-3.5 h-3.5 text-muted-foreground" / > ;
default :
return < Circle className = "w-3.5 h-3.5 text-muted-foreground" / > ;
}
}
// ========== Orchestrator Tab: Plan Controls ==========
function PlanControls ( { planId , status , failedStepId } : {
planId : string ;
status : OrchestrationStatus ;
failedStepId : string | null ;
} ) {
const pauseOrchestration = useOrchestratorStore ( ( s ) = > s . pauseOrchestration ) ;
const resumeOrchestration = useOrchestratorStore ( ( s ) = > s . resumeOrchestration ) ;
const stopOrchestration = useOrchestratorStore ( ( s ) = > s . stopOrchestration ) ;
const retryStep = useOrchestratorStore ( ( s ) = > s . retryStep ) ;
const skipStep = useOrchestratorStore ( ( s ) = > s . skipStep ) ;
if ( status === 'completed' || status === 'cancelled' ) return null ;
const isPausedOnError = status === 'paused' && failedStepId !== null ;
const isPausedByUser = status === 'paused' && failedStepId === null ;
return (
< div className = "flex items-center gap-1.5 mt-2" >
{ status === 'running' && (
< >
< Button variant = "outline" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > pauseOrchestration ( planId ) } >
< Pause className = "w-3 h-3" / >
< / Button >
< Button variant = "destructive" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > stopOrchestration ( planId ) } >
< Square className = "w-3 h-3" / >
< / Button >
< / >
) }
{ isPausedByUser && (
< >
< Button variant = "outline" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > resumeOrchestration ( planId ) } >
< Play className = "w-3 h-3" / >
< / Button >
< Button variant = "destructive" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > stopOrchestration ( planId ) } >
< Square className = "w-3 h-3" / >
< / Button >
< / >
) }
{ isPausedOnError && failedStepId && (
< >
< Button variant = "outline" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > retryStep ( planId , failedStepId ) } >
< RotateCcw className = "w-3 h-3" / >
< / Button >
< Button variant = "outline" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > skipStep ( planId , failedStepId ) } >
< SkipForward className = "w-3 h-3" / >
< / Button >
< Button variant = "destructive" size = "sm" className = "h-6 text-xs gap-1 px-2" onClick = { ( ) = > stopOrchestration ( planId ) } >
< Square className = "w-3 h-3" / >
< / Button >
< / >
) }
< / div >
) ;
}
// ========== Orchestrator Tab: Plan Card ==========
const PlanCard = memo ( function PlanCard ( { runState } : { runState : OrchestrationRunState } ) {
const { plan , status , stepStatuses , currentStepIndex } = runState ;
const { completedCount , totalCount , progress } = useMemo ( ( ) = > {
const statuses = Object . values ( stepStatuses ) ;
const total = statuses . length ;
const completed = statuses . filter ( ( s ) = > s . status === 'completed' || s . status === 'skipped' ) . length ;
return { completedCount : completed , totalCount : total , progress : total > 0 ? ( completed / total ) * 100 : 0 } ;
} , [ stepStatuses ] ) ;
const failedStepId = useMemo ( ( ) = > {
for ( const [ stepId , stepState ] of Object . entries ( stepStatuses ) ) {
if ( stepState . status === 'failed' ) return stepId ;
}
return null ;
} , [ stepStatuses ] ) ;
return (
< div className = "border rounded-md border-border bg-card p-3" >
< div className = "flex items-center gap-2 mb-2" >
< h4 className = "text-xs font-semibold text-foreground truncate flex-1" > { plan . name } < / h4 >
< OrchestratorStatusBadge status = { status } / >
< span className = "text-[10px] text-muted-foreground tabular-nums shrink-0" >
{ completedCount } / { totalCount }
< / span >
< / div >
< div className = "h-1.5 bg-muted rounded-full overflow-hidden mb-2" >
< div
className = { cn (
'h-full transition-all duration-300' ,
status === 'failed' && 'bg-destructive' ,
status === 'completed' && 'bg-green-500' ,
status === 'cancelled' && 'bg-muted-foreground' ,
( status === 'running' || status === 'pending' ) && 'bg-primary' ,
status === 'paused' && 'bg-amber-500' ,
) }
style = { { width : ` ${ progress } % ` } }
/ >
< / div >
< div className = "space-y-0.5 max-h-48 overflow-y-auto" >
{ plan . steps . map ( ( step , index ) = > {
const stepState = stepStatuses [ step . id ] ;
if ( ! stepState ) return null ;
const isCurrent = index === currentStepIndex && status === 'running' ;
return (
< div
key = { step . id }
className = { cn (
'flex items-center gap-2 px-2 py-1 rounded text-xs' ,
isCurrent && 'bg-primary/5' ,
stepState . status === 'failed' && 'bg-destructive/5' ,
) }
>
< StepIcon status = { stepState . status } / >
< span className = { cn (
'truncate flex-1' ,
stepState . status === 'completed' && 'text-muted-foreground' ,
stepState . status === 'skipped' && 'text-muted-foreground line-through' ,
stepState . status === 'failed' && 'text-destructive' ,
) } >
{ step . name }
< / span >
{ stepState . retryCount > 0 && (
< span className = "text-[10px] text-muted-foreground" > × { stepState . retryCount } < / span >
) }
< / div >
) ;
} ) }
< / div >
{ failedStepId && stepStatuses [ failedStepId ] ? . error && (
< div className = "flex items-start gap-1.5 mt-2 px-2" >
< AlertCircle className = "w-3 h-3 text-destructive shrink-0 mt-0.5" / >
< span className = "text-[10px] text-destructive/80 break-words" >
{ stepStatuses [ failedStepId ] . error }
< / span >
< / div >
) }
< PlanControls planId = { plan . id } status = { status } failedStepId = { failedStepId } / >
< / div >
) ;
} ) ;
// ========== Orchestrator Tab: Content ==========
function OrchestratorTabContent() {
const { formatMessage } = useIntl ( ) ;
const activePlans = useOrchestratorStore ( selectActivePlans ) ;
const planEntries = useMemo ( ( ) = > Object . entries ( activePlans ) , [ activePlans ] ) ;
if ( planEntries . length === 0 ) {
return (
< div className = "flex-1 flex items-center justify-center text-muted-foreground p-4" >
< div className = "text-center" >
< Workflow className = "h-6 w-6 mx-auto mb-1.5 opacity-30" / >
< p className = "text-sm" >
{ formatMessage ( { id : 'terminalDashboard.orchestratorPanel.noPlans' , defaultMessage : 'No active orchestrations' } ) }
< / p >
< p className = "text-xs mt-1 opacity-70" >
{ formatMessage ( { id : 'terminalDashboard.orchestratorPanel.noPlansHint' , defaultMessage : 'Run a flow from the Orchestrator to see progress here' } ) }
< / p >
< / div >
< / div >
) ;
}
return (
< div className = "flex-1 min-h-0 overflow-y-auto p-3 space-y-3" >
{ planEntries . map ( ( [ planId , runState ] ) = > (
< PlanCard key = { planId } runState = { runState } / >
) ) }
< / div >
) ;
}
// ========== Main Component ==========
export function QueuePanel ( { embedded = false } : { embedded? : boolean } ) {
const { formatMessage } = useIntl ( ) ;
const [ activeTab , setActiveTab ] = useState < QueueTab > ( 'queue' ) ;
const orchestratorCount = useOrchestratorStore ( selectActivePlanCount ) ;
const queueQuery = useIssueQueue ( ) ;
const queueActiveCount = useMemo ( ( ) = > {
if ( ! queueQuery . data ) return 0 ;
const grouped = queueQuery . data . grouped_items ? ? { } ;
let count = 0 ;
for ( const items of Object . values ( grouped ) ) {
count += items . filter (
( item ) = > item . status === 'pending' || item . status === 'ready' || item . status === 'executing'
) . length ;
}
return count ;
} , [ queueQuery . data ] ) ;
return (
< div className = "flex flex-col h-full" >
{ /* Tab bar */ }
{ ! embedded && (
< div className = "flex items-center border-b border-border shrink-0" >
< button
type = "button"
className = { cn (
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors' ,
activeTab === 'queue'
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
) }
onClick = { ( ) = > setActiveTab ( 'queue' ) }
>
< ListChecks className = "w-3.5 h-3.5" / >
{ formatMessage ( { id : 'terminalDashboard.queuePanel.title' } ) }
{ queueActiveCount > 0 && (
< Badge variant = "secondary" className = "text-[10px] px-1.5 py-0 ml-0.5" >
{ queueActiveCount }
< / Badge >
) }
< / button >
< button
type = "button"
className = { cn (
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 text-xs font-medium transition-colors' ,
activeTab === 'orchestrator'
? 'text-foreground border-b-2 border-primary'
: 'text-muted-foreground hover:text-foreground'
) }
onClick = { ( ) = > setActiveTab ( 'orchestrator' ) }
>
< Workflow className = "w-3.5 h-3.5" / >
{ formatMessage ( { id : 'terminalDashboard.toolbar.orchestrator' , defaultMessage : 'Orchestrator' } ) }
{ orchestratorCount > 0 && (
< Badge variant = "secondary" className = "text-[10px] px-1.5 py-0 ml-0.5" >
{ orchestratorCount }
< / Badge >
) }
< / button >
< / div >
) }
{ /* Tab content */ }
{ activeTab === 'queue' ? (
< QueueTabContent embedded = { embedded } / >
) : (
< OrchestratorTabContent / >
) }
< / div >
) ;