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:
Haileyesus
2026-03-06 01:47:58 +03:00
committed by GitHub
parent 8d28438fe7
commit 844de26ada
254 changed files with 14571 additions and 9347 deletions

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

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

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

View 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} />
</>
);
}

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

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

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

View 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>
)}
</>
);
}

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

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

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

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

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