feat: 添加执行加载状态和全局执行ID去重,优化CLI视图和MCP配置获取

This commit is contained in:
catlog22
2026-02-17 21:01:09 +08:00
parent c588aa69d2
commit 5a937732f4
5 changed files with 136 additions and 23 deletions

View File

@@ -15,6 +15,7 @@ import {
import { useCliStreamStore, type CliExecutionState, type CliOutputLine } from '@/stores/cliStreamStore'; import { useCliStreamStore, type CliExecutionState, type CliOutputLine } from '@/stores/cliStreamStore';
import { MonitorBody } from '@/components/shared/CliStreamMonitor/MonitorBody'; import { MonitorBody } from '@/components/shared/CliStreamMonitor/MonitorBody';
import { MessageRenderer } from '@/components/shared/CliStreamMonitor/MessageRenderer'; import { MessageRenderer } from '@/components/shared/CliStreamMonitor/MessageRenderer';
import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions';
// ========== Types ========== // ========== Types ==========
@@ -68,6 +69,25 @@ function ExecutionNotFoundState({ executionId }: { executionId: string }) {
); );
} }
/**
* FIX-002: Loading state while syncing executions from server
* Shown after page refresh while execution data is being recovered
*/
function ExecutionLoadingState() {
const { formatMessage } = useIntl();
return (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-4">
<Loader2 className="h-8 w-8 animate-spin opacity-50" />
<div className="text-center">
<p className="text-sm">
{formatMessage({ id: 'cliViewer.syncingExecution', defaultMessage: 'Syncing execution data...' })}
</p>
</div>
</div>
);
}
/** /**
* Single output line component with type-based styling * Single output line component with type-based styling
*/ */
@@ -161,6 +181,10 @@ export function ContentArea({ paneId, className }: ContentAreaProps) {
// Get execution data from cliStreamStore // Get execution data from cliStreamStore
const executions = useCliStreamStore((state) => state.executions); const executions = useCliStreamStore((state) => state.executions);
// FIX-002: Get loading state from useActiveCliExecutions
// This helps distinguish between "not found" and "still loading"
const { isLoading: isSyncing } = useActiveCliExecutions(true);
const execution = useMemo(() => { const execution = useMemo(() => {
if (!activeTab?.executionId) return null; if (!activeTab?.executionId) return null;
return executions[activeTab.executionId] || null; return executions[activeTab.executionId] || null;
@@ -173,14 +197,19 @@ export function ContentArea({ paneId, className }: ContentAreaProps) {
return <EmptyTabState />; return <EmptyTabState />;
} }
// No execution data found // FIX-002: Show loading state while syncing if execution not yet available
if (!execution && isSyncing) {
return <ExecutionLoadingState />;
}
// No execution data found (after sync completed)
if (!execution) { if (!execution) {
return <ExecutionNotFoundState executionId={activeTab.executionId} />; return <ExecutionNotFoundState executionId={activeTab.executionId} />;
} }
// Show CLI output // Show CLI output
return <CliOutputDisplay execution={execution} executionId={activeTab.executionId} />; return <CliOutputDisplay execution={execution} executionId={activeTab.executionId} />;
}, [activeTab, execution]); }, [activeTab, execution, isSyncing]);
return ( return (
<div <div

View File

@@ -3708,7 +3708,7 @@ function buildCcwMcpServerConfig(config: {
/** /**
* Fetch CCW Tools MCP configuration by checking if ccw-tools server exists * Fetch CCW Tools MCP configuration by checking if ccw-tools server exists
*/ */
export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> { export async function fetchCcwMcpConfig(currentProjectPath?: string): Promise<CcwMcpConfig> {
try { try {
const config = await fetchMcpConfig(); const config = await fetchMcpConfig();
@@ -3724,13 +3724,27 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
ccwServer = config.userServers['ccw-tools']; ccwServer = config.userServers['ccw-tools'];
} }
// Check project servers // Check project servers - only check current project if specified
if (config.projects) { if (config.projects) {
for (const proj of Object.values(config.projects)) { if (currentProjectPath) {
if (proj.mcpServers?.['ccw-tools']) { // Normalize path for comparison (forward slashes)
installedScopes.push('project'); const normalizedCurrent = currentProjectPath.replace(/\\/g, '/');
if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools']; for (const [key, proj] of Object.entries(config.projects)) {
break; const normalizedKey = key.replace(/\\/g, '/');
if (normalizedKey === normalizedCurrent && proj.mcpServers?.['ccw-tools']) {
installedScopes.push('project');
if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools'];
break;
}
}
} else {
// Fallback: check all projects (legacy behavior)
for (const proj of Object.values(config.projects)) {
if (proj.mcpServers?.['ccw-tools']) {
installedScopes.push('project');
if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools'];
break;
}
} }
} }
} }

View File

@@ -215,6 +215,18 @@ export function CliViewerPage() {
// Auto-add new executions as tabs, distributing across available panes // Auto-add new executions as tabs, distributing across available panes
const addedExecutionsRef = useRef<Set<string>>(new Set()); const addedExecutionsRef = useRef<Set<string>>(new Set());
// FIX-001: Initialize addedExecutionsRef with existing tab executionIds on mount
// This prevents duplicate tabs from being added after page refresh
useEffect(() => {
// Extract executionIds from all existing tabs in all panes
const existingExecutionIds = Object.values(panes).flatMap((pane) =>
pane.tabs.map((tab) => tab.executionId)
);
existingExecutionIds.forEach((id) => addedExecutionsRef.current.add(id));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty deps - only run once on mount
useEffect(() => { useEffect(() => {
const paneIds = Object.keys(panes); const paneIds = Object.keys(panes);
if (paneIds.length === 0) return; if (paneIds.length === 0) return;

View File

@@ -279,10 +279,12 @@ export function McpManagerPage() {
staleTime: 2 * 60 * 1000, // 2 minutes staleTime: 2 * 60 * 1000, // 2 minutes
}); });
const projectPath = useWorkflowStore(selectProjectPath);
// Fetch CCW Tools MCP configuration (Claude mode only) // Fetch CCW Tools MCP configuration (Claude mode only)
const ccwMcpQuery = useQuery({ const ccwMcpQuery = useQuery({
queryKey: ['ccwMcpConfig'], queryKey: ['ccwMcpConfig', projectPath],
queryFn: fetchCcwMcpConfig, queryFn: () => fetchCcwMcpConfig(projectPath ?? undefined),
enabled: cliMode === 'claude', enabled: cliMode === 'claude',
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
}); });
@@ -382,18 +384,20 @@ export function McpManagerPage() {
installedScopes: [] as ('global' | 'project')[], installedScopes: [] as ('global' | 'project')[],
}; };
const ccwMcpQueryKey = ['ccwMcpConfig', projectPath];
const handleToggleCcwTool = async (tool: string, enabled: boolean) => { const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
// Read latest from cache to avoid stale closures // Read latest from cache to avoid stale closures
const currentConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']) ?? ccwConfig; const currentConfig = queryClient.getQueryData<CcwMcpConfig>(ccwMcpQueryKey) ?? ccwConfig;
const currentTools = currentConfig.enabledTools; const currentTools = currentConfig.enabledTools;
const previousConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']); const previousConfig = queryClient.getQueryData<CcwMcpConfig>(ccwMcpQueryKey);
const updatedTools = enabled const updatedTools = enabled
? (currentTools.includes(tool) ? currentTools : [...currentTools, tool]) ? (currentTools.includes(tool) ? currentTools : [...currentTools, tool])
: currentTools.filter((t) => t !== tool); : currentTools.filter((t) => t !== tool);
// Optimistic cache update for immediate UI response // Optimistic cache update for immediate UI response
queryClient.setQueryData(['ccwMcpConfig'], (old: CcwMcpConfig | undefined) => { queryClient.setQueryData(ccwMcpQueryKey, (old: CcwMcpConfig | undefined) => {
if (!old) return old; if (!old) return old;
return { ...old, enabledTools: updatedTools }; return { ...old, enabledTools: updatedTools };
}); });
@@ -402,18 +406,18 @@ export function McpManagerPage() {
await updateCcwConfig({ ...currentConfig, enabledTools: updatedTools }); await updateCcwConfig({ ...currentConfig, enabledTools: updatedTools });
} catch (error) { } catch (error) {
console.error('Failed to toggle CCW tool:', error); console.error('Failed to toggle CCW tool:', error);
queryClient.setQueryData(['ccwMcpConfig'], previousConfig); queryClient.setQueryData(ccwMcpQueryKey, previousConfig);
} }
ccwMcpQuery.refetch(); ccwMcpQuery.refetch();
}; };
const handleUpdateCcwConfig = async (config: Partial<CcwMcpConfig>) => { const handleUpdateCcwConfig = async (config: Partial<CcwMcpConfig>) => {
// Read BEFORE optimistic update to capture actual server state // Read BEFORE optimistic update to capture actual server state
const currentConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']) ?? ccwConfig; const currentConfig = queryClient.getQueryData<CcwMcpConfig>(ccwMcpQueryKey) ?? ccwConfig;
const previousConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']); const previousConfig = queryClient.getQueryData<CcwMcpConfig>(ccwMcpQueryKey);
// Optimistic cache update for immediate UI response // Optimistic cache update for immediate UI response
queryClient.setQueryData(['ccwMcpConfig'], (old: CcwMcpConfig | undefined) => { queryClient.setQueryData(ccwMcpQueryKey, (old: CcwMcpConfig | undefined) => {
if (!old) return old; if (!old) return old;
return { ...old, ...config }; return { ...old, ...config };
}); });
@@ -422,7 +426,7 @@ export function McpManagerPage() {
await updateCcwConfig({ ...currentConfig, ...config }); await updateCcwConfig({ ...currentConfig, ...config });
} catch (error) { } catch (error) {
console.error('Failed to update CCW config:', error); console.error('Failed to update CCW config:', error);
queryClient.setQueryData(['ccwMcpConfig'], previousConfig); queryClient.setQueryData(ccwMcpQueryKey, previousConfig);
} }
ccwMcpQuery.refetch(); ccwMcpQuery.refetch();
}; };
@@ -431,8 +435,6 @@ export function McpManagerPage() {
ccwMcpQuery.refetch(); ccwMcpQuery.refetch();
}; };
const projectPath = useWorkflowStore(selectProjectPath);
// Build conflict map for quick lookup // Build conflict map for quick lookup
const conflictMap = useMemo(() => { const conflictMap = useMemo(() => {
const map = new Map<string, McpServerConflict>(); const map = new Map<string, McpServerConflict>();

View File

@@ -491,6 +491,31 @@ export const useViewerStore = create<ViewerState>()(
return existingTab.id; return existingTab.id;
} }
// FIX-004: Global executionId deduplication (VSCode parity)
// Check all panes for existing tab with same executionId
for (const [pid, p] of Object.entries(state.panes)) {
if (pid === paneId) continue; // Already checked above
const existingInOtherPane = p.tabs.find((t) => t.executionId === executionId);
if (existingInOtherPane) {
// Activate the existing tab in its pane and focus that pane
set(
{
panes: {
...state.panes,
[pid]: {
...p,
activeTabId: existingInOtherPane.id,
},
},
focusedPaneId: pid,
},
false,
'viewer/addTab-existing-global'
);
return existingInOtherPane.id;
}
}
const newTabId = generateTabId(state.nextTabIdCounter); const newTabId = generateTabId(state.nextTabIdCounter);
const maxOrder = pane.tabs.reduce((max, t) => Math.max(max, t.order), 0); const maxOrder = pane.tabs.reduce((max, t) => Math.max(max, t.order), 0);
@@ -575,6 +600,21 @@ export const useViewerStore = create<ViewerState>()(
false, false,
'viewer/removeTab' 'viewer/removeTab'
); );
// FIX-003: Auto-cleanup empty panes after tab removal (VSCode parity)
if (newTabs.length === 0) {
const allPaneIds = getAllPaneIds(get().layout);
// Don't remove if it's the last pane
if (allPaneIds.length > 1) {
// Use queueMicrotask to avoid state mutation during current transaction
queueMicrotask(() => {
const currentState = get();
if (currentState.panes[paneId]?.tabs.length === 0) {
currentState.removePane(paneId);
}
});
}
}
}, },
setActiveTab: (paneId: PaneId, tabId: TabId) => { setActiveTab: (paneId: PaneId, tabId: TabId) => {
@@ -720,6 +760,22 @@ export const useViewerStore = create<ViewerState>()(
false, false,
'viewer/moveTab' 'viewer/moveTab'
); );
// FIX-003: Auto-cleanup empty panes after tab movement (VSCode parity)
// Only cleanup when moving to a different pane and source becomes empty
if (sourcePaneId !== targetPaneId && newSourceTabs.length === 0) {
const allPaneIds = getAllPaneIds(get().layout);
// Don't remove if it's the last pane
if (allPaneIds.length > 1) {
// Use queueMicrotask to avoid state mutation during current transaction
queueMicrotask(() => {
const currentState = get();
if (currentState.panes[sourcePaneId]?.tabs.length === 0) {
currentState.removePane(sourcePaneId);
}
});
}
}
}, },
togglePinTab: (tabId: TabId) => { togglePinTab: (tabId: TabId) => {