File size: 5,558 Bytes
30cc31a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 | // TaskList.tsx — left-rail task list. Polls listTasks every 5s, supports client-side search,
// highlights the active task based on the current pathname.
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ensureDefaultProject, listTasks } from "@/lib/api";
import type { TaskRecord } from "@/lib/types";
function formatRelative(ts: number): string {
const diff = Math.max(0, Date.now() - ts);
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
return `${d}d ago`;
}
export default function TaskList() {
const pathname = usePathname();
const [projectId, setProjectId] = useState<string | null>(null);
const [tasks, setTasks] = useState<TaskRecord[]>([]);
const [query, setQuery] = useState("");
// Resolve default project once on mount.
useEffect(() => {
let cancelled = false;
ensureDefaultProject()
.then((p) => {
if (!cancelled) setProjectId(p.id);
})
.catch(() => {
/* Swallow — empty state will render. */
});
return () => {
cancelled = true;
};
}, []);
// Poll tasks every 5s while projectId is known and component is mounted.
useEffect(() => {
if (!projectId) return;
let cancelled = false;
const fetchTasks = async () => {
try {
const { tasks: next } = await listTasks(projectId);
if (!cancelled) setTasks(next);
} catch {
/* Keep last-known list on transient failure. */
}
};
fetchTasks();
const id = setInterval(fetchTasks, 5000);
return () => {
cancelled = true;
clearInterval(id);
};
}, [projectId]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const sorted = [...tasks].sort((a, b) => b.updatedAt - a.updatedAt);
if (!q) return sorted;
return sorted.filter((t) => t.title.toLowerCase().includes(q));
}, [tasks, query]);
const activeId = useMemo(() => {
const m = pathname?.match(/\/tasks\/([^/]+)/);
return m ? m[1] : null;
}, [pathname]);
return (
<div className="flex flex-col min-h-0">
<div className="px-3 pt-1 pb-2">
<p className="text-[10px] font-semibold uppercase tracking-widest text-muted-fg px-2 mb-1.5">
Tasks
</p>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search tasks…"
className="w-full rounded-lg border border-border bg-white px-3 py-1.5 text-xs text-foreground placeholder:text-muted-fg focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20"
/>
</div>
{filtered.length === 0 ? (
<div className="px-4 py-6 text-center flex flex-col items-center">
{/* Beaker / flask illustration */}
<svg className="w-10 h-10 mb-2" viewBox="0 0 40 40" fill="none" aria-hidden="true">
{/* Flask body */}
<path
d="M16 8 L16 18 L8 34 C7 36 8.5 38 11 38 L29 38 C31.5 38 33 36 32 34 L24 18 L24 8"
stroke="currentColor" className="text-muted-fg/30" strokeWidth="1.2" strokeLinejoin="round" fill="currentColor" fillOpacity="0"
/>
{/* Liquid fill */}
<path
d="M12.5 28 L16.8 20 L23.2 20 L27.5 28 L30 33 C30.5 34 29.8 35 29 35 L11 35 C10.2 35 9.5 34 10 33 Z"
fill="currentColor" className="text-accent/[0.08]"
/>
{/* Flask neck rim */}
<line x1="14" y1="8" x2="26" y2="8" stroke="currentColor" className="text-muted-fg/30" strokeWidth="1.2" strokeLinecap="round" />
{/* Bubbles */}
<circle cx="17" cy="30" r="1.5" fill="currentColor" className="text-accent/20" />
<circle cx="22" cy="27" r="1" fill="currentColor" className="text-accent/15" />
<circle cx="20" cy="32" r="1.2" fill="currentColor" className="text-accent/20" />
</svg>
<p className="text-[11px] text-muted-fg leading-relaxed">
No tasks yet — start a chat to create one.
</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto px-2 pb-2 space-y-0.5">
{filtered.map((t) => {
const isActive = t.id === activeId;
return (
<li key={t.id}>
<Link
href={`/projects/${t.projectId}/tasks/${t.id}`}
className={`group flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors ${
isActive
? "bg-accent/10 text-foreground"
: "text-foreground-dim hover:bg-muted hover:text-foreground"
}`}
>
<span
className={`shrink-0 w-1.5 h-1.5 rounded-full ${
isActive ? "bg-accent" : "bg-muted-fg/40 group-hover:bg-accent/60"
}`}
/>
<span className="flex-1 min-w-0 text-xs font-medium truncate">{t.title}</span>
<span className="shrink-0 text-[10px] text-muted-fg font-mono">
{formatRelative(t.updatedAt)}
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
);
}
|