Files
Claude-Code-Workflow/ccw/frontend/src/components/terminal-dashboard/SessionGroupTree.tsx
catlog22 a897858c6a feat: Enhance team skill router with command architecture and role isolation rules
- Added command architecture section to skill router template, detailing role organization and command delegation.
- Updated role router input parsing to reflect new file structure for roles.
- Introduced role isolation rules to enforce strict boundaries on role responsibilities and output tagging.
- Enhanced team configuration section to include role-specific guidelines and message bus requirements.

feat: Improve terminal dashboard with session status indicators

- Integrated terminal status indicators in the session group tree, displaying active, idle, error, paused, and resuming states.
- Updated session click handling to focus on existing panes or assign sessions to available panes.

feat: Add session lifecycle controls in terminal pane

- Implemented restart, pause, and resume functionalities for terminal sessions with loading states.
- Enhanced UI buttons for session control with appropriate loading indicators and tooltips.

i18n: Update terminal dashboard localization for session controls

- Added translations for restart, pause, and resume session actions in English and Chinese.

chore: Create role command template for command file generation

- Established a comprehensive template for generating command files in roles, including sections for strategy, execution steps, and error handling.
- Included pre-built command patterns for common tasks like exploration, analysis, implementation, validation, review, dispatch, and monitoring.
2026-02-15 12:38:32 +08:00

265 lines
10 KiB
TypeScript

// ========================================
// 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, selectTerminalMetas } from '@/stores';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore';
import { Badge } from '@/components/ui/Badge';
import type { TerminalStatus } from '@/types/terminal-dashboard';
// ========== Status Dot Styles ==========
const statusDotStyles: Record<TerminalStatus, string> = {
active: 'bg-green-500',
idle: 'bg-gray-400',
error: 'bg-red-500',
paused: 'bg-yellow-500',
resuming: 'bg-blue-400 animate-pulse',
};
// ========== SessionGroupTree Component ==========
export function SessionGroupTree() {
const { formatMessage } = useIntl();
const groups = useSessionManagerStore(selectGroups);
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
const createGroup = useSessionManagerStore((s) => s.createGroup);
const moveSessionToGroup = useSessionManagerStore((s) => s.moveSessionToGroup);
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
const sessions = useCliSessionStore((s) => s.sessions);
// Grid store for pane management
const panes = useTerminalGridStore(selectTerminalGridPanes);
const assignSession = useTerminalGridStore((s) => s.assignSession);
const setFocused = useTerminalGridStore((s) => s.setFocused);
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) => {
// Set active terminal in session manager
setActiveTerminal(sessionId);
// Find pane that already has this session, or switch focused pane
const paneWithSession = Object.entries(panes).find(
([, pane]) => pane.sessionId === sessionId
);
if (paneWithSession) {
// Focus the pane that has this session
setFocused(paneWithSession[0]);
} else {
// Find focused pane or first pane, and assign session to it
const focusedPaneId = useTerminalGridStore.getState().focusedPaneId;
const targetPaneId = focusedPaneId || Object.keys(panes)[0];
if (targetPaneId) {
assignSession(targetPaneId, sessionId);
setFocused(targetPaneId);
}
}
},
[setActiveTerminal, panes, setFocused, assignSession]
);
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-1.5 text-muted-foreground p-4">
<Folder className="w-6 h-6 opacity-30" />
<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) => {
const meta = terminalMetas[sessionId];
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
return (
<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>
{/* Status indicator dot */}
<span
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
title={sessionStatus}
/>
<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;