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:
catlog22
2026-03-08 21:38:20 +08:00
parent 9aa07e8d01
commit 62d8aa3623
157 changed files with 36544 additions and 71 deletions

View File

@@ -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

View File

@@ -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');
});
});

View File

@@ -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);

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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> = {

View File

@@ -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();
});
});

View File

@@ -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 */}

View File

@@ -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();
});
});

View File

@@ -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) => {

View 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();
});
});

View File

@@ -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;
}

View 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();
});
});

View File

@@ -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}

View 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({});
});
});

View File

@@ -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: {

View File

@@ -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,

View 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();
});
});

View File

@@ -85,6 +85,10 @@ export const useIssueQueueIntegrationStore = create<IssueQueueIntegrationStore>(
);
},
resetState: () => {
set({ ...initialState }, false, 'resetState');
},
// ========== Queue Status Bridge ==========
_updateQueueItemStatus: (

View 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({});
});
});

View File

@@ -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' }
)

View 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');
});
});

View File

@@ -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', {

View 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({});
});
});

View File

@@ -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) => {

View 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();
}
});
});

View File

@@ -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

View File

@@ -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;
}