feat(extension): complete VS Code extension with kanban board interface (#997)
--------- Co-authored-by: DavidMaliglowka <13022280+DavidMaliglowka@users.noreply.github.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
33
apps/extension/src/webview/components/AppContent.tsx
Normal file
33
apps/extension/src/webview/components/AppContent.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { TaskMasterKanban } from './TaskMasterKanban';
|
||||
import TaskDetailsView from '@/components/TaskDetailsView';
|
||||
import { ConfigView } from '@/components/ConfigView';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
|
||||
export const AppContent: React.FC = () => {
|
||||
const { state, dispatch, sendMessage } = useVSCodeContext();
|
||||
|
||||
if (state.currentView === 'config') {
|
||||
return (
|
||||
<ConfigView
|
||||
sendMessage={sendMessage}
|
||||
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.currentView === 'task-details' && state.selectedTaskId) {
|
||||
return (
|
||||
<TaskDetailsView
|
||||
taskId={state.selectedTaskId}
|
||||
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
|
||||
onNavigateToTask={(taskId: string) =>
|
||||
dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to Kanban view
|
||||
return <TaskMasterKanban />;
|
||||
};
|
||||
138
apps/extension/src/webview/components/EmptyState.tsx
Normal file
138
apps/extension/src/webview/components/EmptyState.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, Terminal, MessageSquare, Plus } from 'lucide-react';
|
||||
import { TaskMasterLogo } from '../../components/TaskMasterLogo';
|
||||
|
||||
interface EmptyStateProps {
|
||||
currentTag: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ currentTag }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full overflow-auto">
|
||||
<div className="max-w-2xl mx-auto text-center p-8">
|
||||
{/* Empty state illustration */}
|
||||
<div className="mb-8 max-w-96 mx-auto">
|
||||
<TaskMasterLogo className="w-32 h-32 mx-auto text-vscode-foreground/20" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-2 text-vscode-foreground">
|
||||
No tasks in "{currentTag}" tag
|
||||
</h2>
|
||||
<p className="text-vscode-foreground/70 mb-8">
|
||||
Get started by adding tasks to this tag using the commands below
|
||||
</p>
|
||||
|
||||
{/* Command suggestions */}
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="bg-vscode-editor-background/50 border border-vscode-panel-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4 text-vscode-terminal-ansiGreen" />
|
||||
<h3 className="font-medium">CLI Commands</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
|
||||
<span className="text-vscode-terminal-ansiYellow">
|
||||
task-master
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiCyan">parse-prd</span>{' '}
|
||||
<span className="text-vscode-foreground/70">
|
||||
<path-to-prd>
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiMagenta">
|
||||
--append
|
||||
</span>
|
||||
<div className="text-xs text-vscode-foreground/50 mt-1">
|
||||
Parse a PRD and append tasks to current tag
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
|
||||
<span className="text-vscode-terminal-ansiYellow">
|
||||
task-master
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiCyan">add-task</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiMagenta">
|
||||
--prompt
|
||||
</span>{' '}
|
||||
<span className="text-vscode-foreground/70">
|
||||
"Your task description"
|
||||
</span>
|
||||
<div className="text-xs text-vscode-foreground/50 mt-1">
|
||||
Add a single task with AI assistance
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
|
||||
<span className="text-vscode-terminal-ansiYellow">
|
||||
task-master
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiCyan">add-task</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiMagenta">--help</span>
|
||||
<div className="text-xs text-vscode-foreground/50 mt-1">
|
||||
View all options for adding tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-vscode-editor-background/50 border border-vscode-panel-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MessageSquare className="w-4 h-4 text-vscode-textLink-foreground" />
|
||||
<h3 className="font-medium">MCP Examples</h3>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
|
||||
<div>
|
||||
<div className="text-vscode-foreground">
|
||||
"Add a task to tag {currentTag}: Implement user
|
||||
authentication"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
|
||||
<div>
|
||||
<div className="text-vscode-foreground">
|
||||
"Parse this PRD and add tasks to {currentTag}: [paste PRD
|
||||
content]"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
|
||||
<div>
|
||||
<div className="text-vscode-foreground">
|
||||
"Create 5 tasks for building a REST API in tag {currentTag}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation link */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<a
|
||||
href="https://docs.task-master.dev"
|
||||
className="inline-flex items-center gap-2 text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Use VS Code API to open external link
|
||||
if (window.acquireVsCodeApi) {
|
||||
const vscode = window.acquireVsCodeApi();
|
||||
vscode.postMessage({
|
||||
type: 'openExternal',
|
||||
url: 'https://docs.task-master.dev'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
View TaskMaster Documentation
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
113
apps/extension/src/webview/components/ErrorBoundary.tsx
Normal file
113
apps/extension/src/webview/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Error Boundary Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: React.ErrorInfo;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('React Error Boundary caught:', error, errorInfo);
|
||||
|
||||
// Log to extension
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo);
|
||||
}
|
||||
|
||||
// Send error to extension for centralized handling
|
||||
if (window.acquireVsCodeApi) {
|
||||
const vscode = window.acquireVsCodeApi();
|
||||
vscode.postMessage({
|
||||
type: 'reactError',
|
||||
data: {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-vscode-background">
|
||||
<div className="max-w-md mx-auto text-center p-6">
|
||||
<div className="w-16 h-16 mx-auto mb-4 text-red-400">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.962-.833-2.732 0L3.732 19c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-vscode-foreground mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-vscode-foreground/70 mb-4">
|
||||
The Task Master Kanban board encountered an unexpected error.
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
errorInfo: undefined
|
||||
})
|
||||
}
|
||||
className="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors"
|
||||
>
|
||||
Reload Extension
|
||||
</button>
|
||||
</div>
|
||||
{this.state.error && (
|
||||
<details className="mt-4 text-left">
|
||||
<summary className="text-sm text-vscode-foreground/50 cursor-pointer">
|
||||
Error Details
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-vscode-foreground/70 bg-vscode-input/30 p-2 rounded overflow-auto max-h-32">
|
||||
{this.state.error.message}
|
||||
{this.state.error.stack && `\n\n${this.state.error.stack}`}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
84
apps/extension/src/webview/components/PollingStatus.tsx
Normal file
84
apps/extension/src/webview/components/PollingStatus.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Polling Status Indicator Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { AppState } from '../types';
|
||||
|
||||
interface PollingStatusProps {
|
||||
polling: AppState['polling'];
|
||||
onRetry?: () => void;
|
||||
}
|
||||
|
||||
export const PollingStatus: React.FC<PollingStatusProps> = ({
|
||||
polling,
|
||||
onRetry
|
||||
}) => {
|
||||
const {
|
||||
isActive,
|
||||
errorCount,
|
||||
isOfflineMode,
|
||||
connectionStatus,
|
||||
reconnectAttempts,
|
||||
maxReconnectAttempts
|
||||
} = polling;
|
||||
|
||||
if (isOfflineMode || connectionStatus === 'offline') {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-1 text-red-400"
|
||||
title="Offline mode - using cached data"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-red-400" />
|
||||
<span className="text-xs">Offline</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="text-xs text-blue-400 hover:underline"
|
||||
title="Attempt to reconnect"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionStatus === 'reconnecting') {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 text-yellow-400"
|
||||
title={`Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
|
||||
<span className="text-xs">Reconnecting</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 text-yellow-400"
|
||||
title={`${errorCount} polling error${errorCount > 1 ? 's' : ''}`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400" />
|
||||
<span className="text-xs">Live (errors)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 text-green-400"
|
||||
title="Live updates active"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
<span className="text-xs">Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
30
apps/extension/src/webview/components/PriorityBadge.tsx
Normal file
30
apps/extension/src/webview/components/PriorityBadge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Priority Badge Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TaskMasterTask } from '../types';
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
priority: TaskMasterTask['priority'];
|
||||
}
|
||||
|
||||
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({ priority }) => {
|
||||
if (!priority) return null;
|
||||
|
||||
const variants = {
|
||||
high: 'destructive' as const,
|
||||
medium: 'default' as const,
|
||||
low: 'secondary' as const
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variants[priority] || 'secondary'}
|
||||
className="text-xs font-normal px-2 py-0.5"
|
||||
>
|
||||
{priority}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
52
apps/extension/src/webview/components/SidebarView.tsx
Normal file
52
apps/extension/src/webview/components/SidebarView.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TaskMasterLogo } from '../../components/TaskMasterLogo';
|
||||
|
||||
interface SidebarViewProps {
|
||||
initialConnectionStatus?: boolean;
|
||||
}
|
||||
|
||||
// Acquire VS Code API only once globally to avoid "already acquired" error
|
||||
const vscode = window.acquireVsCodeApi ? window.acquireVsCodeApi() : null;
|
||||
|
||||
export const SidebarView: React.FC<SidebarViewProps> = ({
|
||||
initialConnectionStatus = false
|
||||
}) => {
|
||||
const [isConnected, setIsConnected] = useState(initialConnectionStatus);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message.type === 'connectionStatus') {
|
||||
setIsConnected(message.data.isConnected);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenBoard = () => {
|
||||
vscode?.postMessage({ command: 'openBoard' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<TaskMasterLogo className="w-20 h-20 mx-auto mb-5 opacity-80 text-vscode-foreground" />
|
||||
|
||||
<h2 className="text-xl font-semibold mb-6 text-vscode-foreground">
|
||||
TaskMaster
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={handleOpenBoard}
|
||||
className="w-full px-4 py-2 bg-vscode-button-background text-vscode-button-foreground rounded hover:bg-vscode-button-hoverBackground transition-colors text-sm font-medium"
|
||||
>
|
||||
Open Kanban Board
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
141
apps/extension/src/webview/components/TagDropdown.tsx
Normal file
141
apps/extension/src/webview/components/TagDropdown.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
interface TagDropdownProps {
|
||||
currentTag: string;
|
||||
availableTags: string[];
|
||||
onTagSwitch: (tagName: string) => Promise<void>;
|
||||
sendMessage: (message: any) => Promise<any>;
|
||||
dispatch: React.Dispatch<any>;
|
||||
}
|
||||
|
||||
export const TagDropdown: React.FC<TagDropdownProps> = ({
|
||||
currentTag,
|
||||
availableTags,
|
||||
onTagSwitch,
|
||||
sendMessage,
|
||||
dispatch
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch tags when component mounts
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
// Handle click outside to close dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const result = await sendMessage({ type: 'getTags' });
|
||||
|
||||
if (result?.tags && result?.currentTag) {
|
||||
const tagNames = result.tags.map((tag: any) => tag.name || tag);
|
||||
dispatch({
|
||||
type: 'SET_TAG_DATA',
|
||||
payload: {
|
||||
currentTag: result.currentTag,
|
||||
availableTags: tagNames
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSwitch = async (tagName: string) => {
|
||||
if (tagName === currentTag) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onTagSwitch(tagName);
|
||||
dispatch({ type: 'SET_CURRENT_TAG', payload: tagName });
|
||||
setIsOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to switch tag:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded hover:bg-vscode-list-hoverBackground transition-colors"
|
||||
>
|
||||
<span className="font-medium">{currentTag}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full mt-1 right-0 bg-background border border-vscode-dropdown-border rounded shadow-lg z-50 min-w-[200px] py-1">
|
||||
{availableTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => handleTagSwitch(tag)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between group
|
||||
${
|
||||
tag === currentTag
|
||||
? 'bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground'
|
||||
: 'hover:bg-vscode-list-hoverBackground text-vscode-dropdown-foreground'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate pr-2">{tag}</span>
|
||||
{tag === currentTag && (
|
||||
<svg
|
||||
className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
80
apps/extension/src/webview/components/TaskCard.tsx
Normal file
80
apps/extension/src/webview/components/TaskCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Task Card Component for Kanban Board
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import type { TaskMasterTask } from '../types';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskMasterTask;
|
||||
dragging?: boolean;
|
||||
onViewDetails?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
task,
|
||||
dragging,
|
||||
onViewDetails
|
||||
}) => {
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onViewDetails?.(task.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<KanbanCard
|
||||
id={task.id}
|
||||
name={task.title}
|
||||
index={0} // Index is not used in our implementation
|
||||
parent={task.status}
|
||||
className="cursor-pointer p-3 transition-shadow hover:shadow-md bg-vscode-editor-background border-vscode-border group"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="space-y-3 h-full flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 flex-shrink-0">
|
||||
<h3 className="font-medium text-sm leading-tight flex-1 min-w-0 text-vscode-foreground">
|
||||
{task.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<PriorityBadge priority={task.priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-vscode-foreground/70 line-clamp-3 leading-relaxed flex-1 min-h-0">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs mt-auto pt-2 flex-shrink-0 border-t border-vscode-border/20">
|
||||
<span className="font-mono text-vscode-foreground/50 flex-shrink-0">
|
||||
#{task.id}
|
||||
</span>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-vscode-foreground/50 flex-shrink-0 ml-2">
|
||||
<span>Deps:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{task.dependencies.map((depId, index) => (
|
||||
<React.Fragment key={depId}>
|
||||
<button
|
||||
className="font-mono hover:text-vscode-link-activeForeground hover:underline transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetails?.(depId);
|
||||
}}
|
||||
>
|
||||
#{depId}
|
||||
</button>
|
||||
{index < task.dependencies!.length - 1 && <span>,</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</KanbanCard>
|
||||
);
|
||||
};
|
||||
242
apps/extension/src/webview/components/TaskEditModal.tsx
Normal file
242
apps/extension/src/webview/components/TaskEditModal.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Task Edit Modal Component
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { TaskMasterTask, TaskUpdates } from '../types';
|
||||
|
||||
interface TaskEditModalProps {
|
||||
task: TaskMasterTask;
|
||||
onSave: (taskId: string, updates: TaskUpdates) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const TaskEditModal: React.FC<TaskEditModalProps> = ({
|
||||
task,
|
||||
onSave,
|
||||
onCancel
|
||||
}) => {
|
||||
const [updates, setUpdates] = useState<TaskUpdates>({
|
||||
title: task.title,
|
||||
description: task.description || '',
|
||||
details: task.details || '',
|
||||
testStrategy: task.testStrategy || '',
|
||||
priority: task.priority,
|
||||
dependencies: task.dependencies || []
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus title input on mount
|
||||
useEffect(() => {
|
||||
titleInputRef.current?.focus();
|
||||
titleInputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await onSave(task.id, updates);
|
||||
} catch (error) {
|
||||
console.error('Failed to save task:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const hasChanges = () => {
|
||||
return (
|
||||
updates.title !== task.title ||
|
||||
updates.description !== (task.description || '') ||
|
||||
updates.details !== (task.details || '') ||
|
||||
updates.testStrategy !== (task.testStrategy || '') ||
|
||||
updates.priority !== task.priority ||
|
||||
JSON.stringify(updates.dependencies) !==
|
||||
JSON.stringify(task.dependencies || [])
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-vscode-editor-background border border-vscode-border rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-vscode-border">
|
||||
<h2 className="text-lg font-semibold">Edit Task #{task.id}</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-vscode-foreground/50 hover:text-vscode-foreground transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={handleSubmit}
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
>
|
||||
{/* Title */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
id="title"
|
||||
type="text"
|
||||
value={updates.title || ''}
|
||||
onChange={(e) =>
|
||||
setUpdates({ ...updates, title: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded-md text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="priority">Priority</Label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between">
|
||||
<span className="capitalize">{updates.priority}</span>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setUpdates({ ...updates, priority: 'high' })}
|
||||
>
|
||||
High
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setUpdates({ ...updates, priority: 'medium' })}
|
||||
>
|
||||
Medium
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setUpdates({ ...updates, priority: 'low' })}
|
||||
>
|
||||
Low
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={updates.description || ''}
|
||||
onChange={(e) =>
|
||||
setUpdates({ ...updates, description: e.target.value })
|
||||
}
|
||||
className="min-h-[80px]"
|
||||
placeholder="Brief description of the task"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="details">Implementation Details</Label>
|
||||
<Textarea
|
||||
id="details"
|
||||
value={updates.details || ''}
|
||||
onChange={(e) =>
|
||||
setUpdates({ ...updates, details: e.target.value })
|
||||
}
|
||||
className="min-h-[120px]"
|
||||
placeholder="Technical details and implementation notes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test Strategy */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="testStrategy">Test Strategy</Label>
|
||||
<Textarea
|
||||
id="testStrategy"
|
||||
value={updates.testStrategy || ''}
|
||||
onChange={(e) =>
|
||||
setUpdates({ ...updates, testStrategy: e.target.value })
|
||||
}
|
||||
className="min-h-[80px]"
|
||||
placeholder="How to test this task"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dependencies">
|
||||
Dependencies (comma-separated task IDs)
|
||||
</Label>
|
||||
<input
|
||||
id="dependencies"
|
||||
type="text"
|
||||
value={updates.dependencies?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
setUpdates({
|
||||
...updates,
|
||||
dependencies: e.target.value
|
||||
.split(',')
|
||||
.map((d) => d.trim())
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded-md text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
|
||||
placeholder="e.g., 1, 2.1, 3"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-vscode-border">
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleSubmit()}
|
||||
disabled={isSaving || !hasChanges()}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
412
apps/extension/src/webview/components/TaskMasterKanban.tsx
Normal file
412
apps/extension/src/webview/components/TaskMasterKanban.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Main Kanban Board Component
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
KanbanBoard,
|
||||
KanbanCards,
|
||||
KanbanHeader,
|
||||
KanbanProvider
|
||||
} from '@/components/ui/shadcn-io/kanban';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { TaskEditModal } from './TaskEditModal';
|
||||
import { PollingStatus } from './PollingStatus';
|
||||
import { TagDropdown } from './TagDropdown';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
import {
|
||||
useTasks,
|
||||
useUpdateTaskStatus,
|
||||
useUpdateTask,
|
||||
taskKeys
|
||||
} from '../hooks/useTaskQueries';
|
||||
import { kanbanStatuses, HEADER_HEIGHT } from '../constants';
|
||||
import type { TaskMasterTask, TaskUpdates } from '../types';
|
||||
|
||||
export const TaskMasterKanban: React.FC = () => {
|
||||
const { state, dispatch, sendMessage, availableHeight } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
error: legacyError,
|
||||
editingTask,
|
||||
polling,
|
||||
currentTag,
|
||||
availableTags
|
||||
} = state;
|
||||
const [activeTask, setActiveTask] = useState<TaskMasterTask | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Use React Query to fetch tasks
|
||||
const {
|
||||
data: serverTasks = [],
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
isSuccess
|
||||
} = useTasks({ tag: currentTag });
|
||||
const updateTaskStatus = useUpdateTaskStatus();
|
||||
const updateTask = useUpdateTask();
|
||||
|
||||
// Debug logging
|
||||
console.log('🔍 TaskMasterKanban Query State:', {
|
||||
isLoading,
|
||||
isFetching,
|
||||
isSuccess,
|
||||
tasksCount: serverTasks?.length,
|
||||
error
|
||||
});
|
||||
|
||||
// Temporary state only for active drag operations
|
||||
const [tempReorderedTasks, setTempReorderedTasks] = useState<
|
||||
TaskMasterTask[] | null
|
||||
>(null);
|
||||
|
||||
// Use temp tasks only if actively set, otherwise use server tasks
|
||||
const tasks = tempReorderedTasks ?? serverTasks;
|
||||
|
||||
// Calculate header height for proper kanban board sizing
|
||||
const kanbanHeight = availableHeight - HEADER_HEIGHT;
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = kanbanStatuses.reduce(
|
||||
(acc, status) => {
|
||||
acc[status.id] = tasks.filter((task) => task.status === status.id);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TaskMasterTask[]>
|
||||
);
|
||||
|
||||
// Debug logging
|
||||
console.log('TaskMasterKanban render:', {
|
||||
tasksCount: tasks.length,
|
||||
currentTag,
|
||||
tasksByStatus: Object.entries(tasksByStatus).map(([status, tasks]) => ({
|
||||
status,
|
||||
count: tasks.length,
|
||||
taskIds: tasks.map((t) => t.id)
|
||||
})),
|
||||
allTaskIds: tasks.map((t) => ({ id: t.id, title: t.title }))
|
||||
});
|
||||
|
||||
// Handle task update
|
||||
const handleUpdateTask = async (taskId: string, updates: TaskUpdates) => {
|
||||
console.log(`🔄 Updating task ${taskId} content:`, updates);
|
||||
|
||||
try {
|
||||
await updateTask.mutateAsync({
|
||||
taskId,
|
||||
updates,
|
||||
options: { append: false, research: false }
|
||||
});
|
||||
|
||||
console.log(`✅ Task ${taskId} content updated successfully`);
|
||||
|
||||
// Close the edit modal
|
||||
dispatch({
|
||||
type: 'SET_EDITING_TASK',
|
||||
payload: { taskId: null }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update task ${taskId}:`, error);
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: `Failed to update task: ${error}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const taskId = event.active.id as string;
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
}
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
// Handle drag cancel
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveTask(null);
|
||||
// Clear any temporary state
|
||||
setTempReorderedTasks(null);
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Reset active task
|
||||
setActiveTask(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
// Clear any temp state if drag was cancelled
|
||||
setTempReorderedTasks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskMasterTask['status'];
|
||||
|
||||
// Find the task
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task || task.status === newStatus) {
|
||||
// Clear temp state if no change needed
|
||||
setTempReorderedTasks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the optimistically reordered tasks
|
||||
const reorderedTasks = tasks.map((t) =>
|
||||
t.id === taskId ? { ...t, status: newStatus } : t
|
||||
);
|
||||
|
||||
// Set temporary state to show immediate visual feedback
|
||||
setTempReorderedTasks(reorderedTasks);
|
||||
|
||||
try {
|
||||
// Update on server - React Query will handle optimistic updates
|
||||
await updateTaskStatus.mutateAsync({ taskId, newStatus });
|
||||
// Clear temp state after mutation starts successfully
|
||||
setTempReorderedTasks(null);
|
||||
} catch (error) {
|
||||
// On error, clear temp state - React Query will revert optimistic update
|
||||
setTempReorderedTasks(null);
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: `Failed to update task status: ${error}`
|
||||
});
|
||||
}
|
||||
},
|
||||
[tasks, updateTaskStatus, dispatch]
|
||||
);
|
||||
|
||||
// Handle retry connection
|
||||
const handleRetry = useCallback(() => {
|
||||
sendMessage({ type: 'retryConnection' });
|
||||
}, [sendMessage]);
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Invalidate all task queries
|
||||
await queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
||||
} finally {
|
||||
// Reset after a short delay to show the animation
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
// Handle tag switching
|
||||
const handleTagSwitch = useCallback(
|
||||
async (tagName: string) => {
|
||||
console.log('Switching to tag:', tagName);
|
||||
await sendMessage({ type: 'switchTag', data: { tagName } });
|
||||
dispatch({
|
||||
type: 'SET_TAG_DATA',
|
||||
payload: { currentTag: tagName, availableTags }
|
||||
});
|
||||
},
|
||||
[sendMessage, dispatch, availableTags]
|
||||
);
|
||||
|
||||
// Use React Query loading state
|
||||
const displayError = error
|
||||
? error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
: legacyError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: `${kanbanHeight}px` }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-vscode-foreground mx-auto mb-4" />
|
||||
<p className="text-sm text-vscode-foreground/70">Loading tasks...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayError) {
|
||||
return (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 m-4">
|
||||
<p className="text-red-400 text-sm">Error: {displayError}</p>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'CLEAR_ERROR' })}
|
||||
className="mt-2 text-sm text-red-400 hover:text-red-300 underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col" style={{ height: `${availableHeight}px` }}>
|
||||
<div className="flex-shrink-0 p-4 bg-vscode-sidebar-background border-b border-vscode-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-vscode-foreground">
|
||||
TaskMaster Kanban
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<TagDropdown
|
||||
currentTag={currentTag}
|
||||
availableTags={availableTags}
|
||||
onTagSwitch={handleTagSwitch}
|
||||
sendMessage={sendMessage}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
|
||||
title="Refresh tasks"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 text-vscode-foreground/70 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<PollingStatus polling={polling} onRetry={handleRetry} />
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${state.isConnected ? 'bg-green-400' : 'bg-red-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-vscode-foreground/70">
|
||||
{state.connectionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'NAVIGATE_TO_CONFIG' })}
|
||||
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
|
||||
title="TaskMaster Configuration"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-vscode-foreground/70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 px-4 py-4 overflow-hidden"
|
||||
style={{ height: `${kanbanHeight}px` }}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyState currentTag={currentTag} />
|
||||
) : (
|
||||
<KanbanProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
className="kanban-container w-full h-full overflow-x-auto overflow-y-hidden"
|
||||
dragOverlay={
|
||||
activeTask ? <TaskCard task={activeTask} dragging /> : null
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4 h-full min-w-fit">
|
||||
{kanbanStatuses.map((status) => {
|
||||
const statusTasks = tasksByStatus[status.id] || [];
|
||||
const hasScrollbar = statusTasks.length > 4;
|
||||
|
||||
return (
|
||||
<KanbanBoard
|
||||
key={status.id}
|
||||
id={status.id}
|
||||
className={`
|
||||
w-80 flex flex-col
|
||||
border border-vscode-border/30
|
||||
rounded-lg
|
||||
bg-vscode-sidebar-background/50
|
||||
`}
|
||||
>
|
||||
<KanbanHeader
|
||||
name={`${status.title} (${statusTasks.length})`}
|
||||
color={status.color}
|
||||
className="px-3 py-3 text-sm font-medium flex-shrink-0 border-b border-vscode-border/30"
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
flex flex-col gap-2
|
||||
overflow-y-auto overflow-x-hidden
|
||||
p-2
|
||||
scrollbar-thin scrollbar-track-transparent
|
||||
${hasScrollbar ? 'pr-1' : ''}
|
||||
`}
|
||||
style={{
|
||||
maxHeight: `${kanbanHeight - 80}px`
|
||||
}}
|
||||
>
|
||||
<KanbanCards>
|
||||
{statusTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onViewDetails={(taskId) => {
|
||||
console.log(
|
||||
'🔍 Navigating to task details:',
|
||||
taskId
|
||||
);
|
||||
dispatch({
|
||||
type: 'NAVIGATE_TO_TASK',
|
||||
payload: taskId
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</KanbanCards>
|
||||
</div>
|
||||
</KanbanBoard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</KanbanProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Edit Modal */}
|
||||
{editingTask?.taskId && editingTask.editData && (
|
||||
<TaskEditModal
|
||||
task={editingTask.editData}
|
||||
onSave={handleUpdateTask}
|
||||
onCancel={() => {
|
||||
dispatch({
|
||||
type: 'SET_EDITING_TASK',
|
||||
payload: { taskId: null }
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
31
apps/extension/src/webview/components/ToastContainer.tsx
Normal file
31
apps/extension/src/webview/components/ToastContainer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Toast Container Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ToastNotification } from './ToastNotification';
|
||||
import type { ToastNotification as ToastType } from '../types';
|
||||
|
||||
interface ToastContainerProps {
|
||||
notifications: ToastType[];
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContainer: React.FC<ToastContainerProps> = ({
|
||||
notifications,
|
||||
onDismiss
|
||||
}) => {
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 pointer-events-none">
|
||||
<div className="flex flex-col items-end pointer-events-auto">
|
||||
{notifications.map((notification) => (
|
||||
<ToastNotification
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onDismiss={onDismiss}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
167
apps/extension/src/webview/components/ToastNotification.tsx
Normal file
167
apps/extension/src/webview/components/ToastNotification.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Toast Notification Component
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import type { ToastNotification as ToastType } from '../types';
|
||||
|
||||
interface ToastNotificationProps {
|
||||
notification: ToastType;
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToastNotification: React.FC<ToastNotificationProps> = ({
|
||||
notification,
|
||||
onDismiss
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [progress, setProgress] = useState(100);
|
||||
const duration = notification.duration || 5000; // 5 seconds default
|
||||
|
||||
useEffect(() => {
|
||||
const progressInterval = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
const decrease = (100 / duration) * 100; // Update every 100ms
|
||||
return Math.max(0, prev - decrease);
|
||||
});
|
||||
}, 100);
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
setTimeout(() => onDismiss(notification.id), 300); // Wait for animation
|
||||
}, duration);
|
||||
|
||||
return () => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [notification.id, duration, onDismiss]);
|
||||
|
||||
const getIcon = () => {
|
||||
switch (notification.type) {
|
||||
case 'success':
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5 text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'info':
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5 text-blue-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'warning':
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5 text-yellow-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.667-2.308-1.667-3.08 0L3.34 19c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case 'error':
|
||||
return (
|
||||
<svg
|
||||
className="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const bgColor = {
|
||||
success: 'bg-green-900/90',
|
||||
info: 'bg-blue-900/90',
|
||||
warning: 'bg-yellow-900/90',
|
||||
error: 'bg-red-900/90'
|
||||
}[notification.type];
|
||||
|
||||
const borderColor = {
|
||||
success: 'border-green-600',
|
||||
info: 'border-blue-600',
|
||||
warning: 'border-yellow-600',
|
||||
error: 'border-red-600'
|
||||
}[notification.type];
|
||||
|
||||
const progressColor = {
|
||||
success: 'bg-green-400',
|
||||
info: 'bg-blue-400',
|
||||
warning: 'bg-yellow-400',
|
||||
error: 'bg-red-400'
|
||||
}[notification.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${bgColor} ${borderColor} border rounded-lg shadow-lg p-4 mb-2 transition-all duration-300 ${
|
||||
isVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full'
|
||||
} max-w-sm w-full relative overflow-hidden`}
|
||||
>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">{getIcon()}</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<h3 className="text-sm font-medium text-white">
|
||||
{notification.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-300">{notification.message}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDismiss(notification.id)}
|
||||
className="ml-4 flex-shrink-0 inline-flex text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
|
||||
>
|
||||
<span className="sr-only">Close</span>
|
||||
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="absolute bottom-0 left-0 w-full h-1 bg-gray-700">
|
||||
<div
|
||||
className={`h-full ${progressColor} transition-all duration-100 ease-linear`}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user