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:
Brad Groux 2026-01-26 05:42:53 -06:00
parent 6810aa5cdf
commit db802ed56d
9 changed files with 347 additions and 8 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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