diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index 8b2a143e..c48dc4f8 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -339,23 +339,26 @@ describe("IssuesPage", () => { expect(screen.getByText("Issues")).toBeInTheDocument(); }); - it("shows 'New Issue' button", () => { + it("shows scope buttons", () => { mockStoreState.loading = false; mockStoreState.issues = []; render(); - expect(screen.getByText("New Issue")).toBeInTheDocument(); + expect(screen.getByText("All")).toBeInTheDocument(); + expect(screen.getByText("Members")).toBeInTheDocument(); + expect(screen.getByText("Agents")).toBeInTheDocument(); }); - it("shows filter buttons", () => { + it("shows filter and display icon buttons", () => { mockStoreState.loading = false; mockStoreState.issues = mockIssues; render(); - expect(screen.getByText("Filter")).toBeInTheDocument(); - expect(screen.getByText("Display")).toBeInTheDocument(); + // Filter and Display are now icon-only buttons, verify they render as buttons + const buttons = screen.getAllByRole("button"); + expect(buttons.length).toBeGreaterThan(0); }); it("shows empty board view when no issues exist", () => { diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 5b59b63c..8574013d 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -10,7 +10,6 @@ import { Columns3, Filter, List, - Plus, SignalHigh, SlidersHorizontal, User, @@ -18,7 +17,6 @@ import { UserPen, } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useIssueStore } from "@/features/issues/store"; import { DropdownMenu, DropdownMenuTrigger, @@ -38,7 +36,6 @@ import { PopoverContent, } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import { useModalStore } from "@/features/modals"; import { ALL_STATUSES, STATUS_CONFIG, @@ -54,13 +51,16 @@ import { CARD_PROPERTY_OPTIONS, type ActorFilterValue, } from "@/features/issues/stores/view-store"; +import { + useIssuesScopeStore, + type IssuesScope, +} from "@/features/issues/stores/issues-scope-store"; import { filterIssues } from "@/features/issues/utils/filter"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import type { Issue } from "@/shared/types"; // --------------------------------------------------------------------------- // HoverCheck — shadcn official pattern (PR #6862) -// Uses data-selected attr instead of Checkbox component to avoid -// DropdownMenuCheckboxItem's focus:**:text-accent-foreground cascade. // --------------------------------------------------------------------------- const FILTER_ITEM_CLASS = @@ -123,6 +123,16 @@ function useIssueCounts(allIssues: Issue[]) { }, [allIssues]); } +// --------------------------------------------------------------------------- +// Scope config +// --------------------------------------------------------------------------- + +const SCOPES: { value: IssuesScope; label: string; description: string }[] = [ + { value: "all", label: "All", description: "All issues in this workspace" }, + { value: "members", label: "Members", description: "Issues assigned to team members" }, + { value: "agents", label: "Agents", description: "Issues assigned to AI agents" }, +]; + // --------------------------------------------------------------------------- // Actor sub-menu content (shared between Assignee and Creator) // --------------------------------------------------------------------------- @@ -262,7 +272,10 @@ function ActorSubContent({ // IssuesHeader // --------------------------------------------------------------------------- -export function IssuesHeader() { +export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { + const scope = useIssuesScopeStore((s) => s.scope); + const setScope = useIssuesScopeStore((s) => s.setScope); + const viewMode = useIssueViewStore((s) => s.viewMode); const statusFilters = useIssueViewStore((s) => s.statusFilters); const priorityFilters = useIssueViewStore((s) => s.priorityFilters); @@ -274,74 +287,69 @@ export function IssuesHeader() { const cardProperties = useIssueViewStore((s) => s.cardProperties); const act = useIssueViewStore.getState(); - const allIssues = useIssueStore((s) => s.issues); - const counts = useIssueCounts(allIssues); + const counts = useIssueCounts(scopedIssues); - const filteredCount = useMemo( - () => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }).length, - [allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], - ); - - const filterCount = getActiveFilterCount({ - statusFilters, - priorityFilters, - assigneeFilters, - includeNoAssignee, - creatorFilters, - }); + const hasActiveFilters = + getActiveFilterCount({ + statusFilters, + priorityFilters, + assigneeFilters, + includeNoAssignee, + creatorFilters, + }) > 0; const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; - const hasActiveFilters = filterCount > 0; return (
-
- {/* View toggle */} - - - {viewMode === "board" ? : } - {viewMode === "board" ? "Board" : "List"} - - } - /> - - - View - act.setViewMode("board")}> - - Board - - act.setViewMode("list")}> - - List - - - - + {/* Left: scope buttons */} +
+ {SCOPES.map((s) => ( + + setScope(s.value)} + > + {s.label} + + } + /> + {s.description} + + ))} +
- {/* Filter — DropdownMenu with sub-menus */} + {/* Right: filter + display + view toggle */} +
+ {/* Filter */} - - - Filter - {hasActiveFilters && ( - - {filterCount} - - )} - - } - /> - + + + + {hasActiveFilters && ( + + )} + + } + /> + } + /> + Filter + + {/* Status */} @@ -472,15 +480,21 @@ export function IssuesHeader() { {/* Display settings */} - - - Display - - } - /> - + + + + + } + /> + } + /> + Display settings + +
Ordering @@ -549,19 +563,43 @@ export function IssuesHeader() {
-
-
- - {filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} - - + {/* View toggle */} + + + + {viewMode === "board" ? ( + + ) : ( + + )} + + } + /> + } + /> + + {viewMode === "board" ? "Board view" : "List view"} + + + + + View + act.setViewMode("board")}> + + Board + + act.setViewMode("list")}> + + List + + + +
); diff --git a/apps/web/features/issues/components/issues-page.tsx b/apps/web/features/issues/components/issues-page.tsx index f09b4e7f..84ad32e6 100644 --- a/apps/web/features/issues/components/issues-page.tsx +++ b/apps/web/features/issues/components/issues-page.tsx @@ -7,6 +7,7 @@ import type { IssueStatus } from "@/shared/types"; import { Skeleton } from "@/components/ui/skeleton"; import { useIssueStore } from "@/features/issues/store"; import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-store"; +import { useIssuesScopeStore } from "@/features/issues/stores/issues-scope-store"; import { ViewStoreProvider } from "@/features/issues/stores/view-store-context"; import { filterIssues } from "@/features/issues/utils/filter"; import { BOARD_STATUSES } from "@/features/issues/config"; @@ -23,6 +24,7 @@ export function IssuesPage() { const allIssues = useIssueStore((s) => s.issues); const loading = useIssueStore((s) => s.loading); const workspace = useWorkspaceStore((s) => s.workspace); + const scope = useIssuesScopeStore((s) => s.scope); const viewMode = useIssueViewStore((s) => s.viewMode); const statusFilters = useIssueViewStore((s) => s.statusFilters); const priorityFilters = useIssueViewStore((s) => s.priorityFilters); @@ -36,11 +38,20 @@ export function IssuesPage() { useEffect(() => { useIssueSelectionStore.getState().clear(); - }, [viewMode]); + }, [viewMode, scope]); + + // Scope pre-filter: narrow by assignee type + const scopedIssues = useMemo(() => { + if (scope === "members") + return allIssues.filter((i) => i.assignee_type === "member"); + if (scope === "agents") + return allIssues.filter((i) => i.assignee_type === "agent"); + return allIssues; + }, [allIssues, scope]); const issues = useMemo( - () => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }), - [allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], + () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }), + [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], ); const visibleStatuses = useMemo(() => { @@ -115,8 +126,8 @@ export function IssuesPage() { Issues
- {/* Header 2: View toggle + filters */} - + {/* Header 2: Scope tabs + filters */} + {/* Content: scrollable */} @@ -124,7 +135,7 @@ export function IssuesPage() { {viewMode === "board" ? ( void; +} + +export const useIssuesScopeStore = create()( + persist( + (set) => ({ + scope: "all", + setScope: (scope) => set({ scope }), + }), + { name: "multica_issues_scope" }, + ), +); diff --git a/apps/web/features/issues/stores/view-store.ts b/apps/web/features/issues/stores/view-store.ts index 01dae9f1..a77759b3 100644 --- a/apps/web/features/issues/stores/view-store.ts +++ b/apps/web/features/issues/stores/view-store.ts @@ -63,7 +63,7 @@ export interface IssueViewState { toggleListCollapsed: (status: IssueStatus) => void; } -const viewStoreSlice = (set: StoreApi["setState"]): IssueViewState => ({ +export const viewStoreSlice = (set: StoreApi["setState"]): IssueViewState => ({ viewMode: "board", statusFilters: [], priorityFilters: [], @@ -162,7 +162,7 @@ const viewStoreSlice = (set: StoreApi["setState"]): IssueViewSta })), }); -const viewStorePersistOptions = (name: string) => ({ +export const viewStorePersistOptions = (name: string) => ({ name, partialize: (state: IssueViewState) => ({ viewMode: state.viewMode, diff --git a/apps/web/features/my-issues/components/my-issues-header.tsx b/apps/web/features/my-issues/components/my-issues-header.tsx index eb934987..f1f24aee 100644 --- a/apps/web/features/my-issues/components/my-issues-header.tsx +++ b/apps/web/features/my-issues/components/my-issues-header.tsx @@ -11,7 +11,6 @@ import { Columns3, Filter, List, - Plus, SignalHigh, SlidersHorizontal, } from "lucide-react"; @@ -35,7 +34,6 @@ import { PopoverContent, } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; -import { useModalStore } from "@/features/modals"; import { ALL_STATUSES, STATUS_CONFIG, @@ -48,11 +46,12 @@ import { CARD_PROPERTY_OPTIONS, } from "@/features/issues/stores/view-store"; import { filterIssues } from "@/features/issues/utils/filter"; +import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import type { Issue } from "@/shared/types"; -import { myIssuesViewStore } from "../stores/my-issues-view-store"; +import { myIssuesViewStore, type MyIssuesScope } from "../stores/my-issues-view-store"; // --------------------------------------------------------------------------- -// HoverCheck — shadcn official pattern (PR #6862) +// HoverCheck // --------------------------------------------------------------------------- const FILTER_ITEM_CLASS = @@ -97,6 +96,16 @@ function useIssueCounts(allIssues: Issue[]) { }, [allIssues]); } +// --------------------------------------------------------------------------- +// Scope config +// --------------------------------------------------------------------------- + +const SCOPES: { value: MyIssuesScope; label: string; description: string }[] = [ + { value: "assigned", label: "Assigned", description: "Issues assigned to me" }, + { value: "created", label: "Created", description: "Issues I created" }, + { value: "agents", label: "My Agents", description: "Issues assigned to my agents" }, +]; + // --------------------------------------------------------------------------- // MyIssuesHeader // --------------------------------------------------------------------------- @@ -108,82 +117,66 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) { const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy); const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection); const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties); + const scope = useStore(myIssuesViewStore, (s) => s.scope); const act = myIssuesViewStore.getState(); const counts = useIssueCounts(allIssues); - const filteredCount = useMemo( - () => - filterIssues(allIssues, { - statusFilters, - priorityFilters, - assigneeFilters: [], - includeNoAssignee: false, - creatorFilters: [], - }).length, - [allIssues, statusFilters, priorityFilters], - ); - - const filterCount = getActiveFilterCount({ statusFilters, priorityFilters }); + const hasActiveFilters = + getActiveFilterCount({ statusFilters, priorityFilters }) > 0; const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; - const hasActiveFilters = filterCount > 0; return (
-
- {/* View toggle */} - - - {viewMode === "board" ? ( - - ) : ( - - )} - {viewMode === "board" ? "Board" : "List"} - - } - /> - - - View - act.setViewMode("board")}> - - Board - - act.setViewMode("list")}> - - List - - - - + {/* Left: scope buttons */} +
+ {SCOPES.map((s) => ( + + act.setScope(s.value)} + > + {s.label} + + } + /> + {s.description} + + ))} +
- {/* Filter — DropdownMenu with sub-menus */} + {/* Right: filter + display + view toggle */} +
+ {/* Filter */} - - - Filter - {hasActiveFilters && ( - - {filterCount} - - )} - - } - /> - + + + + {hasActiveFilters && ( + + )} + + } + /> + } + /> + Filter + + {/* Status */} @@ -270,15 +263,21 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) { {/* Display settings */} - - - Display - - } - /> - + + + + + } + /> + } + /> + Display settings + +
Ordering @@ -349,19 +348,43 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
-
-
- - {filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} - - + {/* View toggle */} + + + + {viewMode === "board" ? ( + + ) : ( + + )} + + } + /> + } + /> + + {viewMode === "board" ? "Board view" : "List view"} + + + + + View + act.setViewMode("board")}> + + Board + + act.setViewMode("list")}> + + List + + + +
); diff --git a/apps/web/features/my-issues/components/my-issues-page.tsx b/apps/web/features/my-issues/components/my-issues-page.tsx index 757c4b49..d8bcf855 100644 --- a/apps/web/features/my-issues/components/my-issues-page.tsx +++ b/apps/web/features/my-issues/components/my-issues-page.tsx @@ -31,6 +31,7 @@ export function MyIssuesPage() { const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode); const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters); const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters); + const scope = useStore(myIssuesViewStore, (s) => s.scope); useEffect(() => { registerViewStoreForWorkspaceSync(myIssuesViewStore); @@ -38,7 +39,7 @@ export function MyIssuesPage() { useEffect(() => { useIssueSelectionStore.getState().clear(); - }, [viewMode]); + }, [viewMode, scope]); const myAgentIds = useMemo(() => { if (!user) return new Set(); @@ -47,19 +48,40 @@ export function MyIssuesPage() { ); }, [agents, user]); - // Pre-filter: union of (assigned to me + my agents + created by me) - const myIssues = useMemo(() => { + // Per-scope issue lists + const assignedToMe = useMemo(() => { + if (!user) return []; + return allIssues.filter( + (i) => i.assignee_type === "member" && i.assignee_id === user.id, + ); + }, [allIssues, user]); + + const myAgentIssues = useMemo(() => { if (!user) return []; return allIssues.filter( (i) => - (i.assignee_type === "member" && i.assignee_id === user.id) || - (i.assignee_type === "agent" && - i.assignee_id && - myAgentIds.has(i.assignee_id)) || - (i.creator_type === "member" && i.creator_id === user.id), + i.assignee_type === "agent" && + i.assignee_id && + myAgentIds.has(i.assignee_id), ); }, [allIssues, user, myAgentIds]); + const createdByMe = useMemo(() => { + if (!user) return []; + return allIssues.filter( + (i) => i.creator_type === "member" && i.creator_id === user.id, + ); + }, [allIssues, user]); + + const myIssues = useMemo(() => { + switch (scope) { + case "assigned": return assignedToMe; + case "agents": return myAgentIssues; + case "created": return createdByMe; + default: return assignedToMe; + } + }, [scope, assignedToMe, myAgentIssues, createdByMe]); + // Apply status/priority filters from view store const issues = useMemo( () => @@ -144,7 +166,7 @@ export function MyIssuesPage() { My Issues
- {/* Header 2: View toggle + filters */} + {/* Header: scope tabs (left) + controls (right) */} {/* Content: scrollable */} diff --git a/apps/web/features/my-issues/stores/my-issues-view-store.ts b/apps/web/features/my-issues/stores/my-issues-view-store.ts index 3f59c776..0b97406f 100644 --- a/apps/web/features/my-issues/stores/my-issues-view-store.ts +++ b/apps/web/features/my-issues/stores/my-issues-view-store.ts @@ -1,5 +1,35 @@ "use client"; -import { createIssueViewStore } from "@/features/issues/stores/view-store"; +import { createStore, type StoreApi } from "zustand/vanilla"; +import { persist } from "zustand/middleware"; +import { + type IssueViewState, + viewStoreSlice, + viewStorePersistOptions, +} from "@/features/issues/stores/view-store"; -export const myIssuesViewStore = createIssueViewStore("multica_my_issues_view"); +export type MyIssuesScope = "assigned" | "created" | "agents"; + +export interface MyIssuesViewState extends IssueViewState { + scope: MyIssuesScope; + setScope: (scope: MyIssuesScope) => void; +} + +const basePersist = viewStorePersistOptions("multica_my_issues_view"); + +export const myIssuesViewStore: StoreApi = createStore()( + persist( + (set) => ({ + ...viewStoreSlice(set as unknown as StoreApi["setState"]), + scope: "assigned" as MyIssuesScope, + setScope: (scope: MyIssuesScope) => set({ scope }), + }), + { + name: basePersist.name, + partialize: (state: MyIssuesViewState) => ({ + ...basePersist.partialize(state), + scope: state.scope, + }), + }, + ), +);