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:
catlog22
2026-03-07 00:00:18 +08:00
parent a9469a5e3b
commit 7ee9b579fa
18 changed files with 2739 additions and 155 deletions

View File

@@ -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);

View File

@@ -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();

View 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;

View File

@@ -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 };
}
}

View File

@@ -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);
}
};

View File

@@ -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

View File

@@ -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;