mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-12 17:21:19 +08:00
Add comprehensive tests for CLI functionality and CodexLens compatibility
- Introduced tests for stale running fallback in CLI watch functionality to ensure proper handling of saved conversations. - Added compatibility tests for CodexLens CLI to verify index initialization despite compatibility conflicts. - Implemented tests for Smart Search MCP usage to validate default settings and path handling. - Created tests for UV Manager to ensure Python preference handling works as expected. - Added a detailed guide for CCW/Codex commands and skills, covering core commands, execution modes, and templates.
This commit is contained in:
@@ -12,7 +12,10 @@ import { router } from './router';
|
||||
import queryClient from './lib/query-client';
|
||||
import type { Locale } from './lib/i18n';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useExecutionMonitorStore } from '@/stores/executionMonitorStore';
|
||||
import { useTerminalPanelStore } from '@/stores/terminalPanelStore';
|
||||
import { useActiveCliExecutions, ACTIVE_CLI_EXECUTIONS_QUERY_KEY } from '@/hooks/useActiveCliExecutions';
|
||||
import { DialogStyleProvider } from '@/contexts/DialogStyleContext';
|
||||
import { initializeCsrfToken } from './lib/api';
|
||||
|
||||
@@ -55,6 +58,10 @@ function QueryInvalidator() {
|
||||
useEffect(() => {
|
||||
// Register callback to invalidate all workspace-related queries on workspace switch
|
||||
const callback = () => {
|
||||
useCliStreamStore.getState().resetState();
|
||||
useExecutionMonitorStore.getState().resetState();
|
||||
useTerminalPanelStore.getState().resetState();
|
||||
queryClient.invalidateQueries({ queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY });
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => {
|
||||
const queryKey = query.queryKey;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Redesigned CLI streaming monitor with smart parsing and message-based layout
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Terminal,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket';
|
||||
|
||||
// New layout components
|
||||
@@ -169,6 +170,7 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
const currentExecutionId = useCliStreamStore((state) => state.currentExecutionId);
|
||||
const removeExecution = useCliStreamStore((state) => state.removeExecution);
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Active execution sync
|
||||
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
|
||||
@@ -221,6 +223,12 @@ export function CliStreamMonitorNew({ isOpen, onClose }: CliStreamMonitorNewProp
|
||||
return filtered;
|
||||
}, [messages, filter, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery('');
|
||||
setFilter('all');
|
||||
setViewMode('preview');
|
||||
}, [projectPath]);
|
||||
|
||||
// Copy message content
|
||||
const handleCopy = useCallback(async (content: string) => {
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { LogBlockList } from '@/components/shared/LogBlock';
|
||||
import { useCliStreamStore, type CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket';
|
||||
|
||||
// New components for Tab + JSON Cards
|
||||
@@ -186,6 +187,7 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
const setCurrentExecution = useCliStreamStore((state) => state.setCurrentExecution);
|
||||
const removeExecution = useCliStreamStore((state) => state.removeExecution);
|
||||
const markExecutionClosedByUser = useCliStreamStore((state) => state.markExecutionClosedByUser);
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Active execution sync
|
||||
const { isLoading: isSyncing, refetch } = useActiveCliExecutions(isOpen);
|
||||
@@ -214,6 +216,13 @@ export function CliStreamMonitor({ isOpen, onClose }: CliStreamMonitorProps) {
|
||||
}
|
||||
}, [executions, currentExecutionId, autoScroll, isUserScrolling]);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchQuery('');
|
||||
setAutoScroll(true);
|
||||
setIsUserScrolling(false);
|
||||
setViewMode('list');
|
||||
}, [projectPath]);
|
||||
|
||||
// Handle scroll to detect user scrolling (with debounce for performance)
|
||||
const handleScrollRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const handleScroll = useCallback(() => {
|
||||
|
||||
288
ccw/frontend/src/hooks/useActiveCliExecutions.test.tsx
Normal file
288
ccw/frontend/src/hooks/useActiveCliExecutions.test.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
// ========================================
|
||||
// useActiveCliExecutions Hook Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as React from 'react';
|
||||
import * as api from '@/lib/api';
|
||||
import { useActiveCliExecutions } from './useActiveCliExecutions';
|
||||
|
||||
const mockProjectState = vi.hoisted(() => ({
|
||||
projectPath: '/test/project',
|
||||
}));
|
||||
|
||||
const mockStoreState = vi.hoisted(() => ({
|
||||
executions: {} as Record<string, any>,
|
||||
cleanupUserClosedExecutions: vi.fn(),
|
||||
isExecutionClosedByUser: vi.fn(() => false),
|
||||
removeExecution: vi.fn(),
|
||||
upsertExecution: vi.fn(),
|
||||
setCurrentExecution: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseCliStreamStore = vi.hoisted(() => {
|
||||
const store = vi.fn();
|
||||
Object.assign(store, {
|
||||
getState: vi.fn(() => mockStoreState),
|
||||
});
|
||||
return store;
|
||||
});
|
||||
|
||||
vi.mock('@/stores/cliStreamStore', () => ({
|
||||
useCliStreamStore: mockUseCliStreamStore,
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/workflowStore', () => ({
|
||||
useWorkflowStore: vi.fn((selector?: (state: { projectPath: string }) => unknown) => (
|
||||
selector
|
||||
? selector({ projectPath: mockProjectState.projectPath })
|
||||
: { projectPath: mockProjectState.projectPath }
|
||||
)),
|
||||
selectProjectPath: (state: { projectPath: string }) => state.projectPath,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/lib/api')>('@/lib/api');
|
||||
return {
|
||||
...actual,
|
||||
fetchExecutionDetail: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
function createTestQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = createTestQueryClient();
|
||||
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function createActiveResponse(executions: Array<Record<string, unknown>>) {
|
||||
return {
|
||||
ok: true,
|
||||
statusText: 'OK',
|
||||
json: vi.fn().mockResolvedValue({ executions }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useActiveCliExecutions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
mockProjectState.projectPath = '/test/project';
|
||||
mockStoreState.executions = {};
|
||||
mockStoreState.cleanupUserClosedExecutions.mockReset();
|
||||
mockStoreState.isExecutionClosedByUser.mockReset();
|
||||
mockStoreState.isExecutionClosedByUser.mockReturnValue(false);
|
||||
mockStoreState.removeExecution.mockReset();
|
||||
mockStoreState.upsertExecution.mockReset();
|
||||
mockStoreState.setCurrentExecution.mockReset();
|
||||
(mockUseCliStreamStore as any).getState.mockReset();
|
||||
(mockUseCliStreamStore as any).getState.mockImplementation(() => mockStoreState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('requests active executions with scoped project path', async () => {
|
||||
fetchMock.mockResolvedValue(createActiveResponse([]));
|
||||
|
||||
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/cli/active?path=%2Ftest%2Fproject');
|
||||
});
|
||||
|
||||
it('filters stale recovered running executions when saved detail is newer', async () => {
|
||||
const startTime = 1_741_392_000_000;
|
||||
mockStoreState.executions = {
|
||||
'exec-stale': {
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: [],
|
||||
startTime,
|
||||
recovered: true,
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.mockResolvedValue(createActiveResponse([
|
||||
{
|
||||
id: 'exec-stale',
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: '[响应] stale output',
|
||||
startTime,
|
||||
},
|
||||
]));
|
||||
|
||||
vi.mocked(api.fetchExecutionDetail).mockResolvedValue({
|
||||
id: 'exec-stale',
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
turns: [],
|
||||
turn_count: 1,
|
||||
created_at: new Date(startTime - 2_000).toISOString(),
|
||||
updated_at: new Date(startTime + 2_000).toISOString(),
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
expect(api.fetchExecutionDetail).toHaveBeenCalledWith('exec-stale', '/test/project');
|
||||
expect(mockStoreState.removeExecution).toHaveBeenCalledWith('exec-stale');
|
||||
expect(mockStoreState.upsertExecution).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes recovered running executions that are absent from the current workspace active list', async () => {
|
||||
mockStoreState.executions = {
|
||||
'exec-old-workspace': {
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: [],
|
||||
startTime: 1_741_394_000_000,
|
||||
recovered: true,
|
||||
},
|
||||
};
|
||||
|
||||
fetchMock.mockResolvedValue(createActiveResponse([]));
|
||||
|
||||
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
expect(mockStoreState.removeExecution).toHaveBeenCalledWith('exec-old-workspace');
|
||||
expect(api.fetchExecutionDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reselects the best remaining execution when current selection becomes invalid', async () => {
|
||||
mockStoreState.executions = {
|
||||
'exec-running': {
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: [],
|
||||
startTime: 1_741_395_000_000,
|
||||
recovered: false,
|
||||
},
|
||||
'exec-completed': {
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
startTime: 1_741_394_000_000,
|
||||
recovered: false,
|
||||
},
|
||||
};
|
||||
|
||||
(mockUseCliStreamStore as any).getState.mockImplementation(() => ({
|
||||
...mockStoreState,
|
||||
currentExecutionId: 'exec-missing',
|
||||
}));
|
||||
fetchMock.mockResolvedValue(createActiveResponse([]));
|
||||
|
||||
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith('exec-running');
|
||||
});
|
||||
|
||||
it('clears current selection when no executions remain after sync', async () => {
|
||||
mockStoreState.executions = {};
|
||||
(mockUseCliStreamStore as any).getState.mockImplementation(() => ({
|
||||
...mockStoreState,
|
||||
currentExecutionId: 'exec-missing',
|
||||
}));
|
||||
fetchMock.mockResolvedValue(createActiveResponse([]));
|
||||
|
||||
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data).toEqual([]);
|
||||
});
|
||||
|
||||
expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('keeps running executions when saved detail is older than active start time', async () => {
|
||||
const startTime = 1_741_393_000_000;
|
||||
|
||||
fetchMock.mockResolvedValue(createActiveResponse([
|
||||
{
|
||||
id: 'exec-live',
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: '[响应] live output',
|
||||
startTime,
|
||||
},
|
||||
]));
|
||||
|
||||
vi.mocked(api.fetchExecutionDetail).mockResolvedValue({
|
||||
id: 'exec-live',
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
turns: [],
|
||||
turn_count: 1,
|
||||
created_at: new Date(startTime - 20_000).toISOString(),
|
||||
updated_at: new Date(startTime - 10_000).toISOString(),
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useActiveCliExecutions(true, 60_000), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.map((execution) => execution.id)).toEqual(['exec-live']);
|
||||
});
|
||||
|
||||
expect(mockStoreState.removeExecution).not.toHaveBeenCalled();
|
||||
expect(mockStoreState.upsertExecution).toHaveBeenCalledWith(
|
||||
'exec-live',
|
||||
expect.objectContaining({
|
||||
status: 'running',
|
||||
recovered: true,
|
||||
})
|
||||
);
|
||||
expect(mockStoreState.setCurrentExecution).toHaveBeenCalledWith('exec-live');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@
|
||||
// Hook for syncing active CLI executions from server
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { fetchExecutionDetail, type ConversationRecord } from '@/lib/api';
|
||||
import { useCliStreamStore, type CliExecutionState } from '@/stores/cliStreamStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
/**
|
||||
* Response type from /api/cli/active endpoint
|
||||
@@ -84,6 +86,104 @@ function parseHistoricalOutput(rawOutput: string, startTime: number) {
|
||||
return historicalLines;
|
||||
}
|
||||
|
||||
function normalizeTimestampMs(value: unknown): number | undefined {
|
||||
if (value instanceof Date) {
|
||||
const time = value.getTime();
|
||||
return Number.isFinite(time) ? time : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value > 0 && value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const numericValue = Number(trimmed);
|
||||
if (Number.isFinite(numericValue)) {
|
||||
return numericValue > 0 && numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isSavedExecutionNewerThanActive(activeStartTime: unknown, savedTimestamp: unknown): boolean {
|
||||
const activeStartTimeMs = normalizeTimestampMs(activeStartTime);
|
||||
if (activeStartTimeMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const savedTimestampMs = normalizeTimestampMs(savedTimestamp);
|
||||
if (savedTimestampMs === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return savedTimestampMs >= activeStartTimeMs;
|
||||
}
|
||||
|
||||
async function filterSupersededRunningExecutions(
|
||||
executions: ActiveCliExecution[],
|
||||
currentExecutions: Record<string, CliExecutionState>,
|
||||
projectPath?: string
|
||||
): Promise<{ filteredExecutions: ActiveCliExecution[]; removedIds: string[] }> {
|
||||
const candidates = executions.filter((execution) => {
|
||||
if (execution.status !== 'running') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const existing = currentExecutions[execution.id];
|
||||
return !existing || existing.recovered;
|
||||
});
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { filteredExecutions: executions, removedIds: [] };
|
||||
}
|
||||
|
||||
const removedIds = new Set<string>();
|
||||
|
||||
await Promise.all(candidates.map(async (execution) => {
|
||||
try {
|
||||
const detail = await fetchExecutionDetail(execution.id, projectPath) as ConversationRecord & { _active?: boolean };
|
||||
if (detail._active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSavedExecutionNewerThanActive(
|
||||
execution.startTime,
|
||||
detail.updated_at || detail.created_at
|
||||
)) {
|
||||
removedIds.add(execution.id);
|
||||
}
|
||||
} catch {
|
||||
// Ignore detail lookup failures and keep server active state.
|
||||
}
|
||||
}));
|
||||
|
||||
if (removedIds.size === 0) {
|
||||
return { filteredExecutions: executions, removedIds: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
filteredExecutions: executions.filter((execution) => !removedIds.has(execution.id)),
|
||||
removedIds: Array.from(removedIds),
|
||||
};
|
||||
}
|
||||
|
||||
function pickPreferredExecutionId(executions: Record<string, CliExecutionState>): string | null {
|
||||
const sortedEntries = Object.entries(executions).sort(([, executionA], [, executionB]) => {
|
||||
if (executionA.status === 'running' && executionB.status !== 'running') return -1;
|
||||
if (executionA.status !== 'running' && executionB.status === 'running') return 1;
|
||||
return executionB.startTime - executionA.startTime;
|
||||
});
|
||||
|
||||
return sortedEntries[0]?.[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query key for active CLI executions
|
||||
*/
|
||||
@@ -104,42 +204,52 @@ export function useActiveCliExecutions(
|
||||
enabled: boolean,
|
||||
refetchInterval: number = 5000
|
||||
) {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ACTIVE_CLI_EXECUTIONS_QUERY_KEY,
|
||||
queryKey: [...ACTIVE_CLI_EXECUTIONS_QUERY_KEY, projectPath || 'default'],
|
||||
queryFn: async () => {
|
||||
// Access store state at execution time to avoid stale closures
|
||||
const store = useCliStreamStore.getState();
|
||||
const currentExecutions = store.executions;
|
||||
const params = new URLSearchParams();
|
||||
if (projectPath) {
|
||||
params.set('path', projectPath);
|
||||
}
|
||||
|
||||
const response = await fetch('/api/cli/active');
|
||||
const activeUrl = params.size > 0
|
||||
? `/api/cli/active?${params.toString()}`
|
||||
: '/api/cli/active';
|
||||
const response = await fetch(activeUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch active executions: ${response.statusText}`);
|
||||
}
|
||||
const data: ActiveCliExecutionsResponse = await response.json();
|
||||
const { filteredExecutions, removedIds } = await filterSupersededRunningExecutions(
|
||||
data.executions,
|
||||
currentExecutions,
|
||||
projectPath || undefined
|
||||
);
|
||||
|
||||
// Get server execution IDs
|
||||
const serverIds = new Set(data.executions.map(e => e.id));
|
||||
removedIds.forEach((executionId) => {
|
||||
store.removeExecution(executionId);
|
||||
});
|
||||
|
||||
const serverIds = new Set(filteredExecutions.map(e => e.id));
|
||||
|
||||
// Clean up userClosedExecutions - remove those no longer on server
|
||||
store.cleanupUserClosedExecutions(serverIds);
|
||||
|
||||
// Remove executions that are no longer on server and were closed by user
|
||||
for (const [id, exec] of Object.entries(currentExecutions)) {
|
||||
if (store.isExecutionClosedByUser(id)) {
|
||||
// User closed this execution, remove from local state
|
||||
store.removeExecution(id);
|
||||
} else if (exec.status !== 'running' && !serverIds.has(id) && exec.recovered) {
|
||||
// Not running, not on server, and was recovered (not user-created)
|
||||
} else if (exec.recovered && !serverIds.has(id)) {
|
||||
store.removeExecution(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Process executions and sync to store
|
||||
let hasNewExecution = false;
|
||||
const now = Date.now();
|
||||
|
||||
for (const exec of data.executions) {
|
||||
// Skip if user closed this execution
|
||||
for (const exec of filteredExecutions) {
|
||||
if (store.isExecutionClosedByUser(exec.id)) {
|
||||
continue;
|
||||
}
|
||||
@@ -151,13 +261,10 @@ export function useActiveCliExecutions(
|
||||
hasNewExecution = true;
|
||||
}
|
||||
|
||||
// Merge existing output with historical output
|
||||
const existingOutput = existing?.output || [];
|
||||
const existingContentSet = new Set(existingOutput.map(o => o.content));
|
||||
const missingLines = historicalOutput.filter(h => !existingContentSet.has(h.content));
|
||||
|
||||
// Prepend missing historical lines before existing output
|
||||
// Skip system start message when prepending
|
||||
const systemMsgIndex = existingOutput.findIndex(o => o.type === 'system');
|
||||
const insertIndex = systemMsgIndex >= 0 ? systemMsgIndex + 1 : 0;
|
||||
|
||||
@@ -166,12 +273,10 @@ export function useActiveCliExecutions(
|
||||
mergedOutput.splice(insertIndex, 0, ...missingLines);
|
||||
}
|
||||
|
||||
// Trim if too long
|
||||
if (mergedOutput.length > MAX_OUTPUT_LINES) {
|
||||
mergedOutput.splice(0, mergedOutput.length - MAX_OUTPUT_LINES);
|
||||
}
|
||||
|
||||
// Add system message for new executions
|
||||
let finalOutput = mergedOutput;
|
||||
if (!existing) {
|
||||
finalOutput = [
|
||||
@@ -195,19 +300,27 @@ export function useActiveCliExecutions(
|
||||
});
|
||||
}
|
||||
|
||||
// Set current execution to first running execution if none selected
|
||||
if (hasNewExecution) {
|
||||
const runningExec = data.executions.find(e => e.status === 'running' && !store.isExecutionClosedByUser(e.id));
|
||||
const runningExec = filteredExecutions.find(e => e.status === 'running' && !store.isExecutionClosedByUser(e.id));
|
||||
if (runningExec && !currentExecutions[runningExec.id]) {
|
||||
store.setCurrentExecution(runningExec.id);
|
||||
}
|
||||
}
|
||||
|
||||
return data.executions;
|
||||
const nextState = useCliStreamStore.getState();
|
||||
const currentExecutionId = nextState.currentExecutionId;
|
||||
if (!currentExecutionId || !nextState.executions[currentExecutionId]) {
|
||||
const preferredExecutionId = pickPreferredExecutionId(nextState.executions);
|
||||
if (preferredExecutionId !== currentExecutionId) {
|
||||
store.setCurrentExecution(preferredExecutionId);
|
||||
}
|
||||
}
|
||||
|
||||
return filteredExecutions;
|
||||
},
|
||||
enabled,
|
||||
refetchInterval,
|
||||
staleTime: 2000, // Consider data fresh for 2 seconds
|
||||
staleTime: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
55
ccw/frontend/src/pages/CliViewerPage.test.ts
Normal file
55
ccw/frontend/src/pages/CliViewerPage.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// ========================================
|
||||
// CliViewerPage Helper Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getStaleViewerTabs } from './cliViewerPage.utils';
|
||||
|
||||
describe('getStaleViewerTabs', () => {
|
||||
it('returns tabs whose execution ids are missing from the current execution map', () => {
|
||||
const panes = {
|
||||
'pane-1': {
|
||||
id: 'pane-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabs: [
|
||||
{ id: 'tab-1', executionId: 'exec-stale', title: 'stale', isPinned: false, order: 1 },
|
||||
{ id: 'tab-2', executionId: 'exec-live', title: 'live', isPinned: false, order: 2 },
|
||||
],
|
||||
},
|
||||
'pane-2': {
|
||||
id: 'pane-2',
|
||||
activeTabId: 'tab-3',
|
||||
tabs: [
|
||||
{ id: 'tab-3', executionId: 'exec-missing', title: 'missing', isPinned: true, order: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const executions = {
|
||||
'exec-live': { tool: 'codex', mode: 'analysis' },
|
||||
};
|
||||
|
||||
expect(getStaleViewerTabs(panes as any, executions)).toEqual([
|
||||
{ paneId: 'pane-1', tabId: 'tab-1', executionId: 'exec-stale' },
|
||||
{ paneId: 'pane-2', tabId: 'tab-3', executionId: 'exec-missing' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns an empty list when all tabs map to current executions', () => {
|
||||
const panes = {
|
||||
'pane-1': {
|
||||
id: 'pane-1',
|
||||
activeTabId: 'tab-1',
|
||||
tabs: [
|
||||
{ id: 'tab-1', executionId: 'exec-live', title: 'live', isPinned: false, order: 1 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const executions = {
|
||||
'exec-live': { tool: 'codex', mode: 'analysis' },
|
||||
};
|
||||
|
||||
expect(getStaleViewerTabs(panes as any, executions)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { useCliStreamStore } from '@/stores/cliStreamStore';
|
||||
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
|
||||
import { useCliStreamWebSocket } from '@/hooks/useCliStreamWebSocket';
|
||||
import { getStaleViewerTabs } from './cliViewerPage.utils';
|
||||
|
||||
// ========================================
|
||||
// Constants
|
||||
@@ -61,13 +62,13 @@ export function CliViewerPage() {
|
||||
const layout = useViewerLayout();
|
||||
const panes = useViewerPanes();
|
||||
const focusedPaneId = useFocusedPaneId();
|
||||
const { initializeDefaultLayout, addTab } = useViewerStore();
|
||||
const { initializeDefaultLayout, addTab, removeTab } = useViewerStore();
|
||||
|
||||
// CLI Stream Store hooks
|
||||
const executions = useCliStreamStore((state) => state.executions);
|
||||
|
||||
// Active execution sync from server
|
||||
useActiveCliExecutions(true);
|
||||
const { isLoading: isSyncing, isFetching: isRefreshing } = useActiveCliExecutions(true);
|
||||
|
||||
// CENTRALIZED WebSocket handler - processes each message only ONCE globally
|
||||
useCliStreamWebSocket();
|
||||
@@ -106,6 +107,18 @@ export function CliViewerPage() {
|
||||
});
|
||||
}, [executions, panes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSyncing || isRefreshing) return;
|
||||
|
||||
const staleTabs = getStaleViewerTabs(panes, executions);
|
||||
if (staleTabs.length === 0) return;
|
||||
|
||||
staleTabs.forEach(({ paneId, tabId, executionId }) => {
|
||||
addedExecutionsRef.current.delete(executionId);
|
||||
removeTab(paneId, tabId);
|
||||
});
|
||||
}, [executions, isRefreshing, isSyncing, panes, removeTab]);
|
||||
|
||||
// Initialize layout if empty
|
||||
useEffect(() => {
|
||||
const paneCount = countPanes(layout);
|
||||
|
||||
22
ccw/frontend/src/pages/cliViewerPage.utils.ts
Normal file
22
ccw/frontend/src/pages/cliViewerPage.utils.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// ========================================
|
||||
// CliViewerPage Utilities
|
||||
// ========================================
|
||||
|
||||
import type { PaneId, PaneState, TabId } from '@/stores/viewerStore';
|
||||
|
||||
export function getStaleViewerTabs(
|
||||
panes: Record<PaneId, PaneState>,
|
||||
executions: Record<string, unknown>
|
||||
): Array<{ paneId: PaneId; tabId: TabId; executionId: string }> {
|
||||
const executionIds = new Set(Object.keys(executions));
|
||||
|
||||
return Object.entries(panes).flatMap(([paneId, pane]) => (
|
||||
pane.tabs
|
||||
.filter((tab) => !executionIds.has(tab.executionId))
|
||||
.map((tab) => ({
|
||||
paneId,
|
||||
tabId: tab.id,
|
||||
executionId: tab.executionId,
|
||||
}))
|
||||
));
|
||||
}
|
||||
63
ccw/frontend/src/stores/cliStreamStore.test.ts
Normal file
63
ccw/frontend/src/stores/cliStreamStore.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// ========================================
|
||||
// CLI Stream Store Tests
|
||||
// ========================================
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { useCliStreamStore, selectActiveExecutionCount } from './cliStreamStore';
|
||||
|
||||
describe('cliStreamStore', () => {
|
||||
beforeEach(() => {
|
||||
useCliStreamStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('removeExecution clears outputs and execution state together', () => {
|
||||
const store = useCliStreamStore.getState();
|
||||
|
||||
store.upsertExecution('exec-1', {
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: [],
|
||||
startTime: 1_741_400_000_000,
|
||||
});
|
||||
store.addOutput('exec-1', {
|
||||
type: 'stdout',
|
||||
content: 'hello',
|
||||
timestamp: 1_741_400_000_100,
|
||||
});
|
||||
|
||||
expect(useCliStreamStore.getState().outputs['exec-1']).toHaveLength(1);
|
||||
expect(useCliStreamStore.getState().executions['exec-1']).toBeDefined();
|
||||
|
||||
store.removeExecution('exec-1');
|
||||
|
||||
expect(useCliStreamStore.getState().outputs['exec-1']).toBeUndefined();
|
||||
expect(useCliStreamStore.getState().executions['exec-1']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resetState clears execution badge state for workspace switches', () => {
|
||||
const store = useCliStreamStore.getState();
|
||||
|
||||
store.upsertExecution('exec-running', {
|
||||
tool: 'codex',
|
||||
mode: 'analysis',
|
||||
status: 'running',
|
||||
output: [],
|
||||
startTime: 1_741_401_000_000,
|
||||
});
|
||||
store.setCurrentExecution('exec-running');
|
||||
store.markExecutionClosedByUser('exec-running');
|
||||
|
||||
expect(selectActiveExecutionCount(useCliStreamStore.getState() as any)).toBe(1);
|
||||
expect(useCliStreamStore.getState().currentExecutionId).toBe('exec-running');
|
||||
|
||||
store.resetState();
|
||||
|
||||
const nextState = useCliStreamStore.getState();
|
||||
expect(selectActiveExecutionCount(nextState as any)).toBe(0);
|
||||
expect(nextState.currentExecutionId).toBeNull();
|
||||
expect(Object.keys(nextState.executions)).toEqual([]);
|
||||
expect(Object.keys(nextState.outputs)).toEqual([]);
|
||||
expect(nextState.userClosedExecutions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -93,6 +93,7 @@ interface CliStreamState extends BlockCacheState {
|
||||
isExecutionClosedByUser: (executionId: string) => boolean;
|
||||
cleanupUserClosedExecutions: (serverIds: Set<string>) => void;
|
||||
setCurrentExecution: (executionId: string | null) => void;
|
||||
resetState: () => void;
|
||||
|
||||
// Block cache methods
|
||||
getBlocks: (executionId: string) => LogBlockData[];
|
||||
@@ -462,15 +463,18 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
|
||||
removeExecution: (executionId: string) => {
|
||||
set((state) => {
|
||||
const newOutputs = { ...state.outputs };
|
||||
const newExecutions = { ...state.executions };
|
||||
const newBlocks = { ...state.blocks };
|
||||
const newLastUpdate = { ...state.lastUpdate };
|
||||
const newDeduplicationWindows = { ...state.deduplicationWindows };
|
||||
delete newOutputs[executionId];
|
||||
delete newExecutions[executionId];
|
||||
delete newBlocks[executionId];
|
||||
delete newLastUpdate[executionId];
|
||||
delete newDeduplicationWindows[executionId];
|
||||
return {
|
||||
outputs: newOutputs,
|
||||
executions: newExecutions,
|
||||
blocks: newBlocks,
|
||||
lastUpdate: newLastUpdate,
|
||||
@@ -513,6 +517,18 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
set({ currentExecutionId: executionId }, false, 'cliStream/setCurrentExecution');
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
set({
|
||||
outputs: {},
|
||||
executions: {},
|
||||
currentExecutionId: null,
|
||||
userClosedExecutions: new Set<string>(),
|
||||
deduplicationWindows: {},
|
||||
blocks: {},
|
||||
lastUpdate: {},
|
||||
}, false, 'cliStream/resetState');
|
||||
},
|
||||
|
||||
// Block cache methods
|
||||
getBlocks: (executionId: string) => {
|
||||
const state = get();
|
||||
|
||||
46
ccw/frontend/src/stores/executionMonitorStore.test.ts
Normal file
46
ccw/frontend/src/stores/executionMonitorStore.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// ========================================
|
||||
// Execution Monitor Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
useExecutionMonitorStore,
|
||||
selectActiveExecutionCount,
|
||||
type ExecutionWSMessage,
|
||||
} from './executionMonitorStore';
|
||||
|
||||
describe('executionMonitorStore', () => {
|
||||
beforeEach(() => {
|
||||
useExecutionMonitorStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears workspace-scoped execution monitor state', () => {
|
||||
const store = useExecutionMonitorStore.getState();
|
||||
const startMessage: ExecutionWSMessage = {
|
||||
type: 'EXECUTION_STARTED',
|
||||
payload: {
|
||||
executionId: 'exec-running',
|
||||
flowId: 'flow-1',
|
||||
sessionKey: 'session-1',
|
||||
stepName: 'Workspace Flow',
|
||||
totalSteps: 3,
|
||||
timestamp: '2026-03-08T12:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
store.handleExecutionMessage(startMessage);
|
||||
|
||||
const activeState = useExecutionMonitorStore.getState();
|
||||
expect(selectActiveExecutionCount(activeState as any)).toBe(1);
|
||||
expect(activeState.currentExecutionId).toBe('exec-running');
|
||||
expect(activeState.isPanelOpen).toBe(true);
|
||||
|
||||
store.resetState();
|
||||
|
||||
const nextState = useExecutionMonitorStore.getState();
|
||||
expect(selectActiveExecutionCount(nextState as any)).toBe(0);
|
||||
expect(nextState.activeExecutions).toEqual({});
|
||||
expect(nextState.currentExecutionId).toBeNull();
|
||||
expect(nextState.isPanelOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ interface ExecutionMonitorActions {
|
||||
setPanelOpen: (open: boolean) => void;
|
||||
clearExecution: (executionId: string) => void;
|
||||
clearAllExecutions: () => void;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
type ExecutionMonitorStore = ExecutionMonitorState & ExecutionMonitorActions;
|
||||
@@ -318,6 +319,10 @@ export const useExecutionMonitorStore = create<ExecutionMonitorStore>()(
|
||||
clearAllExecutions: () => {
|
||||
set({ activeExecutions: {}, currentExecutionId: null }, false, 'clearAllExecutions');
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
set({ ...initialState }, false, 'resetState');
|
||||
},
|
||||
}),
|
||||
{ name: 'ExecutionMonitorStore' }
|
||||
)
|
||||
|
||||
35
ccw/frontend/src/stores/terminalPanelStore.test.ts
Normal file
35
ccw/frontend/src/stores/terminalPanelStore.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// ========================================
|
||||
// Terminal Panel Store Tests
|
||||
// ========================================
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useTerminalPanelStore, selectTerminalCount } from './terminalPanelStore';
|
||||
|
||||
describe('terminalPanelStore', () => {
|
||||
beforeEach(() => {
|
||||
useTerminalPanelStore.getState().resetState();
|
||||
});
|
||||
|
||||
it('resetState clears workspace-scoped terminal tabs and selection', () => {
|
||||
const store = useTerminalPanelStore.getState();
|
||||
|
||||
store.openTerminal('session-a');
|
||||
store.addTerminal('session-b');
|
||||
store.setPanelView('queue');
|
||||
|
||||
const activeState = useTerminalPanelStore.getState();
|
||||
expect(selectTerminalCount(activeState as any)).toBe(2);
|
||||
expect(activeState.activeTerminalId).toBe('session-a');
|
||||
expect(activeState.panelView).toBe('queue');
|
||||
expect(activeState.isPanelOpen).toBe(true);
|
||||
|
||||
store.resetState();
|
||||
|
||||
const nextState = useTerminalPanelStore.getState();
|
||||
expect(selectTerminalCount(nextState as any)).toBe(0);
|
||||
expect(nextState.terminalOrder).toEqual([]);
|
||||
expect(nextState.activeTerminalId).toBeNull();
|
||||
expect(nextState.panelView).toBe('terminal');
|
||||
expect(nextState.isPanelOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,8 @@ export interface TerminalPanelActions {
|
||||
addTerminal: (sessionKey: string) => void;
|
||||
/** Remove a terminal from the order list and adjust active if needed */
|
||||
removeTerminal: (sessionKey: string) => void;
|
||||
/** Reset workspace-scoped terminal panel UI state */
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
export type TerminalPanelStore = TerminalPanelState & TerminalPanelActions;
|
||||
@@ -153,6 +155,10 @@ export const useTerminalPanelStore = create<TerminalPanelStore>()(
|
||||
'removeTerminal'
|
||||
);
|
||||
},
|
||||
|
||||
resetState: () => {
|
||||
set({ ...initialState }, false, 'resetState');
|
||||
},
|
||||
}),
|
||||
{ name: 'TerminalPanelStore' }
|
||||
)
|
||||
|
||||
@@ -112,8 +112,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
sessionDataStore,
|
||||
},
|
||||
false,
|
||||
'setSessions'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -131,8 +130,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
[key]: session,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'addSession'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -140,7 +138,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session) return state;
|
||||
|
||||
@@ -163,8 +161,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateSession'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -172,7 +169,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
const { [key]: removed, ...remainingStore } = state.sessionDataStore;
|
||||
|
||||
return {
|
||||
@@ -187,8 +184,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'removeSession'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -196,7 +192,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session || session.location === 'archived') return state;
|
||||
|
||||
@@ -220,8 +216,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'archiveSession'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -231,7 +226,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session) return state;
|
||||
|
||||
@@ -252,8 +247,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'addTask'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -261,7 +255,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session?.tasks) return state;
|
||||
|
||||
@@ -284,8 +278,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateTask'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -293,7 +286,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session?.tasks) return state;
|
||||
|
||||
@@ -310,8 +303,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'removeTask'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -325,8 +317,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
[key]: session,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setLiteTaskSession'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -336,8 +327,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const { [key]: removed, ...remaining } = state.liteTaskDataStore;
|
||||
return { liteTaskDataStore: remaining };
|
||||
},
|
||||
false,
|
||||
'removeLiteTaskSession'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -351,8 +341,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
[key]: data,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setTaskJson'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -362,38 +351,36 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
const { [key]: removed, ...remaining } = state.taskJsonStore;
|
||||
return { taskJsonStore: remaining };
|
||||
},
|
||||
false,
|
||||
'removeTaskJson'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Active Session ==========
|
||||
|
||||
setActiveSessionId: (sessionId: string | null) => {
|
||||
set({ activeSessionId: sessionId }, false, 'setActiveSessionId');
|
||||
set({ activeSessionId: sessionId }, false);
|
||||
},
|
||||
|
||||
// ========== Project Path ==========
|
||||
|
||||
setProjectPath: (path: string) => {
|
||||
set({ projectPath: path }, false, 'setProjectPath');
|
||||
set({ projectPath: path }, false);
|
||||
},
|
||||
|
||||
addRecentPath: (path: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
(state: WorkflowState) => {
|
||||
// Remove if exists, add to front
|
||||
const filtered = state.recentPaths.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, 10); // Keep max 10
|
||||
return { recentPaths: updated };
|
||||
},
|
||||
false,
|
||||
'addRecentPath'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => {
|
||||
set({ serverPlatform: platform }, false, 'setServerPlatform');
|
||||
set({ serverPlatform: platform }, false);
|
||||
},
|
||||
|
||||
// ========== Workspace Actions ==========
|
||||
@@ -418,8 +405,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
},
|
||||
sessionDataStore,
|
||||
},
|
||||
false,
|
||||
'switchWorkspace'
|
||||
false
|
||||
);
|
||||
|
||||
// Persist projectPath to localStorage manually
|
||||
@@ -434,16 +420,16 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
|
||||
removeRecentPath: async (path: string) => {
|
||||
const updatedPaths = await apiRemoveRecentPath(path);
|
||||
set({ recentPaths: updatedPaths }, false, 'removeRecentPath');
|
||||
set({ recentPaths: updatedPaths }, false);
|
||||
},
|
||||
|
||||
refreshRecentPaths: async () => {
|
||||
const paths = await fetchRecentPaths();
|
||||
set({ recentPaths: paths }, false, 'refreshRecentPaths');
|
||||
set({ recentPaths: paths }, false);
|
||||
},
|
||||
|
||||
registerQueryInvalidator: (callback: () => void) => {
|
||||
set({ _invalidateQueriesCallback: callback }, false, 'registerQueryInvalidator');
|
||||
set({ _invalidateQueriesCallback: callback }, false);
|
||||
},
|
||||
|
||||
// ========== Filters and Sorting ==========
|
||||
@@ -453,8 +439,7 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
(state) => ({
|
||||
filters: { ...state.filters, ...filters },
|
||||
}),
|
||||
false,
|
||||
'setFilters'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
@@ -463,13 +448,12 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
(state) => ({
|
||||
sorting: { ...state.sorting, ...sorting },
|
||||
}),
|
||||
false,
|
||||
'setSorting'
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
resetFilters: () => {
|
||||
set({ filters: defaultFilters, sorting: defaultSorting }, false, 'resetFilters');
|
||||
set({ filters: defaultFilters, sorting: defaultSorting }, false);
|
||||
},
|
||||
|
||||
// ========== Computed Selectors ==========
|
||||
|
||||
Reference in New Issue
Block a user