feat(KB-019): add grouped list view with section headers

- Implement task grouping logic by columns in ListView component
- Add CSS styling for section headers and grouped layout
- Fix React Fragment keys for proper list rendering
- Update tests for grouped list view functionality
- Add changeset for release notes
This commit is contained in:
gsxdsm
2026-03-29 18:43:52 -07:00
parent e79e28e717
commit 95fe32560d
4 changed files with 323 additions and 103 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, Fragment } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
@@ -70,7 +70,7 @@ export function ListView({
}
}, [sortField]);
const filteredAndSortedTasks = useMemo(() => {
const groupedTasks = useMemo(() => {
const filtered = filter
? tasks.filter(
(t) =>
@@ -80,7 +80,7 @@ export function ListView({
)
: tasks;
return [...filtered].sort((a, b) => {
const sorted = [...filtered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "id":
@@ -104,8 +104,23 @@ export function ListView({
}
return sortDirection === "asc" ? comparison : -comparison;
});
// Group by column while preserving sort order within each group
const groups: Record<Column, Task[]> = {
triage: [],
todo: [],
"in-progress": [],
"in-review": [],
done: []
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
}, [tasks, filter, sortField, sortDirection]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
}, [groupedTasks]);
const handleRowClick = useCallback(
async (task: Task) => {
try {
@@ -193,7 +208,7 @@ export function ListView({
)}
</div>
<div className="list-stats">
{filteredAndSortedTasks.length} of {tasks.length} tasks
{filteredCount} of {tasks.length} tasks
</div>
{onNewTask && (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
@@ -222,7 +237,7 @@ export function ListView({
</div>
<div className="list-table-container">
{filteredAndSortedTasks.length === 0 ? (
{filteredCount === 0 ? (
<div className="list-empty">
{filter ? "No tasks match your filter" : "No tasks yet"}
</div>
@@ -253,86 +268,116 @@ export function ListView({
</tr>
</thead>
<tbody>
{filteredAndSortedTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isAgentActive =
!globalPaused &&
!isFailed &&
!isPaused &&
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDragging = draggingTaskId === task.id;
{COLUMNS.map((column) => {
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
// When filtering, hide empty sections entirely
if (filter && isEmpty) return null;
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isAgentActive ? " agent-active" : ""
}${isDragging ? " dragging" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
>
<td className="list-cell list-cell-id">{task.id}</td>
<td className="list-cell list-cell-title">
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
</td>
<td className="list-cell">
{task.status ? (
<span
className={`list-status-badge${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""
}`}
>
{task.status}
</span>
) : (
<span className="list-status-badge">-</span>
)}
</td>
<td className="list-cell">
<span
className="list-column-badge"
style={{
background: `${COLUMN_COLOR_MAP[task.column]}20`,
color: COLUMN_COLOR_MAP[task.column],
}}
>
{COLUMN_LABELS[task.column]}
</span>
</td>
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
<td className="list-cell list-cell-deps">
{task.dependencies && task.dependencies.length > 0 ? (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
) : (
"-"
)}
</td>
<td className="list-cell list-cell-progress">
{task.steps.length > 0 ? (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${getStepProgressPercent(task.steps)}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
</div>
) : (
"-"
)}
</td>
</tr>
<Fragment key={column}>
{/* Section Header */}
<tr className="list-section-header">
<th colSpan={8} className="list-section-cell">
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
<span className="list-section-count">{columnTasks.length}</span>
</th>
</tr>
{/* Task Rows */}
{isEmpty ? (
<tr className="list-section-empty">
<td colSpan={8} className="list-empty-cell">
No tasks
</td>
</tr>
) : (
columnTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isAgentActive =
!globalPaused &&
!isFailed &&
!isPaused &&
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDragging = draggingTaskId === task.id;
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isAgentActive ? " agent-active" : ""
}${isDragging ? " dragging" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
>
<td className="list-cell list-cell-id">{task.id}</td>
<td className="list-cell list-cell-title">
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
</td>
<td className="list-cell">
{task.status ? (
<span
className={`list-status-badge${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""
}`}
>
{task.status}
</span>
) : (
<span className="list-status-badge">-</span>
)}
</td>
<td className="list-cell">
<span
className="list-column-badge"
style={{
background: `${COLUMN_COLOR_MAP[task.column]}20`,
color: COLUMN_COLOR_MAP[task.column],
}}
>
{COLUMN_LABELS[task.column]}
</span>
</td>
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
<td className="list-cell list-cell-deps">
{task.dependencies && task.dependencies.length > 0 ? (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
) : (
"-"
)}
</td>
<td className="list-cell list-cell-progress">
{task.steps.length > 0 ? (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${getStepProgressPercent(task.steps)}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
</div>
) : (
"-"
)}
</td>
</tr>
);
})
)}
</Fragment>
);
})}
</tbody>