Files
Claude-Code-Workflow/ccw/frontend/src/pages/CliViewerPage.tsx
catlog22 9aa07e8d01 Add comprehensive tests for CLI functionality and CodexLens compatibility
- Introduced tests for stale running fallback in CLI watch functionality to ensure proper handling of saved conversations.
- Added compatibility tests for CodexLens CLI to verify index initialization despite compatibility conflicts.
- Implemented tests for Smart Search MCP usage to validate default settings and path handling.
- Created tests for UV Manager to ensure Python preference handling works as expected.
- Added a detailed guide for CCW/Codex commands and skills, covering core commands, execution modes, and templates.
2026-03-08 17:30:39 +08:00

169 lines
5.4 KiB
TypeScript

// ========================================
// CLI Viewer Page
// ========================================
// Multi-pane CLI output viewer with configurable layouts
// Integrates with viewerStore for state management
// Includes WebSocket integration and execution recovery
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { LayoutContainer, CliViewerToolbar } from '@/components/cli-viewer';
import {
useViewerStore,
useViewerLayout,
useViewerPanes,
useFocusedPaneId,
type AllotmentLayout,
} from '@/stores/viewerStore';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket';
import { getStaleViewerTabs } from './cliViewerPage.utils';
// ========================================
// Constants
// ========================================
const DEFAULT_LAYOUT = 'split-h' as const;
// ========================================
// Helper Functions
// ========================================
/**
* Count total panes in layout
*/
function countPanes(layout: AllotmentLayout): number {
let count = 0;
const traverse = (children: (string | AllotmentLayout)[]) => {
for (const child of children) {
if (typeof child === 'string') {
count++;
} else {
traverse(child.children);
}
}
};
traverse(layout.children);
return count;
}
// ========================================
// Main Component
// ========================================
export function CliViewerPage() {
const [searchParams, setSearchParams] = useSearchParams();
// Fullscreen state
const [isFullscreen, setIsFullscreen] = useState(false);
// Store hooks
const layout = useViewerLayout();
const panes = useViewerPanes();
const focusedPaneId = useFocusedPaneId();
const { initializeDefaultLayout, addTab, removeTab } = useViewerStore();
// CLI Stream Store hooks
const executions = useCliStreamStore((state) => state.executions);
// Active execution sync from server
const { isLoading: isSyncing, isFetching: isRefreshing } = useActiveCliExecutions(true);
// CENTRALIZED WebSocket handler - processes each message only ONCE globally
useCliStreamWebSocket();
// Auto-add new executions as tabs, distributing across available panes
const addedExecutionsRef = useRef<Set<string>>(new Set());
// FIX-001: Initialize addedExecutionsRef with existing tab executionIds on mount
// This prevents duplicate tabs from being added after page refresh
useEffect(() => {
// Extract executionIds from all existing tabs in all panes
const existingExecutionIds = Object.values(panes).flatMap((pane) =>
pane.tabs.map((tab) => tab.executionId)
);
existingExecutionIds.forEach((id) => addedExecutionsRef.current.add(id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty deps - only run once on mount
useEffect(() => {
const paneIds = Object.keys(panes);
if (paneIds.length === 0) return;
const storeAddTab = useViewerStore.getState().addTab;
const newExecutionIds = Object.keys(executions).filter(
(id) => !addedExecutionsRef.current.has(id)
);
if (newExecutionIds.length === 0) return;
newExecutionIds.forEach((executionId, index) => {
addedExecutionsRef.current.add(executionId);
const exec = executions[executionId];
const toolShort = exec.tool.split('-')[0];
const targetPaneId = paneIds[index % paneIds.length];
storeAddTab(targetPaneId, executionId, `${toolShort} (${exec.mode})`);
});
}, [executions, panes]);
useEffect(() => {
if (isSyncing || isRefreshing) return;
const staleTabs = getStaleViewerTabs(panes, executions);
if (staleTabs.length === 0) return;
staleTabs.forEach(({ paneId, tabId, executionId }) => {
addedExecutionsRef.current.delete(executionId);
removeTab(paneId, tabId);
});
}, [executions, isRefreshing, isSyncing, panes, removeTab]);
// Initialize layout if empty
useEffect(() => {
const paneCount = countPanes(layout);
if (paneCount === 0) {
initializeDefaultLayout(DEFAULT_LAYOUT);
}
}, [layout, initializeDefaultLayout]);
// Handle executionId from URL params
useEffect(() => {
const executionId = searchParams.get('executionId');
if (executionId && focusedPaneId) {
addTab(focusedPaneId, executionId, `Execution ${executionId.slice(0, 8)}`);
setSearchParams((prev) => {
const newParams = new URLSearchParams(prev);
newParams.delete('executionId');
return newParams;
});
}
}, [searchParams, focusedPaneId, addTab, setSearchParams]);
// Toggle fullscreen handler
const handleToggleFullscreen = () => {
setIsFullscreen((prev) => !prev);
};
return (
<div className="h-full flex flex-col">
{/* ======================================== */}
{/* Toolbar */}
{/* ======================================== */}
<CliViewerToolbar
isFullscreen={isFullscreen}
onToggleFullscreen={handleToggleFullscreen}
/>
{/* ======================================== */}
{/* Layout Container */}
{/* ======================================== */}
<div className="flex-1 min-h-0 bg-background">
<LayoutContainer />
</div>
</div>
);
}
export default CliViewerPage;