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

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