mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-11 17:21:03 +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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user