mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-07 16:41:06 +08:00
feat: add DDD scan, sync, and update commands for document indexing
- Implemented `/ddd:scan` command to analyze existing codebases and generate document indices without specifications. This includes phases for project structure analysis, component discovery, feature inference, and requirement extraction. - Introduced `/ddd:sync` command for post-task synchronization, updating document indices, generating action logs, and refreshing feature/component documentation after development tasks. - Added `/ddd:update` command for lightweight incremental updates to the document index, allowing for quick impact checks during development and pre-commit validation. - Created `execute.md` for the coordinator role in the team lifecycle, detailing the spawning of executor team-workers for IMPL tasks. - Added `useHasHydrated` hook to determine if the Zustand workflow store has been rehydrated from localStorage, improving state management reliability.
This commit is contained in:
@@ -19,6 +19,7 @@ import { useNotificationStore, selectCurrentQuestion, selectCurrentPopupCard } f
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
|
||||
import { useHasHydrated } from '@/hooks/useHasHydrated';
|
||||
|
||||
export interface AppShellProps {
|
||||
/** Callback for refresh action */
|
||||
@@ -40,9 +41,14 @@ export function AppShell({
|
||||
// Workspace initialization from URL query parameter
|
||||
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
|
||||
const projectPath = useWorkflowStore((state) => state.projectPath);
|
||||
const hasHydrated = useWorkflowStore((state) => state._hasHydrated);
|
||||
const hasHydrated = useHasHydrated();
|
||||
const location = useLocation();
|
||||
|
||||
// Manually trigger hydration on mount (needed because of skipHydration: true in store config)
|
||||
useEffect(() => {
|
||||
useWorkflowStore.persist.rehydrate();
|
||||
}, []);
|
||||
|
||||
// Immersive mode (fullscreen) - hide chrome
|
||||
const isImmersiveMode = useAppStore(selectIsImmersiveMode);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ChevronDown, X, FolderOpen, Check, Loader2 } from 'lucide-react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { selectFolder } from '@/lib/nativeDialog';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import {
|
||||
@@ -81,6 +82,7 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
const recentPaths = useWorkflowStore((state) => state.recentPaths);
|
||||
const switchWorkspace = useWorkflowStore((state) => state.switchWorkspace);
|
||||
const removeRecentPath = useWorkflowStore((state) => state.removeRecentPath);
|
||||
const { error: showError } = useNotifications();
|
||||
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isManualOpen, setIsManualOpen] = useState(false);
|
||||
@@ -113,11 +115,27 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
|
||||
);
|
||||
|
||||
const handleBrowseFolder = useCallback(async () => {
|
||||
const selected = await selectFolder(projectPath || undefined);
|
||||
if (selected) {
|
||||
await handleSwitchWorkspace(selected);
|
||||
const result = await selectFolder(projectPath || undefined);
|
||||
|
||||
// User cancelled the dialog - no action needed
|
||||
if (result.cancelled) {
|
||||
return;
|
||||
}
|
||||
}, [projectPath, handleSwitchWorkspace]);
|
||||
|
||||
// Error occurred - show error notification
|
||||
if (result.error) {
|
||||
showError(
|
||||
formatMessage({ id: 'workspace.selector.browseError' }),
|
||||
result.error
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Successfully selected a folder
|
||||
if (result.path) {
|
||||
await handleSwitchWorkspace(result.path);
|
||||
}
|
||||
}, [projectPath, handleSwitchWorkspace, showError, formatMessage]);
|
||||
|
||||
const handleManualPathSubmit = useCallback(async () => {
|
||||
const trimmedPath = manualPath.trim();
|
||||
|
||||
55
ccw/frontend/src/hooks/useHasHydrated.ts
Normal file
55
ccw/frontend/src/hooks/useHasHydrated.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// ========================================
|
||||
// useHasHydrated Hook
|
||||
// ========================================
|
||||
// Determines if the Zustand workflow store has been rehydrated from localStorage
|
||||
// Uses Zustand persist middleware's onFinishHydration callback for reliable detection
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useWorkflowStore } from '@/stores/workflowStore';
|
||||
|
||||
/**
|
||||
* A hook to determine if the Zustand workflow store has been rehydrated.
|
||||
* Returns `true` once the persisted state has been loaded from localStorage.
|
||||
*
|
||||
* This hook uses the Zustand persist middleware's onFinishHydration callback
|
||||
* instead of relying on internal state management, which avoids circular
|
||||
* reference issues during store initialization.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const hasHydrated = useHasHydrated();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* if (!hasHydrated) return;
|
||||
* // Safe to access persisted store values here
|
||||
* }, [hasHydrated]);
|
||||
*
|
||||
* if (!hasHydrated) return <LoadingSpinner />;
|
||||
* return <Content />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useHasHydrated(): boolean {
|
||||
const [hydrated, setHydrated] = useState(() => {
|
||||
// Check initial hydration status synchronously
|
||||
return useWorkflowStore.persist.hasHydrated();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// If already hydrated, no need to subscribe
|
||||
if (hydrated) return;
|
||||
|
||||
// Subscribe to hydration completion event
|
||||
// onFinishHydration returns an unsubscribe function
|
||||
const unsubscribe = useWorkflowStore.persist.onFinishHydration(() => {
|
||||
setHydrated(true);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [hydrated]);
|
||||
|
||||
return hydrated;
|
||||
}
|
||||
|
||||
export default useHasHydrated;
|
||||
@@ -1,36 +1,109 @@
|
||||
/**
|
||||
* Native OS dialog helpers
|
||||
* Calls server-side endpoints that open system-native file/folder picker dialogs.
|
||||
*/
|
||||
// ========================================
|
||||
// Native OS Dialog Helpers
|
||||
// ========================================
|
||||
// Calls server-side endpoints that open system-native file/folder picker dialogs.
|
||||
// Returns structured DialogResult objects for clear success/cancel/error handling.
|
||||
|
||||
export async function selectFolder(initialDir?: string): Promise<string | null> {
|
||||
/**
|
||||
* Represents the result of a native dialog operation.
|
||||
*/
|
||||
export interface DialogResult {
|
||||
/** The selected path. Null if cancelled or an error occurred. */
|
||||
path: string | null;
|
||||
/** True if the user cancelled the dialog. */
|
||||
cancelled: boolean;
|
||||
/** An error message if the operation failed. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a native OS folder selection dialog.
|
||||
*
|
||||
* @param initialDir - Optional directory to start the dialog in
|
||||
* @returns DialogResult with path, cancelled status, and optional error
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await selectFolder('/home/user/projects');
|
||||
* if (result.path) {
|
||||
* console.log('Selected:', result.path);
|
||||
* } else if (result.cancelled) {
|
||||
* console.log('User cancelled');
|
||||
* } else if (result.error) {
|
||||
* console.error('Error:', result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function selectFolder(initialDir?: string): Promise<DialogResult> {
|
||||
try {
|
||||
const res = await fetch('/api/dialog/select-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ initialDir }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
path: null,
|
||||
cancelled: false,
|
||||
error: `Server responded with status: ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.cancelled) return null;
|
||||
return data.path || null;
|
||||
} catch {
|
||||
return null;
|
||||
if (data.cancelled) {
|
||||
return { path: null, cancelled: true };
|
||||
}
|
||||
|
||||
return { path: data.path || null, cancelled: false };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
return { path: null, cancelled: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectFile(initialDir?: string): Promise<string | null> {
|
||||
/**
|
||||
* Opens a native OS file selection dialog.
|
||||
*
|
||||
* @param initialDir - Optional directory to start the dialog in
|
||||
* @returns DialogResult with path, cancelled status, and optional error
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await selectFile('/home/user/documents');
|
||||
* if (result.path) {
|
||||
* console.log('Selected:', result.path);
|
||||
* } else if (result.cancelled) {
|
||||
* console.log('User cancelled');
|
||||
* } else if (result.error) {
|
||||
* console.error('Error:', result.error);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function selectFile(initialDir?: string): Promise<DialogResult> {
|
||||
try {
|
||||
const res = await fetch('/api/dialog/select-file', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ initialDir }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
path: null,
|
||||
cancelled: false,
|
||||
error: `Server responded with status: ${res.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data.cancelled) return null;
|
||||
return data.path || null;
|
||||
} catch {
|
||||
return null;
|
||||
if (data.cancelled) {
|
||||
return { path: null, cancelled: true };
|
||||
}
|
||||
|
||||
return { path: data.path || null, cancelled: false };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'An unknown error occurred';
|
||||
return { path: null, cancelled: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,9 +113,9 @@ function FilePathInput({ value, onChange, placeholder }: FilePathInputProps) {
|
||||
const handleBrowse = async () => {
|
||||
const { selectFile } = await import('@/lib/nativeDialog');
|
||||
const initialDir = value ? value.replace(/[/\\][^/\\]*$/, '') : undefined;
|
||||
const selected = await selectFile(initialDir);
|
||||
if (selected) {
|
||||
onChange(selected);
|
||||
const result = await selectFile(initialDir);
|
||||
if (result.path && !result.cancelled && !result.error) {
|
||||
onChange(result.path);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
// Manages workflow sessions, tasks, and related data
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type {
|
||||
WorkflowStore,
|
||||
WorkflowState,
|
||||
@@ -16,6 +15,35 @@ import type {
|
||||
} from '../types/store';
|
||||
import { switchWorkspace as apiSwitchWorkspace, fetchRecentPaths, removeRecentPath as apiRemoveRecentPath } from '../lib/api';
|
||||
|
||||
// LocalStorage key for persisting projectPath
|
||||
const STORAGE_KEY = 'ccw-workflow-store';
|
||||
|
||||
// Helper to load persisted projectPath from localStorage
|
||||
const loadPersistedPath = (): string => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
return data?.state?.projectPath || '';
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Helper to persist projectPath to localStorage
|
||||
const persistPath = (projectPath: string): void => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
state: { projectPath },
|
||||
version: 1,
|
||||
}));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to generate session key from ID
|
||||
const sessionKey = (sessionId: string): string => {
|
||||
return `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
@@ -34,14 +62,14 @@ const defaultSorting: WorkflowSorting = {
|
||||
direction: 'desc',
|
||||
};
|
||||
|
||||
// Initial state
|
||||
// Initial state - load persisted projectPath from localStorage
|
||||
const initialState: WorkflowState = {
|
||||
// Core data
|
||||
workflowData: {
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
},
|
||||
projectPath: '',
|
||||
projectPath: loadPersistedPath(),
|
||||
recentPaths: [],
|
||||
serverPlatform: 'win32',
|
||||
|
||||
@@ -57,14 +85,11 @@ const initialState: WorkflowState = {
|
||||
filters: defaultFilters,
|
||||
sorting: defaultSorting,
|
||||
|
||||
// Hydration state (internal)
|
||||
_hasHydrated: false,
|
||||
};
|
||||
|
||||
export const useWorkflowStore = create<WorkflowStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Session Actions ==========
|
||||
@@ -396,6 +421,9 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
'switchWorkspace'
|
||||
);
|
||||
|
||||
// Persist projectPath to localStorage manually
|
||||
persistPath(response.projectPath);
|
||||
|
||||
// Trigger query invalidation callback
|
||||
const callback = get()._invalidateQueriesCallback;
|
||||
if (callback) {
|
||||
@@ -417,10 +445,6 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
set({ _invalidateQueriesCallback: callback }, false, 'registerQueryInvalidator');
|
||||
},
|
||||
|
||||
setHasHydrated: (state: boolean) => {
|
||||
set({ _hasHydrated: state }, false, 'setHasHydrated');
|
||||
},
|
||||
|
||||
// ========== Filters and Sorting ==========
|
||||
|
||||
setFilters: (filters: Partial<WorkflowFilters>) => {
|
||||
@@ -521,46 +545,15 @@ export const useWorkflowStore = create<WorkflowStore>()(
|
||||
}),
|
||||
{
|
||||
name: 'ccw-workflow-store',
|
||||
version: 1, // State version for migration support
|
||||
// Only persist projectPath - minimal state for workspace switching
|
||||
partialize: (state) => ({
|
||||
projectPath: state.projectPath,
|
||||
}),
|
||||
migrate: (persistedState, version) => {
|
||||
// Migration logic for future state shape changes
|
||||
if (version < 1) {
|
||||
// No migrations needed for initial version
|
||||
// Example: if (version === 0) { persistedState.newField = defaultValue; }
|
||||
}
|
||||
return persistedState as typeof persistedState;
|
||||
},
|
||||
onRehydrateStorage: () => {
|
||||
// Only log in development to avoid noise in production
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[WorkflowStore] Hydrating from localStorage...');
|
||||
}
|
||||
return (state, error) => {
|
||||
if (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[WorkflowStore] Rehydration error:', error);
|
||||
return;
|
||||
}
|
||||
// Mark hydration as complete
|
||||
useWorkflowStore.getState().setHasHydrated(true);
|
||||
if (state?.projectPath) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[WorkflowStore] Rehydrated with persisted projectPath:', state.projectPath);
|
||||
}
|
||||
// The initialization logic is now handled by AppShell.tsx
|
||||
// to correctly prioritize URL parameters over localStorage.
|
||||
}
|
||||
};
|
||||
},
|
||||
// Skip automatic hydration to avoid TDZ error during module initialization
|
||||
// Hydration will be triggered manually in AppShell after mount
|
||||
skipHydration: true,
|
||||
}
|
||||
),
|
||||
{ name: 'WorkflowStore' }
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
|
||||
@@ -335,9 +335,6 @@ export interface WorkflowState {
|
||||
|
||||
// Query invalidation callback (internal)
|
||||
_invalidateQueriesCallback?: () => void;
|
||||
|
||||
// Hydration state (internal)
|
||||
_hasHydrated: boolean;
|
||||
}
|
||||
|
||||
export interface WorkflowActions {
|
||||
@@ -372,7 +369,6 @@ export interface WorkflowActions {
|
||||
removeRecentPath: (path: string) => Promise<void>;
|
||||
refreshRecentPaths: () => Promise<void>;
|
||||
registerQueryInvalidator: (callback: () => void) => void;
|
||||
setHasHydrated: (state: boolean) => void;
|
||||
|
||||
// Filters and sorting
|
||||
setFilters: (filters: Partial<WorkflowFilters>) => void;
|
||||
|
||||
Reference in New Issue
Block a user