From 5a937732f434e8363062c4d669ff20f3edef3f75 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Tue, 17 Feb 2026 21:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E5=92=8C=E5=85=A8=E5=B1=80?= =?UTF-8?q?=E6=89=A7=E8=A1=8CID=E5=8E=BB=E9=87=8D=EF=BC=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96CLI=E8=A7=86=E5=9B=BE=E5=92=8CMCP=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=8E=B7=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/cli-viewer/ContentArea.tsx | 37 ++++++++++-- ccw/frontend/src/lib/api.ts | 28 +++++++--- ccw/frontend/src/pages/CliViewerPage.tsx | 12 ++++ ccw/frontend/src/pages/McpManagerPage.tsx | 26 +++++---- ccw/frontend/src/stores/viewerStore.ts | 56 +++++++++++++++++++ 5 files changed, 136 insertions(+), 23 deletions(-) diff --git a/ccw/frontend/src/components/cli-viewer/ContentArea.tsx b/ccw/frontend/src/components/cli-viewer/ContentArea.tsx index e7781158..3c8cf383 100644 --- a/ccw/frontend/src/components/cli-viewer/ContentArea.tsx +++ b/ccw/frontend/src/components/cli-viewer/ContentArea.tsx @@ -15,6 +15,7 @@ import { import { useCliStreamStore, type CliExecutionState, type CliOutputLine } from '@/stores/cliStreamStore'; import { MonitorBody } from '@/components/shared/CliStreamMonitor/MonitorBody'; import { MessageRenderer } from '@/components/shared/CliStreamMonitor/MessageRenderer'; +import { useActiveCliExecutions } from '@/hooks/useActiveCliExecutions'; // ========== 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 ( +
+ +
+

+ {formatMessage({ id: 'cliViewer.syncingExecution', defaultMessage: 'Syncing execution data...' })} +

+
+
+ ); +} + /** * Single output line component with type-based styling */ @@ -157,10 +177,14 @@ function CliOutputDisplay({ execution, executionId }: { execution: CliExecutionS export function ContentArea({ paneId, className }: ContentAreaProps) { // Get active tab using the selector const activeTab = useViewerStore((state) => selectActiveTab(state, paneId)); - + // Get execution data from cliStreamStore 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(() => { if (!activeTab?.executionId) return null; return executions[activeTab.executionId] || null; @@ -173,14 +197,19 @@ export function ContentArea({ paneId, className }: ContentAreaProps) { return ; } - // No execution data found + // FIX-002: Show loading state while syncing if execution not yet available + if (!execution && isSyncing) { + return ; + } + + // No execution data found (after sync completed) if (!execution) { return ; } // Show CLI output return ; - }, [activeTab, execution]); + }, [activeTab, execution, isSyncing]); return (
{ +export async function fetchCcwMcpConfig(currentProjectPath?: string): Promise { try { const config = await fetchMcpConfig(); @@ -3724,13 +3724,27 @@ export async function fetchCcwMcpConfig(): Promise { ccwServer = config.userServers['ccw-tools']; } - // Check project servers + // Check project servers - only check current project if specified if (config.projects) { - for (const proj of Object.values(config.projects)) { - if (proj.mcpServers?.['ccw-tools']) { - installedScopes.push('project'); - if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools']; - break; + if (currentProjectPath) { + // Normalize path for comparison (forward slashes) + const normalizedCurrent = currentProjectPath.replace(/\\/g, '/'); + for (const [key, proj] of Object.entries(config.projects)) { + 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; + } } } } diff --git a/ccw/frontend/src/pages/CliViewerPage.tsx b/ccw/frontend/src/pages/CliViewerPage.tsx index 3ec9799d..bf8baeea 100644 --- a/ccw/frontend/src/pages/CliViewerPage.tsx +++ b/ccw/frontend/src/pages/CliViewerPage.tsx @@ -215,6 +215,18 @@ export function CliViewerPage() { // Auto-add new executions as tabs, distributing across available panes const addedExecutionsRef = useRef>(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(() => { const paneIds = Object.keys(panes); if (paneIds.length === 0) return; diff --git a/ccw/frontend/src/pages/McpManagerPage.tsx b/ccw/frontend/src/pages/McpManagerPage.tsx index 521e67e1..e11c5d91 100644 --- a/ccw/frontend/src/pages/McpManagerPage.tsx +++ b/ccw/frontend/src/pages/McpManagerPage.tsx @@ -279,10 +279,12 @@ export function McpManagerPage() { staleTime: 2 * 60 * 1000, // 2 minutes }); + const projectPath = useWorkflowStore(selectProjectPath); + // Fetch CCW Tools MCP configuration (Claude mode only) const ccwMcpQuery = useQuery({ - queryKey: ['ccwMcpConfig'], - queryFn: fetchCcwMcpConfig, + queryKey: ['ccwMcpConfig', projectPath], + queryFn: () => fetchCcwMcpConfig(projectPath ?? undefined), enabled: cliMode === 'claude', staleTime: 5 * 60 * 1000, // 5 minutes }); @@ -382,18 +384,20 @@ export function McpManagerPage() { installedScopes: [] as ('global' | 'project')[], }; + const ccwMcpQueryKey = ['ccwMcpConfig', projectPath]; + const handleToggleCcwTool = async (tool: string, enabled: boolean) => { // Read latest from cache to avoid stale closures - const currentConfig = queryClient.getQueryData(['ccwMcpConfig']) ?? ccwConfig; + const currentConfig = queryClient.getQueryData(ccwMcpQueryKey) ?? ccwConfig; const currentTools = currentConfig.enabledTools; - const previousConfig = queryClient.getQueryData(['ccwMcpConfig']); + const previousConfig = queryClient.getQueryData(ccwMcpQueryKey); const updatedTools = enabled ? (currentTools.includes(tool) ? currentTools : [...currentTools, tool]) : currentTools.filter((t) => t !== tool); // Optimistic cache update for immediate UI response - queryClient.setQueryData(['ccwMcpConfig'], (old: CcwMcpConfig | undefined) => { + queryClient.setQueryData(ccwMcpQueryKey, (old: CcwMcpConfig | undefined) => { if (!old) return old; return { ...old, enabledTools: updatedTools }; }); @@ -402,18 +406,18 @@ export function McpManagerPage() { await updateCcwConfig({ ...currentConfig, enabledTools: updatedTools }); } catch (error) { console.error('Failed to toggle CCW tool:', error); - queryClient.setQueryData(['ccwMcpConfig'], previousConfig); + queryClient.setQueryData(ccwMcpQueryKey, previousConfig); } ccwMcpQuery.refetch(); }; const handleUpdateCcwConfig = async (config: Partial) => { // Read BEFORE optimistic update to capture actual server state - const currentConfig = queryClient.getQueryData(['ccwMcpConfig']) ?? ccwConfig; - const previousConfig = queryClient.getQueryData(['ccwMcpConfig']); + const currentConfig = queryClient.getQueryData(ccwMcpQueryKey) ?? ccwConfig; + const previousConfig = queryClient.getQueryData(ccwMcpQueryKey); // Optimistic cache update for immediate UI response - queryClient.setQueryData(['ccwMcpConfig'], (old: CcwMcpConfig | undefined) => { + queryClient.setQueryData(ccwMcpQueryKey, (old: CcwMcpConfig | undefined) => { if (!old) return old; return { ...old, ...config }; }); @@ -422,7 +426,7 @@ export function McpManagerPage() { await updateCcwConfig({ ...currentConfig, ...config }); } catch (error) { console.error('Failed to update CCW config:', error); - queryClient.setQueryData(['ccwMcpConfig'], previousConfig); + queryClient.setQueryData(ccwMcpQueryKey, previousConfig); } ccwMcpQuery.refetch(); }; @@ -431,8 +435,6 @@ export function McpManagerPage() { ccwMcpQuery.refetch(); }; - const projectPath = useWorkflowStore(selectProjectPath); - // Build conflict map for quick lookup const conflictMap = useMemo(() => { const map = new Map(); diff --git a/ccw/frontend/src/stores/viewerStore.ts b/ccw/frontend/src/stores/viewerStore.ts index d1b707a2..6383fdec 100644 --- a/ccw/frontend/src/stores/viewerStore.ts +++ b/ccw/frontend/src/stores/viewerStore.ts @@ -491,6 +491,31 @@ export const useViewerStore = create()( 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 maxOrder = pane.tabs.reduce((max, t) => Math.max(max, t.order), 0); @@ -575,6 +600,21 @@ export const useViewerStore = create()( false, '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) => { @@ -720,6 +760,22 @@ export const useViewerStore = create()( false, '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) => {