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