Redesign Tasks screen: expandable TaskCard, sidebar controls, and list panel

- Redesign TaskCard to match v1 Task Details panel layout:
  header with task ID + status badge + cancel button,
  always-visible summary row (Status, Last Updated, Created At, TTL)
  with Expand/Collapse button, expandable section with status message,
  progress bar, error, and Full Task Object JSON viewer
- Add TaskControls group: search input + status filter dropdown +
  Refresh/Clear History buttons (mirrors HistoryControls pattern)
- Add TaskListPanel group: scrollable panel with ListToggle for
  expand/collapse all, splits tasks into Active/Completed sections
  with counts, filters by search text and status
- Refactor TasksScreen to sidebar + panel layout matching HistoryScreen:
  manages search/filter state internally, flat tasks array
- Simplify TaskCard callbacks to just onCancel
- Extract named .withProps() subcomponents (HeaderLeftGroup,
  HeaderRightGroup, SectionTitle, SummaryRow, DetailRow) for
  AGENTS.md compliance
- Update all stories (TaskCard, TaskControls, TaskListPanel,
  TasksScreen, ConnectedView)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-29 22:03:54 -04:00
commit c494d2feb4
2 changed files with 639 additions and 276 deletions

View file

@ -0,0 +1,88 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { TaskListPanel } from "./TaskListPanel";
import type { TaskCardProps } from "../TaskCard/TaskCard";
const sampleTasks: TaskCardProps[] = [
{
taskId: "d0b22eba71fa36229ce5c4dfadeaa7de",
status: "running",
method: "tools/call",
target: "generate_report",
progress: 80,
progressDescription: "Synthesizing findings...",
startedAt: "3/29/2026, 8:18:20 PM",
lastUpdated: "3/29/2026, 8:18:22 PM",
ttl: 300000,
isListExpanded: true,
onCancel: fn(),
},
{
taskId: "4100b5e0b0ed9cd0023330342d1bf647",
status: "waiting",
method: "resources/read",
target: "file:///data/report.csv",
startedAt: "3/29/2026, 8:17:55 PM",
lastUpdated: "3/29/2026, 8:17:55 PM",
ttl: 300000,
isListExpanded: true,
onCancel: fn(),
},
{
taskId: "d487b49aa39023d907b5a2a5b506cb3",
status: "completed",
method: "tools/call",
target: "generate_summary",
startedAt: "3/29/2026, 8:16:47 PM",
completedAt: "3/29/2026, 8:16:51 PM",
lastUpdated: "3/29/2026, 8:16:51 PM",
ttl: 300000,
progressDescription: "Generating report...",
isListExpanded: true,
onCancel: fn(),
},
{
taskId: "e6ebffd9cca84ddd1646d3c579a4d453",
status: "failed",
method: "tools/call",
target: "fetch_remote_data",
startedAt: "3/29/2026, 8:17:27 PM",
lastUpdated: "3/29/2026, 8:17:28 PM",
ttl: 300000,
error: "Connection refused: upstream server not responding",
isListExpanded: true,
onCancel: fn(),
},
];
const meta: Meta<typeof TaskListPanel> = {
title: "Groups/TaskListPanel",
component: TaskListPanel,
args: {
tasks: sampleTasks,
searchText: "",
},
};
export default meta;
type Story = StoryObj<typeof TaskListPanel>;
export const Default: Story = {};
export const FilteredByStatus: Story = {
args: {
statusFilter: "running",
},
};
export const WithSearch: Story = {
args: {
searchText: "generate",
},
};
export const Empty: Story = {
args: {
tasks: [],
},
};

View file

@ -0,0 +1,130 @@
import { useMemo, useState } from "react";
import { Group, Paper, ScrollArea, Stack, Text, Title } from "@mantine/core";
import { TaskCard } from "../TaskCard/TaskCard";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import type { TaskCardProps } from "../TaskCard/TaskCard";
export interface TaskListPanelProps {
tasks: TaskCardProps[];
searchText: string;
statusFilter?: string;
}
const PanelContainer = Paper.withProps({
withBorder: true,
p: "lg",
flex: 1,
variant: "panel",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
function taskKey(task: TaskCardProps): string {
return task.taskId;
}
function formatActiveTitle(count: number): string {
return `Active (${count})`;
}
function formatCompletedTitle(count: number): string {
return `Completed (${count})`;
}
function isActiveStatus(status: string): boolean {
return status === "waiting" || status === "running";
}
function matchesFilters(
task: TaskCardProps,
searchText: string,
statusFilter?: string,
): boolean {
if (statusFilter && task.status !== statusFilter) return false;
if (searchText) {
const term = searchText.toLowerCase();
const searchable =
`${task.taskId} ${task.method} ${task.target ?? ""} ${task.status}`.toLowerCase();
if (!searchable.includes(term)) return false;
}
return true;
}
export function TaskListPanel({
tasks,
searchText,
statusFilter,
}: TaskListPanelProps) {
const [compact, setCompact] = useState(false);
const filteredTasks = useMemo(
() => tasks.filter((t) => matchesFilters(t, searchText, statusFilter)),
[tasks, searchText, statusFilter],
);
const activeTasks = useMemo(
() => filteredTasks.filter((t) => isActiveStatus(t.status)),
[filteredTasks],
);
const completedTasks = useMemo(
() => filteredTasks.filter((t) => !isActiveStatus(t.status)),
[filteredTasks],
);
const hasResults = filteredTasks.length > 0;
return (
<PanelContainer>
<Group justify="space-between" mb="sm">
<Title order={4}>Tasks</Title>
{hasResults && (
<ListToggle
compact={compact}
onToggle={() => setCompact((c) => !c)}
/>
)}
</Group>
{!hasResults ? (
<EmptyState>No tasks</EmptyState>
) : (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<Stack gap="md">
{activeTasks.length > 0 && (
<>
<Title order={5}>{formatActiveTitle(activeTasks.length)}</Title>
{activeTasks.map((task) => (
<TaskCard
key={taskKey(task)}
{...task}
isListExpanded={!compact}
/>
))}
</>
)}
{completedTasks.length > 0 && (
<>
<Title order={5}>
{formatCompletedTitle(completedTasks.length)}
</Title>
{completedTasks.map((task) => (
<TaskCard
key={taskKey(task)}
{...task}
isListExpanded={!compact}
/>
))}
</>
)}
</Stack>
</ScrollArea.Autosize>
)}
</PanelContainer>
);
}