mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
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:
246
ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
Normal file
246
ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user