mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-14 17:41:22 +08:00
Add unit tests for various components and stores in the terminal dashboard
- Implement tests for AssociationHighlight, DashboardToolbar, QueuePanel, SessionGroupTree, and TerminalDashboardPage to ensure proper functionality and state management. - Create tests for cliSessionStore, issueQueueIntegrationStore, queueExecutionStore, queueSchedulerStore, sessionManagerStore, and terminalGridStore to validate state resets and workspace scoping. - Mock necessary dependencies and state management hooks to isolate tests and ensure accurate behavior.
This commit is contained in:
@@ -11,13 +11,19 @@ import { Toaster } from 'sonner';
|
||||
import { router } from './router';
|
||||
import queryClient from './lib/query-client';
|
||||
import type { Locale } from './lib/i18n';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { fetchCliSessions, initializeCsrfToken } from './lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useExecutionMonitorStore } from '@/stores/executionMonitorStore';
|
||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||
import { useIssueQueueIntegrationStore } from '@/stores/issueQueueIntegrationStore';
|
||||
import { useQueueExecutionStore } from '@/stores/queueExecutionStore';
|
||||
import { useQueueSchedulerStore } from '@/stores/queueSchedulerStore';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useTerminalGridStore } from '@/stores/terminalGridStore';
|
||||
import { useActiveCliExecutions, ACTIVE_CLI_EXECUTIONS_QUERY_KEY } from '@/hooks/useActiveCliExecutions';
|
||||
import { DialogStyleProvider } from '@/contexts/DialogStyleContext';
|
||||
import { initializeCsrfToken } from './lib/api';
|
||||
|
||||
interface AppProps {
|
||||
locale: Locale;
|
||||
@@ -39,6 +45,7 @@ function App({ locale, messages }: AppProps) {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DialogStyleProvider>
|
||||
<QueryInvalidator />
|
||||
<CliSessionSync />
|
||||
<CliExecutionSync />
|
||||
<RouterProvider router={router} />
|
||||
<Toaster richColors position="top-right" />
|
||||
@@ -59,8 +66,21 @@ function QueryInvalidator() {
|
||||
// Register callback to invalidate all workspace-related queries on workspace switch
|
||||
const callback = () => {
|
||||
useCliStreamStore.getState().resetState();
|
||||
useCliSessionStore.getState().resetState();
|
||||
useExecutionMonitorStore.getState().resetState();
|
||||
useSessionManagerStore.getState().resetState();
|
||||
useIssueQueueIntegrationStore.getState().resetState();
|
||||
useQueueExecutionStore.getState().resetState();
|
||||
const queueSchedulerStore = useQueueSchedulerStore.getState();
|
||||
queueSchedulerStore.resetState();
|
||||
const nextProjectPath = useWorkflowStore.getState().projectPath;
|
||||
if (nextProjectPath) {
|
||||
void queueSchedulerStore.loadInitialState().catch((error) => {
|
||||
console.error('[QueueSchedulerSync] Failed to sync scheduler state:', error);
|
||||
});
|
||||
}
|
||||
useTerminalPanelStore.getState().resetState();
|
||||
useTerminalGridStore.getState().resetWorkspaceState();
|
||||
queryClient.invalidateQueries({ queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
@@ -86,6 +106,41 @@ function QueryInvalidator() {
|
||||
* CLI Execution Sync component
|
||||
* Syncs active CLI executions in the background to keep the count updated in Header
|
||||
*/
|
||||
function CliSessionSync() {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const setSessions = useCliSessionStore((state) => state.setSessions);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (!projectPath) {
|
||||
setSessions([]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
fetchCliSessions(projectPath)
|
||||
.then(({ sessions }) => {
|
||||
if (!cancelled) {
|
||||
setSessions(sessions);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[CliSessionSync] Failed to sync CLI sessions:', error);
|
||||
if (!cancelled) {
|
||||
setSessions([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectPath, setSessions]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function CliExecutionSync() {
|
||||
// Always sync active CLI executions with a longer polling interval
|
||||
// This ensures the activeCliCount badge in Header shows correct count on initial load
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
// ========================================
|
||||
// Association Highlight Tests
|
||||
// ========================================
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AssociationHighlightProvider, useAssociationHighlight } from './AssociationHighlight';
|
||||
|
||||
function Probe({ chain, scopeKey }: { chain: { issueId: string | null; queueItemId: string | null; sessionId: string | null } | null; scopeKey: string }) {
|
||||
return (
|
||||
<AssociationHighlightProvider scopeKey={scopeKey}>
|
||||
<ProbeInner chain={chain} />
|
||||
</AssociationHighlightProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ProbeInner({ chain }: { chain: { issueId: string | null; queueItemId: string | null; sessionId: string | null } | null }) {
|
||||
const { chain: activeChain, setChain } = useAssociationHighlight();
|
||||
|
||||
useEffect(() => {
|
||||
setChain(chain);
|
||||
}, [chain, setChain]);
|
||||
|
||||
return <div data-testid="chain">{activeChain?.issueId ?? 'none'}</div>;
|
||||
}
|
||||
|
||||
describe('AssociationHighlightProvider', () => {
|
||||
it('clears highlighted chain when scopeKey changes', () => {
|
||||
const { rerender } = render(
|
||||
<Probe
|
||||
scopeKey="workspace-a"
|
||||
chain={{ issueId: 'ISSUE-1', queueItemId: 'Q-1', sessionId: 'S-1' }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chain').textContent).toBe('ISSUE-1');
|
||||
|
||||
rerender(
|
||||
<Probe
|
||||
scopeKey="workspace-b"
|
||||
chain={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('chain').textContent).toBe('none');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { AssociationChain } from '@/types/terminal-dashboard';
|
||||
@@ -37,8 +39,22 @@ const AssociationHighlightContext = createContext<AssociationHighlightContextTyp
|
||||
|
||||
// ========== Provider ==========
|
||||
|
||||
export function AssociationHighlightProvider({ children }: { children: ReactNode }) {
|
||||
export function AssociationHighlightProvider({
|
||||
children,
|
||||
scopeKey,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
scopeKey?: string | null;
|
||||
}) {
|
||||
const [chain, setChainState] = useState<AssociationChain | null>(null);
|
||||
const lastScopeKeyRef = useRef(scopeKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastScopeKeyRef.current !== scopeKey) {
|
||||
lastScopeKeyRef.current = scopeKey;
|
||||
setChainState(null);
|
||||
}
|
||||
}, [scopeKey]);
|
||||
|
||||
const setChain = useCallback((nextChain: AssociationChain | null) => {
|
||||
setChainState(nextChain);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// ========================================
|
||||
// DashboardToolbar Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithI18n, screen, fireEvent } from '@/test/i18n';
|
||||
import { DashboardToolbar } from './DashboardToolbar';
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
currentProjectPath: 'D:/workspace-a',
|
||||
resetLayout: vi.fn(),
|
||||
createSessionAndAssign: vi.fn(),
|
||||
updateTerminalMeta: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useIssues', () => ({
|
||||
useIssues: () => ({ openCount: 0 }),
|
||||
useIssueQueue: () => ({ data: { grouped_items: {} } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: (selector: (state: { projectPath: string | null }) => unknown) =>
|
||||
selector({ projectPath: mockState.currentProjectPath }),
|
||||
selectProjectPath: (state: { projectPath: string | null }) => state.projectPath,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/issueQueueIntegrationStore', () => ({
|
||||
useIssueQueueIntegrationStore: (selector: (state: { associationChain: null }) => unknown) =>
|
||||
selector({ associationChain: null }),
|
||||
selectAssociationChain: (state: { associationChain: null }) => state.associationChain,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/terminalGridStore', () => ({
|
||||
useTerminalGridStore: (selector: (state: {
|
||||
resetLayout: typeof mockState.resetLayout;
|
||||
focusedPaneId: string;
|
||||
createSessionAndAssign: typeof mockState.createSessionAndAssign;
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
resetLayout: mockState.resetLayout,
|
||||
focusedPaneId: 'pane-1',
|
||||
createSessionAndAssign: mockState.createSessionAndAssign,
|
||||
}),
|
||||
selectTerminalGridFocusedPaneId: (state: { focusedPaneId: string }) => state.focusedPaneId,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/executionMonitorStore', () => ({
|
||||
useExecutionMonitorStore: (selector: (state: { count: number }) => unknown) => selector({ count: 0 }),
|
||||
selectActiveExecutionCount: (state: { count: number }) => state.count,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/sessionManagerStore', () => ({
|
||||
useSessionManagerStore: (selector: (state: { updateTerminalMeta: typeof mockState.updateTerminalMeta }) => unknown) =>
|
||||
selector({ updateTerminalMeta: mockState.updateTerminalMeta }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/configStore', () => ({
|
||||
useConfigStore: (selector: (state: { featureFlags: Record<string, boolean> }) => unknown) =>
|
||||
selector({
|
||||
featureFlags: {
|
||||
dashboardQueuePanelEnabled: true,
|
||||
dashboardInspectorEnabled: true,
|
||||
dashboardExecutionMonitorEnabled: true,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/queueSchedulerStore', () => ({
|
||||
useQueueSchedulerStore: (selector: (state: { status: string }) => unknown) => selector({ status: 'idle' }),
|
||||
selectQueueSchedulerStatus: (state: { status: string }) => state.status,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/notificationStore', () => ({
|
||||
toast: {
|
||||
error: mockState.toastError,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./CliConfigModal', () => ({
|
||||
CliConfigModal: ({ isOpen }: { isOpen: boolean }) =>
|
||||
isOpen ? <div data-testid="cli-config-modal">open</div> : null,
|
||||
}));
|
||||
|
||||
describe('DashboardToolbar', () => {
|
||||
beforeEach(() => {
|
||||
mockState.currentProjectPath = 'D:/workspace-a';
|
||||
mockState.resetLayout.mockReset();
|
||||
mockState.createSessionAndAssign.mockReset();
|
||||
mockState.updateTerminalMeta.mockReset();
|
||||
mockState.toastError.mockReset();
|
||||
});
|
||||
|
||||
it('closes the CLI config modal when workspace changes', () => {
|
||||
const view = renderWithI18n(
|
||||
<DashboardToolbar
|
||||
activePanel={null}
|
||||
onTogglePanel={() => undefined}
|
||||
isFileSidebarOpen
|
||||
onToggleFileSidebar={() => undefined}
|
||||
isSessionSidebarOpen
|
||||
onToggleSessionSidebar={() => undefined}
|
||||
isFullscreen={false}
|
||||
onToggleFullscreen={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to configure and launch a CLI session'));
|
||||
expect(screen.getByTestId('cli-config-modal')).toBeInTheDocument();
|
||||
|
||||
mockState.currentProjectPath = 'D:/workspace-b';
|
||||
view.rerender(
|
||||
<DashboardToolbar
|
||||
activePanel={null}
|
||||
onTogglePanel={() => undefined}
|
||||
isFileSidebarOpen
|
||||
onToggleFileSidebar={() => undefined}
|
||||
isSessionSidebarOpen
|
||||
onToggleSessionSidebar={() => undefined}
|
||||
isFullscreen={false}
|
||||
onToggleFullscreen={() => undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('cli-config-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
// Provides toggle buttons for floating panels (Issues/Queue/Inspector)
|
||||
// and layout preset controls. Sessions sidebar is always visible.
|
||||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -124,6 +124,11 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsCreating(false);
|
||||
setIsConfigOpen(false);
|
||||
}, [projectPath]);
|
||||
|
||||
// Helper to get or create a focused pane
|
||||
const getOrCreateFocusedPane = useCallback(() => {
|
||||
if (focusedPaneId) return focusedPaneId;
|
||||
|
||||
@@ -258,6 +258,20 @@ export function IssuePanel() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (sentTimerRef.current) clearTimeout(sentTimerRef.current);
|
||||
if (queuedTimerRef.current) clearTimeout(queuedTimerRef.current);
|
||||
setSelectedIds(new Set());
|
||||
setIsSending(false);
|
||||
setJustSent(false);
|
||||
setExecutionMethod('skill-team-issue');
|
||||
setIsSendConfigOpen(false);
|
||||
setCustomPrompt('');
|
||||
setIsAddingToQueue(false);
|
||||
setJustQueued(false);
|
||||
setQueueMode('write');
|
||||
}, [projectPath]);
|
||||
|
||||
// Sort: open/in_progress first, then by priority (critical > high > medium > low)
|
||||
const sortedIssues = useMemo(() => {
|
||||
const priorityOrder: Record<string, number> = {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// ========================================
|
||||
// QueuePanel Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithI18n, screen, fireEvent } from '@/test/i18n';
|
||||
import { QueuePanel } from './QueuePanel';
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
currentProjectPath: 'D:/workspace-a',
|
||||
loadInitialState: vi.fn(),
|
||||
buildAssociationChain: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useIssues', () => ({
|
||||
useIssueQueue: () => ({ data: null, isLoading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: (selector: (state: { projectPath: string | null }) => unknown) =>
|
||||
selector({ projectPath: mockState.currentProjectPath }),
|
||||
selectProjectPath: (state: { projectPath: string | null }) => state.projectPath,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/issueQueueIntegrationStore', () => ({
|
||||
useIssueQueueIntegrationStore: (selector: (state: {
|
||||
associationChain: null;
|
||||
buildAssociationChain: typeof mockState.buildAssociationChain;
|
||||
}) => unknown) =>
|
||||
selector({ associationChain: null, buildAssociationChain: mockState.buildAssociationChain }),
|
||||
selectAssociationChain: (state: { associationChain: null }) => state.associationChain,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/queueExecutionStore', () => ({
|
||||
useQueueExecutionStore: () => [],
|
||||
selectByQueueItem: () => () => [],
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/queueSchedulerStore', () => ({
|
||||
useQueueSchedulerStore: (selector: (state: {
|
||||
status: string;
|
||||
items: never[];
|
||||
loadInitialState: typeof mockState.loadInitialState;
|
||||
}) => unknown) =>
|
||||
selector({ status: 'idle', items: [], loadInitialState: mockState.loadInitialState }),
|
||||
selectQueueSchedulerStatus: (state: { status: string }) => state.status,
|
||||
selectQueueItems: (state: { items: never[] }) => state.items,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/orchestratorStore', () => ({
|
||||
useOrchestratorStore: (selector: (state: { activePlans: Record<string, never>; activePlanCount: number }) => unknown) =>
|
||||
selector({ activePlans: {}, activePlanCount: 0 }),
|
||||
selectActivePlans: (state: { activePlans: Record<string, never> }) => state.activePlans,
|
||||
selectActivePlanCount: (state: { activePlanCount: number }) => state.activePlanCount,
|
||||
}));
|
||||
|
||||
describe('QueuePanel', () => {
|
||||
beforeEach(() => {
|
||||
mockState.currentProjectPath = 'D:/workspace-a';
|
||||
mockState.loadInitialState.mockReset();
|
||||
mockState.buildAssociationChain.mockReset();
|
||||
});
|
||||
|
||||
it('resets the active tab back to queue when workspace changes', () => {
|
||||
const view = renderWithI18n(<QueuePanel />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /orchestrator/i }));
|
||||
expect(screen.getByText('No active orchestrations')).toBeInTheDocument();
|
||||
|
||||
mockState.currentProjectPath = 'D:/workspace-b';
|
||||
view.rerender(<QueuePanel />);
|
||||
|
||||
expect(screen.queryByText('No active orchestrations')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
selectActivePlanCount,
|
||||
type OrchestrationRunState,
|
||||
} from '@/stores/orchestratorStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import type { StepStatus, OrchestrationStatus } from '@/types/orchestrator';
|
||||
import type { QueueItem as ApiQueueItem } from '@/lib/api';
|
||||
import type { QueueItem as SchedulerQueueItem, QueueItemStatus as SchedulerQueueItemStatus } from '@/types/queue-frontend-types';
|
||||
@@ -506,6 +507,7 @@ function OrchestratorTabContent() {
|
||||
export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [activeTab, setActiveTab] = useState<QueueTab>('queue');
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const orchestratorCount = useOrchestratorStore(selectActivePlanCount);
|
||||
|
||||
// Scheduler store data for active count
|
||||
@@ -536,6 +538,10 @@ export function QueuePanel({ embedded = false }: { embedded?: boolean }) {
|
||||
return count;
|
||||
}, [useSchedulerData, schedulerItems, queueQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('queue');
|
||||
}, [projectPath]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tab bar */}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// ========================================
|
||||
// SessionGroupTree Tests
|
||||
// ========================================
|
||||
|
||||
import { act } from 'react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { renderWithI18n, screen, fireEvent } from '@/test/i18n';
|
||||
import { SessionGroupTree } from './SessionGroupTree';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||
import { useTerminalGridStore } from '@/stores/terminalGridStore';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
|
||||
describe('SessionGroupTree', () => {
|
||||
beforeEach(() => {
|
||||
useCliSessionStore.getState().resetState();
|
||||
useSessionManagerStore.getState().resetState();
|
||||
useTerminalGridStore.getState().resetLayout('single');
|
||||
|
||||
act(() => {
|
||||
useWorkflowStore.setState({ projectPath: 'D:/workspace-a' });
|
||||
});
|
||||
|
||||
useCliSessionStore.getState().setSessions([
|
||||
{
|
||||
sessionKey: 'session-1',
|
||||
shellKind: 'bash',
|
||||
workingDir: 'D:/workspace-a',
|
||||
tool: 'codex',
|
||||
createdAt: '2026-03-08T12:00:00.000Z',
|
||||
updatedAt: '2026-03-08T12:00:00.000Z',
|
||||
isPaused: false,
|
||||
},
|
||||
]);
|
||||
useSessionManagerStore.getState().updateTerminalMeta('session-1', {
|
||||
tag: 'workspace-a-tag',
|
||||
status: 'active',
|
||||
});
|
||||
});
|
||||
|
||||
it('collapses expanded tag groups when workspace changes', () => {
|
||||
renderWithI18n(<SessionGroupTree />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /workspace-a-tag/i }));
|
||||
expect(screen.getByText('codex')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
useWorkflowStore.setState({ projectPath: 'D:/workspace-b' });
|
||||
});
|
||||
|
||||
expect(screen.queryByText('codex')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
// Tree view for CLI sessions grouped by tag.
|
||||
// Sessions are automatically grouped by their tag (e.g., "gemini-143052").
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ChevronRight,
|
||||
@@ -15,6 +15,7 @@ import { cn } from '@/lib/utils';
|
||||
import { useSessionManagerStore, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
@@ -44,6 +45,11 @@ export function SessionGroupTree() {
|
||||
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
||||
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedTags(new Set());
|
||||
}, [projectPath]);
|
||||
|
||||
const toggleTag = useCallback((tag: string) => {
|
||||
setExpandedTags((prev) => {
|
||||
|
||||
220
ccw/frontend/src/hooks/useWebSocket.test.tsx
Normal file
220
ccw/frontend/src/hooks/useWebSocket.test.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
// ========================================
|
||||
// useWebSocket Hook Tests
|
||||
// ========================================
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useExecutionMonitorStore } from '@/stores/executionMonitorStore';
|
||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
|
||||
class MockWebSocket {
|
||||
static readonly OPEN = 1;
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
readonly url: string;
|
||||
readyState = 0;
|
||||
onopen: ((event: Event) => void) | null = null;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onclose: ((event: CloseEvent) => void) | null = null;
|
||||
onerror: ((event: Event) => void) | null = null;
|
||||
send = vi.fn();
|
||||
close = vi.fn(() => {
|
||||
this.readyState = 3;
|
||||
this.onclose?.(new CloseEvent('close'));
|
||||
});
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
open() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.(new Event('open'));
|
||||
}
|
||||
|
||||
message(payload: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(payload) } as MessageEvent);
|
||||
}
|
||||
}
|
||||
|
||||
function createSession(sessionKey: string, workingDir = 'D:/workspace-a') {
|
||||
return {
|
||||
sessionKey,
|
||||
shellKind: 'pwsh',
|
||||
workingDir,
|
||||
createdAt: '2026-03-08T12:00:00.000Z',
|
||||
updatedAt: '2026-03-08T12:00:00.000Z',
|
||||
isPaused: false,
|
||||
};
|
||||
}
|
||||
|
||||
function connectHook() {
|
||||
const hook = renderHook(() => useWebSocket({ enabled: true }));
|
||||
const socket = MockWebSocket.instances[MockWebSocket.instances.length - 1];
|
||||
if (!socket) {
|
||||
throw new Error('Expected WebSocket to be created');
|
||||
}
|
||||
|
||||
act(() => {
|
||||
socket.open();
|
||||
});
|
||||
|
||||
return { ...hook, socket };
|
||||
}
|
||||
|
||||
describe('useWebSocket workspace scoping', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
MockWebSocket.instances = [];
|
||||
|
||||
useCliSessionStore.getState().resetState();
|
||||
useExecutionMonitorStore.getState().resetState();
|
||||
useSessionManagerStore.getState().resetState();
|
||||
useWorkflowStore.setState({ projectPath: 'D:\\workspace-a' });
|
||||
|
||||
vi.stubGlobal('WebSocket', MockWebSocket as unknown as typeof WebSocket);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useCliSessionStore.getState().resetState();
|
||||
useExecutionMonitorStore.getState().resetState();
|
||||
useSessionManagerStore.getState().resetState();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('ignores scoped CLI and execution messages from another workspace', () => {
|
||||
const { socket } = connectHook();
|
||||
|
||||
act(() => {
|
||||
socket.message({
|
||||
type: 'CLI_SESSION_CREATED',
|
||||
payload: {
|
||||
session: createSession('session-foreign', 'D:/workspace-b'),
|
||||
timestamp: '2026-03-08T12:00:01.000Z',
|
||||
projectPath: 'D:/workspace-b',
|
||||
},
|
||||
});
|
||||
socket.message({
|
||||
type: 'CLI_SESSION_LOCKED',
|
||||
payload: {
|
||||
sessionKey: 'session-foreign',
|
||||
reason: 'Foreign execution',
|
||||
executionId: 'exec-foreign',
|
||||
timestamp: '2026-03-08T12:00:02.000Z',
|
||||
projectPath: 'D:/workspace-b',
|
||||
},
|
||||
});
|
||||
socket.message({
|
||||
type: 'EXECUTION_STARTED',
|
||||
payload: {
|
||||
executionId: 'exec-foreign',
|
||||
flowId: 'flow-foreign',
|
||||
sessionKey: 'session-foreign',
|
||||
stepName: 'Foreign flow',
|
||||
totalSteps: 2,
|
||||
timestamp: '2026-03-08T12:00:03.000Z',
|
||||
projectPath: 'D:/workspace-b',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(useCliSessionStore.getState().sessions['session-foreign']).toBeUndefined();
|
||||
expect(useSessionManagerStore.getState().terminalMetas['session-foreign']).toBeUndefined();
|
||||
expect(useExecutionMonitorStore.getState().activeExecutions['exec-foreign']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles matching scoped messages and legacy messages for known sessions', () => {
|
||||
const { socket } = connectHook();
|
||||
|
||||
act(() => {
|
||||
socket.message({
|
||||
type: 'CLI_SESSION_CREATED',
|
||||
payload: {
|
||||
session: createSession('session-local', 'D:/workspace-a/subdir'),
|
||||
timestamp: '2026-03-08T12:00:01.000Z',
|
||||
projectPath: 'd:/workspace-a',
|
||||
},
|
||||
});
|
||||
socket.message({
|
||||
type: 'CLI_SESSION_OUTPUT',
|
||||
payload: {
|
||||
sessionKey: 'session-local',
|
||||
data: 'hello from current workspace',
|
||||
timestamp: '2026-03-08T12:00:02.000Z',
|
||||
},
|
||||
});
|
||||
socket.message({
|
||||
type: 'CLI_SESSION_LOCKED',
|
||||
payload: {
|
||||
sessionKey: 'session-local',
|
||||
reason: 'Current execution',
|
||||
executionId: 'exec-local',
|
||||
timestamp: '2026-03-08T12:00:03.000Z',
|
||||
projectPath: 'D:/workspace-a',
|
||||
},
|
||||
});
|
||||
socket.message({
|
||||
type: 'EXECUTION_STARTED',
|
||||
payload: {
|
||||
executionId: 'exec-local',
|
||||
flowId: 'flow-local',
|
||||
sessionKey: 'session-local',
|
||||
stepName: 'Current flow',
|
||||
totalSteps: 3,
|
||||
timestamp: '2026-03-08T12:00:04.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const cliState = useCliSessionStore.getState();
|
||||
expect(cliState.sessions['session-local']?.workingDir).toBe('D:/workspace-a/subdir');
|
||||
expect(cliState.outputChunks['session-local']).toEqual([
|
||||
{
|
||||
data: 'hello from current workspace',
|
||||
timestamp: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
|
||||
const sessionManagerState = useSessionManagerStore.getState();
|
||||
expect(sessionManagerState.terminalMetas['session-local']?.isLocked).toBe(true);
|
||||
expect(sessionManagerState.terminalMetas['session-local']?.lockedByExecutionId).toBe('exec-local');
|
||||
|
||||
const executionState = useExecutionMonitorStore.getState();
|
||||
expect(executionState.activeExecutions['exec-local']?.sessionKey).toBe('session-local');
|
||||
expect(executionState.currentExecutionId).toBe('exec-local');
|
||||
});
|
||||
|
||||
it('ignores legacy unscoped messages when session is unknown', () => {
|
||||
const { socket } = connectHook();
|
||||
|
||||
act(() => {
|
||||
socket.message({
|
||||
type: 'CLI_SESSION_OUTPUT',
|
||||
payload: {
|
||||
sessionKey: 'session-unknown',
|
||||
data: 'should be ignored',
|
||||
timestamp: '2026-03-08T12:00:02.000Z',
|
||||
},
|
||||
});
|
||||
socket.message({
|
||||
type: 'EXECUTION_STARTED',
|
||||
payload: {
|
||||
executionId: 'exec-unknown',
|
||||
flowId: 'flow-unknown',
|
||||
sessionKey: 'session-unknown',
|
||||
stepName: 'Unknown flow',
|
||||
totalSteps: 1,
|
||||
timestamp: '2026-03-08T12:00:03.000Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(useCliSessionStore.getState().outputChunks['session-unknown']).toBeUndefined();
|
||||
expect(useExecutionMonitorStore.getState().activeExecutions['exec-unknown']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,13 @@ import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import {
|
||||
handleSessionLockedMessage,
|
||||
handleSessionUnlockedMessage,
|
||||
useSessionManagerStore,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import {
|
||||
useExecutionMonitorStore,
|
||||
type ExecutionWSMessage,
|
||||
} from '@/stores/executionMonitorStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import {
|
||||
OrchestratorMessageSchema,
|
||||
type OrchestratorWebSocketMessage,
|
||||
@@ -28,6 +30,15 @@ import type { ToolCallKind, ToolCallExecution } from '../types/toolCall';
|
||||
const RECONNECT_DELAY_BASE = 1000; // 1 second
|
||||
const RECONNECT_DELAY_MAX = 30000; // 30 seconds
|
||||
const RECONNECT_DELAY_MULTIPLIER = 1.5;
|
||||
const WORKSPACE_SCOPED_CLI_MESSAGE_TYPES = new Set([
|
||||
'CLI_SESSION_CREATED',
|
||||
'CLI_SESSION_OUTPUT',
|
||||
'CLI_SESSION_CLOSED',
|
||||
'CLI_SESSION_PAUSED',
|
||||
'CLI_SESSION_RESUMED',
|
||||
'CLI_SESSION_LOCKED',
|
||||
'CLI_SESSION_UNLOCKED',
|
||||
]);
|
||||
|
||||
// Access store state/actions via getState() - avoids calling hooks in callbacks/effects
|
||||
// This is the zustand-recommended pattern for non-rendering store access
|
||||
@@ -71,6 +82,85 @@ function getStoreState() {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWorkspacePath(path: string | null | undefined): string | null {
|
||||
if (typeof path !== 'string') return null;
|
||||
|
||||
const normalized = path.trim().replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!normalized) return null;
|
||||
|
||||
return /^[a-z]:/i.test(normalized) ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
function getCurrentWorkspacePath(): string | null {
|
||||
return normalizeWorkspacePath(selectProjectPath(useWorkflowStore.getState()));
|
||||
}
|
||||
|
||||
function isProjectPathInCurrentWorkspace(projectPath: string | null | undefined): boolean {
|
||||
const currentWorkspacePath = getCurrentWorkspacePath();
|
||||
if (!currentWorkspacePath) return true;
|
||||
|
||||
return normalizeWorkspacePath(projectPath) === currentWorkspacePath;
|
||||
}
|
||||
|
||||
function isPathInCurrentWorkspace(candidatePath: string | null | undefined): boolean {
|
||||
const currentWorkspacePath = getCurrentWorkspacePath();
|
||||
if (!currentWorkspacePath) return true;
|
||||
|
||||
const normalizedCandidatePath = normalizeWorkspacePath(candidatePath);
|
||||
if (!normalizedCandidatePath) return false;
|
||||
|
||||
return (
|
||||
normalizedCandidatePath === currentWorkspacePath ||
|
||||
normalizedCandidatePath.startsWith(`${currentWorkspacePath}/`)
|
||||
);
|
||||
}
|
||||
|
||||
function isKnownCliSession(sessionKey: string | null | undefined): boolean {
|
||||
if (typeof sessionKey !== 'string' || !sessionKey) return false;
|
||||
|
||||
if (sessionKey in useCliSessionStore.getState().sessions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return sessionKey in useSessionManagerStore.getState().terminalMetas;
|
||||
}
|
||||
|
||||
function shouldHandleCliSessionMessage(data: { type?: string; payload?: Record<string, unknown> }): boolean {
|
||||
const currentWorkspacePath = getCurrentWorkspacePath();
|
||||
if (!currentWorkspacePath) return true;
|
||||
|
||||
const payload = data.payload ?? {};
|
||||
if (typeof payload.projectPath === 'string') {
|
||||
return isProjectPathInCurrentWorkspace(payload.projectPath);
|
||||
}
|
||||
|
||||
if (data.type === 'CLI_SESSION_CREATED') {
|
||||
const session = payload.session as { workingDir?: string } | undefined;
|
||||
return isPathInCurrentWorkspace(session?.workingDir);
|
||||
}
|
||||
|
||||
return isKnownCliSession(typeof payload.sessionKey === 'string' ? payload.sessionKey : null);
|
||||
}
|
||||
|
||||
function shouldHandleExecutionWsMessage(message: ExecutionWSMessage): boolean {
|
||||
const currentWorkspacePath = getCurrentWorkspacePath();
|
||||
if (!currentWorkspacePath) return true;
|
||||
|
||||
if (typeof message.payload.projectPath === 'string') {
|
||||
return isProjectPathInCurrentWorkspace(message.payload.projectPath);
|
||||
}
|
||||
|
||||
if (message.payload.executionId in useExecutionMonitorStore.getState().activeExecutions) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'EXECUTION_STARTED') {
|
||||
return isKnownCliSession(message.payload.sessionKey ?? null);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export interface UseWebSocketOptions {
|
||||
enabled?: boolean;
|
||||
onMessage?: (message: OrchestratorWebSocketMessage) => void;
|
||||
@@ -162,6 +252,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
// Handle CLI messages
|
||||
if (data.type?.startsWith('CLI_')) {
|
||||
if (
|
||||
WORKSPACE_SCOPED_CLI_MESSAGE_TYPES.has(data.type) &&
|
||||
!shouldHandleCliSessionMessage(data as { type?: string; payload?: Record<string, unknown> })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
// ========== PTY CLI Sessions ==========
|
||||
case 'CLI_SESSION_CREATED': {
|
||||
@@ -293,8 +390,13 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
|
||||
// Handle EXECUTION messages (from orchestrator execution-in-session)
|
||||
if (data.type?.startsWith('EXECUTION_')) {
|
||||
const executionMessage = data as ExecutionWSMessage;
|
||||
if (!shouldHandleExecutionWsMessage(executionMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleExecutionMessage = useExecutionMonitorStore.getState().handleExecutionMessage;
|
||||
handleExecutionMessage(data as ExecutionWSMessage);
|
||||
handleExecutionMessage(executionMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
97
ccw/frontend/src/pages/TerminalDashboardPage.test.tsx
Normal file
97
ccw/frontend/src/pages/TerminalDashboardPage.test.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
// ========================================
|
||||
// TerminalDashboardPage Tests
|
||||
// ========================================
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderWithI18n, screen, fireEvent } from '@/test/i18n';
|
||||
import { TerminalDashboardPage } from './TerminalDashboardPage';
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
currentProjectPath: 'D:/workspace-a',
|
||||
toggleImmersiveMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('allotment', () => {
|
||||
const Pane = ({ children }: { children: ReactNode }) => <div>{children}</div>;
|
||||
const Allotment = ({ children }: { children: ReactNode }) => <div>{children}</div>;
|
||||
Object.assign(Allotment, { Pane });
|
||||
return { Allotment };
|
||||
});
|
||||
|
||||
vi.mock('@/components/terminal-dashboard/AssociationHighlight', () => ({
|
||||
AssociationHighlightProvider: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/terminal-dashboard/DashboardToolbar', () => ({
|
||||
DashboardToolbar: ({ activePanel, onTogglePanel }: { activePanel: string | null; onTogglePanel: (panelId: 'queue') => void }) => (
|
||||
<div>
|
||||
<div data-testid="active-panel">{activePanel ?? 'none'}</div>
|
||||
<button type="button" onClick={() => onTogglePanel('queue')}>
|
||||
open-queue
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/terminal-dashboard/FloatingPanel', () => ({
|
||||
FloatingPanel: ({ isOpen, children }: { isOpen: boolean; children: ReactNode }) =>
|
||||
isOpen ? <div data-testid="floating-panel-open">{children}</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/terminal-dashboard/TerminalGrid', () => ({ TerminalGrid: () => <div>terminal-grid</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/SessionGroupTree', () => ({ SessionGroupTree: () => <div>session-tree</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/IssuePanel', () => ({ IssuePanel: () => <div>issue-panel</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/QueuePanel', () => ({ QueuePanel: () => <div>queue-panel</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/QueueListColumn', () => ({ QueueListColumn: () => <div>queue-list</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/SchedulerPanel', () => ({ SchedulerPanel: () => <div>scheduler-panel</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/BottomInspector', () => ({ InspectorContent: () => <div>inspector-panel</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/ExecutionMonitorPanel', () => ({ ExecutionMonitorPanel: () => <div>execution-panel</div> }));
|
||||
vi.mock('@/components/terminal-dashboard/FileSidebarPanel', () => ({
|
||||
FileSidebarPanel: () => <div>file-sidebar</div>,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: (selector: (state: { projectPath: string | null }) => unknown) =>
|
||||
selector({ projectPath: mockState.currentProjectPath }),
|
||||
selectProjectPath: (state: { projectPath: string | null }) => state.projectPath,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/appStore', () => ({
|
||||
useAppStore: (selector: (state: { isImmersiveMode: boolean; toggleImmersiveMode: () => void }) => unknown) =>
|
||||
selector({ isImmersiveMode: false, toggleImmersiveMode: mockState.toggleImmersiveMode }),
|
||||
selectIsImmersiveMode: (state: { isImmersiveMode: boolean }) => state.isImmersiveMode,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/configStore', () => ({
|
||||
useConfigStore: (selector: (state: { featureFlags: Record<string, boolean> }) => unknown) =>
|
||||
selector({
|
||||
featureFlags: {
|
||||
dashboardQueuePanelEnabled: true,
|
||||
dashboardInspectorEnabled: true,
|
||||
dashboardExecutionMonitorEnabled: true,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('TerminalDashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
mockState.currentProjectPath = 'D:/workspace-a';
|
||||
mockState.toggleImmersiveMode.mockReset();
|
||||
});
|
||||
|
||||
it('clears the active floating panel when workspace changes', () => {
|
||||
const view = renderWithI18n(<TerminalDashboardPage />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'open-queue' }));
|
||||
|
||||
expect(screen.getByTestId('active-panel').textContent).toBe('queue');
|
||||
expect(screen.getByText('queue-panel')).toBeInTheDocument();
|
||||
|
||||
mockState.currentProjectPath = 'D:/workspace-b';
|
||||
view.rerender(<TerminalDashboardPage />);
|
||||
|
||||
expect(screen.getByTestId('active-panel').textContent).toBe('none');
|
||||
expect(screen.queryByText('queue-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
// Floating panels: Issues, Queue, Inspector, Execution Monitor (overlay, mutually exclusive)
|
||||
// Fullscreen mode: Uses global isImmersiveMode to hide app chrome (Header + Sidebar)
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Allotment } from 'allotment';
|
||||
import 'allotment/dist/style.css';
|
||||
@@ -54,9 +54,13 @@ export function TerminalDashboardPage() {
|
||||
setActivePanel(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setActivePanel(null);
|
||||
}, [projectPath]);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col overflow-hidden ${isImmersiveMode ? 'h-screen' : 'h-[calc(100vh-56px)]'}`}>
|
||||
<AssociationHighlightProvider>
|
||||
<AssociationHighlightProvider scopeKey={projectPath ?? 'default'}>
|
||||
{/* Global toolbar */}
|
||||
<DashboardToolbar
|
||||
activePanel={activePanel}
|
||||
|
||||
39
ccw/frontend/src/stores/cliSessionStore.test.ts
Normal file
39
ccw/frontend/src/stores/cliSessionStore.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// ========================================
|
||||
// CLI Session Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useCliSessionStore } from './cliSessionStore';
|
||||
|
||||
describe('cliSessionStore', () => {
|
||||
beforeEach(() => {
|
||||
useCliSessionStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears workspace-scoped sessions and output buffers', () => {
|
||||
const store = useCliSessionStore.getState();
|
||||
|
||||
store.setSessions([
|
||||
{
|
||||
sessionKey: 'session-1',
|
||||
shellKind: 'bash',
|
||||
workingDir: 'D:/workspace-a',
|
||||
tool: 'codex',
|
||||
createdAt: '2026-03-08T12:00:00.000Z',
|
||||
updatedAt: '2026-03-08T12:00:00.000Z',
|
||||
isPaused: false,
|
||||
},
|
||||
]);
|
||||
store.appendOutput('session-1', 'hello world', 1_741_430_000_000);
|
||||
|
||||
expect(useCliSessionStore.getState().sessions['session-1']).toBeDefined();
|
||||
expect(useCliSessionStore.getState().outputChunks['session-1']).toHaveLength(1);
|
||||
|
||||
store.resetState();
|
||||
|
||||
const nextState = useCliSessionStore.getState();
|
||||
expect(nextState.sessions).toEqual({});
|
||||
expect(nextState.outputChunks).toEqual({});
|
||||
expect(nextState.outputBytes).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,7 @@ interface CliSessionState {
|
||||
upsertSession: (session: CliSessionMeta) => void;
|
||||
removeSession: (sessionKey: string) => void;
|
||||
updateSessionPausedState: (sessionKey: string, isPaused: boolean) => void;
|
||||
resetState: () => void;
|
||||
|
||||
setBuffer: (sessionKey: string, buffer: string) => void;
|
||||
appendOutput: (sessionKey: string, data: string, timestamp?: number) => void;
|
||||
@@ -48,12 +49,16 @@ function utf8ByteLength(value: string): number {
|
||||
return utf8Encoder.encode(value).length;
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
sessions: {},
|
||||
outputChunks: {},
|
||||
outputBytes: {},
|
||||
};
|
||||
|
||||
export const useCliSessionStore = create<CliSessionState>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
sessions: {},
|
||||
outputChunks: {},
|
||||
outputBytes: {},
|
||||
...initialState,
|
||||
|
||||
setSessions: (sessions) =>
|
||||
set((state) => {
|
||||
@@ -103,6 +108,8 @@ export const useCliSessionStore = create<CliSessionState>()(
|
||||
};
|
||||
}),
|
||||
|
||||
resetState: () => set({ ...initialState }),
|
||||
|
||||
setBuffer: (sessionKey, buffer) =>
|
||||
set((state) => ({
|
||||
outputChunks: {
|
||||
|
||||
@@ -52,7 +52,8 @@ export interface ExecutionWSMessage {
|
||||
payload: {
|
||||
executionId: string;
|
||||
flowId: string;
|
||||
sessionKey: string;
|
||||
sessionKey?: string;
|
||||
projectPath?: string;
|
||||
stepId?: string;
|
||||
stepName?: string;
|
||||
totalSteps?: number;
|
||||
@@ -117,7 +118,7 @@ export const useExecutionMonitorStore = create<ExecutionMonitorStore>()(
|
||||
executionId,
|
||||
flowId,
|
||||
flowName: stepName || 'Workflow',
|
||||
sessionKey,
|
||||
sessionKey: sessionKey ?? '',
|
||||
status: 'running',
|
||||
totalSteps: totalSteps || 0,
|
||||
completedSteps: 0,
|
||||
|
||||
45
ccw/frontend/src/stores/issueQueueIntegrationStore.test.ts
Normal file
45
ccw/frontend/src/stores/issueQueueIntegrationStore.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// ========================================
|
||||
// Issue Queue Integration Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useIssueQueueIntegrationStore } from './issueQueueIntegrationStore';
|
||||
import { useQueueExecutionStore } from './queueExecutionStore';
|
||||
|
||||
describe('issueQueueIntegrationStore', () => {
|
||||
beforeEach(() => {
|
||||
useIssueQueueIntegrationStore.getState().resetState();
|
||||
useQueueExecutionStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears selected issue and association chain', () => {
|
||||
useQueueExecutionStore.getState().addExecution({
|
||||
id: 'queue-exec-1',
|
||||
queueItemId: 'Q-1',
|
||||
issueId: 'ISSUE-1',
|
||||
solutionId: 'SOL-1',
|
||||
type: 'session',
|
||||
sessionKey: 'session-1',
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
startedAt: '2026-03-08T12:00:00.000Z',
|
||||
});
|
||||
|
||||
const store = useIssueQueueIntegrationStore.getState();
|
||||
store.buildAssociationChain('ISSUE-1', 'issue');
|
||||
|
||||
expect(useIssueQueueIntegrationStore.getState().selectedIssueId).toBe('ISSUE-1');
|
||||
expect(useIssueQueueIntegrationStore.getState().associationChain).toEqual({
|
||||
issueId: 'ISSUE-1',
|
||||
queueItemId: 'Q-1',
|
||||
sessionId: 'session-1',
|
||||
});
|
||||
|
||||
store.resetState();
|
||||
|
||||
const nextState = useIssueQueueIntegrationStore.getState();
|
||||
expect(nextState.selectedIssueId).toBeNull();
|
||||
expect(nextState.associationChain).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -85,6 +85,10 @@ export const useIssueQueueIntegrationStore = create<IssueQueueIntegrationStore>(
|
||||
);
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
set({ ...initialState }, false, 'resetState');
|
||||
},
|
||||
|
||||
// ========== Queue Status Bridge ==========
|
||||
|
||||
_updateQueueItemStatus: (
|
||||
|
||||
35
ccw/frontend/src/stores/queueExecutionStore.test.ts
Normal file
35
ccw/frontend/src/stores/queueExecutionStore.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// ========================================
|
||||
// Queue Execution Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useQueueExecutionStore } from './queueExecutionStore';
|
||||
|
||||
describe('queueExecutionStore', () => {
|
||||
beforeEach(() => {
|
||||
useQueueExecutionStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears workspace-scoped queue execution tracking', () => {
|
||||
const store = useQueueExecutionStore.getState();
|
||||
|
||||
store.addExecution({
|
||||
id: 'queue-exec-1',
|
||||
queueItemId: 'Q-1',
|
||||
issueId: 'ISSUE-1',
|
||||
solutionId: 'SOL-1',
|
||||
type: 'session',
|
||||
sessionKey: 'session-1',
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
startedAt: '2026-03-08T12:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(useQueueExecutionStore.getState().executions['queue-exec-1']).toBeDefined();
|
||||
|
||||
store.resetState();
|
||||
|
||||
expect(useQueueExecutionStore.getState().executions).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,8 @@ export interface QueueExecutionActions {
|
||||
removeExecution: (id: string) => void;
|
||||
/** Remove all completed and failed executions */
|
||||
clearCompleted: () => void;
|
||||
/** Reset workspace-scoped queue execution state */
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
export type QueueExecutionStore = QueueExecutionState & QueueExecutionActions;
|
||||
@@ -150,6 +152,10 @@ export const useQueueExecutionStore = create<QueueExecutionStore>()(
|
||||
'clearCompleted'
|
||||
);
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
set({ ...initialState }, false, 'resetState');
|
||||
},
|
||||
}),
|
||||
{ name: 'QueueExecutionStore' }
|
||||
)
|
||||
|
||||
131
ccw/frontend/src/stores/queueSchedulerStore.test.ts
Normal file
131
ccw/frontend/src/stores/queueSchedulerStore.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// ========================================
|
||||
// Queue Scheduler Store Tests
|
||||
// ========================================
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { QueueSchedulerState } from '@/types/queue-frontend-types';
|
||||
|
||||
type QueueSchedulerModule = typeof import('./queueSchedulerStore');
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createState(status: QueueSchedulerState['status'], issueId: string): QueueSchedulerState {
|
||||
return {
|
||||
status,
|
||||
items: [
|
||||
{
|
||||
item_id: `${issueId}-Q1`,
|
||||
issue_id: issueId,
|
||||
status: status === 'running' ? 'executing' : 'pending',
|
||||
tool: 'codex',
|
||||
prompt: `Handle ${issueId}`,
|
||||
depends_on: [],
|
||||
execution_order: 1,
|
||||
execution_group: 'wave-1',
|
||||
createdAt: '2026-03-08T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
sessionPool: {},
|
||||
config: {
|
||||
maxConcurrentSessions: 3,
|
||||
sessionIdleTimeoutMs: 60_000,
|
||||
resumeKeySessionBindingTimeoutMs: 300_000,
|
||||
},
|
||||
currentConcurrency: status === 'running' ? 1 : 0,
|
||||
lastActivityAt: '2026-03-08T12:00:00.000Z',
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createFetchResponse(state: QueueSchedulerState) {
|
||||
return {
|
||||
ok: true,
|
||||
json: vi.fn().mockResolvedValue(state),
|
||||
};
|
||||
}
|
||||
|
||||
describe('queueSchedulerStore', () => {
|
||||
let useQueueSchedulerStore: QueueSchedulerModule['useQueueSchedulerStore'];
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchMock = vi.fn();
|
||||
global.fetch = fetchMock as unknown as typeof fetch;
|
||||
({ useQueueSchedulerStore } = await import('./queueSchedulerStore'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.clearAllTimers();
|
||||
vi.useRealTimers();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.clearAllTimers();
|
||||
useQueueSchedulerStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears workspace-scoped scheduler state', () => {
|
||||
useQueueSchedulerStore.getState().handleSchedulerMessage({
|
||||
type: 'QUEUE_SCHEDULER_STATE_UPDATE',
|
||||
state: createState('running', 'ISSUE-1'),
|
||||
timestamp: '2026-03-08T12:00:00.000Z',
|
||||
});
|
||||
|
||||
expect(useQueueSchedulerStore.getState().status).toBe('running');
|
||||
expect(useQueueSchedulerStore.getState().items).toHaveLength(1);
|
||||
|
||||
useQueueSchedulerStore.getState().resetState();
|
||||
|
||||
const nextState = useQueueSchedulerStore.getState();
|
||||
expect(nextState.status).toBe('idle');
|
||||
expect(nextState.items).toEqual([]);
|
||||
expect(nextState.sessionPool).toEqual({});
|
||||
expect(nextState.currentConcurrency).toBe(0);
|
||||
expect(nextState.error).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores stale loadInitialState responses after workspace reset', async () => {
|
||||
const staleResponse = createDeferred<ReturnType<typeof createFetchResponse>>();
|
||||
const freshResponse = createDeferred<ReturnType<typeof createFetchResponse>>();
|
||||
|
||||
fetchMock
|
||||
.mockImplementationOnce(() => staleResponse.promise)
|
||||
.mockImplementationOnce(() => freshResponse.promise);
|
||||
|
||||
const firstLoad = useQueueSchedulerStore.getState().loadInitialState();
|
||||
|
||||
useQueueSchedulerStore.getState().resetState();
|
||||
|
||||
const secondLoad = useQueueSchedulerStore.getState().loadInitialState();
|
||||
|
||||
freshResponse.resolve(createFetchResponse(createState('paused', 'ISSUE-NEW')));
|
||||
await secondLoad;
|
||||
|
||||
expect(useQueueSchedulerStore.getState().status).toBe('paused');
|
||||
expect(useQueueSchedulerStore.getState().items[0]?.issue_id).toBe('ISSUE-NEW');
|
||||
|
||||
staleResponse.resolve(createFetchResponse(createState('running', 'ISSUE-OLD')));
|
||||
await firstLoad;
|
||||
|
||||
expect(useQueueSchedulerStore.getState().status).toBe('paused');
|
||||
expect(useQueueSchedulerStore.getState().items[0]?.issue_id).toBe('ISSUE-NEW');
|
||||
});
|
||||
});
|
||||
@@ -57,6 +57,8 @@ interface QueueSchedulerActions {
|
||||
stopQueue: () => Promise<void>;
|
||||
/** Reset the queue scheduler via POST /api/queue/scheduler/reset */
|
||||
resetQueue: () => Promise<void>;
|
||||
/** Clear workspace-scoped scheduler state and invalidate stale loads */
|
||||
resetState: () => void;
|
||||
/** Update scheduler config via POST /api/queue/scheduler/config */
|
||||
updateConfig: (config: Partial<QueueSchedulerConfig>) => Promise<void>;
|
||||
}
|
||||
@@ -75,6 +77,8 @@ const initialState: QueueSchedulerStoreState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
let loadInitialStateRequestVersion = 0;
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
|
||||
@@ -173,6 +177,8 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
|
||||
},
|
||||
|
||||
loadInitialState: async () => {
|
||||
const requestVersion = ++loadInitialStateRequestVersion;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/queue/scheduler/state', {
|
||||
credentials: 'same-origin',
|
||||
@@ -181,6 +187,11 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
|
||||
throw new Error(`Failed to load scheduler state: ${response.statusText}`);
|
||||
}
|
||||
const data: QueueSchedulerState = await response.json();
|
||||
|
||||
if (requestVersion !== loadInitialStateRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
set(
|
||||
{
|
||||
status: data.status,
|
||||
@@ -195,6 +206,10 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
|
||||
'loadInitialState'
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestVersion !== loadInitialStateRequestVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Silently ignore network errors (backend not connected)
|
||||
// Only log non-network errors
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
@@ -287,6 +302,11 @@ export const useQueueSchedulerStore = create<QueueSchedulerStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
loadInitialStateRequestVersion += 1;
|
||||
set({ ...initialState }, false, 'resetState');
|
||||
},
|
||||
|
||||
updateConfig: async (config: Partial<QueueSchedulerConfig>) => {
|
||||
try {
|
||||
const response = await fetch('/api/queue/scheduler/config', {
|
||||
|
||||
37
ccw/frontend/src/stores/sessionManagerStore.test.ts
Normal file
37
ccw/frontend/src/stores/sessionManagerStore.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// ========================================
|
||||
// Session Manager Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useSessionManagerStore } from './sessionManagerStore';
|
||||
|
||||
describe('sessionManagerStore', () => {
|
||||
beforeEach(() => {
|
||||
useSessionManagerStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears workspace-scoped terminal metadata and selection', () => {
|
||||
const store = useSessionManagerStore.getState();
|
||||
|
||||
store.createGroup('Workspace Group');
|
||||
store.setActiveTerminal('session-1');
|
||||
store.updateTerminalMeta('session-1', {
|
||||
title: 'Session 1',
|
||||
status: 'active',
|
||||
alertCount: 2,
|
||||
tag: 'workspace-a',
|
||||
});
|
||||
|
||||
const activeState = useSessionManagerStore.getState();
|
||||
expect(activeState.groups).toHaveLength(1);
|
||||
expect(activeState.activeTerminalId).toBe('session-1');
|
||||
expect(activeState.terminalMetas['session-1']?.status).toBe('active');
|
||||
|
||||
store.resetState();
|
||||
|
||||
const nextState = useSessionManagerStore.getState();
|
||||
expect(nextState.groups).toEqual([]);
|
||||
expect(nextState.activeTerminalId).toBeNull();
|
||||
expect(nextState.terminalMetas).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -182,6 +182,14 @@ export const useSessionManagerStore = create<SessionManagerStore>()(
|
||||
);
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
if (_workerRef) {
|
||||
_workerRef.terminate();
|
||||
_workerRef = null;
|
||||
}
|
||||
set({ ...initialState }, false, 'resetState');
|
||||
},
|
||||
|
||||
// ========== Layout Management ==========
|
||||
|
||||
setGroupLayout: (layout: SessionLayout) => {
|
||||
|
||||
38
ccw/frontend/src/stores/terminalGridStore.test.ts
Normal file
38
ccw/frontend/src/stores/terminalGridStore.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// ========================================
|
||||
// Terminal Grid Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useTerminalGridStore } from './terminalGridStore';
|
||||
|
||||
describe('terminalGridStore', () => {
|
||||
beforeEach(() => {
|
||||
useTerminalGridStore.getState().resetLayout('single');
|
||||
});
|
||||
|
||||
it('resetWorkspaceState clears pane session bindings while preserving layout', () => {
|
||||
const store = useTerminalGridStore.getState();
|
||||
|
||||
store.resetLayout('split-h');
|
||||
const configuredState = useTerminalGridStore.getState();
|
||||
const paneIds = Object.keys(configuredState.panes);
|
||||
const originalLayout = configuredState.layout;
|
||||
|
||||
store.assignSession(paneIds[0], 'session-a', 'codex');
|
||||
store.showFileInPane(paneIds[1], 'D:/workspace-a/file.ts');
|
||||
store.setFocused(paneIds[1]);
|
||||
|
||||
store.resetWorkspaceState();
|
||||
|
||||
const nextState = useTerminalGridStore.getState();
|
||||
expect(nextState.layout).toEqual(originalLayout);
|
||||
expect(Object.keys(nextState.panes)).toEqual(paneIds);
|
||||
expect(nextState.focusedPaneId).toBe(paneIds[1]);
|
||||
for (const paneId of paneIds) {
|
||||
expect(nextState.panes[paneId]?.sessionId).toBeNull();
|
||||
expect(nextState.panes[paneId]?.cliTool).toBeNull();
|
||||
expect(nextState.panes[paneId]?.displayMode).toBe('terminal');
|
||||
expect(nextState.panes[paneId]?.filePath).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,8 @@ export interface TerminalGridActions {
|
||||
assignSession: (paneId: PaneId, sessionId: string | null, cliTool?: string | null) => void;
|
||||
setFocused: (paneId: PaneId) => void;
|
||||
resetLayout: (preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => void;
|
||||
/** Clear workspace-scoped pane bindings while preserving layout */
|
||||
resetWorkspaceState: () => void;
|
||||
/** Create a new CLI session and assign it to a new pane (auto-split from specified pane) */
|
||||
createSessionAndAssign: (
|
||||
paneId: PaneId,
|
||||
@@ -302,6 +304,42 @@ export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
);
|
||||
},
|
||||
|
||||
resetWorkspaceState: () => {
|
||||
const state = get();
|
||||
const paneIds = Object.keys(state.panes) as PaneId[];
|
||||
if (paneIds.length === 0) {
|
||||
set({ ...initialState }, false, 'terminalGrid/resetWorkspaceState');
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPanes = paneIds.reduce<Record<PaneId, TerminalPaneState>>((acc, paneId) => {
|
||||
const pane = state.panes[paneId];
|
||||
acc[paneId] = {
|
||||
...pane,
|
||||
sessionId: null,
|
||||
cliTool: null,
|
||||
displayMode: 'terminal',
|
||||
filePath: null,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<PaneId, TerminalPaneState>);
|
||||
|
||||
const nextFocusedPaneId = state.focusedPaneId && nextPanes[state.focusedPaneId]
|
||||
? state.focusedPaneId
|
||||
: paneIds[0] ?? null;
|
||||
|
||||
set(
|
||||
{
|
||||
layout: state.layout,
|
||||
panes: nextPanes,
|
||||
focusedPaneId: nextFocusedPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/resetWorkspaceState'
|
||||
);
|
||||
},
|
||||
|
||||
createSessionAndAssign: async (paneId, config, projectPath) => {
|
||||
try {
|
||||
// 1. Create the CLI session via API
|
||||
|
||||
@@ -85,6 +85,8 @@ export interface SessionManagerActions {
|
||||
setActiveTerminal: (sessionId: string | null) => void;
|
||||
/** Update metadata for a specific terminal */
|
||||
updateTerminalMeta: (sessionId: string, meta: Partial<TerminalMeta>) => void;
|
||||
/** Reset workspace-scoped dashboard session state */
|
||||
resetState: () => void;
|
||||
/** Set the terminal grid layout */
|
||||
setGroupLayout: (layout: SessionLayout) => void;
|
||||
/** Spawn the monitor Web Worker (idempotent) */
|
||||
@@ -135,6 +137,8 @@ export interface IssueQueueIntegrationActions {
|
||||
setSelectedIssue: (issueId: string | null) => void;
|
||||
/** Build a full association chain from any entity ID (issue, queue item, or session) */
|
||||
buildAssociationChain: (entityId: string, entityType: 'issue' | 'queue' | 'session') => void;
|
||||
/** Reset workspace-scoped issue/queue linkage state */
|
||||
resetState: () => void;
|
||||
/** Internal: update queue item status bridging to queueExecutionStore */
|
||||
_updateQueueItemStatus: (queueItemId: string, status: string, sessionId?: string) => void;
|
||||
}
|
||||
|
||||
@@ -1232,6 +1232,7 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
|
||||
flowId: execution.flowId,
|
||||
status: execution.status,
|
||||
timestamp,
|
||||
projectPath: workflowDir,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
@@ -1247,6 +1248,7 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
|
||||
payload: {
|
||||
sessionKey,
|
||||
timestamp,
|
||||
projectPath: workflowDir,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
@@ -1460,7 +1462,8 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
|
||||
sessionKey: sessionKey,
|
||||
stepName: flow.name,
|
||||
totalSteps: flow.nodes.length,
|
||||
timestamp: now
|
||||
timestamp: now,
|
||||
projectPath: workflowDir,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1471,7 +1474,8 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
|
||||
sessionKey: sessionKey,
|
||||
reason: `Executing workflow: ${flow.name}`,
|
||||
executionId: execId,
|
||||
timestamp: now
|
||||
timestamp: now,
|
||||
projectPath: workflowDir,
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1731,6 +1735,7 @@ export async function handleOrchestratorRoutes(ctx: RouteContext): Promise<boole
|
||||
flowId: execution.flowId,
|
||||
reason: 'User requested stop',
|
||||
timestamp: now,
|
||||
projectPath: workflowDir,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -397,8 +397,9 @@ export class CliSessionManager {
|
||||
payload: {
|
||||
sessionKey,
|
||||
data,
|
||||
timestamp: nowIso()
|
||||
} satisfies CliSessionOutputEvent
|
||||
timestamp: nowIso(),
|
||||
projectPath: this.projectRoot,
|
||||
} satisfies CliSessionOutputEvent & { projectPath: string }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -410,7 +411,8 @@ export class CliSessionManager {
|
||||
sessionKey,
|
||||
exitCode,
|
||||
signal,
|
||||
timestamp: nowIso()
|
||||
timestamp: nowIso(),
|
||||
projectPath: this.projectRoot,
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -426,7 +428,11 @@ export class CliSessionManager {
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_CREATED',
|
||||
payload: { session: this.getSession(sessionKey), timestamp: nowIso() }
|
||||
payload: {
|
||||
session: this.getSession(sessionKey),
|
||||
timestamp: nowIso(),
|
||||
projectPath: this.projectRoot,
|
||||
}
|
||||
});
|
||||
|
||||
return this.getSession(sessionKey)!;
|
||||
@@ -464,7 +470,14 @@ export class CliSessionManager {
|
||||
session.pty.kill();
|
||||
} finally {
|
||||
this.sessions.delete(sessionKey);
|
||||
broadcastToClients({ type: 'CLI_SESSION_CLOSED', payload: { sessionKey, timestamp: nowIso() } });
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_CLOSED',
|
||||
payload: {
|
||||
sessionKey,
|
||||
timestamp: nowIso(),
|
||||
projectPath: this.projectRoot,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,7 +499,11 @@ export class CliSessionManager {
|
||||
session.updatedAt = nowIso();
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_PAUSED',
|
||||
payload: { sessionKey, timestamp: nowIso() }
|
||||
payload: {
|
||||
sessionKey,
|
||||
timestamp: nowIso(),
|
||||
projectPath: this.projectRoot,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to pause session ${sessionKey}: ${(err as Error).message}`);
|
||||
@@ -512,7 +529,11 @@ export class CliSessionManager {
|
||||
session.lastActivityAt = Date.now();
|
||||
broadcastToClients({
|
||||
type: 'CLI_SESSION_RESUMED',
|
||||
payload: { sessionKey, timestamp: nowIso() }
|
||||
payload: {
|
||||
sessionKey,
|
||||
timestamp: nowIso(),
|
||||
projectPath: this.projectRoot,
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to resume session ${sessionKey}: ${(err as Error).message}`);
|
||||
|
||||
@@ -1265,6 +1265,12 @@ function shouldRetryWithStaticGraphPreference(args: string[], error?: string): b
|
||||
&& Boolean(error && /Options --static-graph and --no-static-graph are mutually exclusive/i.test(error));
|
||||
}
|
||||
|
||||
function shouldRetryWithCentralizedPreference(args: string[], error?: string): boolean {
|
||||
return !args.includes('--centralized')
|
||||
&& !args.includes('--distributed')
|
||||
&& Boolean(error && /Options --centralized and --distributed are mutually exclusive/i.test(error));
|
||||
}
|
||||
|
||||
function stripAnsiCodes(value: string): string {
|
||||
return value
|
||||
.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
@@ -1398,6 +1404,11 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
|
||||
transform: (currentArgs: string[]) => [...currentArgs, '--static-graph'],
|
||||
warning: 'CodexLens CLI hit a Typer static-graph option conflict; retried with explicit --static-graph.',
|
||||
},
|
||||
{
|
||||
shouldRetry: shouldRetryWithCentralizedPreference,
|
||||
transform: (currentArgs: string[]) => [...currentArgs, '--centralized'],
|
||||
warning: 'CodexLens CLI hit a Typer centralized/distributed option conflict; retried with explicit --centralized.',
|
||||
},
|
||||
];
|
||||
|
||||
for (const retry of compatibilityRetries) {
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* - Multi-backend search routing with RRF ranking
|
||||
*
|
||||
* Actions:
|
||||
* - init: Initialize CodexLens index
|
||||
* - init: Initialize CodexLens static index
|
||||
* - embed: Generate semantic/vector embeddings for the index
|
||||
* - search: Intelligent search with fuzzy (default) or semantic mode
|
||||
* - status: Check index status
|
||||
* - update: Incremental index update for changed files
|
||||
@@ -20,15 +21,20 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import { statSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { existsSync, readFileSync, statSync } from 'fs';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
import {
|
||||
ensureReady as ensureCodexLensReady,
|
||||
ensureLiteLLMEmbedderReady,
|
||||
executeCodexLens,
|
||||
getVenvPythonPath,
|
||||
} from './codex-lens.js';
|
||||
import type { ProgressInfo } from './codex-lens.js';
|
||||
import { getProjectRoot } from '../utils/path-validator.js';
|
||||
import { getCodexLensDataDir } from '../utils/codexlens-path.js';
|
||||
import { EXEC_TIMEOUTS } from '../utils/exec-constants.js';
|
||||
import { generateRotationEndpoints } from '../config/litellm-api-config-manager.js';
|
||||
import type { RotationEndpointConfig } from '../config/litellm-api-config-manager.js';
|
||||
|
||||
// Timing utilities for performance analysis
|
||||
const TIMING_ENABLED = process.env.SMART_SEARCH_TIMING === '1' || process.env.DEBUG?.includes('timing');
|
||||
@@ -65,10 +71,10 @@ function createTimer(): { mark: (name: string) => void; getTimings: () => Timing
|
||||
|
||||
// Define Zod schema for validation
|
||||
const ParamsSchema = z.object({
|
||||
// Action: search (content), find_files (path/name pattern), init, init_force, status, update (incremental), watch
|
||||
// Action: search (content), find_files (path/name pattern), init, init_force, embed, status, update (incremental), watch
|
||||
// Note: search_files is deprecated, use search with output_mode='files_only'
|
||||
// init: incremental index (skip existing), init_force: force full rebuild (delete and recreate)
|
||||
action: z.enum(['init', 'init_force', 'search', 'search_files', 'find_files', 'status', 'update', 'watch']).default('search'),
|
||||
// init: static FTS index by default, embed: generate semantic/vector embeddings, init_force: force full rebuild (delete and recreate)
|
||||
action: z.enum(['init', 'init_force', 'embed', 'search', 'search_files', 'find_files', 'status', 'update', 'watch']).default('search'),
|
||||
query: z.string().optional().describe('Content search query (for action="search")'),
|
||||
pattern: z.string().optional().describe('Glob pattern for path matching (for action="find_files")'),
|
||||
mode: z.enum(['fuzzy', 'semantic']).default('fuzzy'),
|
||||
@@ -79,6 +85,10 @@ const ParamsSchema = z.object({
|
||||
maxResults: z.number().default(5), // Default 5 with full content
|
||||
includeHidden: z.boolean().default(false),
|
||||
languages: z.array(z.string()).optional(),
|
||||
embeddingBackend: z.string().optional().describe('Embedding backend for action="embed": fastembed/local or litellm/api.'),
|
||||
embeddingModel: z.string().optional().describe('Embedding model/profile for action="embed". Examples: "code", "fast", "qwen3-embedding-sf".'),
|
||||
apiMaxWorkers: z.number().int().min(1).optional().describe('Max concurrent API embedding workers for action="embed". Recommended: 8-16 for litellm/api when multiple endpoints are configured.'),
|
||||
force: z.boolean().default(false).describe('Force regeneration for action="embed".'),
|
||||
limit: z.number().default(5), // Default 5 with full content
|
||||
extraFilesCount: z.number().default(10), // Additional file-only results
|
||||
maxContentLength: z.number().default(200), // Max content length for truncation (50-2000)
|
||||
@@ -313,6 +323,11 @@ interface SearchMetadata {
|
||||
totalFiles?: number;
|
||||
};
|
||||
progressHistory?: ProgressInfo[];
|
||||
api_max_workers?: number;
|
||||
endpoint_count?: number;
|
||||
use_gpu?: boolean;
|
||||
cascade_strategy?: string;
|
||||
staged_stage2_mode?: string;
|
||||
}
|
||||
|
||||
interface SearchResult {
|
||||
@@ -344,6 +359,11 @@ interface CodexLensConfig {
|
||||
reranker_backend?: string; // 'onnx' (local) or 'api'
|
||||
reranker_model?: string;
|
||||
reranker_top_k?: number;
|
||||
api_max_workers?: number;
|
||||
api_batch_size?: number;
|
||||
cascade_strategy?: string;
|
||||
staged_stage2_mode?: string;
|
||||
static_graph_enabled?: boolean;
|
||||
}
|
||||
|
||||
interface IndexStatus {
|
||||
@@ -357,6 +377,39 @@ interface IndexStatus {
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
function readCodexLensSettingsSnapshot(): Partial<CodexLensConfig> {
|
||||
const settingsPath = join(getCodexLensDataDir(), 'settings.json');
|
||||
if (!existsSync(settingsPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, any>;
|
||||
const embedding = (parsed.embedding ?? {}) as Record<string, any>;
|
||||
const reranker = (parsed.reranker ?? {}) as Record<string, any>;
|
||||
const api = (parsed.api ?? {}) as Record<string, any>;
|
||||
const cascade = (parsed.cascade ?? {}) as Record<string, any>;
|
||||
const staged = (parsed.staged ?? {}) as Record<string, any>;
|
||||
const indexing = (parsed.indexing ?? {}) as Record<string, any>;
|
||||
|
||||
return {
|
||||
embedding_backend: normalizeEmbeddingBackend(typeof embedding.backend === 'string' ? embedding.backend : undefined),
|
||||
embedding_model: typeof embedding.model === 'string' ? embedding.model : undefined,
|
||||
reranker_enabled: typeof reranker.enabled === 'boolean' ? reranker.enabled : undefined,
|
||||
reranker_backend: typeof reranker.backend === 'string' ? reranker.backend : undefined,
|
||||
reranker_model: typeof reranker.model === 'string' ? reranker.model : undefined,
|
||||
reranker_top_k: typeof reranker.top_k === 'number' ? reranker.top_k : undefined,
|
||||
api_max_workers: typeof api.max_workers === 'number' ? api.max_workers : undefined,
|
||||
api_batch_size: typeof api.batch_size === 'number' ? api.batch_size : undefined,
|
||||
cascade_strategy: typeof cascade.strategy === 'string' ? cascade.strategy : undefined,
|
||||
staged_stage2_mode: typeof staged.stage2_mode === 'string' ? staged.stage2_mode : undefined,
|
||||
static_graph_enabled: typeof indexing.static_graph_enabled === 'boolean' ? indexing.static_graph_enabled : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from string (for JSON parsing)
|
||||
*/
|
||||
@@ -464,6 +517,99 @@ function filterResultsToTargetFile<T extends { file: string }>(results: T[], sco
|
||||
return results.filter((result) => normalizeResultFilePath(result.file, scope.workingDirectory) === normalizedTarget);
|
||||
}
|
||||
|
||||
function parseCodexLensJsonOutput(output: string | undefined): any | null {
|
||||
const cleanOutput = stripAnsi(output || '').trim();
|
||||
if (!cleanOutput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
cleanOutput,
|
||||
...cleanOutput.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.startsWith('{') || line.startsWith('[')),
|
||||
];
|
||||
|
||||
const firstBrace = cleanOutput.indexOf('{');
|
||||
const lastBrace = cleanOutput.lastIndexOf('}');
|
||||
if (firstBrace !== -1 && lastBrace > firstBrace) {
|
||||
candidates.push(cleanOutput.slice(firstBrace, lastBrace + 1));
|
||||
}
|
||||
|
||||
const firstBracket = cleanOutput.indexOf('[');
|
||||
const lastBracket = cleanOutput.lastIndexOf(']');
|
||||
if (firstBracket !== -1 && lastBracket > firstBracket) {
|
||||
candidates.push(cleanOutput.slice(firstBracket, lastBracket + 1));
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return JSON.parse(candidate);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function mapCodexLensSemanticMatches(data: any[], scope: SearchScope, maxContentLength: number): SemanticMatch[] {
|
||||
return filterResultsToTargetFile(data.map((item: any) => {
|
||||
const rawScore = item.score || 0;
|
||||
const similarityScore = rawScore > 0 ? 1 / (1 + rawScore) : 1;
|
||||
return {
|
||||
file: item.path || item.file,
|
||||
score: similarityScore,
|
||||
content: truncateContent(item.content || item.excerpt, maxContentLength),
|
||||
symbol: item.symbol || null,
|
||||
};
|
||||
}), scope);
|
||||
}
|
||||
|
||||
function parsePlainTextFileMatches(output: string | undefined, scope: SearchScope): SemanticMatch[] {
|
||||
const lines = stripAnsi(output || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const fileLines = lines.filter((line) => {
|
||||
if (line.includes('RuntimeWarning:') || line.startsWith('warn(') || line.startsWith('Warning:')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedPath = /^[a-zA-Z]:[\\/]|^\//.test(line)
|
||||
? line
|
||||
: resolve(scope.workingDirectory, line);
|
||||
|
||||
try {
|
||||
return statSync(resolvedPath).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return filterResultsToTargetFile(
|
||||
[...new Set(fileLines)].map((file, index) => ({
|
||||
file,
|
||||
score: Math.max(0.1, 1 - index * 0.05),
|
||||
content: '',
|
||||
symbol: null,
|
||||
})),
|
||||
scope,
|
||||
);
|
||||
}
|
||||
|
||||
function hasCentralizedVectorArtifacts(indexRoot: unknown): boolean {
|
||||
if (typeof indexRoot !== 'string' || !indexRoot.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const resolvedRoot = resolve(indexRoot);
|
||||
return [
|
||||
join(resolvedRoot, '_vectors.hnsw'),
|
||||
join(resolvedRoot, '_vectors_meta.db'),
|
||||
join(resolvedRoot, '_binary_vectors.mmap'),
|
||||
].every((artifactPath) => existsSync(artifactPath));
|
||||
}
|
||||
|
||||
function collectBackendError(
|
||||
errors: string[],
|
||||
backendName: string,
|
||||
@@ -498,18 +644,20 @@ async function checkIndexStatus(path: string = '.'): Promise<IndexStatus> {
|
||||
try {
|
||||
// Fetch both status and config in parallel
|
||||
const [statusResult, configResult] = await Promise.all([
|
||||
executeCodexLens(['status', '--json'], { cwd: scope.workingDirectory }),
|
||||
executeCodexLens(['config', 'show', '--json'], { cwd: scope.workingDirectory }),
|
||||
executeCodexLens(['index', 'status', scope.workingDirectory], { cwd: scope.workingDirectory }),
|
||||
executeCodexLens(['config', '--json'], { cwd: scope.workingDirectory }),
|
||||
]);
|
||||
|
||||
// Parse config
|
||||
let config: CodexLensConfig | null = null;
|
||||
const settingsConfig = readCodexLensSettingsSnapshot();
|
||||
let config: CodexLensConfig | null = Object.keys(settingsConfig).length > 0 ? { ...settingsConfig } : null;
|
||||
if (configResult.success && configResult.output) {
|
||||
try {
|
||||
const cleanConfigOutput = stripAnsi(configResult.output);
|
||||
const parsedConfig = JSON.parse(cleanConfigOutput);
|
||||
const configData = parsedConfig.result || parsedConfig;
|
||||
config = {
|
||||
...settingsConfig,
|
||||
config_file: configData.config_file,
|
||||
index_dir: configData.index_dir,
|
||||
embedding_backend: configData.embedding_backend,
|
||||
@@ -540,13 +688,21 @@ async function checkIndexStatus(path: string = '.'): Promise<IndexStatus> {
|
||||
const parsed = JSON.parse(cleanOutput);
|
||||
// Handle both direct and nested response formats (status returns {success, result: {...}})
|
||||
const status = parsed.result || parsed;
|
||||
const indexed = status.projects_count > 0 || status.total_files > 0;
|
||||
|
||||
// Get embeddings coverage from comprehensive status
|
||||
const embeddingsData = status.embeddings || {};
|
||||
const embeddingsCoverage = embeddingsData.coverage_percent || 0;
|
||||
const has_embeddings = embeddingsCoverage >= 50; // Threshold: 50%
|
||||
const totalChunks = embeddingsData.total_chunks || 0;
|
||||
const totalIndexes = Number(embeddingsData.total_indexes || 0);
|
||||
const indexesWithEmbeddings = Number(embeddingsData.indexes_with_embeddings || 0);
|
||||
const totalChunks = Number(embeddingsData.total_chunks || 0);
|
||||
const hasCentralizedVectors = hasCentralizedVectorArtifacts(status.index_root);
|
||||
let embeddingsCoverage = typeof embeddingsData.coverage_percent === 'number'
|
||||
? embeddingsData.coverage_percent
|
||||
: (totalIndexes > 0 ? (indexesWithEmbeddings / totalIndexes) * 100 : 0);
|
||||
if (hasCentralizedVectors) {
|
||||
embeddingsCoverage = Math.max(embeddingsCoverage, 100);
|
||||
}
|
||||
const indexed = Boolean(status.projects_count > 0 || status.total_files > 0 || status.index_root || totalIndexes > 0 || totalChunks > 0);
|
||||
const has_embeddings = indexesWithEmbeddings > 0 || embeddingsCoverage > 0 || totalChunks > 0 || hasCentralizedVectors;
|
||||
|
||||
// Extract model info if available
|
||||
const modelInfoData = embeddingsData.model_info;
|
||||
@@ -563,9 +719,9 @@ async function checkIndexStatus(path: string = '.'): Promise<IndexStatus> {
|
||||
if (!indexed) {
|
||||
warning = 'No CodexLens index found. Run smart_search(action="init") to create index for better search results.';
|
||||
} else if (embeddingsCoverage === 0) {
|
||||
warning = 'Index exists but no embeddings generated. Run: codexlens embeddings-generate --recursive';
|
||||
warning = 'Index exists but no embeddings generated. Run smart_search(action="embed") to build the vector index.';
|
||||
} else if (embeddingsCoverage < 50) {
|
||||
warning = `Embeddings coverage is ${embeddingsCoverage.toFixed(1)}% (below 50%). Hybrid search will use exact mode. Run: codexlens embeddings-generate --recursive`;
|
||||
warning = `Embeddings coverage is ${embeddingsCoverage.toFixed(1)}% (below 50%). Hybrid search will degrade. Run smart_search(action="embed") to improve vector coverage.`;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -777,9 +933,198 @@ function buildRipgrepCommand(params: {
|
||||
return { command: 'rg', args, tokens };
|
||||
}
|
||||
|
||||
function normalizeEmbeddingBackend(backend?: string): string | undefined {
|
||||
if (!backend) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = backend.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === 'api') {
|
||||
return 'litellm';
|
||||
}
|
||||
if (normalized === 'local') {
|
||||
return 'fastembed';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const EMBED_PROGRESS_PREFIX = '__CCW_EMBED_PROGRESS__';
|
||||
|
||||
function resolveEmbeddingEndpoints(backend?: string): RotationEndpointConfig[] {
|
||||
if (backend !== 'litellm') {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return generateRotationEndpoints(getProjectRoot()).filter((endpoint) => {
|
||||
const apiKey = endpoint.api_key?.trim() ?? '';
|
||||
return Boolean(
|
||||
apiKey &&
|
||||
apiKey.length > 8 &&
|
||||
!/^\*+$/.test(apiKey) &&
|
||||
endpoint.api_base?.trim() &&
|
||||
endpoint.model?.trim()
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function resolveApiWorkerCount(
|
||||
requestedWorkers: number | undefined,
|
||||
backend: string | undefined,
|
||||
endpoints: RotationEndpointConfig[]
|
||||
): number | undefined {
|
||||
if (backend !== 'litellm') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof requestedWorkers === 'number' && Number.isFinite(requestedWorkers)) {
|
||||
return Math.max(1, Math.floor(requestedWorkers));
|
||||
}
|
||||
|
||||
if (endpoints.length <= 1) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return Math.min(16, Math.max(4, endpoints.length * 2));
|
||||
}
|
||||
|
||||
function extractEmbedJsonLine(stdout: string): string | undefined {
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter((line) => !line.startsWith(EMBED_PROGRESS_PREFIX));
|
||||
|
||||
return [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'));
|
||||
}
|
||||
|
||||
async function executeEmbeddingsViaPython(params: {
|
||||
projectPath: string;
|
||||
backend?: string;
|
||||
model?: string;
|
||||
force: boolean;
|
||||
maxWorkers?: number;
|
||||
endpoints?: RotationEndpointConfig[];
|
||||
}): Promise<{ success: boolean; error?: string; progressMessages?: string[] }> {
|
||||
const { projectPath, backend, model, force, maxWorkers, endpoints = [] } = params;
|
||||
const pythonCode = `
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from codexlens.storage.registry import RegistryStore
|
||||
from codexlens.cli.embedding_manager import generate_dense_embeddings_centralized
|
||||
|
||||
target_path = Path(r"__PROJECT_PATH__").expanduser().resolve()
|
||||
backend = __BACKEND__
|
||||
model = __MODEL__
|
||||
force = __FORCE__
|
||||
max_workers = __MAX_WORKERS__
|
||||
endpoints = json.loads(r'''__ENDPOINTS_JSON__''')
|
||||
|
||||
def progress_update(message: str):
|
||||
print("__CCW_EMBED_PROGRESS__" + str(message), flush=True)
|
||||
|
||||
registry = RegistryStore()
|
||||
registry.initialize()
|
||||
try:
|
||||
project = registry.get_project(target_path)
|
||||
if project is None:
|
||||
print(json.dumps({"success": False, "error": f"No index found for: {target_path}"}), flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
index_root = Path(project.index_root)
|
||||
result = generate_dense_embeddings_centralized(
|
||||
index_root,
|
||||
embedding_backend=backend,
|
||||
model_profile=model,
|
||||
force=force,
|
||||
use_gpu=True,
|
||||
max_workers=max_workers,
|
||||
endpoints=endpoints if endpoints else None,
|
||||
progress_callback=progress_update,
|
||||
)
|
||||
|
||||
print(json.dumps(result), flush=True)
|
||||
if not result.get("success"):
|
||||
sys.exit(1)
|
||||
finally:
|
||||
registry.close()
|
||||
`
|
||||
.replace('__PROJECT_PATH__', projectPath.replace(/\\/g, '\\\\'))
|
||||
.replace('__BACKEND__', backend ? JSON.stringify(backend) : 'None')
|
||||
.replace('__MODEL__', model ? JSON.stringify(model) : 'None')
|
||||
.replace('__FORCE__', force ? 'True' : 'False')
|
||||
.replace('__MAX_WORKERS__', typeof maxWorkers === 'number' ? String(Math.max(1, Math.floor(maxWorkers))) : 'None')
|
||||
.replace('__ENDPOINTS_JSON__', JSON.stringify(endpoints).replace(/\\/g, '\\\\').replace(/'''/g, "\\'\\'\\'"));
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(getVenvPythonPath(), ['-c', pythonCode], {
|
||||
cwd: projectPath,
|
||||
shell: false,
|
||||
timeout: 1800000,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
const progressMessages: string[] = [];
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => {
|
||||
const chunk = data.toString();
|
||||
stdout += chunk;
|
||||
for (const line of chunk.split(/\r?\n/)) {
|
||||
if (line.startsWith(EMBED_PROGRESS_PREFIX)) {
|
||||
progressMessages.push(line.slice(EMBED_PROGRESS_PREFIX.length).trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({ success: false, error: `Failed to start embeddings process: ${err.message}`, progressMessages });
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const jsonLine = extractEmbedJsonLine(stdout);
|
||||
if (jsonLine) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonLine) as { success?: boolean; error?: string };
|
||||
if (parsed.success) {
|
||||
resolve({ success: true, progressMessages });
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
success: false,
|
||||
error: parsed.error || stderr.trim() || stdout.trim() || `Embeddings process exited with code ${code}`,
|
||||
progressMessages,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to generic error handling below.
|
||||
}
|
||||
}
|
||||
|
||||
resolve({
|
||||
success: code === 0,
|
||||
error: code === 0 ? undefined : (stderr.trim() || stdout.trim() || `Embeddings process exited with code ${code}`),
|
||||
progressMessages,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: init - Initialize CodexLens index (FTS only, no embeddings)
|
||||
* For semantic/vector search, use ccw view dashboard or codexlens CLI directly
|
||||
* For semantic/vector search, follow with action="embed" to generate vectors.
|
||||
* @param params - Search parameters
|
||||
* @param force - If true, force full rebuild (delete existing index first)
|
||||
*/
|
||||
@@ -853,6 +1198,80 @@ async function executeInitAction(params: Params, force: boolean = false): Promis
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: embed - Generate semantic/vector embeddings for an indexed project
|
||||
*/
|
||||
async function executeEmbedAction(params: Params): Promise<SearchResult> {
|
||||
const { path = '.', embeddingBackend, embeddingModel, apiMaxWorkers, force = false } = params;
|
||||
const scope = resolveSearchScope(path);
|
||||
|
||||
const readyStatus = await ensureCodexLensReady();
|
||||
if (!readyStatus.ready) {
|
||||
return {
|
||||
success: false,
|
||||
error: `CodexLens not available: ${readyStatus.error}. CodexLens will be auto-installed on first use.`,
|
||||
};
|
||||
}
|
||||
|
||||
const currentStatus = await checkIndexStatus(scope.workingDirectory);
|
||||
const normalizedBackend = normalizeEmbeddingBackend(embeddingBackend) || currentStatus.config?.embedding_backend;
|
||||
const trimmedModel = embeddingModel?.trim() || currentStatus.config?.embedding_model;
|
||||
const endpoints = resolveEmbeddingEndpoints(normalizedBackend);
|
||||
const configuredApiMaxWorkers = currentStatus.config?.api_max_workers;
|
||||
const effectiveApiMaxWorkers = typeof apiMaxWorkers === 'number'
|
||||
? Math.max(1, Math.floor(apiMaxWorkers))
|
||||
: (typeof configuredApiMaxWorkers === 'number'
|
||||
? Math.max(1, Math.floor(configuredApiMaxWorkers))
|
||||
: resolveApiWorkerCount(undefined, normalizedBackend, endpoints));
|
||||
|
||||
if (normalizedBackend === 'litellm') {
|
||||
const embedderReady = await ensureLiteLLMEmbedderReady();
|
||||
if (!embedderReady.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: embedderReady.error || 'LiteLLM embedder is not ready.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const result = await executeEmbeddingsViaPython({
|
||||
projectPath: scope.workingDirectory,
|
||||
backend: normalizedBackend,
|
||||
model: trimmedModel,
|
||||
force,
|
||||
maxWorkers: effectiveApiMaxWorkers,
|
||||
endpoints,
|
||||
});
|
||||
|
||||
const indexStatus = result.success ? await checkIndexStatus(scope.workingDirectory) : currentStatus;
|
||||
const coverage = indexStatus?.embeddings_coverage_percent;
|
||||
const coverageText = coverage !== undefined ? ` (${coverage.toFixed(1)}% coverage)` : '';
|
||||
const progressMessage = result.progressMessages && result.progressMessages.length > 0
|
||||
? result.progressMessages[result.progressMessages.length - 1]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
success: result.success,
|
||||
error: result.error,
|
||||
message: result.success
|
||||
? `Embeddings generated for ${path}${coverageText}`
|
||||
: undefined,
|
||||
metadata: {
|
||||
action: 'embed',
|
||||
path: scope.workingDirectory,
|
||||
backend: normalizedBackend || indexStatus?.config?.embedding_backend,
|
||||
embeddings_coverage_percent: coverage,
|
||||
api_max_workers: effectiveApiMaxWorkers,
|
||||
endpoint_count: endpoints.length,
|
||||
use_gpu: true,
|
||||
cascade_strategy: currentStatus.config?.cascade_strategy,
|
||||
staged_stage2_mode: currentStatus.config?.staged_stage2_mode,
|
||||
note: progressMessage,
|
||||
},
|
||||
status: indexStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Action: status - Check CodexLens index status
|
||||
*/
|
||||
@@ -885,6 +1304,15 @@ async function executeStatusAction(params: Params): Promise<SearchResult> {
|
||||
// Embedding backend info
|
||||
const embeddingType = cfg.embedding_backend === 'litellm' ? 'API' : 'Local';
|
||||
statusParts.push(`Embedding: ${embeddingType} (${cfg.embedding_model || 'default'})`);
|
||||
if (typeof cfg.api_max_workers === 'number') {
|
||||
statusParts.push(`API Workers: ${cfg.api_max_workers}`);
|
||||
}
|
||||
if (cfg.cascade_strategy) {
|
||||
statusParts.push(`Cascade: ${cfg.cascade_strategy}`);
|
||||
}
|
||||
if (cfg.staged_stage2_mode) {
|
||||
statusParts.push(`Stage2: ${cfg.staged_stage2_mode}`);
|
||||
}
|
||||
|
||||
// Reranker info
|
||||
if (cfg.reranker_enabled) {
|
||||
@@ -1583,8 +2011,11 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
timer.mark('index_status_check');
|
||||
|
||||
// Request more results to support split (full content + extra files)
|
||||
// NOTE: Current CodexLens search CLI in this environment rejects value-taking options
|
||||
// like --limit/--offset/--method for search. Keep the invocation minimal and apply
|
||||
// pagination/selection in CCW after parsing results.
|
||||
const totalToFetch = maxResults + extraFilesCount;
|
||||
const args = ['search', query, '--limit', totalToFetch.toString(), '--offset', offset.toString(), '--method', 'dense_rerank', '--json'];
|
||||
const args = ['search', query, '--json'];
|
||||
if (enrich) {
|
||||
args.push('--enrich');
|
||||
}
|
||||
@@ -1619,22 +2050,10 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
let baselineInfo: { score: number; count: number } | null = null;
|
||||
let initialCount = 0;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
|
||||
const data = parsed.result?.results || parsed.results || parsed;
|
||||
allResults = filterResultsToTargetFile((Array.isArray(data) ? data : []).map((item: any) => {
|
||||
const rawScore = item.score || 0;
|
||||
// Hybrid mode returns distance scores (lower is better).
|
||||
// Convert to similarity scores (higher is better) for consistency.
|
||||
// Formula: similarity = 1 / (1 + distance)
|
||||
const similarityScore = rawScore > 0 ? 1 / (1 + rawScore) : 1;
|
||||
return {
|
||||
file: item.path || item.file,
|
||||
score: similarityScore,
|
||||
content: truncateContent(item.content || item.excerpt, maxContentLength),
|
||||
symbol: item.symbol || null,
|
||||
};
|
||||
}), scope);
|
||||
const parsedOutput = parseCodexLensJsonOutput(result.output);
|
||||
const parsedData = parsedOutput?.result?.results || parsedOutput?.results || parsedOutput;
|
||||
if (Array.isArray(parsedData)) {
|
||||
allResults = mapCodexLensSemanticMatches(parsedData, scope, maxContentLength);
|
||||
timer.mark('parse_results');
|
||||
|
||||
initialCount = allResults.length;
|
||||
@@ -1655,19 +2074,24 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
|
||||
// 4. Re-sort by adjusted scores
|
||||
allResults.sort((a, b) => b.score - a.score);
|
||||
timer.mark('post_processing');
|
||||
} catch {
|
||||
return {
|
||||
success: true,
|
||||
results: [],
|
||||
output: result.output,
|
||||
metadata: {
|
||||
mode: 'hybrid',
|
||||
backend: 'codexlens',
|
||||
count: 0,
|
||||
query,
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning, 'Failed to parse JSON output'),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
allResults = parsePlainTextFileMatches(result.output, scope);
|
||||
if (allResults.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
results: [],
|
||||
output: result.output,
|
||||
metadata: {
|
||||
mode: 'hybrid',
|
||||
backend: 'codexlens',
|
||||
count: 0,
|
||||
query,
|
||||
warning: mergeWarnings(indexStatus.warning, result.warning, 'Failed to parse JSON output'),
|
||||
},
|
||||
};
|
||||
}
|
||||
timer.mark('parse_results');
|
||||
initialCount = allResults.length;
|
||||
}
|
||||
|
||||
// Split results: first N with full content, rest as file paths only
|
||||
@@ -2164,6 +2588,13 @@ Recommended MCP flow: use **action=\"search\"** for lookups, **action=\"init\"**
|
||||
* **init_force**: Force full rebuild (delete and recreate static index).
|
||||
* *path* (string): Directory to index (default: current).
|
||||
|
||||
* **embed**: Generate semantic/vector embeddings for an indexed project.
|
||||
* *path* (string): Directory to embed (default: current).
|
||||
* *embeddingBackend* (string): 'litellm'/'api' for remote API embeddings, 'fastembed'/'local' for local embeddings.
|
||||
* *embeddingModel* (string): Embedding model/profile to use.
|
||||
* *apiMaxWorkers* (number): Max concurrent API embedding workers. Defaults to auto-sizing from the configured endpoint pool.
|
||||
* *force* (boolean): Regenerate embeddings even if they already exist.
|
||||
|
||||
* **status**: Check index status. (No required params)
|
||||
|
||||
* **update**: Incremental index update.
|
||||
@@ -2175,16 +2606,17 @@ Recommended MCP flow: use **action=\"search\"** for lookups, **action=\"init\"**
|
||||
**Examples:**
|
||||
smart_search(query="authentication logic") # Content search (default action)
|
||||
smart_search(query="MyClass", mode="semantic") # Semantic search
|
||||
smart_search(action="find_files", pattern="*.ts") # Find TypeScript files
|
||||
smart_search(action=\"embed\", path=\"/project\", embeddingBackend=\"api\", apiMaxWorkers=8) # Build API vector index
|
||||
smart_search(action="init", path="/project") # Build static FTS index
|
||||
smart_search(action="embed", path="/project", embeddingBackend="api") # Build API vector index
|
||||
smart_search(query="auth", limit=10, offset=0) # Paginated search`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['init', 'init_force', 'search', 'find_files', 'status', 'update', 'watch', 'search_files'],
|
||||
description: 'Action: search (content search; default and recommended), find_files (path pattern matching), init (create static FTS index, incremental), init_force (force full rebuild), status (check index), update (incremental refresh), watch (auto-update watcher; opt-in). Note: search_files is deprecated.',
|
||||
enum: ['init', 'init_force', 'embed', 'search', 'find_files', 'status', 'update', 'watch', 'search_files'],
|
||||
description: 'Action: search (content search; default and recommended), find_files (path pattern matching), init (create static FTS index, incremental), init_force (force full rebuild), embed (generate semantic/vector embeddings), status (check index), update (incremental refresh), watch (auto-update watcher; opt-in). Note: search_files is deprecated.',
|
||||
default: 'search',
|
||||
},
|
||||
query: {
|
||||
@@ -2259,6 +2691,23 @@ Recommended MCP flow: use **action=\"search\"** for lookups, **action=\"init\"**
|
||||
items: { type: 'string' },
|
||||
description: 'Languages to index (for init action). Example: ["javascript", "typescript"]',
|
||||
},
|
||||
embeddingBackend: {
|
||||
type: 'string',
|
||||
description: 'Embedding backend for action="embed": litellm/api (remote API) or fastembed/local (local GPU/CPU).',
|
||||
},
|
||||
embeddingModel: {
|
||||
type: 'string',
|
||||
description: 'Embedding model/profile for action="embed". Examples: "code", "fast", "qwen3-embedding-sf".',
|
||||
},
|
||||
apiMaxWorkers: {
|
||||
type: 'number',
|
||||
description: 'Max concurrent API embedding workers for action="embed". Defaults to auto-sizing from the configured endpoint pool.',
|
||||
},
|
||||
force: {
|
||||
type: 'boolean',
|
||||
description: 'Force regeneration for action="embed".',
|
||||
default: false,
|
||||
},
|
||||
enrich: {
|
||||
type: 'boolean',
|
||||
description: 'Enrich search results with code graph relationships (calls, imports, called_by, imported_by).',
|
||||
@@ -2625,6 +3074,10 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
|
||||
result = await executeInitAction(parsed.data, true);
|
||||
break;
|
||||
|
||||
case 'embed':
|
||||
result = await executeEmbedAction(parsed.data);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
result = await executeStatusAction(parsed.data);
|
||||
break;
|
||||
|
||||
@@ -39,6 +39,11 @@ describe('Smart Search MCP usage defaults and path handling', async () => {
|
||||
assert.equal(props.maxResults.default, 5);
|
||||
assert.equal(props.limit.default, 5);
|
||||
assert.match(schema.description, /static FTS index/i);
|
||||
assert.match(schema.description, /semantic\/vector embeddings/i);
|
||||
assert.ok(props.action.enum.includes('embed'));
|
||||
assert.match(props.embeddingBackend.description, /litellm\/api/i);
|
||||
assert.match(props.apiMaxWorkers.description, /endpoint pool/i);
|
||||
assert.match(schema.description, /apiMaxWorkers=8/i);
|
||||
assert.match(props.path.description, /single file path/i);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user