mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
feat(US-701): Archive sidebar with search/filter
- Added listArchivedTasks() to TaskService - Added GET /api/tasks/archived endpoint - Added useArchivedTasks() hook - Created ArchiveSidebar component with: - Search by title/description/tags - Filter by task type - Filter by project - Click to view task details - Added Archive button to Header toolbar
This commit is contained in:
parent
6810aa5cdf
commit
db802ed56d
9 changed files with 347 additions and 8 deletions
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
| ID | Title | Status | Dependencies | Notes |
|
||||
|----|-------|--------|--------------|-------|
|
||||
| US-701 | Archive sidebar | ⏳ Todo | None | Slide-out viewer with search/filter for archived tasks |
|
||||
| US-701 | Archive sidebar | ✅ Done | None | Slide-out viewer with search/filter for archived tasks |
|
||||
| US-702 | Restore from archive | ⏳ Todo | US-701 | Unarchive tasks back to board |
|
||||
| US-703 | Subtasks | ⏳ Todo | None | Nested tasks with parent completion logic |
|
||||
| US-704 | Task dependencies | ⏳ Todo | US-703 | Block tasks until dependencies complete |
|
||||
|
|
@ -21,6 +21,7 @@
|
|||
| US-708 | Preview mode | ⏳ Todo | None | Embedded browser for dev server preview |
|
||||
| US-709 | Merge conflict resolution | ⏳ Todo | None | Visual conflict resolver UI |
|
||||
| US-710 | Time tracking | ⏳ Todo | None | Start/stop timer, manual entry, reports |
|
||||
| US-711 | Running indicator on cards | ⏳ Todo | None | Spinner/pulse animation when agent running |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -90,6 +91,11 @@
|
|||
- Time summary per project
|
||||
- Export time report
|
||||
|
||||
### US-711: Running indicator on task cards
|
||||
- Visual indicator (spinner or pulse) on task card
|
||||
- Shows when agent is actively running
|
||||
- Easy to spot active work at a glance from board view
|
||||
|
||||
---
|
||||
|
||||
## Progress Log
|
||||
|
|
|
|||
|
|
@ -1,4 +1,34 @@
|
|||
[
|
||||
{
|
||||
"id": "activity_1769427062484_c9s4q6kiq",
|
||||
"type": "task_created",
|
||||
"taskId": "task_20260126_PcoH5E",
|
||||
"taskTitle": "US-711: Running indicator on task cards",
|
||||
"details": {
|
||||
"type": "code",
|
||||
"priority": "medium",
|
||||
"project": "veritas-kanban"
|
||||
},
|
||||
"timestamp": "2026-01-26T11:31:02.484Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769426843932_va7q38kfq",
|
||||
"type": "task_updated",
|
||||
"taskId": "task_20260126_wzj1d0",
|
||||
"taskTitle": "US-701: Archive sidebar",
|
||||
"timestamp": "2026-01-26T11:27:23.932Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769426832160_ckd9ynr0s",
|
||||
"type": "status_changed",
|
||||
"taskId": "task_20260126_wzj1d0",
|
||||
"taskTitle": "US-701: Archive sidebar",
|
||||
"details": {
|
||||
"from": "todo",
|
||||
"status": "in-progress"
|
||||
},
|
||||
"timestamp": "2026-01-26T11:27:12.160Z"
|
||||
},
|
||||
{
|
||||
"id": "activity_1769426654428_vcko3ka0i",
|
||||
"type": "task_created",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,17 @@ router.get('/', async (_req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/archived - List archived tasks
|
||||
router.get('/archived', async (_req, res) => {
|
||||
try {
|
||||
const tasks = await taskService.listArchivedTasks();
|
||||
res.json(tasks);
|
||||
} catch (error) {
|
||||
console.error('Error listing archived tasks:', error);
|
||||
res.status(500).json({ error: 'Failed to list archived tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id - Get single task
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -103,15 +103,10 @@ export class AgentService {
|
|||
// Spawn agent process
|
||||
const worktreePath = this.expandPath(task.git.worktreePath);
|
||||
const args = [...agentConfig.args];
|
||||
|
||||
// Add prompt for certain agents
|
||||
if (agent === 'claude-code' || agent === 'amp') {
|
||||
args.push(prompt);
|
||||
}
|
||||
|
||||
const childProcess = spawn(agentConfig.command, args, {
|
||||
cwd: worktreePath,
|
||||
shell: true,
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
|
|
@ -119,6 +114,17 @@ export class AgentService {
|
|||
TERM: 'xterm-256color',
|
||||
},
|
||||
});
|
||||
|
||||
// Debug: check if streams exist
|
||||
console.log(`[Agent ${attemptId}] stdout: ${!!childProcess.stdout}, stderr: ${!!childProcess.stderr}, stdin: ${!!childProcess.stdin}`);
|
||||
|
||||
// Send prompt via stdin for agents that expect it (claude-code, amp)
|
||||
if (agent === 'claude-code' || agent === 'amp') {
|
||||
console.log(`[Agent ${attemptId}] Sending prompt via stdin (${prompt.length} chars)`);
|
||||
childProcess.stdin?.write(prompt);
|
||||
childProcess.stdin?.end();
|
||||
console.log(`[Agent ${attemptId}] stdin closed`);
|
||||
}
|
||||
|
||||
// Store in running agents
|
||||
runningAgents.set(taskId, {
|
||||
|
|
@ -136,6 +142,7 @@ export class AgentService {
|
|||
// Handle stdout
|
||||
childProcess.stdout?.on('data', async (data: Buffer) => {
|
||||
const content = data.toString();
|
||||
console.log(`[Agent ${attemptId}] stdout:`, content.slice(0, 100));
|
||||
const output: AgentOutput = {
|
||||
type: 'stdout',
|
||||
content,
|
||||
|
|
@ -148,6 +155,7 @@ export class AgentService {
|
|||
// Handle stderr
|
||||
childProcess.stderr?.on('data', async (data: Buffer) => {
|
||||
const content = data.toString();
|
||||
console.log(`[Agent ${attemptId}] stderr:`, content.slice(0, 100));
|
||||
const output: AgentOutput = {
|
||||
type: 'stderr',
|
||||
content,
|
||||
|
|
|
|||
|
|
@ -203,4 +203,29 @@ export class TaskService {
|
|||
|
||||
return true;
|
||||
}
|
||||
|
||||
async listArchivedTasks(): Promise<Task[]> {
|
||||
await this.ensureDirectories();
|
||||
|
||||
const files = await fs.readdir(this.archiveDir);
|
||||
const mdFiles = files.filter(f => f.endsWith('.md'));
|
||||
|
||||
const tasks = await Promise.all(
|
||||
mdFiles.map(async (filename) => {
|
||||
const filepath = path.join(this.archiveDir, filename);
|
||||
const content = await fs.readFile(filepath, 'utf-8');
|
||||
return this.parseTaskFile(content, filename);
|
||||
})
|
||||
);
|
||||
|
||||
// Sort by updated date, newest first
|
||||
return tasks.sort((a: Task, b: Task) =>
|
||||
new Date(b.updated).getTime() - new Date(a.updated).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
async getArchivedTask(id: string): Promise<Task | null> {
|
||||
const tasks = await this.listArchivedTasks();
|
||||
return tasks.find(t => t.id === id) || null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
236
web/src/components/layout/ArchiveSidebar.tsx
Normal file
236
web/src/components/layout/ArchiveSidebar.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { Archive, RefreshCw, Search, Calendar, FolderOpen } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useArchivedTasks } from '@/hooks/useTasks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Task, TaskType } from '@veritas-kanban/shared';
|
||||
|
||||
interface ArchiveSidebarProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
|
||||
const typeIcons: Record<TaskType, string> = {
|
||||
code: '💻',
|
||||
research: '🔬',
|
||||
content: '📝',
|
||||
automation: '🤖',
|
||||
};
|
||||
|
||||
const typeLabels: Record<TaskType, string> = {
|
||||
code: 'Code',
|
||||
research: 'Research',
|
||||
content: 'Content',
|
||||
automation: 'Automation',
|
||||
};
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ArchivedTaskItem({ task, onClick }: { task: Task; onClick?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 py-3 px-2 rounded-md transition-colors",
|
||||
onClick ? "hover:bg-muted/50 cursor-pointer" : ""
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-lg flex-shrink-0">
|
||||
{typeIcons[task.type]}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{task.title}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||
{task.project && (
|
||||
<span className="flex items-center gap-1">
|
||||
<FolderOpen className="h-3 w-3" />
|
||||
{task.project}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(task.updated)}
|
||||
</span>
|
||||
</div>
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{task.tags.slice(0, 3).map(tag => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs px-1 py-0">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{task.tags.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0">
|
||||
+{task.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArchiveSidebar({ open, onOpenChange, onTaskClick }: ArchiveSidebarProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [projectFilter, setProjectFilter] = useState<string>('all');
|
||||
|
||||
const { data: archivedTasks, isLoading, refetch, isRefetching } = useArchivedTasks();
|
||||
|
||||
// Get unique projects for filter dropdown
|
||||
const projects = useMemo(() => {
|
||||
if (!archivedTasks) return [];
|
||||
const projectSet = new Set(archivedTasks.map(t => t.project).filter(Boolean) as string[]);
|
||||
return Array.from(projectSet).sort();
|
||||
}, [archivedTasks]);
|
||||
|
||||
// Filter tasks
|
||||
const filteredTasks = useMemo(() => {
|
||||
if (!archivedTasks) return [];
|
||||
|
||||
return archivedTasks.filter(task => {
|
||||
// Search filter
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase();
|
||||
const matchesTitle = task.title.toLowerCase().includes(searchLower);
|
||||
const matchesDescription = task.description?.toLowerCase().includes(searchLower);
|
||||
const matchesTags = task.tags?.some(t => t.toLowerCase().includes(searchLower));
|
||||
if (!matchesTitle && !matchesDescription && !matchesTags) return false;
|
||||
}
|
||||
|
||||
// Type filter
|
||||
if (typeFilter !== 'all' && task.type !== typeFilter) return false;
|
||||
|
||||
// Project filter
|
||||
if (projectFilter !== 'all' && task.project !== projectFilter) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [archivedTasks, search, typeFilter, projectFilter]);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-[400px] sm:w-[450px] p-0">
|
||||
<SheetHeader className="px-4 py-3 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Archive className="h-5 w-5" />
|
||||
Archive
|
||||
{archivedTasks && archivedTasks.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1">
|
||||
{archivedTasks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => refetch()}
|
||||
disabled={isRefetching}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isRefetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative mt-2">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search archived tasks..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{Object.entries(typeLabels).map(([type, label]) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{typeIcons[type as TaskType]} {label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<Select value={projectFilter} onValueChange={setProjectFilter}>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="Project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Projects</SelectItem>
|
||||
{projects.map(project => (
|
||||
<SelectItem key={project} value={project}>
|
||||
{project}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<ScrollArea className="h-[calc(100vh-200px)]">
|
||||
<div className="px-2 py-2">
|
||||
{isLoading ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
Loading archived tasks...
|
||||
</div>
|
||||
) : filteredTasks.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{archivedTasks?.length === 0
|
||||
? 'No archived tasks yet'
|
||||
: 'No tasks match your filters'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{filteredTasks.map(task => (
|
||||
<ArchivedTaskItem
|
||||
key={task.id}
|
||||
task={task}
|
||||
onClick={onTaskClick ? () => onTaskClick(task) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { Plus, Settings, Keyboard, Activity } from 'lucide-react';
|
||||
import { Plus, Settings, Keyboard, Activity, Archive } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CreateTaskDialog } from '@/components/task/CreateTaskDialog';
|
||||
import { SettingsDialog } from '@/components/settings/SettingsDialog';
|
||||
import { ActivitySidebar } from './ActivitySidebar';
|
||||
import { ArchiveSidebar } from './ArchiveSidebar';
|
||||
import { useState } from 'react';
|
||||
import { useKeyboard } from '@/hooks/useKeyboard';
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ export function Header() {
|
|||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [activityOpen, setActivityOpen] = useState(false);
|
||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||
const { setOpenCreateDialog, openHelpDialog } = useKeyboard();
|
||||
|
||||
// Register the create dialog opener with keyboard context (ref, no useEffect needed)
|
||||
|
|
@ -51,6 +53,14 @@ export function Header() {
|
|||
>
|
||||
<Activity className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setArchiveOpen(true)}
|
||||
title="Archive"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -65,6 +75,7 @@ export function Header() {
|
|||
<CreateTaskDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<ActivitySidebar open={activityOpen} onOpenChange={setActivityOpen} />
|
||||
<ArchiveSidebar open={archiveOpen} onOpenChange={setArchiveOpen} />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,13 @@ export function useTasks() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useArchivedTasks() {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', 'archived'],
|
||||
queryFn: api.tasks.listArchived,
|
||||
});
|
||||
}
|
||||
|
||||
export function useTask(id: string) {
|
||||
return useQuery({
|
||||
queryKey: ['tasks', id],
|
||||
|
|
|
|||
|
|
@ -57,6 +57,11 @@ export const api = {
|
|||
return handleResponse<Task[]>(response);
|
||||
},
|
||||
|
||||
listArchived: async (): Promise<Task[]> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/archived`);
|
||||
return handleResponse<Task[]>(response);
|
||||
},
|
||||
|
||||
get: async (id: string): Promise<Task> => {
|
||||
const response = await fetch(`${API_BASE}/tasks/${id}`);
|
||||
return handleResponse<Task>(response);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue