feat(web): add My Issues page with grouped filters

Add a new "My Issues" page accessible from the sidebar, positioned
between Inbox and Issues. Issues are grouped into three collapsible
sections: assigned to me, assigned to my agents, and created by me.
This commit is contained in:
Jiayuan 2026-03-31 12:53:21 +08:00
parent 2152fec4ee
commit d199b6c728
4 changed files with 181 additions and 0 deletions

View file

@ -14,6 +14,7 @@ import {
Check,
BookOpenText,
SquarePen,
CircleUser,
} from "lucide-react";
import { WorkspaceAvatar } from "@/features/workspace";
import { useIssueDraftStore } from "@/features/issues/stores/draft-store";
@ -46,6 +47,7 @@ import { useModalStore } from "@/features/modals";
const primaryNav = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
{ href: "/my-issues", label: "My Issues", icon: CircleUser },
{ href: "/issues", label: "Issues", icon: ListTodo },
];

View file

@ -0,0 +1,7 @@
"use client";
import { MyIssuesPage } from "@/features/my-issues";
export default function Page() {
return <MyIssuesPage />;
}

View file

@ -0,0 +1,171 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { ChevronRight, User, Bot, SquarePen, ListTodo } from "lucide-react";
import { Accordion } from "@base-ui/react/accordion";
import type { Issue } from "@/shared/types";
import { useAuthStore } from "@/features/auth";
import { useWorkspaceStore } from "@/features/workspace";
import { useIssueStore } from "@/features/issues/store";
import { WorkspaceAvatar } from "@/features/workspace";
import { StatusIcon } from "@/features/issues/components/status-icon";
import { PriorityIcon } from "@/features/issues/components/priority-icon";
import { ActorAvatar } from "@/components/common/actor-avatar";
import { Skeleton } from "@/components/ui/skeleton";
interface GroupConfig {
key: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const GROUPS: GroupConfig[] = [
{ key: "assigned_to_me", label: "Assigned to me", icon: User },
{ key: "assigned_to_my_agents", label: "Assigned to my agents", icon: Bot },
{ key: "created_by_me", label: "Created by me", icon: SquarePen },
];
function formatDate(date: string): string {
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
function IssueRow({ issue }: { issue: Issue }) {
return (
<Link
href={`/issues/${issue.id}`}
className="flex h-9 items-center gap-2 px-4 text-sm transition-colors hover:bg-accent/50"
>
<PriorityIcon priority={issue.priority} className="shrink-0" />
<StatusIcon status={issue.status} className="h-3.5 w-3.5 shrink-0" />
<span className="w-16 shrink-0 text-xs text-muted-foreground">
{issue.identifier}
</span>
<span className="min-w-0 flex-1 truncate">{issue.title}</span>
{issue.due_date && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatDate(issue.due_date)}
</span>
)}
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={20}
/>
)}
</Link>
);
}
export function MyIssuesPage() {
const user = useAuthStore((s) => s.user);
const workspace = useWorkspaceStore((s) => s.workspace);
const agents = useWorkspaceStore((s) => s.agents);
const allIssues = useIssueStore((s) => s.issues);
const loading = useIssueStore((s) => s.loading);
const myAgentIds = useMemo(() => {
if (!user) return new Set<string>();
return new Set(agents.filter((a) => a.owner_id === user.id).map((a) => a.id));
}, [agents, user]);
const grouped = useMemo(() => {
if (!user) return new Map<string, Issue[]>();
const assignedToMe = allIssues.filter(
(i) => i.assignee_type === "member" && i.assignee_id === user.id,
);
const assignedToMyAgents = allIssues.filter(
(i) => i.assignee_type === "agent" && i.assignee_id && myAgentIds.has(i.assignee_id),
);
const createdByMe = allIssues.filter(
(i) => i.creator_type === "member" && i.creator_id === user.id,
);
const map = new Map<string, Issue[]>();
map.set("assigned_to_me", assignedToMe);
map.set("assigned_to_my_agents", assignedToMyAgents);
map.set("created_by_me", createdByMe);
return map;
}, [allIssues, user, myAgentIds]);
if (loading) {
return (
<div className="flex flex-1 min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b px-4">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-32" />
</div>
<div className="flex-1 p-4 space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
))}
</div>
</div>
);
}
return (
<div className="flex flex-1 min-h-0 flex-col">
{/* Header: Workspace breadcrumb */}
<div className="flex h-12 shrink-0 items-center gap-1.5 border-b px-4">
<WorkspaceAvatar name={workspace?.name ?? "W"} size="sm" />
<span className="text-sm text-muted-foreground">
{workspace?.name ?? "Workspace"}
</span>
<ChevronRight className="h-3 w-3 text-muted-foreground" />
<span className="text-sm font-medium">My Issues</span>
</div>
{/* Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<Accordion.Root
multiple
className="space-y-1"
defaultValue={GROUPS.map((g) => g.key)}
>
{GROUPS.map((group) => {
const issues = grouped.get(group.key) ?? [];
const Icon = group.icon;
return (
<Accordion.Item key={group.key} value={group.key}>
<Accordion.Header className="flex h-10 items-center rounded-lg bg-muted/40 transition-colors hover:bg-accent/30">
<Accordion.Trigger className="group/trigger flex flex-1 items-center gap-2 px-3 h-full text-left outline-none">
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground transition-transform group-aria-expanded/trigger:rotate-90" />
<Icon className="size-3.5 text-muted-foreground" />
<span className="text-sm font-medium">
{group.label}
</span>
<span className="text-xs text-muted-foreground">
{issues.length}
</span>
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Panel className="pt-1">
{issues.length > 0 ? (
issues.map((issue) => (
<IssueRow key={issue.id} issue={issue} />
))
) : (
<p className="py-6 text-center text-xs text-muted-foreground">
No issues
</p>
)}
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion.Root>
</div>
</div>
);
}

View file

@ -0,0 +1 @@
export { MyIssuesPage } from "./components/my-issues-page";