feat: add Terminal Dashboard components and state management

- Implemented DashboardToolbar for managing panel toggles and layout presets.
- Created FloatingPanel for a generic sliding panel interface.
- Developed TerminalGrid for rendering a recursive layout of terminal panes.
- Added TerminalPane to encapsulate individual terminal instances with toolbar actions.
- Introduced layout utilities for managing Allotment layout trees.
- Established Zustand store for terminal grid state management, supporting pane operations and layout resets.
This commit is contained in:
catlog22
2026-02-14 22:13:45 +08:00
parent 37d19ada75
commit 75558dc411
28 changed files with 3375 additions and 2598 deletions

View File

@@ -1,136 +0,0 @@
// ========================================
// BottomPanel Component
// ========================================
// Full-width collapsible bottom panel with Queue + Inspector tabs.
// Replaces the separate BottomInspector + middle-column QueuePanel layout.
// Queue tab shows inline count badge; Inspector tab shows chain indicator.
import { useState, useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { ListChecks, Info, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
import { QueuePanel } from './QueuePanel';
import { InspectorContent } from './BottomInspector';
import { useIssueQueue } from '@/hooks/useIssues';
import {
useIssueQueueIntegrationStore,
selectAssociationChain,
} from '@/stores/issueQueueIntegrationStore';
// ========== Types ==========
type TabId = 'queue' | 'inspector';
// ========== Component ==========
export function BottomPanel() {
const { formatMessage } = useIntl();
const [isOpen, setIsOpen] = useState(false);
const [activeTab, setActiveTab] = useState<TabId>('queue');
const queueQuery = useIssueQueue();
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
// Count queue items for badge
const queueCount = useMemo(() => {
if (!queueQuery.data) return 0;
const grouped = queueQuery.data.grouped_items ?? {};
let count = 0;
for (const items of Object.values(grouped)) {
count += items.length;
}
return count;
}, [queueQuery.data]);
const hasChain = associationChain !== null;
const toggle = useCallback(() => {
setIsOpen((prev) => !prev);
}, []);
const handleTabClick = useCallback((tab: TabId) => {
setActiveTab(tab);
setIsOpen(true);
}, []);
return (
<div
className={cn(
'border-t border-border bg-muted/30 shrink-0 transition-all duration-200',
)}
>
{/* Tab bar (always visible, ~36px) */}
<div className="flex items-center gap-0 shrink-0">
{/* Queue tab */}
<button
onClick={() => handleTabClick('queue')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors border-b-2',
activeTab === 'queue' && isOpen
? 'border-b-primary text-foreground font-medium'
: 'border-b-transparent text-muted-foreground hover:text-foreground',
)}
>
<ListChecks className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.bottomPanel.queueTab' })}
{queueCount > 0 && (
<Badge variant="info" className="text-[10px] px-1.5 py-0 ml-0.5">
{queueCount}
</Badge>
)}
</button>
{/* Inspector tab */}
<button
onClick={() => handleTabClick('inspector')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors border-b-2',
activeTab === 'inspector' && isOpen
? 'border-b-primary text-foreground font-medium'
: 'border-b-transparent text-muted-foreground hover:text-foreground',
)}
>
<Info className="w-3.5 h-3.5" />
{formatMessage({ id: 'terminalDashboard.bottomPanel.inspectorTab' })}
{hasChain && (
<span className="ml-1 w-2 h-2 rounded-full bg-primary shrink-0" />
)}
</button>
{/* Collapse/expand toggle at right */}
<button
onClick={toggle}
className="ml-auto px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
title={formatMessage({
id: isOpen
? 'terminalDashboard.bottomPanel.collapse'
: 'terminalDashboard.bottomPanel.expand',
})}
>
{isOpen ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronUp className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Collapsible content area */}
<div
className={cn(
'overflow-hidden transition-all duration-200',
isOpen ? 'max-h-[280px] opacity-100' : 'max-h-0 opacity-0',
)}
>
<div className="h-[280px] border-t border-border/50">
{activeTab === 'queue' ? (
<QueuePanel embedded />
) : (
<InspectorContent />
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,197 @@
// ========================================
// DashboardToolbar Component
// ========================================
// Top toolbar for Terminal Dashboard V2.
// Provides toggle buttons for floating panels (Sessions/Issues/Queue/Inspector)
// and layout preset controls.
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
FolderTree,
AlertCircle,
ListChecks,
Info,
LayoutGrid,
Columns2,
Rows2,
Square,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/Badge';
import {
useSessionManagerStore,
selectGroups,
selectTerminalMetas,
} from '@/stores/sessionManagerStore';
import {
useIssueQueueIntegrationStore,
selectAssociationChain,
} from '@/stores/issueQueueIntegrationStore';
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
import { useTerminalGridStore } from '@/stores/terminalGridStore';
import type { TerminalStatus } from '@/types/terminal-dashboard';
// ========== Types ==========
export type PanelId = 'sessions' | 'issues' | 'queue' | 'inspector';
interface DashboardToolbarProps {
activePanel: PanelId | null;
onTogglePanel: (panelId: PanelId) => void;
}
// ========== Layout Presets ==========
const LAYOUT_PRESETS = [
{ id: 'single' as const, icon: Square, labelId: 'terminalDashboard.toolbar.layoutSingle' },
{ id: 'split-h' as const, icon: Columns2, labelId: 'terminalDashboard.toolbar.layoutSplitH' },
{ id: 'split-v' as const, icon: Rows2, labelId: 'terminalDashboard.toolbar.layoutSplitV' },
{ id: 'grid-2x2' as const, icon: LayoutGrid, labelId: 'terminalDashboard.toolbar.layoutGrid' },
];
// ========== Component ==========
export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolbarProps) {
const { formatMessage } = useIntl();
// Session count
const groups = useSessionManagerStore(selectGroups);
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
const sessionCount = useMemo(() => {
const allSessionIds = groups.flatMap((g) => g.sessionIds);
let activeCount = 0;
for (const sid of allSessionIds) {
const meta = terminalMetas[sid];
const status: TerminalStatus = meta?.status ?? 'idle';
if (status === 'active') activeCount++;
}
return activeCount > 0 ? activeCount : allSessionIds.length;
}, [groups, terminalMetas]);
// Issues count
const { openCount } = useIssues();
// Queue count
const queueQuery = useIssueQueue();
const queueCount = useMemo(() => {
if (!queueQuery.data) return 0;
const grouped = queueQuery.data.grouped_items ?? {};
let count = 0;
for (const items of Object.values(grouped)) {
count += items.length;
}
return count;
}, [queueQuery.data]);
// Inspector chain indicator
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
const hasChain = associationChain !== null;
// Layout preset handler
const resetLayout = useTerminalGridStore((s) => s.resetLayout);
const handlePreset = useCallback(
(preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => {
resetLayout(preset);
},
[resetLayout]
);
return (
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
{/* Panel toggle buttons */}
<ToolbarButton
icon={FolderTree}
label={formatMessage({ id: 'terminalDashboard.toolbar.sessions' })}
isActive={activePanel === 'sessions'}
onClick={() => onTogglePanel('sessions')}
badge={sessionCount > 0 ? sessionCount : undefined}
/>
<ToolbarButton
icon={AlertCircle}
label={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
isActive={activePanel === 'issues'}
onClick={() => onTogglePanel('issues')}
badge={openCount > 0 ? openCount : undefined}
/>
<ToolbarButton
icon={ListChecks}
label={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
isActive={activePanel === 'queue'}
onClick={() => onTogglePanel('queue')}
badge={queueCount > 0 ? queueCount : undefined}
/>
<ToolbarButton
icon={Info}
label={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
isActive={activePanel === 'inspector'}
onClick={() => onTogglePanel('inspector')}
dot={hasChain}
/>
{/* Separator */}
<div className="w-px h-5 bg-border mx-1" />
{/* Layout presets */}
{LAYOUT_PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handlePreset(preset.id)}
className={cn(
'p-1.5 rounded transition-colors',
'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
title={formatMessage({ id: preset.labelId })}
>
<preset.icon className="w-3.5 h-3.5" />
</button>
))}
{/* Right-aligned title */}
<span className="ml-auto text-xs text-muted-foreground font-medium">
{formatMessage({ id: 'terminalDashboard.page.title' })}
</span>
</div>
);
}
// ========== Toolbar Button ==========
function ToolbarButton({
icon: Icon,
label,
isActive,
onClick,
badge,
dot,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
isActive: boolean;
onClick: () => void;
badge?: number;
dot?: boolean;
}) {
return (
<button
onClick={onClick}
className={cn(
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
isActive
? 'bg-primary/10 text-primary font-medium'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Icon className="w-3.5 h-3.5" />
<span>{label}</span>
{badge !== undefined && badge > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
{badge}
</Badge>
)}
{dot && (
<span className="ml-0.5 w-2 h-2 rounded-full bg-primary shrink-0" />
)}
</button>
);
}

View File

@@ -0,0 +1,101 @@
// ========================================
// FloatingPanel Component
// ========================================
// Generic floating panel container (Drawer style).
// Slides in from left or right side, overlaying the terminal grid.
import { useCallback, useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { cn } from '@/lib/utils';
// ========== Types ==========
interface FloatingPanelProps {
isOpen: boolean;
onClose: () => void;
title: string;
side?: 'left' | 'right';
width?: number;
children: React.ReactNode;
}
// ========== Component ==========
export function FloatingPanel({
isOpen,
onClose,
title,
side = 'left',
width = 320,
children,
}: FloatingPanelProps) {
const panelRef = useRef<HTMLDivElement>(null);
// Close on Escape key
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) onClose();
},
[onClose]
);
return (
<>
{/* Backdrop */}
<div
className={cn(
'fixed inset-0 z-40 transition-opacity duration-200',
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
)}
style={{ top: '40px' }} // Below toolbar
onClick={handleBackdropClick}
>
<div className="absolute inset-0 bg-black/20" />
</div>
{/* Panel */}
<div
ref={panelRef}
className={cn(
'fixed z-50 flex flex-col bg-background border-border shadow-lg',
'transition-transform duration-200 ease-out',
side === 'left' && 'left-0 border-r',
side === 'right' && 'right-0 border-l',
// Transform based on open state and side
side === 'left' && (isOpen ? 'translate-x-0' : '-translate-x-full'),
side === 'right' && (isOpen ? 'translate-x-0' : 'translate-x-full'),
)}
style={{
top: '40px', // Below toolbar
height: 'calc(100vh - 56px - 40px)', // Subtract both app header and toolbar
width: `${width}px`,
}}
>
{/* Panel header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<h2 className="text-sm font-semibold">{title}</h2>
<button
onClick={onClose}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Panel content */}
<div className="flex-1 min-h-0 overflow-hidden">
{children}
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,118 @@
// ========================================
// TerminalGrid Component
// ========================================
// Recursive Allotment renderer for the terminal split pane layout.
// Mirrors the LayoutContainer pattern from cli-viewer but renders
// TerminalPane components as leaf nodes.
import { useCallback, useMemo } from 'react';
import { Allotment } from 'allotment';
import 'allotment/dist/style.css';
import { cn } from '@/lib/utils';
import { isPaneId } from '@/lib/layout-utils';
import {
useTerminalGridStore,
selectTerminalGridLayout,
selectTerminalGridPanes,
} from '@/stores/terminalGridStore';
import type { AllotmentLayoutGroup } from '@/stores/viewerStore';
import { TerminalPane } from './TerminalPane';
// ========== Types ==========
interface GridGroupRendererProps {
group: AllotmentLayoutGroup;
minSize: number;
onSizeChange: (sizes: number[]) => void;
}
// ========== Recursive Group Renderer ==========
function GridGroupRenderer({ group, minSize, onSizeChange }: GridGroupRendererProps) {
const panes = useTerminalGridStore(selectTerminalGridPanes);
const handleChange = useCallback(
(sizes: number[]) => {
onSizeChange(sizes);
},
[onSizeChange]
);
const validChildren = useMemo(() => {
return group.children.filter((child) => {
if (isPaneId(child)) {
return panes[child] !== undefined;
}
return true;
});
}, [group.children, panes]);
if (validChildren.length === 0) {
return null;
}
return (
<Allotment
vertical={group.direction === 'vertical'}
defaultSizes={group.sizes}
onChange={handleChange}
className="h-full"
>
{validChildren.map((child, index) => (
<Allotment.Pane key={isPaneId(child) ? child : `group-${index}`} minSize={minSize}>
{isPaneId(child) ? (
<TerminalPane paneId={child} />
) : (
<GridGroupRenderer
group={child}
minSize={minSize}
onSizeChange={onSizeChange}
/>
)}
</Allotment.Pane>
))}
</Allotment>
);
}
// ========== Main Component ==========
export function TerminalGrid({ className }: { className?: string }) {
const layout = useTerminalGridStore(selectTerminalGridLayout);
const panes = useTerminalGridStore(selectTerminalGridPanes);
const setLayout = useTerminalGridStore((s) => s.setLayout);
const handleSizeChange = useCallback(
(sizes: number[]) => {
setLayout({ ...layout, sizes });
},
[layout, setLayout]
);
const content = useMemo(() => {
if (!layout.children || layout.children.length === 0) {
return null;
}
// Single pane: render directly without Allotment wrapper
if (layout.children.length === 1 && isPaneId(layout.children[0])) {
const paneId = layout.children[0];
if (!panes[paneId]) return null;
return <TerminalPane paneId={paneId} />;
}
return (
<GridGroupRenderer
group={layout}
minSize={150}
onSizeChange={handleSizeChange}
/>
);
}, [layout, panes, handleSizeChange]);
return (
<div className={cn('h-full w-full overflow-hidden bg-background', className)}>
{content}
</div>
);
}

View File

@@ -0,0 +1,246 @@
// ========================================
// TerminalPane Component
// ========================================
// Single terminal pane = PaneToolbar + TerminalInstance.
// Renders within the TerminalGrid recursive layout.
import { useCallback, useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
SplitSquareHorizontal,
SplitSquareVertical,
Eraser,
AlertTriangle,
X,
Terminal,
ChevronDown,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TerminalInstance } from './TerminalInstance';
import {
useTerminalGridStore,
selectTerminalGridPanes,
selectTerminalGridFocusedPaneId,
} from '@/stores/terminalGridStore';
import {
useSessionManagerStore,
selectGroups,
selectTerminalMetas,
} from '@/stores/sessionManagerStore';
import {
useIssueQueueIntegrationStore,
selectAssociationChain,
} from '@/stores/issueQueueIntegrationStore';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { getAllPaneIds } from '@/lib/layout-utils';
import type { PaneId } from '@/stores/viewerStore';
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',
};
// ========== Props ==========
interface TerminalPaneProps {
paneId: PaneId;
}
// ========== Component ==========
export function TerminalPane({ paneId }: TerminalPaneProps) {
const { formatMessage } = useIntl();
// Grid store
const panes = useTerminalGridStore(selectTerminalGridPanes);
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
const layout = useTerminalGridStore((s) => s.layout);
const splitPane = useTerminalGridStore((s) => s.splitPane);
const closePane = useTerminalGridStore((s) => s.closePane);
const assignSession = useTerminalGridStore((s) => s.assignSession);
const setFocused = useTerminalGridStore((s) => s.setFocused);
const pane = panes[paneId];
const sessionId = pane?.sessionId ?? null;
const isFocused = focusedPaneId === paneId;
const canClose = getAllPaneIds(layout).length > 1;
// Session data
const groups = useSessionManagerStore(selectGroups);
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
const sessions = useCliSessionStore((s) => s.sessions);
// Association chain for linked issue badge
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
const linkedIssueId = useMemo(() => {
if (!sessionId || !associationChain) return null;
if (associationChain.sessionId === sessionId) return associationChain.issueId;
return null;
}, [sessionId, associationChain]);
// Terminal metadata
const meta = sessionId ? terminalMetas[sessionId] : null;
const status: TerminalStatus = meta?.status ?? 'idle';
const alertCount = meta?.alertCount ?? 0;
// Build session options for dropdown
const sessionOptions = useMemo(() => {
const allSessionIds = groups.flatMap((g) => g.sessionIds);
return allSessionIds.map((sid) => {
const s = sessions[sid];
const name = s ? (s.tool ? `${s.tool} - ${s.shellKind}` : s.shellKind) : sid;
return { id: sid, name };
});
}, [groups, sessions]);
// Handlers
const handleFocus = useCallback(() => {
setFocused(paneId);
}, [paneId, setFocused]);
const handleSplitH = useCallback(() => {
splitPane(paneId, 'horizontal');
}, [paneId, splitPane]);
const handleSplitV = useCallback(() => {
splitPane(paneId, 'vertical');
}, [paneId, splitPane]);
const handleClose = useCallback(() => {
closePane(paneId);
}, [paneId, closePane]);
const handleSessionChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
assignSession(paneId, value || null);
},
[paneId, assignSession]
);
const handleClear = useCallback(() => {
// Clear is handled by re-assigning the same session (triggers reset in TerminalInstance)
if (sessionId) {
assignSession(paneId, null);
// Use microtask to re-assign after clearing
queueMicrotask(() => assignSession(paneId, sessionId));
}
}, [paneId, sessionId, assignSession]);
return (
<div
className={cn(
'flex flex-col h-full border border-border/50 rounded-sm overflow-hidden',
isFocused && 'ring-1 ring-primary/40'
)}
onClick={handleFocus}
>
{/* PaneToolbar */}
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/30 shrink-0">
{/* Left: Session selector + status */}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{sessionId && (
<span
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
/>
)}
<div className="relative min-w-0 flex-1 max-w-[180px]">
<select
value={sessionId ?? ''}
onChange={handleSessionChange}
className={cn(
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
'appearance-none pr-5 truncate',
!sessionId && 'text-muted-foreground'
)}
>
<option value="">
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
</option>
{sessionOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.name}
</option>
))}
</select>
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
</div>
{/* Center: Linked issue badge */}
{linkedIssueId && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary shrink-0">
{linkedIssueId}
</span>
)}
{/* Right: Action buttons */}
<div className="flex items-center gap-0.5 shrink-0">
<button
onClick={handleSplitH}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title={formatMessage({ id: 'terminalDashboard.pane.splitHorizontal' })}
>
<SplitSquareHorizontal className="w-3.5 h-3.5" />
</button>
<button
onClick={handleSplitV}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title={formatMessage({ id: 'terminalDashboard.pane.splitVertical' })}
>
<SplitSquareVertical className="w-3.5 h-3.5" />
</button>
{sessionId && (
<button
onClick={handleClear}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title={formatMessage({ id: 'terminalDashboard.pane.clearTerminal' })}
>
<Eraser className="w-3.5 h-3.5" />
</button>
)}
{alertCount > 0 && (
<span className="flex items-center gap-0.5 px-1 text-destructive">
<AlertTriangle className="w-3 h-3" />
<span className="text-[10px] font-semibold tabular-nums">
{alertCount > 99 ? '99+' : alertCount}
</span>
</span>
)}
{canClose && (
<button
onClick={handleClose}
className="p-1 rounded hover:bg-destructive/10 transition-colors text-muted-foreground hover:text-destructive"
title={formatMessage({ id: 'terminalDashboard.pane.closePane' })}
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Terminal content */}
{sessionId ? (
<div className="flex-1 min-h-0">
<TerminalInstance sessionId={sessionId} />
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Terminal className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
<p className="text-sm">
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
</p>
<p className="text-xs mt-1 opacity-70">
{formatMessage({ id: 'terminalDashboard.pane.selectSessionHint' })}
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,113 +0,0 @@
// ========================================
// TerminalTabBar Component
// ========================================
// Horizontal tab strip for terminal sessions in the Terminal Dashboard.
// Renders tabs from sessionManagerStore groups with status indicators and alert badges.
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Terminal, AlertTriangle } 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);
// Total alerts across all terminals
const totalAlerts = useMemo(() => {
let count = 0;
for (const meta of Object.values(terminalMetas)) {
count += meta.alertCount;
}
return count;
}, [terminalMetas]);
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>
);
})}
{/* Total alerts indicator at right end */}
{totalAlerts > 0 && (
<div className="ml-auto flex items-center gap-1 px-3 py-2 shrink-0 text-destructive">
<AlertTriangle className="w-3.5 h-3.5" />
<span className="text-[10px] font-semibold tabular-nums">
{totalAlerts > 99 ? '99+' : totalAlerts}
</span>
</div>
)}
</div>
);
}

View File

@@ -1,168 +0,0 @@
// ========================================
// TerminalWorkbench Component
// ========================================
// Container for the right panel of the Terminal Dashboard.
// Combines TerminalTabBar (tab switching) and TerminalInstance (xterm.js).
// When no terminal is active, shows selected issue detail preview
// or a compact empty state with action hints.
import { useMemo } from 'react';
import { useIntl } from 'react-intl';
import {
Terminal,
CircleDot,
Tag,
Clock,
User,
} from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import {
useSessionManagerStore,
selectSessionManagerActiveTerminalId,
} from '@/stores/sessionManagerStore';
import {
useIssueQueueIntegrationStore,
selectSelectedIssueId,
} from '@/stores/issueQueueIntegrationStore';
import { useIssues } from '@/hooks/useIssues';
import type { Issue } from '@/lib/api';
import { TerminalTabBar } from './TerminalTabBar';
import { TerminalInstance } from './TerminalInstance';
// ========== Priority Styles ==========
const PRIORITY_VARIANT: Record<Issue['priority'], 'destructive' | 'warning' | 'info' | 'secondary'> = {
critical: 'destructive',
high: 'warning',
medium: 'info',
low: 'secondary',
};
const STATUS_COLORS: Record<Issue['status'], string> = {
open: 'text-info',
in_progress: 'text-warning',
resolved: 'text-success',
closed: 'text-muted-foreground',
completed: 'text-success',
};
// ========== Issue Detail Preview ==========
function IssueDetailPreview({ issue }: { issue: Issue }) {
const { formatMessage } = useIntl();
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-lg mx-auto space-y-4">
{/* Header hint */}
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
{formatMessage({ id: 'terminalDashboard.workbench.issuePreview' })}
</p>
{/* Title + Status */}
<div className="space-y-2">
<div className="flex items-start gap-2">
<CircleDot className={cn('w-4 h-4 shrink-0 mt-0.5', STATUS_COLORS[issue.status] ?? 'text-muted-foreground')} />
<h3 className="text-base font-semibold text-foreground leading-snug">
{issue.title}
</h3>
</div>
<div className="flex items-center gap-2 pl-6">
<Badge variant={PRIORITY_VARIANT[issue.priority]} className="text-[10px] px-1.5 py-0">
{issue.priority}
</Badge>
<span className="text-[10px] text-muted-foreground font-mono">{issue.id}</span>
</div>
</div>
{/* Context / Description */}
{issue.context && (
<div className="rounded-md border border-border bg-muted/20 p-3">
<p className="text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
{issue.context}
</p>
</div>
)}
{/* Metadata rows */}
<div className="space-y-1.5 text-xs text-muted-foreground">
{issue.labels && issue.labels.length > 0 && (
<div className="flex items-center gap-2">
<Tag className="w-3.5 h-3.5 shrink-0" />
<div className="flex items-center gap-1 flex-wrap">
{issue.labels.map((label) => (
<span key={label} className="px-1.5 py-0.5 rounded bg-muted text-[10px]">
{label}
</span>
))}
</div>
</div>
)}
{issue.assignee && (
<div className="flex items-center gap-2">
<User className="w-3.5 h-3.5 shrink-0" />
<span>{issue.assignee}</span>
</div>
)}
{issue.createdAt && (
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 shrink-0" />
<span>{new Date(issue.createdAt).toLocaleString()}</span>
</div>
)}
</div>
{/* Hint */}
<p className="text-[10px] text-muted-foreground/60 pt-2">
{formatMessage({ id: 'terminalDashboard.workbench.issuePreviewHint' })}
</p>
</div>
</div>
);
}
// ========== Component ==========
export function TerminalWorkbench() {
const { formatMessage } = useIntl();
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
const selectedIssueId = useIssueQueueIntegrationStore(selectSelectedIssueId);
const { issues } = useIssues();
// Find selected issue for preview
const selectedIssue = useMemo(() => {
if (!selectedIssueId) return null;
return issues.find((i) => i.id === selectedIssueId) ?? null;
}, [selectedIssueId, issues]);
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>
) : selectedIssue ? (
/* Issue detail preview when no terminal but issue is selected */
<IssueDetailPreview issue={selectedIssue} />
) : (
/* Compact empty state */
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Terminal className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
<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>
);
}