mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: 添加执行加载状态和全局执行ID去重,优化CLI视图和MCP配置获取
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user