mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-11 17:07:40 +00:00
Refactor/shared and tasks components (#473)
* refactor: remove unused TasksSettings component
* refactor: migrate TodoList component to a new file with improved structure and normalization logic
* refactor: Move Tooltip and DarkModeToggle to shared/ui
* refactor: Move Tooltip and DarkModeToggle to shared/view/ui
* refactor: move GeminiLogo to llm-logo-provider and update imports
* refactor: remove unused GeminiStatus component
* refactor: move components in src/components/ui to src/shared/view/ui
* refactor: move ErrorBoundary component to main-content/view and update imports
* refactor: move VersionUpgradeModal to its own module
* refactor(wizard): rebuild project creation flow as modular TypeScript components
Replace the monolithic `ProjectCreationWizard.jsx` with a feature-based TS
implementation under `src/components/project-creation-wizard`, while preserving
existing behavior and improving readability, maintainability, and state isolation.
Why:
- The previous wizard mixed API logic, flow state, folder browsing, and UI in one file.
- Refactoring and testing were difficult due to tightly coupled concerns.
- We needed stronger type safety and localized component state.
What changed:
- Deleted:
- `src/components/ProjectCreationWizard.jsx`
- Added new modular structure:
- `src/components/project-creation-wizard/index.ts`
- `src/components/project-creation-wizard/ProjectCreationWizard.tsx`
- `src/components/project-creation-wizard/types.ts`
- `src/components/project-creation-wizard/data/workspaceApi.ts`
- `src/components/project-creation-wizard/hooks/useGithubTokens.ts`
- `src/components/project-creation-wizard/utils/pathUtils.ts`
- `src/components/project-creation-wizard/components/*`
- `WizardProgress`, `WizardFooter`, `ErrorBanner`
- `StepTypeSelection`, `StepConfiguration`, `StepReview`
- `WorkspacePathField`, `GithubAuthenticationCard`, `FolderBrowserModal`
- Updated import usage:
- `src/components/sidebar/view/subcomponents/SidebarModals.tsx`
now imports from `../../../project-creation-wizard`.
Implementation details:
- Migrated wizard logic to TypeScript using `type` aliases only.
- Kept component prop types colocated in each component file.
- Split responsibilities by feature:
- container/orchestration in `ProjectCreationWizard.tsx`
- API/SSE and request parsing in `data/workspaceApi.ts`
- GitHub token loading/caching behavior in `useGithubTokens`
- path/URL helpers in `utils/pathUtils.ts`
- Localized UI-only state to child components:
- folder browser modal state (current path, hidden folders, create-folder input)
- path suggestion dropdown state with debounced lookup
- Preserved existing UX flows:
- step navigation and validation
- existing/new workspace modes
- optional GitHub clone + auth modes
- clone progress via SSE
- folder browsing + folder creation
- Added focused comments for non-obvious logic (debounce, SSE auth constraint, path edge cases).
* refactor(quick-settings): migrate panel to typed feature-based modules
Refactor QuickSettingsPanel from a single JSX component into a modular TypeScript feature structure while preserving behavior and translations.
Highlights:
- Replace legacy src/components/QuickSettingsPanel.jsx with a typed entrypoint (src/components/QuickSettingsPanel.tsx).
- Introduce src/components/quick-settings-panel/ with clear separation of concerns:
- view/: panel shell, header, handle, section wrappers, toggle rows, and content sections.
- hooks/: drag interactions and whisper mode persistence.
- constants.ts and types.ts for shared config and strict local typing.
- Move drag logic into useQuickSettingsDrag with explicit touch/mouse handling, drag threshold detection, click suppression after drag, position clamping, and localStorage persistence.
- Keep user-visible behavior intact:
- same open/close panel interactions.
- same mobile/desktop drag behavior and persisted handle position.
- same quick preference toggles and wiring to useUiPreferences.
- same hidden whisper section behavior and localStorage/event updates.
- Improve readability and maintainability by extracting repetitive setting rows and section scaffolding into reusable components.
- Add focused comments around non-obvious behavior (drag click suppression, touch scroll lock, hidden whisper section intent).
- Keep files small and reviewable (all new/changed files are under 300 lines).
Validation:
- npm run typecheck
- npm run build
* refactor(quick-settings-panel): restructure QuickSettingsPanel import and create index file
* refactor(shared): move shared ui components to share/view/ui without subfolders
* refactor(LanguageSelector): move LanguageSelector to shared UI components
* refactor(prd-editor): modularize PRD editor with typed feature modules
Break the legacy PRDEditor.jsx monolith into a feature-based TypeScript architecture under src/components/prd-editor while keeping behavior parity and readability.
Key changes:
- Replace PRDEditor.jsx with a typed orchestrator component and a compatibility export bridge at src/components/PRDEditor.tsx.
- Split responsibilities into dedicated hooks: document loading/init, existing PRD registry fetching, save workflow with overwrite detection, and keyboard shortcuts.
- Split UI into focused view components: header, editor/preview body, footer stats, loading state, generate-tasks modal, and overwrite-confirm modal.
- Move filename concerns into utility helpers (sanitize, extension handling, default naming) and centralize template/constants.
- Keep component-local state close to the UI that owns it (workspace controls/modal toggles), while shared workflow state remains in the feature container.
- Reuse the existing MarkdownPreview component for safer markdown rendering instead of ad-hoc HTML conversion.
- Update TaskMasterPanel integration to consume typed PRDEditor directly (remove any-cast) and pass isExisting metadata for correct overwrite behavior.
- Keep all new/changed files below 300 lines and add targeted comments where behavior needs clarification.
Validation:
- npm run typecheck
- npm run build
* refactor(TaskMasterPanel): update PRDEditor import path to match new structure
* refactor(TaskMaster): Remove unused TaskMasterSetupWizard and TaskMasterStatus components
* refactor(TaskDetail): remove unused TaskIndicator import
* refactor(task-master): migrate tasks to a typed feature module
- introduce a new feature-oriented TaskMaster domain under src/components/task-master
- add typed TaskMaster context/provider with explicit project, task, MCP, and loading state handling
- split task UI into focused components (panel, board, toolbar, content, card, detail modal, setup/help modals, banner)
- move task board filtering/sorting/kanban derivation into dedicated hooks and utilities
- relocate CreateTaskModal into the feature module and keep task views modular/readable
- remove legacy monolithic TaskList/TaskDetail/TaskCard files and route main task panel to the new feature panel
- replace contexts/TaskMasterContext.jsx with a typed contexts/TaskMasterContext.ts re-export to the feature context
- update MainContent project sync logic to compare by project name to avoid state churn
- validation: npm run typecheck, npm run build
* refactor(MobileNav): remove unused React import and TaskMasterContext
* refactor(auth): migrate login and setup flows to typed feature module
- Introduce a new feature-based auth module under src/components/auth with clear separation of concerns:\n - context/AuthContext.tsx for session lifecycle, onboarding status checks, token persistence, and auth actions\n - view/* components for loading, route guarding, form layout, input fields, and error display\n - shared auth constants, utility helpers, and type aliases (no interfaces)\n- Convert login and setup UIs to TypeScript and keep form state local to each component for readability and component-level ownership\n- Add explicit API payload typing and safe JSON parsing helpers to improve resilience when backend responses are malformed or incomplete\n- Centralize error fallback handling for auth requests to reduce repeated logic
- Replace legacy auth entrypoints with the new feature module in app wiring:\n - App now imports AuthProvider and ProtectedRoute from src/components/auth\n - WebSocketContext, TaskMasterContext, and Onboarding now consume useAuth from the new typed auth context\n- Remove duplicated legacy auth screens (LoginForm.jsx, SetupForm.jsx, ProtectedRoute.jsx)\n- Keep backward compatibility by turning src/contexts/AuthContext.jsx into a thin re-export of the new provider/hook
Result: auth code now follows a feature/domain structure, is fully typed, easier to navigate, and cleaner to extend without touching unrelated UI areas.
* refactor(AppContent): update MobileNav import path and add MobileNav component
* refactor(DiffViewer): rename different diff viewers and place them in different components
* refactor(components): reorganize onboarding/provider auth/sidebar indicator into domain features
- Move onboarding out of root-level components into a dedicated feature module:
- add src/components/onboarding/view/Onboarding.tsx
- split onboarding UI into focused subcomponents:
- OnboardingStepProgress
- GitConfigurationStep
- AgentConnectionsStep
- AgentConnectionCard
- add onboarding-local types and utils for provider status and validation helpers
- Move multi-provider login modal into a dedicated provider-auth feature:
- add src/components/provider-auth/view/ProviderLoginModal.tsx
- add src/components/provider-auth/types.ts
- keep provider-specific command/title behavior and Gemini setup guidance
- preserve compatibility for both onboarding flow and settings login flow
- Move TaskIndicator into the sidebar domain:
- add src/components/sidebar/view/subcomponents/TaskIndicator.tsx
- update SidebarProjectItem to consume local sidebar TaskIndicator
- Update integration points to the new structure:
- ProtectedRoute now imports onboarding from onboarding feature
- Settings now imports ProviderLoginModal directly (remove legacy cast wrapper)
- git panel consumers now import shared GitDiffViewer by explicit name
- Rename git shared diff view to clearer domain naming:
- replace shared DiffViewer with shared GitDiffViewer
- update FileChangeItem and CommitHistoryItem imports accordingly
- Remove superseded root-level legacy components:
- delete src/components/LoginModal.jsx
- delete src/components/Onboarding.jsx
- delete src/components/TaskIndicator.jsx
- delete old src/components/git-panel/view/shared/DiffViewer.tsx
- Result:
- clearer feature boundaries (auth vs onboarding vs provider-auth vs sidebar)
- easier navigation and ownership by domain
- preserved runtime behavior with improved readability and modularity
* refactor(MainContent): remove TaskMasterPanel import and relocate to task-master component
* fix: update import paths for Input component in FileTree and FileTreeNode
* refactor(FileTree): make file tree context menu a typescript component and move it inside the file tree view
* refactor(FileTree): remove unused ScrollArea import
* feat: setup eslint with typescript and react rules, add unused imports plugin
* fix: remove unused imports, functions, and types after discovering using `npm run lint`
* feat: setup eslint-plugin-react, react-refresh, import-x, and tailwindcss plugins with recommended rules and configurations
* chore: reformat files after running `npm run lint:fix`
* chore: add omments about eslint config plugin uses
* feat: add husky and lint-staged for pre-commit linting
* feat: setup commitlint with conventional config
* fix: i18n translations
---------
Co-authored-by: Haileyesus <something@gmail.com>
Co-authored-by: viper151 <simosmik@gmail.com>
This commit is contained in:
273
src/components/task-master/context/TaskMasterContext.tsx
Normal file
273
src/components/task-master/context/TaskMasterContext.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import { useAuth } from '../../auth/context/AuthContext';
|
||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||
import type {
|
||||
TaskMasterContextError,
|
||||
TaskMasterContextValue,
|
||||
TaskMasterMcpStatus,
|
||||
TaskMasterProject,
|
||||
TaskMasterProjectInfo,
|
||||
TaskMasterProjectInput,
|
||||
TaskMasterTask,
|
||||
TaskMasterWebSocketMessage,
|
||||
} from '../types';
|
||||
|
||||
const TaskMasterContext = createContext<TaskMasterContextValue | null>(null);
|
||||
|
||||
function createTaskMasterError(context: string, error: unknown): TaskMasterContextError {
|
||||
const message = error instanceof Error ? error.message : `Failed to ${context}`;
|
||||
return {
|
||||
message,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function enrichProject(project: TaskMasterProject): TaskMasterProject {
|
||||
return {
|
||||
...project,
|
||||
taskMasterConfigured: project.taskmaster?.hasTaskmaster ?? false,
|
||||
taskMasterStatus: project.taskmaster?.status ?? 'not-configured',
|
||||
taskCount: Number(project.taskmaster?.metadata?.taskCount ?? 0),
|
||||
completedCount: Number(project.taskmaster?.metadata?.completed ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function getNextTask(tasks: TaskMasterTask[]): TaskMasterTask | null {
|
||||
return tasks.find((task) => task.status === 'pending' || task.status === 'in-progress') ?? null;
|
||||
}
|
||||
|
||||
function isTaskMasterMessage(
|
||||
message: TaskMasterWebSocketMessage | null,
|
||||
): message is TaskMasterWebSocketMessage & { type: string } {
|
||||
if (!message?.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return message.type.startsWith('taskmaster-');
|
||||
}
|
||||
|
||||
export function useTaskMaster() {
|
||||
const context = useContext(TaskMasterContext);
|
||||
if (!context) {
|
||||
throw new Error('useTaskMaster must be used within a TaskMasterProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function TaskMasterProvider({ children }: { children: React.ReactNode }) {
|
||||
const { latestMessage } = useWebSocket();
|
||||
const { user, token, isLoading: isAuthLoading } = useAuth();
|
||||
|
||||
const [projects, setProjects] = useState<TaskMasterProject[]>([]);
|
||||
const [currentProject, setCurrentProjectState] = useState<TaskMasterProject | null>(null);
|
||||
const [projectTaskMaster, setProjectTaskMaster] = useState<TaskMasterProjectInfo | null>(null);
|
||||
const [mcpServerStatus, setMcpServerStatus] = useState<TaskMasterMcpStatus>(null);
|
||||
|
||||
const [tasks, setTasks] = useState<TaskMasterTask[]>([]);
|
||||
const [nextTask, setNextTask] = useState<TaskMasterTask | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
|
||||
const [error, setError] = useState<TaskMasterContextError | null>(null);
|
||||
|
||||
const currentProjectNameRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
currentProjectNameRef.current = currentProject?.name ?? null;
|
||||
}, [currentProject?.name]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((context: string, caughtError: unknown) => {
|
||||
console.error(`TaskMaster ${context} error:`, caughtError);
|
||||
setError(createTaskMasterError(context, caughtError));
|
||||
}, []);
|
||||
|
||||
const setCurrentProject = useCallback((project: TaskMasterProjectInput) => {
|
||||
const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null;
|
||||
setCurrentProjectState(normalizedProject);
|
||||
setProjectTaskMaster(normalizedProject?.taskmaster ?? null);
|
||||
|
||||
// Project-scoped task data is reset immediately to avoid stale task rendering.
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
}, []);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
if (!user || !token) {
|
||||
setProjects([]);
|
||||
setCurrentProjectState(null);
|
||||
setProjectTaskMaster(null);
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get('/projects');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
const loadedProjects = Array.isArray(data) ? (data as TaskMasterProject[]) : [];
|
||||
const enrichedProjects = loadedProjects.map((project) => enrichProject(project));
|
||||
|
||||
setProjects(enrichedProjects);
|
||||
|
||||
const currentProjectName = currentProjectNameRef.current;
|
||||
if (!currentProjectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null;
|
||||
setCurrentProjectState(matchingProject);
|
||||
setProjectTaskMaster(matchingProject?.taskmaster ?? null);
|
||||
} catch (caughtError) {
|
||||
handleError('load projects', caughtError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [clearError, handleError, token, user]);
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
const projectName = currentProject?.name;
|
||||
|
||||
if (!projectName || !user || !token) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingTasks(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`);
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to load tasks');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tasks?: TaskMasterTask[] };
|
||||
const loadedTasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
|
||||
setTasks(loadedTasks);
|
||||
setNextTask(getNextTask(loadedTasks));
|
||||
} catch (caughtError) {
|
||||
handleError('load tasks', caughtError);
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
}, [clearError, currentProject?.name, handleError, token, user]);
|
||||
|
||||
const refreshMCPStatus = useCallback(async () => {
|
||||
if (!user || !token) {
|
||||
setMcpServerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMCP(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get('/mcp-utils/taskmaster-server');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load MCP status: ${response.status}`);
|
||||
}
|
||||
|
||||
const status = (await response.json()) as TaskMasterMcpStatus;
|
||||
setMcpServerStatus(status);
|
||||
} catch (caughtError) {
|
||||
handleError('check MCP server status', caughtError);
|
||||
setMcpServerStatus(null);
|
||||
} finally {
|
||||
setIsLoadingMCP(false);
|
||||
}
|
||||
}, [clearError, handleError, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && user && token) {
|
||||
void refreshProjects();
|
||||
void refreshMCPStatus();
|
||||
}
|
||||
}, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.name && user && token) {
|
||||
void refreshTasks();
|
||||
}
|
||||
}, [currentProject?.name, refreshTasks, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const message = latestMessage as TaskMasterWebSocketMessage | null;
|
||||
if (!isTaskMasterMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-project-updated' && message.projectName) {
|
||||
void refreshProjects();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) {
|
||||
void refreshTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-mcp-status-changed') {
|
||||
void refreshMCPStatus();
|
||||
}
|
||||
}, [currentProject?.name, latestMessage, refreshMCPStatus, refreshProjects, refreshTasks]);
|
||||
|
||||
const contextValue = useMemo<TaskMasterContextValue>(
|
||||
() => ({
|
||||
projects,
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
tasks,
|
||||
nextTask,
|
||||
isLoading,
|
||||
isLoadingTasks,
|
||||
isLoadingMCP,
|
||||
error,
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
refreshTasks,
|
||||
refreshMCPStatus,
|
||||
clearError,
|
||||
}),
|
||||
[
|
||||
clearError,
|
||||
currentProject,
|
||||
error,
|
||||
isLoading,
|
||||
isLoadingMCP,
|
||||
isLoadingTasks,
|
||||
mcpServerStatus,
|
||||
nextTask,
|
||||
projectTaskMaster,
|
||||
projects,
|
||||
refreshMCPStatus,
|
||||
refreshProjects,
|
||||
refreshTasks,
|
||||
setCurrentProject,
|
||||
tasks,
|
||||
],
|
||||
);
|
||||
|
||||
return <TaskMasterContext.Provider value={contextValue}>{children}</TaskMasterContext.Provider>;
|
||||
}
|
||||
|
||||
export default TaskMasterContext;
|
||||
64
src/components/task-master/hooks/useProjectPrdFiles.ts
Normal file
64
src/components/task-master/hooks/useProjectPrdFiles.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { PrdFile } from '../types';
|
||||
|
||||
type UseProjectPrdFilesOptions = {
|
||||
projectName?: string;
|
||||
};
|
||||
|
||||
type PrdResponse = {
|
||||
prdFiles?: PrdFile[];
|
||||
prds?: PrdFile[];
|
||||
};
|
||||
|
||||
function normalizePrdResponse(responseData: PrdResponse): PrdFile[] {
|
||||
if (Array.isArray(responseData.prdFiles)) {
|
||||
return responseData.prdFiles;
|
||||
}
|
||||
|
||||
if (Array.isArray(responseData.prds)) {
|
||||
return responseData.prds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {
|
||||
const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]);
|
||||
const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false);
|
||||
|
||||
const refreshPrdFiles = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
setPrdFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingPrdFiles(true);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setPrdFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PrdResponse;
|
||||
setPrdFiles(normalizePrdResponse(data));
|
||||
} catch (error) {
|
||||
console.error('Failed to load PRD files:', error);
|
||||
setPrdFiles([]);
|
||||
} finally {
|
||||
setIsLoadingPrdFiles(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPrdFiles();
|
||||
}, [refreshPrdFiles]);
|
||||
|
||||
return {
|
||||
prdFiles,
|
||||
isLoadingPrdFiles,
|
||||
refreshPrdFiles,
|
||||
};
|
||||
}
|
||||
97
src/components/task-master/hooks/useTaskBoardState.ts
Normal file
97
src/components/task-master/hooks/useTaskBoardState.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder, TaskBoardView, TaskKanbanColumn, TaskMasterTask } from '../types';
|
||||
import { buildKanbanColumns } from '../utils/taskKanban';
|
||||
import { sortTasks, toggleSortOrder } from '../utils/taskSorting';
|
||||
|
||||
type UseTaskBoardStateOptions = {
|
||||
tasks: TaskMasterTask[];
|
||||
defaultView?: TaskBoardView;
|
||||
};
|
||||
|
||||
function matchesSearch(task: TaskMasterTask, searchTerm: string): boolean {
|
||||
if (!searchTerm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedSearch = searchTerm.toLowerCase();
|
||||
const description = typeof task.description === 'string' ? task.description : '';
|
||||
|
||||
return (
|
||||
task.title.toLowerCase().includes(normalizedSearch)
|
||||
|| description.toLowerCase().includes(normalizedSearch)
|
||||
|| String(task.id).toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
export function useTaskBoardState({ tasks, defaultView = 'kanban' }: UseTaskBoardStateOptions) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [sortField, setSortField] = useState<TaskBoardSortField>('id');
|
||||
const [sortOrder, setSortOrder] = useState<TaskBoardSortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<TaskBoardView>(defaultView);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return [...new Set(tasks.map((task) => task.status).filter(Boolean))] as string[];
|
||||
}, [tasks]);
|
||||
|
||||
const priorities = useMemo(() => {
|
||||
return [...new Set(tasks.map((task) => task.priority).filter(Boolean))] as string[];
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
const filtered = tasks.filter((task) => {
|
||||
const status = task.status ?? 'pending';
|
||||
const priority = task.priority ?? 'medium';
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
const matchesPriority = priorityFilter === 'all' || priority === priorityFilter;
|
||||
|
||||
return matchesSearch(task, searchTerm) && matchesStatus && matchesPriority;
|
||||
});
|
||||
|
||||
return sortTasks(filtered, sortField, sortOrder);
|
||||
}, [tasks, searchTerm, statusFilter, priorityFilter, sortField, sortOrder]);
|
||||
|
||||
const kanbanColumns = useMemo<TaskKanbanColumn[]>(() => {
|
||||
return buildKanbanColumns(filteredTasks, t);
|
||||
}, [filteredTasks, t]);
|
||||
|
||||
const handleSortChange = (nextSortField: TaskBoardSortField) => {
|
||||
setSortOrder((currentOrder) => toggleSortOrder(sortField, currentOrder, nextSortField));
|
||||
setSortField(nextSortField);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('all');
|
||||
setPriorityFilter('all');
|
||||
};
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
priorityFilter,
|
||||
setPriorityFilter,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
statuses,
|
||||
priorities,
|
||||
filteredTasks,
|
||||
kanbanColumns,
|
||||
handleSortChange,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
4
src/components/task-master/index.ts
Normal file
4
src/components/task-master/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as TaskMasterPanel } from './view/TaskMasterPanel';
|
||||
export { default as NextTaskBanner } from './view/NextTaskBanner';
|
||||
|
||||
export { TaskMasterProvider, useTaskMaster } from './context/TaskMasterContext';
|
||||
128
src/components/task-master/types.ts
Normal file
128
src/components/task-master/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Project } from '../../types/app';
|
||||
|
||||
export type TaskId = string | number;
|
||||
|
||||
export type TaskStatus =
|
||||
| 'pending'
|
||||
| 'in-progress'
|
||||
| 'done'
|
||||
| 'review'
|
||||
| 'blocked'
|
||||
| 'deferred'
|
||||
| 'cancelled'
|
||||
| string;
|
||||
|
||||
export type TaskPriority = 'high' | 'medium' | 'low' | string;
|
||||
|
||||
export type TaskMasterTask = {
|
||||
id: TaskId;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
details?: string;
|
||||
testStrategy?: string;
|
||||
parentId?: TaskId;
|
||||
dependencies?: TaskId[];
|
||||
subtasks?: TaskMasterTask[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskReference = {
|
||||
id: TaskId;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskSelection = TaskMasterTask | TaskReference;
|
||||
|
||||
export type PrdFile = {
|
||||
name: string;
|
||||
content?: string;
|
||||
isExisting?: boolean;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskMasterProjectInfo = {
|
||||
hasTaskmaster?: boolean;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskMasterProject = Project & {
|
||||
taskMasterConfigured?: boolean;
|
||||
taskMasterStatus?: string;
|
||||
taskCount?: number;
|
||||
completedCount?: number;
|
||||
taskmaster?: TaskMasterProjectInfo;
|
||||
};
|
||||
|
||||
export type TaskMasterProjectInput = TaskMasterProject | Project | null;
|
||||
|
||||
export type TaskMasterContextError = {
|
||||
message: string;
|
||||
context: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type TaskMasterMcpStatus = {
|
||||
hasMCPServer?: boolean;
|
||||
isConfigured?: boolean;
|
||||
hasApiKeys?: boolean;
|
||||
scope?: string;
|
||||
config?: {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
envVars?: string[];
|
||||
type?: string;
|
||||
};
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export type TaskMasterWebSocketMessage = {
|
||||
type?: string;
|
||||
projectName?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskMasterContextValue = {
|
||||
projects: TaskMasterProject[];
|
||||
currentProject: TaskMasterProject | null;
|
||||
projectTaskMaster: TaskMasterProjectInfo | null;
|
||||
mcpServerStatus: TaskMasterMcpStatus;
|
||||
tasks: TaskMasterTask[];
|
||||
nextTask: TaskMasterTask | null;
|
||||
isLoading: boolean;
|
||||
isLoadingTasks: boolean;
|
||||
isLoadingMCP: boolean;
|
||||
error: TaskMasterContextError | null;
|
||||
refreshProjects: () => Promise<void>;
|
||||
setCurrentProject: (project: TaskMasterProjectInput) => void;
|
||||
refreshTasks: () => Promise<void>;
|
||||
refreshMCPStatus: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
};
|
||||
|
||||
export type TaskBoardView = 'kanban' | 'list' | 'grid';
|
||||
|
||||
export type TaskBoardSortField = 'id' | 'title' | 'status' | 'priority' | 'updated';
|
||||
|
||||
export type TaskBoardSortOrder = 'asc' | 'desc';
|
||||
|
||||
export type TaskKanbanColumn = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
color: string;
|
||||
headerColor: string;
|
||||
tasks: TaskMasterTask[];
|
||||
};
|
||||
72
src/components/task-master/utils/taskKanban.ts
Normal file
72
src/components/task-master/utils/taskKanban.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { TaskKanbanColumn, TaskMasterTask } from '../types';
|
||||
|
||||
const KANBAN_COLUMN_CONFIG = [
|
||||
{
|
||||
id: 'pending',
|
||||
titleKey: 'kanban.pending',
|
||||
status: 'pending',
|
||||
color: 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700',
|
||||
headerColor: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200',
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
titleKey: 'kanban.inProgress',
|
||||
status: 'in-progress',
|
||||
color: 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700',
|
||||
headerColor: 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200',
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
titleKey: 'kanban.done',
|
||||
status: 'done',
|
||||
color: 'bg-emerald-50 dark:bg-emerald-900/50 border-emerald-200 dark:border-emerald-700',
|
||||
headerColor: 'bg-emerald-100 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200',
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
titleKey: 'kanban.blocked',
|
||||
status: 'blocked',
|
||||
color: 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-700',
|
||||
headerColor: 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200',
|
||||
},
|
||||
{
|
||||
id: 'deferred',
|
||||
titleKey: 'kanban.deferred',
|
||||
status: 'deferred',
|
||||
color: 'bg-amber-50 dark:bg-amber-900/50 border-amber-200 dark:border-amber-700',
|
||||
headerColor: 'bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-200',
|
||||
},
|
||||
{
|
||||
id: 'cancelled',
|
||||
titleKey: 'kanban.cancelled',
|
||||
status: 'cancelled',
|
||||
color: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700',
|
||||
headerColor: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CORE_WORKFLOW_STATUSES = new Set(['pending', 'in-progress', 'done']);
|
||||
|
||||
export function buildKanbanColumns(tasks: TaskMasterTask[], t: TFunction<'tasks'>): TaskKanbanColumn[] {
|
||||
const tasksByStatus = tasks.reduce<Record<string, TaskMasterTask[]>>((accumulator, task) => {
|
||||
const status = task.status ?? 'pending';
|
||||
if (!accumulator[status]) {
|
||||
accumulator[status] = [];
|
||||
}
|
||||
accumulator[status].push(task);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return KANBAN_COLUMN_CONFIG.filter((column) => {
|
||||
const hasTasks = (tasksByStatus[column.status] ?? []).length > 0;
|
||||
return hasTasks || CORE_WORKFLOW_STATUSES.has(column.status);
|
||||
}).map((column) => ({
|
||||
id: column.id,
|
||||
title: t(column.titleKey),
|
||||
status: column.status,
|
||||
color: column.color,
|
||||
headerColor: column.headerColor,
|
||||
tasks: tasksByStatus[column.status] ?? [],
|
||||
}));
|
||||
}
|
||||
100
src/components/task-master/utils/taskSorting.ts
Normal file
100
src/components/task-master/utils/taskSorting.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder, TaskMasterTask } from '../types';
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
pending: 1,
|
||||
'in-progress': 2,
|
||||
review: 3,
|
||||
done: 4,
|
||||
blocked: 5,
|
||||
deferred: 6,
|
||||
cancelled: 7,
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER: Record<string, number> = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
};
|
||||
|
||||
function toComparableIdParts(taskId: string | number): number[] {
|
||||
return String(taskId)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.map((part) => (Number.isNaN(part) ? 0 : part));
|
||||
}
|
||||
|
||||
function compareTaskIds(leftId: string | number, rightId: string | number): number {
|
||||
const leftParts = toComparableIdParts(leftId);
|
||||
const rightParts = toComparableIdParts(rightId);
|
||||
const maxDepth = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxDepth; index += 1) {
|
||||
const left = leftParts[index] ?? 0;
|
||||
const right = rightParts[index] ?? 0;
|
||||
if (left !== right) {
|
||||
return left - right;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getSortValue(task: TaskMasterTask, field: TaskBoardSortField): number | string {
|
||||
if (field === 'title') {
|
||||
return task.title.toLowerCase();
|
||||
}
|
||||
|
||||
if (field === 'status') {
|
||||
return STATUS_ORDER[task.status ?? 'pending'] ?? 999;
|
||||
}
|
||||
|
||||
if (field === 'priority') {
|
||||
return PRIORITY_ORDER[task.priority ?? 'medium'] ?? 0;
|
||||
}
|
||||
|
||||
if (field === 'updated') {
|
||||
const timestamp = task.updatedAt ?? task.createdAt ?? '';
|
||||
return new Date(timestamp).getTime() || 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortTasks(
|
||||
tasks: TaskMasterTask[],
|
||||
field: TaskBoardSortField,
|
||||
order: TaskBoardSortOrder,
|
||||
): TaskMasterTask[] {
|
||||
const sortedTasks = [...tasks];
|
||||
|
||||
sortedTasks.sort((leftTask, rightTask) => {
|
||||
const direction = order === 'asc' ? 1 : -1;
|
||||
|
||||
if (field === 'id') {
|
||||
return compareTaskIds(leftTask.id, rightTask.id) * direction;
|
||||
}
|
||||
|
||||
const leftValue = getSortValue(leftTask, field);
|
||||
const rightValue = getSortValue(rightTask, field);
|
||||
|
||||
if (typeof leftValue === 'string' && typeof rightValue === 'string') {
|
||||
return leftValue.localeCompare(rightValue) * direction;
|
||||
}
|
||||
|
||||
return (Number(leftValue) - Number(rightValue)) * direction;
|
||||
});
|
||||
|
||||
return sortedTasks;
|
||||
}
|
||||
|
||||
export function toggleSortOrder(
|
||||
currentField: TaskBoardSortField,
|
||||
currentOrder: TaskBoardSortOrder,
|
||||
nextField: TaskBoardSortField,
|
||||
): TaskBoardSortOrder {
|
||||
if (currentField !== nextField) {
|
||||
return 'asc';
|
||||
}
|
||||
|
||||
return currentOrder === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
213
src/components/task-master/view/NextTaskBanner.tsx
Normal file
213
src/components/task-master/view/NextTaskBanner.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Eye,
|
||||
Flag,
|
||||
List,
|
||||
Play,
|
||||
Settings,
|
||||
Target,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import TaskDetailModal from './TaskDetailModal';
|
||||
import TaskMasterSetupModal from './modals/TaskMasterSetupModal';
|
||||
|
||||
type NextTaskBannerProps = {
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
onStartTask?: (() => void) | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function PriorityIndicator({ priority }: { priority?: string }) {
|
||||
if (priority === 'high') {
|
||||
return (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-red-100 dark:bg-red-900/50" title="High Priority">
|
||||
<Zap className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (priority === 'medium') {
|
||||
return (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-100 dark:bg-amber-900/50" title="Medium Priority">
|
||||
<Flag className="h-2.5 w-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-gray-100 dark:bg-gray-800" title="Low Priority">
|
||||
<Circle className="h-2.5 w-2.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NextTaskBanner({ onShowAllTasks = null, onStartTask = null, className = '' }: NextTaskBannerProps) {
|
||||
const {
|
||||
nextTask,
|
||||
tasks,
|
||||
currentProject,
|
||||
isLoadingTasks,
|
||||
projectTaskMaster,
|
||||
refreshTasks,
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
} = useTaskMaster();
|
||||
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||
const [showSetupDetails, setShowSetupDetails] = useState(false);
|
||||
|
||||
if (!currentProject || isLoadingTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasTasks = Array.isArray(tasks) && tasks.length > 0;
|
||||
const hasTaskMaster = Boolean(projectTaskMaster?.hasTaskmaster || currentProject.taskmaster?.hasTaskmaster);
|
||||
|
||||
const handleSetupRefresh = () => {
|
||||
void refreshProjects();
|
||||
setCurrentProject(currentProject);
|
||||
void refreshTasks();
|
||||
};
|
||||
|
||||
if (!hasTasks && !hasTaskMaster) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4', className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<List className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">TaskMaster AI is not configured</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
className="flex items-center gap-1 rounded bg-blue-600 px-2 py-1 text-xs text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Terminal className="h-3 w-3" />
|
||||
Initialize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSetupDetails((current) => !current)}
|
||||
className="mt-2 flex items-center gap-1 text-xs text-blue-700 hover:underline dark:text-blue-300"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
{showSetupDetails ? 'Hide details' : 'What is TaskMaster?'}
|
||||
</button>
|
||||
|
||||
{showSetupDetails && (
|
||||
<div className="mt-3 space-y-1 text-xs text-blue-900 dark:text-blue-100">
|
||||
<p>- AI-powered task management with dependencies and subtasks.</p>
|
||||
<p>- PRD-driven task generation for faster project bootstrapping.</p>
|
||||
<p>- Kanban and list views for day-to-day execution.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskMasterSetupModal
|
||||
isOpen={showSetupModal}
|
||||
project={currentProject}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
onAfterClose={handleSetupRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (nextTask) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4', className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50">
|
||||
<Target className="h-3 w-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400">Task {nextTask.id}</span>
|
||||
<PriorityIndicator priority={nextTask.priority} />
|
||||
</div>
|
||||
<p className="line-clamp-1 text-sm font-medium text-slate-900 dark:text-slate-100">{nextTask.title}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
onClick={() => onStartTask?.()}
|
||||
className="flex items-center gap-1 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<Play className="h-3 w-3" />
|
||||
Start Task
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowTaskDetail(true)}
|
||||
className="rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
title="View task details"
|
||||
>
|
||||
<Eye className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="rounded-md border border-slate-300 px-2 py-1.5 text-xs text-slate-600 hover:bg-slate-100 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
title="View all tasks"
|
||||
>
|
||||
<List className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDetailModal
|
||||
task={nextTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={() => setShowTaskDetail(false)}
|
||||
onStatusChange={() => {
|
||||
void refreshTasks();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTasks) {
|
||||
const completedTasks = tasks.filter((task) => task.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div className={cn('bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{completedTasks === tasks.length ? 'All tasks complete' : 'No pending tasks'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{completedTasks}/{tasks.length}
|
||||
</span>
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="rounded bg-purple-600 px-2 py-1 text-xs text-white transition-colors hover:bg-purple-700"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
199
src/components/task-master/view/TaskBoard.tsx
Normal file
199
src/components/task-master/view/TaskBoard.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { api } from '../../../utils/api';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import { useTaskBoardState } from '../hooks/useTaskBoardState';
|
||||
import type { PrdFile, TaskBoardView, TaskMasterProject, TaskMasterTask, TaskSelection } from '../types';
|
||||
import TaskBoardContent from './TaskBoardContent';
|
||||
import TaskBoardToolbar from './TaskBoardToolbar';
|
||||
import TaskEmptyState from './TaskEmptyState';
|
||||
import CreateTaskModal from './modals/CreateTaskModal';
|
||||
import TaskHelpModal from './modals/TaskHelpModal';
|
||||
import TaskMasterSetupModal from './modals/TaskMasterSetupModal';
|
||||
|
||||
type TaskBoardProps = {
|
||||
tasks?: TaskMasterTask[];
|
||||
onTaskClick?: ((task: TaskSelection) => void) | null;
|
||||
className?: string;
|
||||
showParentTasks?: boolean;
|
||||
defaultView?: TaskBoardView;
|
||||
currentProject?: TaskMasterProject | null;
|
||||
onTaskCreated?: (() => void) | null;
|
||||
onShowPRDEditor?: ((file?: PrdFile) => void) | null;
|
||||
existingPRDs?: PrdFile[];
|
||||
onRefreshPRDs?: ((showNotification?: boolean) => void) | null;
|
||||
};
|
||||
|
||||
export default function TaskBoard({
|
||||
tasks = [],
|
||||
onTaskClick = null,
|
||||
className = '',
|
||||
showParentTasks = false,
|
||||
defaultView = 'kanban',
|
||||
currentProject = null,
|
||||
onTaskCreated = null,
|
||||
onShowPRDEditor = null,
|
||||
existingPRDs = [],
|
||||
onRefreshPRDs = null,
|
||||
}: TaskBoardProps) {
|
||||
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showHelpModal, setShowHelpModal] = useState(false);
|
||||
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
priorityFilter,
|
||||
setPriorityFilter,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
statuses,
|
||||
priorities,
|
||||
filteredTasks,
|
||||
kanbanColumns,
|
||||
handleSortChange,
|
||||
clearFilters,
|
||||
} = useTaskBoardState({ tasks, defaultView });
|
||||
|
||||
const hasTaskMasterDirectory = Boolean(
|
||||
currentProject?.taskMasterConfigured
|
||||
|| currentProject?.taskmaster?.hasTaskmaster
|
||||
|| projectTaskMaster?.hasTaskmaster,
|
||||
);
|
||||
|
||||
const loadPrdAndOpenEditor = async (prd: PrdFile) => {
|
||||
if (!currentProject?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load PRD ${prd.name}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: data.content ?? '',
|
||||
isExisting: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to open PRD in editor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAfterSetup = () => {
|
||||
void refreshProjects();
|
||||
if (currentProject) {
|
||||
setCurrentProject(currentProject);
|
||||
}
|
||||
void refreshTasks();
|
||||
onRefreshPRDs?.(false);
|
||||
};
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<TaskEmptyState
|
||||
className={className}
|
||||
hasTaskMasterDirectory={hasTaskMasterDirectory}
|
||||
existingPrds={existingPRDs}
|
||||
onOpenSetupModal={() => setShowSetupModal(true)}
|
||||
onCreatePrd={() => onShowPRDEditor?.()}
|
||||
onOpenPrd={(prd) => {
|
||||
void loadPrdAndOpenEditor(prd);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TaskMasterSetupModal
|
||||
isOpen={showSetupModal}
|
||||
project={currentProject}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
onAfterClose={refreshAfterSetup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<TaskBoardToolbar
|
||||
hasProject={Boolean(currentProject)}
|
||||
hasTaskMasterConfigured={hasTaskMasterDirectory}
|
||||
totalTaskCount={tasks.length}
|
||||
filteredTaskCount={filteredTasks.length}
|
||||
searchTerm={searchTerm}
|
||||
onSearchTermChange={setSearchTerm}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={() => setShowFilters((current) => !current)}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
priorityFilter={priorityFilter}
|
||||
onPriorityFilterChange={setPriorityFilter}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onSortConfigChange={(field, order) => {
|
||||
setSortField(field);
|
||||
setSortOrder(order);
|
||||
}}
|
||||
statuses={statuses}
|
||||
priorities={priorities}
|
||||
onClearFilters={clearFilters}
|
||||
existingPrds={existingPRDs}
|
||||
onCreatePrd={() => onShowPRDEditor?.()}
|
||||
onOpenPrd={(prd) => {
|
||||
void loadPrdAndOpenEditor(prd);
|
||||
}}
|
||||
onOpenHelp={() => setShowHelpModal(true)}
|
||||
onOpenCreateTask={() => setShowCreateModal(true)}
|
||||
/>
|
||||
|
||||
<TaskBoardContent
|
||||
viewMode={viewMode}
|
||||
filteredTaskCount={filteredTasks.length}
|
||||
kanbanColumns={kanbanColumns}
|
||||
filteredTasks={filteredTasks}
|
||||
showParentTasks={showParentTasks}
|
||||
onTaskClick={(task) => onTaskClick?.(task)}
|
||||
/>
|
||||
|
||||
<CreateTaskModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
onTaskCreated?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<TaskHelpModal
|
||||
isOpen={showHelpModal}
|
||||
onClose={() => setShowHelpModal(false)}
|
||||
onCreatePrd={() => onShowPRDEditor?.()}
|
||||
/>
|
||||
|
||||
<TaskMasterSetupModal
|
||||
isOpen={showSetupModal}
|
||||
project={currentProject}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
onAfterClose={refreshAfterSetup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/task-master/view/TaskBoardContent.tsx
Normal file
124
src/components/task-master/view/TaskBoardContent.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { TaskBoardView, TaskKanbanColumn, TaskMasterTask, TaskSelection } from '../types';
|
||||
import TaskCard from './TaskCard';
|
||||
|
||||
type TaskBoardContentProps = {
|
||||
viewMode: TaskBoardView;
|
||||
filteredTaskCount: number;
|
||||
kanbanColumns: TaskKanbanColumn[];
|
||||
filteredTasks: TaskMasterTask[];
|
||||
showParentTasks: boolean;
|
||||
onTaskClick: (task: TaskSelection) => void;
|
||||
};
|
||||
|
||||
function KanbanColumns({
|
||||
columns,
|
||||
showParentTasks,
|
||||
onTaskClick,
|
||||
}: {
|
||||
columns: TaskKanbanColumn[];
|
||||
showParentTasks: boolean;
|
||||
onTaskClick: (task: TaskSelection) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-6',
|
||||
columns.length === 1 && 'grid-cols-1 max-w-md mx-auto',
|
||||
columns.length === 2 && 'grid-cols-1 md:grid-cols-2',
|
||||
columns.length === 3 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
columns.length === 4 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
columns.length === 5 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
||||
columns.length >= 6 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div key={column.id} className={cn('rounded-xl border shadow-sm transition-shadow hover:shadow-md', column.color)}>
|
||||
<div className={cn('px-4 py-3 rounded-t-xl border-b', column.headerColor)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">{column.title}</h3>
|
||||
<span className="rounded-full bg-white/60 px-2 py-1 text-xs font-medium dark:bg-black/20">
|
||||
{column.tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-300px)] min-h-[200px] space-y-3 overflow-y-auto p-3">
|
||||
{column.tasks.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-400 dark:text-gray-500">
|
||||
<div className="mx-auto mb-2 flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div className="h-3 w-3 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('kanban.noTasksYet')}</div>
|
||||
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{column.status === 'pending'
|
||||
? t('kanban.tasksWillAppear')
|
||||
: column.status === 'in-progress'
|
||||
? t('kanban.moveTasksHere')
|
||||
: column.status === 'done'
|
||||
? t('kanban.completedTasksHere')
|
||||
: t('kanban.statusTasksHere')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
column.tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={String(task.id)}
|
||||
task={task}
|
||||
onClick={() => onTaskClick(task)}
|
||||
showParent={showParentTasks}
|
||||
className="w-full shadow-sm hover:shadow-md"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskBoardContent({
|
||||
viewMode,
|
||||
filteredTaskCount,
|
||||
kanbanColumns,
|
||||
filteredTasks,
|
||||
showParentTasks,
|
||||
onTaskClick,
|
||||
}: TaskBoardContentProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (filteredTaskCount === 0) {
|
||||
return (
|
||||
<div className="py-12 text-center">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<Search className="mx-auto mb-4 h-12 w-12 opacity-50" />
|
||||
<h3 className="mb-2 text-lg font-medium">{t('noMatchingTasks.title')}</h3>
|
||||
<p className="text-sm">{t('noMatchingTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'kanban') {
|
||||
return <KanbanColumns columns={kanbanColumns} showParentTasks={showParentTasks} onTaskClick={onTaskClick} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('gap-4', viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3' : 'space-y-4')}>
|
||||
{filteredTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={String(task.id)}
|
||||
task={task}
|
||||
onClick={() => onTaskClick(task)}
|
||||
showParent={showParentTasks}
|
||||
className={viewMode === 'grid' ? 'h-full' : ''}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
src/components/task-master/view/TaskBoardToolbar.tsx
Normal file
266
src/components/task-master/view/TaskBoardToolbar.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Columns,
|
||||
FileText,
|
||||
Filter,
|
||||
Grid,
|
||||
HelpCircle,
|
||||
List,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PrdFile, TaskBoardSortField, TaskBoardSortOrder, TaskBoardView } from '../types';
|
||||
import TaskFiltersPanel from './shared/TaskFiltersPanel';
|
||||
import TaskQuickSortBar from './shared/TaskQuickSortBar';
|
||||
|
||||
type TaskBoardToolbarProps = {
|
||||
hasProject: boolean;
|
||||
hasTaskMasterConfigured: boolean;
|
||||
totalTaskCount: number;
|
||||
filteredTaskCount: number;
|
||||
searchTerm: string;
|
||||
onSearchTermChange: (value: string) => void;
|
||||
viewMode: TaskBoardView;
|
||||
onViewModeChange: (viewMode: TaskBoardView) => void;
|
||||
showFilters: boolean;
|
||||
onToggleFilters: () => void;
|
||||
statusFilter: string;
|
||||
onStatusFilterChange: (status: string) => void;
|
||||
priorityFilter: string;
|
||||
onPriorityFilterChange: (priority: string) => void;
|
||||
sortField: TaskBoardSortField;
|
||||
sortOrder: TaskBoardSortOrder;
|
||||
onSortChange: (field: TaskBoardSortField) => void;
|
||||
onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
onClearFilters: () => void;
|
||||
existingPrds: PrdFile[];
|
||||
onCreatePrd: () => void;
|
||||
onOpenPrd: (prd: PrdFile) => void;
|
||||
onOpenHelp: () => void;
|
||||
onOpenCreateTask: () => void;
|
||||
};
|
||||
|
||||
export default function TaskBoardToolbar({
|
||||
hasProject,
|
||||
hasTaskMasterConfigured,
|
||||
totalTaskCount,
|
||||
filteredTaskCount,
|
||||
searchTerm,
|
||||
onSearchTermChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
priorityFilter,
|
||||
onPriorityFilterChange,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onSortConfigChange,
|
||||
statuses,
|
||||
priorities,
|
||||
onClearFilters,
|
||||
existingPrds,
|
||||
onCreatePrd,
|
||||
onOpenPrd,
|
||||
onOpenHelp,
|
||||
onOpenCreateTask,
|
||||
}: TaskBoardToolbarProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
const [isPrdDropdownOpen, setIsPrdDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsPrdDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="relative max-w-md flex-1">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchTermChange(event.target.value)}
|
||||
placeholder={t('search.placeholder')}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
|
||||
<button
|
||||
onClick={() => onViewModeChange('kanban')}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
viewMode === 'kanban'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
|
||||
)}
|
||||
title={t('views.kanban')}
|
||||
>
|
||||
<Columns className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
|
||||
)}
|
||||
title={t('views.list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
viewMode === 'grid'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
|
||||
)}
|
||||
title={t('views.grid')}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggleFilters}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors',
|
||||
showFilters
|
||||
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('filters.button')}</span>
|
||||
<ChevronDown className={cn('w-4 h-4 transition-transform', showFilters && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{hasProject && (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenHelp}
|
||||
className="rounded-lg border border-gray-300 p-2 text-gray-600 hover:bg-gray-100 hover:text-blue-600 dark:border-gray-600 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-blue-400"
|
||||
title={t('buttons.help')}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{existingPrds.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsPrdDropdownOpen((current) => !current)}
|
||||
className="flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-2 font-medium text-white hover:bg-purple-700"
|
||||
title={t('buttons.prdsAvailable', { count: existingPrds.length })}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('buttons.prds')}</span>
|
||||
<span className="min-w-5 rounded-full bg-purple-500 px-1.5 py-0.5 text-center text-xs">
|
||||
{existingPrds.length}
|
||||
</span>
|
||||
<ChevronDown className={cn('w-3 h-3 transition-transform hidden sm:block', isPrdDropdownOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isPrdDropdownOpen && (
|
||||
<div className="absolute right-0 top-full z-30 mt-2 w-56 rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCreatePrd();
|
||||
setIsPrdDropdownOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm font-medium text-purple-700 hover:bg-purple-50 dark:text-purple-300 dark:hover:bg-purple-900/30"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('buttons.createNewPRD')}
|
||||
</button>
|
||||
|
||||
<div className="my-1 border-t border-gray-200 dark:border-gray-700" />
|
||||
|
||||
{existingPrds.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={() => {
|
||||
onOpenPrd(prd);
|
||||
setIsPrdDropdownOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="truncate">{prd.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCreatePrd}
|
||||
className="flex items-center gap-2 rounded-lg bg-purple-600 px-3 py-2 font-medium text-white hover:bg-purple-700"
|
||||
title={t('buttons.addPRD')}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('buttons.addPRD')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(hasTaskMasterConfigured || totalTaskCount > 0) && (
|
||||
<button
|
||||
onClick={onOpenCreateTask}
|
||||
className="flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 font-medium text-white hover:bg-blue-700"
|
||||
title={t('buttons.addTask')}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{t('buttons.addTask')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskFiltersPanel
|
||||
showFilters={showFilters}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
priorityFilter={priorityFilter}
|
||||
onPriorityFilterChange={onPriorityFilterChange}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortConfigChange={onSortConfigChange}
|
||||
statuses={statuses}
|
||||
priorities={priorities}
|
||||
filteredTaskCount={filteredTaskCount}
|
||||
totalTaskCount={totalTaskCount}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
<TaskQuickSortBar sortField={sortField} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
210
src/components/task-master/view/TaskCard.tsx
Normal file
210
src/components/task-master/view/TaskCard.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
Clock,
|
||||
Minus,
|
||||
Pause,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Tooltip } from '../../../shared/view/ui';
|
||||
import type { TaskMasterTask } from '../types';
|
||||
|
||||
type TaskCardProps = {
|
||||
task: TaskMasterTask;
|
||||
onClick?: (() => void) | null;
|
||||
showParent?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type TaskStatusStyle = {
|
||||
icon: typeof Circle;
|
||||
statusText: string;
|
||||
iconColor: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
function getStatusStyle(status?: string): TaskStatusStyle {
|
||||
if (status === 'done') {
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
statusText: 'Done',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
textColor: 'text-green-900 dark:text-green-100',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'in-progress') {
|
||||
return {
|
||||
icon: Clock,
|
||||
statusText: 'In Progress',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
textColor: 'text-blue-900 dark:text-blue-100',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'review') {
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
statusText: 'Review',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
textColor: 'text-amber-900 dark:text-amber-100',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'deferred') {
|
||||
return {
|
||||
icon: Pause,
|
||||
statusText: 'Deferred',
|
||||
iconColor: 'text-gray-500 dark:text-gray-400',
|
||||
textColor: 'text-gray-700 dark:text-gray-300',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'cancelled') {
|
||||
return {
|
||||
icon: X,
|
||||
statusText: 'Cancelled',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
textColor: 'text-red-900 dark:text-red-100',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: Circle,
|
||||
statusText: 'Pending',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
textColor: 'text-slate-900 dark:text-slate-100',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPriorityIcon(priority?: string) {
|
||||
if (priority === 'high') {
|
||||
return (
|
||||
<Tooltip content="High priority">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-red-100 dark:bg-red-900/30">
|
||||
<ChevronUp className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (priority === 'medium') {
|
||||
return (
|
||||
<Tooltip content="Medium priority">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-amber-100 dark:bg-amber-900/30">
|
||||
<Minus className="h-2.5 w-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (priority === 'low') {
|
||||
return (
|
||||
<Tooltip content="Low priority">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-blue-100 dark:bg-blue-900/30">
|
||||
<Circle className="h-1.5 w-1.5 fill-current text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="No priority set">
|
||||
<div className="flex h-4 w-4 items-center justify-center rounded bg-gray-100 dark:bg-gray-800">
|
||||
<Circle className="h-1.5 w-1.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function getSubtaskProgress(task: TaskMasterTask): { completed: number; total: number; percentage: number } {
|
||||
const subtasks = task.subtasks ?? [];
|
||||
const total = subtasks.length;
|
||||
const completed = subtasks.filter((subtask) => subtask.status === 'done').length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return { completed, total, percentage };
|
||||
}
|
||||
|
||||
function TaskCard({ task, onClick = null, showParent = false, className = '' }: TaskCardProps) {
|
||||
const statusStyle = getStatusStyle(task.status);
|
||||
const progress = getSubtaskProgress(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-3',
|
||||
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200',
|
||||
onClick ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick ?? undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Tooltip content={`Task ID: ${task.id}`}>
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 font-mono text-xs text-gray-500 dark:bg-gray-700 dark:text-gray-400">
|
||||
{task.id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<h3 className="line-clamp-2 text-sm font-medium leading-tight text-gray-900 dark:text-white">
|
||||
{task.title}
|
||||
</h3>
|
||||
|
||||
{showParent && task.parentId && (
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Task {task.parentId}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">{renderPriorityIcon(task.priority)}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
|
||||
<Tooltip content={`Depends on: ${task.dependencies.map((dependency) => `Task ${dependency}`).join(', ')}`}>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
<span>Depends on: {task.dependencies.join(', ')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip content={`Status: ${statusStyle.statusText}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-full', statusStyle.iconColor.replace('text-', 'bg-'))} />
|
||||
<span className={cn('text-xs font-medium', statusStyle.textColor)}>{statusStyle.statusText}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{progress.total > 0 && (
|
||||
<div className="ml-3">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
|
||||
<div className="h-1.5 flex-1 rounded-full bg-gray-200 dark:bg-gray-700" title={`${progress.completed} of ${progress.total} subtasks completed`}>
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-300', task.status === 'done' ? 'bg-green-500' : 'bg-blue-500')}
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TaskCard);
|
||||
318
src/components/task-master/view/TaskDetailModal.tsx
Normal file
318
src/components/task-master/view/TaskDetailModal.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Clock,
|
||||
Copy,
|
||||
Edit,
|
||||
Pause,
|
||||
Save,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import { api } from '../../../utils/api';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import type { TaskId, TaskMasterTask, TaskReference } from '../types';
|
||||
|
||||
type TaskDetailModalProps = {
|
||||
task: TaskMasterTask | null;
|
||||
isOpen?: boolean;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onEdit?: ((task: TaskMasterTask) => void) | null;
|
||||
onStatusChange?: ((taskId: TaskId, status: string) => void) | null;
|
||||
onTaskClick?: ((task: TaskReference) => void) | null;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in-progress', label: 'In Progress' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'deferred', label: 'Deferred' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
function getStatusIcon(status?: string) {
|
||||
if (status === 'done') return CheckCircle;
|
||||
if (status === 'in-progress') return Clock;
|
||||
if (status === 'review') return AlertCircle;
|
||||
if (status === 'deferred') return Pause;
|
||||
if (status === 'cancelled') return X;
|
||||
return Circle;
|
||||
}
|
||||
|
||||
function getPriorityBadgeClass(priority?: string): string {
|
||||
if (priority === 'high') return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
|
||||
if (priority === 'medium') return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
|
||||
if (priority === 'low') return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
|
||||
return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
|
||||
}
|
||||
|
||||
export default function TaskDetailModal({
|
||||
task,
|
||||
isOpen = true,
|
||||
className = '',
|
||||
onClose,
|
||||
onEdit = null,
|
||||
onStatusChange = null,
|
||||
onTaskClick = null,
|
||||
}: TaskDetailModalProps) {
|
||||
const { currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTestStrategy, setShowTestStrategy] = useState(false);
|
||||
const [editableTask, setEditableTask] = useState<TaskMasterTask | null>(task);
|
||||
|
||||
useEffect(() => {
|
||||
setEditableTask(task);
|
||||
setIsEditMode(false);
|
||||
}, [task]);
|
||||
|
||||
const StatusIcon = useMemo(() => getStatusIcon(task?.status), [task?.status]);
|
||||
|
||||
if (!isOpen || !task || !editableTask) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!currentProject?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
if (editableTask.title !== task.title) {
|
||||
updates.title = editableTask.title;
|
||||
}
|
||||
|
||||
if (editableTask.description !== task.description) {
|
||||
updates.description = editableTask.description ?? '';
|
||||
}
|
||||
|
||||
if (editableTask.details !== task.details) {
|
||||
updates.details = editableTask.details ?? '';
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setIsEditMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to update task');
|
||||
}
|
||||
|
||||
setIsEditMode(false);
|
||||
await refreshTasks();
|
||||
onEdit?.(editableTask);
|
||||
} catch (error) {
|
||||
console.error('Failed to save task changes:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to update task');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusSelect = async (nextStatus: string) => {
|
||||
if (!currentProject?.name || nextStatus === task.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: nextStatus });
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to update task status');
|
||||
}
|
||||
|
||||
await refreshTasks();
|
||||
onStatusChange?.(task.id, nextStatus);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to update task status');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 md:p-4">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl flex flex-col',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700 md:p-6">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<StatusIcon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => copyTextToClipboard(String(task.id))}
|
||||
className="mb-2 inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
title="Copy task ID"
|
||||
>
|
||||
<span>Task {task.id}</span>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
{isEditMode ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editableTask.title}
|
||||
onChange={(event) => setEditableTask({ ...editableTask, title: event.target.value })}
|
||||
className="w-full border-b-2 border-blue-500 bg-transparent text-lg font-semibold text-gray-900 focus:outline-none dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="line-clamp-2 text-lg font-semibold text-gray-900 dark:text-white md:text-xl">{task.title}</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={isSaving}
|
||||
className="rounded-md p-2 text-green-600 hover:bg-green-50 disabled:opacity-50 dark:hover:bg-green-950"
|
||||
title="Save"
|
||||
>
|
||||
<Save className={cn('w-5 h-5', isSaving && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditableTask(task);
|
||||
setIsEditMode(false);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditMode(true)}
|
||||
className="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title="Edit task"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="rounded-md p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800" title="Close">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto p-4 md:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<select
|
||||
value={task.status ?? 'pending'}
|
||||
onChange={(event) => {
|
||||
void handleStatusSelect(event.target.value);
|
||||
}}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<div className={cn('px-3 py-2 rounded-md text-sm font-medium capitalize', getPriorityBadgeClass(task.priority))}>
|
||||
{task.priority ?? 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
|
||||
{Array.isArray(task.dependencies) && task.dependencies.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.dependencies.map((dependency) => (
|
||||
<button
|
||||
key={String(dependency)}
|
||||
onClick={() => onTaskClick?.({ id: dependency })}
|
||||
className="rounded bg-blue-100 px-2 py-1 text-sm text-blue-700 hover:bg-blue-200 dark:bg-blue-900 dark:text-blue-300 dark:hover:bg-blue-800"
|
||||
>
|
||||
<ArrowRight className="mr-1 inline h-3 w-3" />
|
||||
{dependency}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">No dependencies</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
{isEditMode ? (
|
||||
<textarea
|
||||
rows={4}
|
||||
value={editableTask.description ?? ''}
|
||||
onChange={(event) => setEditableTask({ ...editableTask, description: event.target.value })}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
|
||||
/>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">{task.description || 'No description provided'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.details && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowDetails((current) => !current)}
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Implementation Details</span>
|
||||
{showDetails ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
|
||||
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">{task.details}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.testStrategy && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowTestStrategy((current) => !current)}
|
||||
className="flex w-full items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Test Strategy</span>
|
||||
{showTestStrategy ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
{showTestStrategy && (
|
||||
<div className="border-t border-gray-200 bg-blue-50 p-4 dark:border-gray-700 dark:bg-blue-950/30">
|
||||
<p className="whitespace-pre-wrap text-gray-700 dark:text-gray-300">{task.testStrategy}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/task-master/view/TaskEmptyState.tsx
Normal file
134
src/components/task-master/view/TaskEmptyState.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { FileText, Settings, Terminal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PrdFile } from '../types';
|
||||
|
||||
type TaskEmptyStateProps = {
|
||||
className?: string;
|
||||
hasTaskMasterDirectory: boolean;
|
||||
existingPrds: PrdFile[];
|
||||
onOpenSetupModal: () => void;
|
||||
onCreatePrd: () => void;
|
||||
onOpenPrd: (prd: PrdFile) => void;
|
||||
};
|
||||
|
||||
export default function TaskEmptyState({
|
||||
className = '',
|
||||
hasTaskMasterDirectory,
|
||||
existingPrds,
|
||||
onOpenSetupModal,
|
||||
onCreatePrd,
|
||||
onOpenPrd,
|
||||
}: TaskEmptyStateProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (!hasTaskMasterDirectory) {
|
||||
return (
|
||||
<div className={cn('text-center py-12', className)}>
|
||||
<div className="mx-auto max-w-md">
|
||||
<div className="mb-4 text-blue-600 dark:text-blue-400">
|
||||
<Settings className="mx-auto mb-4 h-12 w-12" />
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{t('notConfigured.title')}</h3>
|
||||
<p className="mb-6 text-sm text-gray-600 dark:text-gray-400">{t('notConfigured.description')}</p>
|
||||
|
||||
<div className="mb-6 rounded-lg bg-blue-50 p-4 text-left dark:bg-blue-950">
|
||||
<h4 className="mb-3 text-sm font-medium text-blue-900 dark:text-blue-100">{t('notConfigured.whatIsTitle')}</h4>
|
||||
<div className="space-y-1 text-xs text-blue-800 dark:text-blue-200">
|
||||
<p>- {t('notConfigured.features.aiPowered')}</p>
|
||||
<p>- {t('notConfigured.features.prdTemplates')}</p>
|
||||
<p>- {t('notConfigured.features.dependencyTracking')}</p>
|
||||
<p>- {t('notConfigured.features.progressVisualization')}</p>
|
||||
<p>- {t('notConfigured.features.cliIntegration')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenSetupModal}
|
||||
className="mx-auto flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Terminal className="h-4 w-4" />
|
||||
{t('notConfigured.initializeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('text-center py-12', className)}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-6 rounded-xl border border-blue-200 bg-gradient-to-r from-blue-50 to-indigo-50 p-6 text-left dark:border-blue-800 dark:from-blue-950/50 dark:to-indigo-950/50">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('gettingStarted.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
|
||||
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">1. {t('gettingStarted.steps.createPRD.title')}</h4>
|
||||
<p className="mb-3 text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.createPRD.description')}</p>
|
||||
|
||||
<button
|
||||
onClick={onCreatePrd}
|
||||
className="inline-flex items-center gap-2 rounded bg-purple-100 px-2 py-1 text-xs text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
{t('gettingStarted.steps.createPRD.addButton')}
|
||||
</button>
|
||||
|
||||
{existingPrds.length > 0 && (
|
||||
<div className="mt-3 border-t border-gray-200 pt-3 dark:border-gray-700">
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">{t('gettingStarted.steps.createPRD.existingPRDs')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{existingPrds.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={() => onOpenPrd(prd)}
|
||||
className="inline-flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
<FileText className="h-3 w-3" />
|
||||
{prd.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
|
||||
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">2. {t('gettingStarted.steps.generateTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.generateTasks.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
|
||||
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">3. {t('gettingStarted.steps.analyzeTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.analyzeTasks.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-100 bg-white p-3 dark:border-blue-800/50 dark:bg-gray-800/60">
|
||||
<h4 className="mb-1 font-medium text-gray-900 dark:text-white">4. {t('gettingStarted.steps.startBuilding.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.startBuilding.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCreatePrd}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-purple-600 px-4 py-2 font-medium text-white hover:bg-purple-700"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('gettingStarted.tip')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/task-master/view/TaskMasterPanel.tsx
Normal file
150
src/components/task-master/view/TaskMasterPanel.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import PRDEditor from '../../prd-editor';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import { useProjectPrdFiles } from '../hooks/useProjectPrdFiles';
|
||||
import type { PrdFile, TaskMasterTask, TaskSelection } from '../types';
|
||||
import TaskBoard from './TaskBoard';
|
||||
import TaskDetailModal from './TaskDetailModal';
|
||||
|
||||
type TaskMasterPanelProps = {
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
const PRD_SAVE_MESSAGE = 'PRD saved successfully!';
|
||||
|
||||
export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
|
||||
const { tasks, currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
const [selectedTask, setSelectedTask] = useState<TaskMasterTask | null>(null);
|
||||
const [isTaskDetailOpen, setIsTaskDetailOpen] = useState(false);
|
||||
|
||||
const [isPrdEditorOpen, setIsPrdEditorOpen] = useState(false);
|
||||
const [selectedPrd, setSelectedPrd] = useState<PrdFile | null>(null);
|
||||
|
||||
const [prdNotification, setPrdNotification] = useState<string | null>(null);
|
||||
const notificationTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectName: currentProject?.name });
|
||||
|
||||
const showPrdNotification = useCallback((message: string) => {
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current);
|
||||
}
|
||||
|
||||
setPrdNotification(message);
|
||||
|
||||
notificationTimeoutRef.current = window.setTimeout(() => {
|
||||
setPrdNotification(null);
|
||||
notificationTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const refreshPrdData = useCallback(
|
||||
async (showNotification = false) => {
|
||||
await refreshPrdFiles();
|
||||
if (showNotification) {
|
||||
showPrdNotification(PRD_SAVE_MESSAGE);
|
||||
}
|
||||
},
|
||||
[refreshPrdFiles, showPrdNotification],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(taskSelection: TaskSelection) => {
|
||||
const selectedId = String(taskSelection.id);
|
||||
|
||||
if (!taskSelection.title) {
|
||||
const fullTask = tasks.find((task) => String(task.id) === selectedId) ?? null;
|
||||
if (fullTask) {
|
||||
setSelectedTask(fullTask);
|
||||
setIsTaskDetailOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTask(taskSelection as TaskMasterTask);
|
||||
setIsTaskDetailOpen(true);
|
||||
},
|
||||
[tasks],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`h-full ${isVisible ? 'block' : 'hidden'}`}>
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<TaskBoard
|
||||
tasks={tasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
showParentTasks
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
currentProject={currentProject}
|
||||
onTaskCreated={refreshTasks}
|
||||
onShowPRDEditor={(prd) => {
|
||||
setSelectedPrd(prd ?? null);
|
||||
setIsPrdEditorOpen(true);
|
||||
}}
|
||||
existingPRDs={prdFiles}
|
||||
onRefreshPRDs={(showNotification = false) => {
|
||||
void refreshPrdData(showNotification);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
isOpen={isTaskDetailOpen}
|
||||
onClose={() => {
|
||||
setIsTaskDetailOpen(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
onStatusChange={() => {
|
||||
void refreshTasks();
|
||||
}}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
|
||||
{isPrdEditorOpen && (
|
||||
<PRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={() => {
|
||||
setIsPrdEditorOpen(false);
|
||||
setSelectedPrd(null);
|
||||
}}
|
||||
isNewFile={!selectedPrd?.isExisting}
|
||||
file={{
|
||||
name: selectedPrd?.name || 'prd.txt',
|
||||
content: selectedPrd?.content || '',
|
||||
isExisting: selectedPrd?.isExisting,
|
||||
}}
|
||||
onSave={async () => {
|
||||
setIsPrdEditorOpen(false);
|
||||
setSelectedPrd(null);
|
||||
await refreshPrdData(true);
|
||||
await refreshTasks();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prdNotification && (
|
||||
<div className="animate-in slide-in-from-bottom-2 fixed bottom-4 right-4 z-50 duration-300">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-green-600 px-4 py-3 text-white shadow-lg">
|
||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium">{prdNotification}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
src/components/task-master/view/modals/CreateTaskModal.tsx
Normal file
73
src/components/task-master/view/modals/CreateTaskModal.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
|
||||
type CreateTaskModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
||||
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create AI-Generated Task</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-900/20">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
||||
<Sparkles className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-semibold text-blue-900 dark:text-blue-100">Pro tip: ask Claude Code directly</h4>
|
||||
<p className="mb-3 text-sm text-blue-800 dark:text-blue-200">
|
||||
Ask for a task in chat with context and requirements. TaskMaster can generate implementation-ready tasks.
|
||||
</p>
|
||||
<div className="rounded border border-blue-200 bg-white p-3 dark:border-blue-700 dark:bg-gray-800">
|
||||
<p className="mb-1 text-xs font-medium text-gray-600 dark:text-gray-400">Example:</p>
|
||||
<p className="font-mono text-sm text-gray-900 dark:text-white">
|
||||
Please add a task for profile image uploads and include best-practice research.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 text-center dark:border-gray-700">
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm font-medium text-blue-600 underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
||||
>
|
||||
View TaskMaster documentation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/components/task-master/view/modals/TaskHelpModal.tsx
Normal file
129
src/components/task-master/view/modals/TaskHelpModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ExternalLink, FileText, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type TaskHelpModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreatePrd: () => void;
|
||||
};
|
||||
|
||||
type HelpStep = {
|
||||
index: number;
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
export default function TaskHelpModal({ isOpen, onClose, onCreatePrd }: TaskHelpModalProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps: HelpStep[] = [
|
||||
{
|
||||
index: 1,
|
||||
title: t('gettingStarted.steps.createPRD.title'),
|
||||
description: t('gettingStarted.steps.createPRD.description'),
|
||||
accent: 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: t('gettingStarted.steps.generateTasks.title'),
|
||||
description: t('gettingStarted.steps.generateTasks.description'),
|
||||
accent: 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40',
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
title: t('gettingStarted.steps.analyzeTasks.title'),
|
||||
description: t('gettingStarted.steps.analyzeTasks.description'),
|
||||
accent: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40',
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
title: t('gettingStarted.steps.startBuilding.title'),
|
||||
description: t('gettingStarted.steps.startBuilding.description'),
|
||||
accent: 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-950/40',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm">
|
||||
<div className="max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-6 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
||||
<FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('helpGuide.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('helpGuide.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
title="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(90vh-120px)] space-y-4 overflow-y-auto p-6">
|
||||
{steps.map((step) => (
|
||||
<div key={step.index} className={`rounded-lg border p-4 ${step.accent}`}>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-sm font-semibold text-white">
|
||||
{step.index}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="mb-2 font-medium text-gray-900 dark:text-white">{step.title}</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{step.description}</p>
|
||||
|
||||
{step.index === 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onCreatePrd();
|
||||
onClose();
|
||||
}}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-lg bg-purple-100 px-3 py-1.5 text-sm text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-300 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<h4 className="mb-2 font-medium text-gray-900 dark:text-white">{t('helpGuide.proTips.title')}</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li>{t('helpGuide.proTips.search')}</li>
|
||||
<li>{t('helpGuide.proTips.views')}</li>
|
||||
<li>{t('helpGuide.proTips.filters')}</li>
|
||||
<li>{t('helpGuide.proTips.details')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/40">
|
||||
<h4 className="mb-2 font-medium text-blue-900 dark:text-blue-100">{t('helpGuide.learnMore.title')}</h4>
|
||||
<p className="mb-3 text-sm text-blue-800 dark:text-blue-200">{t('helpGuide.learnMore.description')}</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{t('helpGuide.learnMore.githubButton')}
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/task-master/view/modals/TaskMasterSetupModal.tsx
Normal file
102
src/components/task-master/view/modals/TaskMasterSetupModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Terminal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import Shell from '../../../shell/view/Shell';
|
||||
import type { TaskMasterProject } from '../../types';
|
||||
|
||||
type TaskMasterSetupModalProps = {
|
||||
isOpen: boolean;
|
||||
project: TaskMasterProject | null;
|
||||
onClose: () => void;
|
||||
onAfterClose?: (() => void) | null;
|
||||
};
|
||||
|
||||
export default function TaskMasterSetupModal({ isOpen, project, onClose, onAfterClose = null }: TaskMasterSetupModalProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
|
||||
|
||||
if (!isOpen || !project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
onClose();
|
||||
setIsTaskMasterComplete(false);
|
||||
|
||||
// Delay refresh slightly so the CLI has time to flush writes to disk.
|
||||
window.setTimeout(() => {
|
||||
onAfterClose?.();
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-16 backdrop-blur-sm">
|
||||
<div className="flex h-[600px] w-full max-w-4xl flex-col rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
||||
<Terminal className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('setupModal.title')}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('setupModal.subtitle', { projectName: project.displayName })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
title="Close"
|
||||
>
|
||||
<Plus className="h-5 w-5 rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full overflow-hidden rounded-lg bg-black">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={null}
|
||||
initialCommand="npx task-master init"
|
||||
isPlainShell
|
||||
isActive
|
||||
onProcessComplete={(exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
setIsTaskMasterComplete(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{isTaskMasterComplete ? (
|
||||
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500" />
|
||||
{t('setupModal.completed')}
|
||||
</span>
|
||||
) : (
|
||||
t('setupModal.willStart')
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
isTaskMasterComplete
|
||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600',
|
||||
)}
|
||||
>
|
||||
{isTaskMasterComplete ? t('setupModal.closeContinueButton') : t('setupModal.closeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/task-master/view/shared/TaskFiltersPanel.tsx
Normal file
108
src/components/task-master/view/shared/TaskFiltersPanel.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';
|
||||
|
||||
type TaskFiltersPanelProps = {
|
||||
showFilters: boolean;
|
||||
statusFilter: string;
|
||||
onStatusFilterChange: (status: string) => void;
|
||||
priorityFilter: string;
|
||||
onPriorityFilterChange: (priority: string) => void;
|
||||
sortField: TaskBoardSortField;
|
||||
sortOrder: TaskBoardSortOrder;
|
||||
onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
filteredTaskCount: number;
|
||||
totalTaskCount: number;
|
||||
onClearFilters: () => void;
|
||||
};
|
||||
|
||||
export default function TaskFiltersPanel({
|
||||
showFilters,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
priorityFilter,
|
||||
onPriorityFilterChange,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortConfigChange,
|
||||
statuses,
|
||||
priorities,
|
||||
filteredTaskCount,
|
||||
totalTaskCount,
|
||||
onClearFilters,
|
||||
}: TaskFiltersPanelProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (!showFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-lg bg-gray-50 p-4 dark:bg-gray-800">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t('filters.status')}</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => onStatusFilterChange(event.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">{t('filters.allStatuses')}</option>
|
||||
{statuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{t(`statuses.${status}`, status)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t('filters.priority')}</label>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(event) => onPriorityFilterChange(event.target.value)}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">{t('filters.allPriorities')}</option>
|
||||
{priorities.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{t(`priorities.${priority}`, priority)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{t('filters.sortBy')}</label>
|
||||
<select
|
||||
value={`${sortField}-${sortOrder}`}
|
||||
onChange={(event) => {
|
||||
const [field, order] = event.target.value.split('-') as [TaskBoardSortField, TaskBoardSortOrder];
|
||||
onSortConfigChange(field, order);
|
||||
}}
|
||||
className="w-full rounded-md border border-gray-300 bg-white px-3 py-2 dark:border-gray-600 dark:bg-gray-800"
|
||||
>
|
||||
<option value="id-asc">{t('sort.idAsc')}</option>
|
||||
<option value="id-desc">{t('sort.idDesc')}</option>
|
||||
<option value="title-asc">{t('sort.titleAsc')}</option>
|
||||
<option value="title-desc">{t('sort.titleDesc')}</option>
|
||||
<option value="status-asc">{t('sort.statusAsc')}</option>
|
||||
<option value="status-desc">{t('sort.statusDesc')}</option>
|
||||
<option value="priority-asc">{t('sort.priorityAsc')}</option>
|
||||
<option value="priority-desc">{t('sort.priorityDesc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('filters.showing', { filtered: filteredTaskCount, total: totalTaskCount })}
|
||||
</div>
|
||||
<button onClick={onClearFilters} className="text-sm font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300">
|
||||
{t('filters.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/task-master/view/shared/TaskQuickSortBar.tsx
Normal file
62
src/components/task-master/view/shared/TaskQuickSortBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';
|
||||
|
||||
type TaskQuickSortBarProps = {
|
||||
sortField: TaskBoardSortField;
|
||||
sortOrder: TaskBoardSortOrder;
|
||||
onSortChange: (field: TaskBoardSortField) => void;
|
||||
};
|
||||
|
||||
function getSortIcon(field: TaskBoardSortField, currentField: TaskBoardSortField, currentOrder: TaskBoardSortOrder) {
|
||||
if (field !== currentField) {
|
||||
return <ArrowUpDown className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
return currentOrder === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />;
|
||||
}
|
||||
|
||||
export default function TaskQuickSortBar({ sortField, sortOrder, onSortChange }: TaskQuickSortBarProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onSortChange('id')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
|
||||
sortField === 'id'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{t('sort.id')} {getSortIcon('id', sortField, sortOrder)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSortChange('status')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
|
||||
sortField === 'status'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{t('sort.status')} {getSortIcon('status', sortField, sortOrder)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSortChange('priority')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
|
||||
sortField === 'priority'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{t('sort.priority')} {getSortIcon('priority', sortField, sortOrder)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user