Make the Work board filter functional

This commit is contained in:
ZacharyZhang-NY committed 2026-05-28 22:16:34 -04:00
1 parent ea1af95536
commit 881747341e
3 files changed
+201 -3

No files matched your search

@@ -72,6 +72,51 @@
.option-card .opt-title { font-size: 13.5px; font-weight: 540; }
.option-card .opt-sub { font-size: 12px; color: var(--text-3); margin-top: 4px; }
.popover-anchor { position: relative; display: inline-flex; }
.popover-panel {
position: absolute;
top: calc(100% + 6px);
z-index: 35;
min-width: 208px;
max-width: 280px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--r-md);
box-shadow: var(--shadow-md);
padding: 6px;
display: flex;
flex-direction: column;
gap: 1px;
}
.popover-panel.end { right: 0; }
.popover-panel.start { left: 0; }
.menu-label {
padding: 6px 8px 4px;
font-size: 11px;
font-weight: 540;
letter-spacing: 0.02em;
color: var(--text-3);
}
.menu-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: var(--r-sm);
background: transparent;
color: var(--text-2);
font-size: 13px;
text-align: left;
width: 100%;
}
.menu-row:hover { background: var(--hover); color: var(--text-1); }
.menu-row.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
.menu-row.danger { color: var(--red); }
.menu-row.danger:hover { background: rgba(210, 59, 59, 0.1); color: var(--red); }
.menu-row .menu-check { margin-left: auto; color: var(--accent); display: inline-flex; }
.menu-divider { height: 1px; background: var(--border-soft); margin: 4px 0; }
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
::-webkit-scrollbar-track { background: transparent; }
@@ -1,12 +1,15 @@
import { useMemo, useState } from "react";
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
import { MenuDivider, MenuLabel, MenuRow, Popover } from "../shared/Popover";
import { TopbarActions } from "../shared/TopbarActions";
import {
TASK_PRIORITY,
TASK_STATUS,
type AgentRecord,
type GoalRecord,
type ProviderRecord,
type ProjectRecord,
type TaskPriority,
type TaskRecord,
type TaskRunRecord,
type TaskStatus,
@@ -35,8 +38,23 @@ export function WorkPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const [view, setView] = useState<View>("board");
const [newTaskOpen, setNewTaskOpen] = useState(false);
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
const tasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
const allTasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
const tasks = useMemo(
() =>
allTasks.filter((task) => {
if (priorityFilter !== "all" && task.priority !== priorityFilter) return false;
if (assigneeFilter !== "all" && task.assigneeAgentId !== assigneeFilter) return false;
return true;
}),
[allTasks, priorityFilter, assigneeFilter],
);
const assigneeCount = useMemo(
() => new Set(tasks.map((task) => task.assigneeAgentId).filter((id): id is string => id !== null)).size,
[tasks],
);
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
const projects = useMemo(() => snapshot.projects.filter((project) => project.orgId === orgId), [snapshot.projects, orgId]);
const goals = useMemo(() => snapshot.goals.filter((goal) => goal.orgId === orgId), [snapshot.goals, orgId]);
@@ -57,12 +75,18 @@ export function WorkPage({ snapshot, actions }: PageProps) {
<div className="page-wide">
<TopbarActions>
<Segmented value={view} onChange={setView} options={VIEW_OPTIONS} />
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
<WorkFilter
agents={agents}
priority={priorityFilter}
assignee={assigneeFilter}
onPriority={setPriorityFilter}
onAssignee={setAssigneeFilter}
/>
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
</TopbarActions>
<h1 className="page-h1">Work</h1>
<p className="page-sub">
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {agents.length} {agents.length === 1 ? "assignee" : "assignees"}
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {assigneeCount} {assigneeCount === 1 ? "assignee" : "assignees"}
</p>
{view === "board" && (
@@ -87,6 +111,61 @@ export function WorkPage({ snapshot, actions }: PageProps) {
);
}
function WorkFilter({
agents,
priority,
assignee,
onPriority,
onAssignee,
}: {
agents: AgentRecord[];
priority: TaskPriority | "all";
assignee: string;
onPriority: (next: TaskPriority | "all") => void;
onAssignee: (next: string) => void;
}) {
const active = priority !== "all" || assignee !== "all";
const priorities: ReadonlyArray<{ id: TaskPriority | "all"; label: string }> = [
{ id: "all", label: "All priorities" },
{ id: TASK_PRIORITY.HIGH, label: "High" },
{ id: TASK_PRIORITY.NORMAL, label: "Normal" },
{ id: TASK_PRIORITY.LOW, label: "Low" },
];
return (
<Popover
trigger={({ toggle }) => (
<button type="button" className="button ghost" aria-label="Filter tasks" onClick={toggle}>
<Icon name="filter" size={13} color={active ? "var(--accent)" : "currentColor"} />
</button>
)}
>
{() => (
<>
<MenuLabel>Priority</MenuLabel>
{priorities.map((option) => (
<MenuRow key={option.id} active={priority === option.id} onClick={() => onPriority(option.id)}>
{option.label}
{priority === option.id && <span className="menu-check"><Icon name="check" size={13} /></span>}
</MenuRow>
))}
<MenuDivider />
<MenuLabel>Assignee</MenuLabel>
<MenuRow active={assignee === "all"} onClick={() => onAssignee("all")}>
All assignees
{assignee === "all" && <span className="menu-check"><Icon name="check" size={13} /></span>}
</MenuRow>
{agents.map((agent) => (
<MenuRow key={agent.id} active={assignee === agent.id} onClick={() => onAssignee(agent.id)}>
{agent.name}
{assignee === agent.id && <span className="menu-check"><Icon name="check" size={13} /></span>}
</MenuRow>
))}
</>
)}
</Popover>
);
}
function BoardView({
tasks,
agents,
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
/** Anchored popover used for filter and overflow menus. Closes on outside
* click or Escape so triggers across the app stay functional and consistent. */
export function Popover({
align = "end",
trigger,
children,
}: {
align?: "start" | "end";
trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
children: (close: () => void) => ReactNode;
}) {
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: MouseEvent) => {
if (anchorRef.current && !anchorRef.current.contains(event.target as Node)) setOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onPointerDown);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onPointerDown);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
return (
<div className="popover-anchor" ref={anchorRef}>
{trigger({ open, toggle: () => setOpen((value) => !value) })}
{open && (
<div className={`popover-panel ${align}`.trim()} role="menu">
{children(() => setOpen(false))}
</div>
)}
</div>
);
}
export function MenuLabel({ children }: { children: ReactNode }) {
return <div className="menu-label">{children}</div>;
}
export function MenuRow({
active = false,
danger = false,
onClick,
children,
}: {
active?: boolean;
danger?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
className={`menu-row ${active ? "active" : ""} ${danger ? "danger" : ""}`.replace(/\s+/g, " ").trim()}
onClick={onClick}
>
{children}
</button>
);
}
export function MenuDivider() {
return <div className="menu-divider" />;
}