Merge pull request #293 from multica-ai/feature/issues-scope-tabs

feat(web): add scope tabs to Issues and My Issues pages
This commit is contained in:
Naiyuan Qing 2026-04-01 18:49:07 +08:00 committed by GitHub
commit ae1c05af60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 347 additions and 199 deletions

View file

@ -339,23 +339,26 @@ describe("IssuesPage", () => {
expect(screen.getByText("Issues")).toBeInTheDocument(); expect(screen.getByText("Issues")).toBeInTheDocument();
}); });
it("shows 'New Issue' button", () => { it("shows scope buttons", () => {
mockStoreState.loading = false; mockStoreState.loading = false;
mockStoreState.issues = []; mockStoreState.issues = [];
render(<IssuesPage />); render(<IssuesPage />);
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.loading = false;
mockStoreState.issues = mockIssues; mockStoreState.issues = mockIssues;
render(<IssuesPage />); render(<IssuesPage />);
expect(screen.getByText("Filter")).toBeInTheDocument(); // Filter and Display are now icon-only buttons, verify they render as buttons
expect(screen.getByText("Display")).toBeInTheDocument(); const buttons = screen.getAllByRole("button");
expect(buttons.length).toBeGreaterThan(0);
}); });
it("shows empty board view when no issues exist", () => { it("shows empty board view when no issues exist", () => {

View file

@ -10,7 +10,6 @@ import {
Columns3, Columns3,
Filter, Filter,
List, List,
Plus,
SignalHigh, SignalHigh,
SlidersHorizontal, SlidersHorizontal,
User, User,
@ -18,7 +17,6 @@ import {
UserPen, UserPen,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useIssueStore } from "@/features/issues/store";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -38,7 +36,6 @@ import {
PopoverContent, PopoverContent,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useModalStore } from "@/features/modals";
import { import {
ALL_STATUSES, ALL_STATUSES,
STATUS_CONFIG, STATUS_CONFIG,
@ -54,13 +51,16 @@ import {
CARD_PROPERTY_OPTIONS, CARD_PROPERTY_OPTIONS,
type ActorFilterValue, type ActorFilterValue,
} from "@/features/issues/stores/view-store"; } 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 { filterIssues } from "@/features/issues/utils/filter";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { Issue } from "@/shared/types"; import type { Issue } from "@/shared/types";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// HoverCheck — shadcn official pattern (PR #6862) // 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 = const FILTER_ITEM_CLASS =
@ -123,6 +123,16 @@ function useIssueCounts(allIssues: Issue[]) {
}, [allIssues]); }, [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) // Actor sub-menu content (shared between Assignee and Creator)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -262,7 +272,10 @@ function ActorSubContent({
// IssuesHeader // 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 viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters); const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters); const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
@ -274,74 +287,69 @@ export function IssuesHeader() {
const cardProperties = useIssueViewStore((s) => s.cardProperties); const cardProperties = useIssueViewStore((s) => s.cardProperties);
const act = useIssueViewStore.getState(); const act = useIssueViewStore.getState();
const allIssues = useIssueStore((s) => s.issues); const counts = useIssueCounts(scopedIssues);
const counts = useIssueCounts(allIssues);
const filteredCount = useMemo( const hasActiveFilters =
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }).length, getActiveFilterCount({
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], statusFilters,
); priorityFilters,
assigneeFilters,
const filterCount = getActiveFilterCount({ includeNoAssignee,
statusFilters, creatorFilters,
priorityFilters, }) > 0;
assigneeFilters,
includeNoAssignee,
creatorFilters,
});
const sortLabel = const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters = filterCount > 0;
return ( return (
<div className="flex h-12 shrink-0 items-center justify-between px-4"> <div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2"> {/* Left: scope buttons */}
{/* View toggle */} <div className="flex items-center gap-1">
<DropdownMenu> {SCOPES.map((s) => (
<DropdownMenuTrigger <Tooltip key={s.value}>
render={ <TooltipTrigger
<Button variant="outline" size="sm"> render={
{viewMode === "board" ? <Columns3 className="size-3.5" /> : <List className="size-3.5" />} <Button
{viewMode === "board" ? "Board" : "List"} variant="outline"
</Button> size="sm"
} className={
/> scope === s.value
<DropdownMenuContent align="start" className="w-auto"> ? "bg-accent text-accent-foreground hover:bg-accent/80"
<DropdownMenuGroup> : "text-muted-foreground"
<DropdownMenuLabel>View</DropdownMenuLabel> }
<DropdownMenuItem onClick={() => act.setViewMode("board")}> onClick={() => setScope(s.value)}
<Columns3 /> >
Board {s.label}
</DropdownMenuItem> </Button>
<DropdownMenuItem onClick={() => act.setViewMode("list")}> }
<List /> />
List <TooltipContent side="bottom">{s.description}</TooltipContent>
</DropdownMenuItem> </Tooltip>
</DropdownMenuGroup> ))}
</DropdownMenuContent> </div>
</DropdownMenu>
{/* Filter — DropdownMenu with sub-menus */} {/* Right: filter + display + view toggle */}
<div className="flex items-center gap-1">
{/* Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <Tooltip>
render={ <DropdownMenuTrigger
<Button render={
variant="outline" <TooltipTrigger
size="sm" render={
className={hasActiveFilters ? "border-primary/50 text-primary" : ""} <Button variant="outline" size="icon-sm" className="relative">
> <Filter className="size-4" />
<Filter className="size-3.5" /> {hasActiveFilters && (
Filter <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
{hasActiveFilters && ( )}
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-medium text-primary-foreground"> </Button>
{filterCount} }
</span> />
)} }
</Button> />
} <TooltipContent side="bottom">Filter</TooltipContent>
/> </Tooltip>
<DropdownMenuContent align="start" className="w-auto"> <DropdownMenuContent align="end" className="w-auto">
{/* Status */} {/* Status */}
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger> <DropdownMenuSubTrigger>
@ -472,15 +480,21 @@ export function IssuesHeader() {
{/* Display settings */} {/* Display settings */}
<Popover> <Popover>
<PopoverTrigger <Tooltip>
render={ <PopoverTrigger
<Button variant="outline" size="sm"> render={
<SlidersHorizontal className="size-3.5" /> <TooltipTrigger
Display render={
</Button> <Button variant="outline" size="icon-sm">
} <SlidersHorizontal className="size-4" />
/> </Button>
<PopoverContent align="start" className="w-64 p-0"> }
/>
}
/>
<TooltipContent side="bottom">Display settings</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
<div className="border-b px-3 py-2.5"> <div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
Ordering Ordering
@ -549,19 +563,43 @@ export function IssuesHeader() {
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div>
<div className="flex items-center gap-3"> {/* View toggle */}
<span className="text-xs text-muted-foreground"> <DropdownMenu>
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} <Tooltip>
</span> <DropdownMenuTrigger
<Button render={
size="sm" <TooltipTrigger
onClick={() => useModalStore.getState().open("create-issue")} render={
> <Button variant="outline" size="icon-sm">
<Plus /> {viewMode === "board" ? (
New Issue <Columns3 className="size-4" />
</Button> ) : (
<List className="size-4" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">
{viewMode === "board" ? "Board view" : "List view"}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
<Columns3 />
Board
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
<List />
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
); );

View file

@ -7,6 +7,7 @@ import type { IssueStatus } from "@/shared/types";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useIssueStore } from "@/features/issues/store"; import { useIssueStore } from "@/features/issues/store";
import { useIssueViewStore, initFilterWorkspaceSync } from "@/features/issues/stores/view-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 { ViewStoreProvider } from "@/features/issues/stores/view-store-context";
import { filterIssues } from "@/features/issues/utils/filter"; import { filterIssues } from "@/features/issues/utils/filter";
import { BOARD_STATUSES } from "@/features/issues/config"; import { BOARD_STATUSES } from "@/features/issues/config";
@ -23,6 +24,7 @@ export function IssuesPage() {
const allIssues = useIssueStore((s) => s.issues); const allIssues = useIssueStore((s) => s.issues);
const loading = useIssueStore((s) => s.loading); const loading = useIssueStore((s) => s.loading);
const workspace = useWorkspaceStore((s) => s.workspace); const workspace = useWorkspaceStore((s) => s.workspace);
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode); const viewMode = useIssueViewStore((s) => s.viewMode);
const statusFilters = useIssueViewStore((s) => s.statusFilters); const statusFilters = useIssueViewStore((s) => s.statusFilters);
const priorityFilters = useIssueViewStore((s) => s.priorityFilters); const priorityFilters = useIssueViewStore((s) => s.priorityFilters);
@ -36,11 +38,20 @@ export function IssuesPage() {
useEffect(() => { useEffect(() => {
useIssueSelectionStore.getState().clear(); 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( const issues = useMemo(
() => filterIssues(allIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }), () => filterIssues(scopedIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters }),
[allIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters], [scopedIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters],
); );
const visibleStatuses = useMemo(() => { const visibleStatuses = useMemo(() => {
@ -115,8 +126,8 @@ export function IssuesPage() {
<span className="text-sm font-medium">Issues</span> <span className="text-sm font-medium">Issues</span>
</div> </div>
{/* Header 2: View toggle + filters */} {/* Header 2: Scope tabs + filters */}
<IssuesHeader /> <IssuesHeader scopedIssues={scopedIssues} />
{/* Content: scrollable */} {/* Content: scrollable */}
<ViewStoreProvider store={useIssueViewStore}> <ViewStoreProvider store={useIssueViewStore}>
@ -124,7 +135,7 @@ export function IssuesPage() {
{viewMode === "board" ? ( {viewMode === "board" ? (
<BoardView <BoardView
issues={issues} issues={issues}
allIssues={allIssues} allIssues={scopedIssues}
visibleStatuses={visibleStatuses} visibleStatuses={visibleStatuses}
hiddenStatuses={hiddenStatuses} hiddenStatuses={hiddenStatuses}
onMoveIssue={handleMoveIssue} onMoveIssue={handleMoveIssue}

View file

@ -0,0 +1,21 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type IssuesScope = "all" | "members" | "agents";
interface IssuesScopeState {
scope: IssuesScope;
setScope: (scope: IssuesScope) => void;
}
export const useIssuesScopeStore = create<IssuesScopeState>()(
persist(
(set) => ({
scope: "all",
setScope: (scope) => set({ scope }),
}),
{ name: "multica_issues_scope" },
),
);

View file

@ -63,7 +63,7 @@ export interface IssueViewState {
toggleListCollapsed: (status: IssueStatus) => void; toggleListCollapsed: (status: IssueStatus) => void;
} }
const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
viewMode: "board", viewMode: "board",
statusFilters: [], statusFilters: [],
priorityFilters: [], priorityFilters: [],
@ -162,7 +162,7 @@ const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewSta
})), })),
}); });
const viewStorePersistOptions = (name: string) => ({ export const viewStorePersistOptions = (name: string) => ({
name, name,
partialize: (state: IssueViewState) => ({ partialize: (state: IssueViewState) => ({
viewMode: state.viewMode, viewMode: state.viewMode,

View file

@ -11,7 +11,6 @@ import {
Columns3, Columns3,
Filter, Filter,
List, List,
Plus,
SignalHigh, SignalHigh,
SlidersHorizontal, SlidersHorizontal,
} from "lucide-react"; } from "lucide-react";
@ -35,7 +34,6 @@ import {
PopoverContent, PopoverContent,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useModalStore } from "@/features/modals";
import { import {
ALL_STATUSES, ALL_STATUSES,
STATUS_CONFIG, STATUS_CONFIG,
@ -48,11 +46,12 @@ import {
CARD_PROPERTY_OPTIONS, CARD_PROPERTY_OPTIONS,
} from "@/features/issues/stores/view-store"; } from "@/features/issues/stores/view-store";
import { filterIssues } from "@/features/issues/utils/filter"; import { filterIssues } from "@/features/issues/utils/filter";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import type { Issue } from "@/shared/types"; 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 = const FILTER_ITEM_CLASS =
@ -97,6 +96,16 @@ function useIssueCounts(allIssues: Issue[]) {
}, [allIssues]); }, [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 // MyIssuesHeader
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -108,82 +117,66 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy); const sortBy = useStore(myIssuesViewStore, (s) => s.sortBy);
const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection); const sortDirection = useStore(myIssuesViewStore, (s) => s.sortDirection);
const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties); const cardProperties = useStore(myIssuesViewStore, (s) => s.cardProperties);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
const act = myIssuesViewStore.getState(); const act = myIssuesViewStore.getState();
const counts = useIssueCounts(allIssues); const counts = useIssueCounts(allIssues);
const filteredCount = useMemo( const hasActiveFilters =
() => getActiveFilterCount({ statusFilters, priorityFilters }) > 0;
filterIssues(allIssues, {
statusFilters,
priorityFilters,
assigneeFilters: [],
includeNoAssignee: false,
creatorFilters: [],
}).length,
[allIssues, statusFilters, priorityFilters],
);
const filterCount = getActiveFilterCount({ statusFilters, priorityFilters });
const sortLabel = const sortLabel =
SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual"; SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Manual";
const hasActiveFilters = filterCount > 0;
return ( return (
<div className="flex h-12 shrink-0 items-center justify-between px-4"> <div className="flex h-12 shrink-0 items-center justify-between px-4">
<div className="flex items-center gap-2"> {/* Left: scope buttons */}
{/* View toggle */} <div className="flex items-center gap-1">
<DropdownMenu> {SCOPES.map((s) => (
<DropdownMenuTrigger <Tooltip key={s.value}>
render={ <TooltipTrigger
<Button variant="outline" size="sm"> render={
{viewMode === "board" ? ( <Button
<Columns3 className="size-3.5" /> variant="outline"
) : ( size="sm"
<List className="size-3.5" /> className={
)} scope === s.value
{viewMode === "board" ? "Board" : "List"} ? "bg-accent text-accent-foreground hover:bg-accent/80"
</Button> : "text-muted-foreground"
} }
/> onClick={() => act.setScope(s.value)}
<DropdownMenuContent align="start" className="w-auto"> >
<DropdownMenuGroup> {s.label}
<DropdownMenuLabel>View</DropdownMenuLabel> </Button>
<DropdownMenuItem onClick={() => act.setViewMode("board")}> }
<Columns3 /> />
Board <TooltipContent side="bottom">{s.description}</TooltipContent>
</DropdownMenuItem> </Tooltip>
<DropdownMenuItem onClick={() => act.setViewMode("list")}> ))}
<List /> </div>
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
{/* Filter — DropdownMenu with sub-menus */} {/* Right: filter + display + view toggle */}
<div className="flex items-center gap-1">
{/* Filter */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <Tooltip>
render={ <DropdownMenuTrigger
<Button render={
variant="outline" <TooltipTrigger
size="sm" render={
className={ <Button variant="outline" size="icon-sm" className="relative">
hasActiveFilters ? "border-primary/50 text-primary" : "" <Filter className="size-4" />
} {hasActiveFilters && (
> <span className="absolute top-0 right-0 size-1.5 rounded-full bg-brand" />
<Filter className="size-3.5" /> )}
Filter </Button>
{hasActiveFilters && ( }
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-xs font-medium text-primary-foreground"> />
{filterCount} }
</span> />
)} <TooltipContent side="bottom">Filter</TooltipContent>
</Button> </Tooltip>
} <DropdownMenuContent align="end" className="w-auto">
/>
<DropdownMenuContent align="start" className="w-auto">
{/* Status */} {/* Status */}
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger> <DropdownMenuSubTrigger>
@ -270,15 +263,21 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
{/* Display settings */} {/* Display settings */}
<Popover> <Popover>
<PopoverTrigger <Tooltip>
render={ <PopoverTrigger
<Button variant="outline" size="sm"> render={
<SlidersHorizontal className="size-3.5" /> <TooltipTrigger
Display render={
</Button> <Button variant="outline" size="icon-sm">
} <SlidersHorizontal className="size-4" />
/> </Button>
<PopoverContent align="start" className="w-64 p-0"> }
/>
}
/>
<TooltipContent side="bottom">Display settings</TooltipContent>
</Tooltip>
<PopoverContent align="end" className="w-64 p-0">
<div className="border-b px-3 py-2.5"> <div className="border-b px-3 py-2.5">
<span className="text-xs font-medium text-muted-foreground"> <span className="text-xs font-medium text-muted-foreground">
Ordering Ordering
@ -349,19 +348,43 @@ export function MyIssuesHeader({ allIssues }: { allIssues: Issue[] }) {
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div>
<div className="flex items-center gap-3"> {/* View toggle */}
<span className="text-xs text-muted-foreground"> <DropdownMenu>
{filteredCount} {filteredCount === 1 ? "Issue" : "Issues"} <Tooltip>
</span> <DropdownMenuTrigger
<Button render={
size="sm" <TooltipTrigger
onClick={() => useModalStore.getState().open("create-issue")} render={
> <Button variant="outline" size="icon-sm">
<Plus /> {viewMode === "board" ? (
New Issue <Columns3 className="size-4" />
</Button> ) : (
<List className="size-4" />
)}
</Button>
}
/>
}
/>
<TooltipContent side="bottom">
{viewMode === "board" ? "Board view" : "List view"}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuGroup>
<DropdownMenuLabel>View</DropdownMenuLabel>
<DropdownMenuItem onClick={() => act.setViewMode("board")}>
<Columns3 />
Board
</DropdownMenuItem>
<DropdownMenuItem onClick={() => act.setViewMode("list")}>
<List />
List
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
); );

View file

@ -31,6 +31,7 @@ export function MyIssuesPage() {
const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode); const viewMode = useStore(myIssuesViewStore, (s) => s.viewMode);
const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters); const statusFilters = useStore(myIssuesViewStore, (s) => s.statusFilters);
const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters); const priorityFilters = useStore(myIssuesViewStore, (s) => s.priorityFilters);
const scope = useStore(myIssuesViewStore, (s) => s.scope);
useEffect(() => { useEffect(() => {
registerViewStoreForWorkspaceSync(myIssuesViewStore); registerViewStoreForWorkspaceSync(myIssuesViewStore);
@ -38,7 +39,7 @@ export function MyIssuesPage() {
useEffect(() => { useEffect(() => {
useIssueSelectionStore.getState().clear(); useIssueSelectionStore.getState().clear();
}, [viewMode]); }, [viewMode, scope]);
const myAgentIds = useMemo(() => { const myAgentIds = useMemo(() => {
if (!user) return new Set<string>(); if (!user) return new Set<string>();
@ -47,19 +48,40 @@ export function MyIssuesPage() {
); );
}, [agents, user]); }, [agents, user]);
// Pre-filter: union of (assigned to me + my agents + created by me) // Per-scope issue lists
const myIssues = useMemo(() => { 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 []; if (!user) return [];
return allIssues.filter( return allIssues.filter(
(i) => (i) =>
(i.assignee_type === "member" && i.assignee_id === user.id) || i.assignee_type === "agent" &&
(i.assignee_type === "agent" && i.assignee_id &&
i.assignee_id && myAgentIds.has(i.assignee_id),
myAgentIds.has(i.assignee_id)) ||
(i.creator_type === "member" && i.creator_id === user.id),
); );
}, [allIssues, user, myAgentIds]); }, [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 // Apply status/priority filters from view store
const issues = useMemo( const issues = useMemo(
() => () =>
@ -144,7 +166,7 @@ export function MyIssuesPage() {
<span className="text-sm font-medium">My Issues</span> <span className="text-sm font-medium">My Issues</span>
</div> </div>
{/* Header 2: View toggle + filters */} {/* Header: scope tabs (left) + controls (right) */}
<MyIssuesHeader allIssues={myIssues} /> <MyIssuesHeader allIssues={myIssues} />
{/* Content: scrollable */} {/* Content: scrollable */}

View file

@ -1,5 +1,35 @@
"use client"; "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<MyIssuesViewState> = createStore<MyIssuesViewState>()(
persist(
(set) => ({
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
scope: "assigned" as MyIssuesScope,
setScope: (scope: MyIssuesScope) => set({ scope }),
}),
{
name: basePersist.name,
partialize: (state: MyIssuesViewState) => ({
...basePersist.partialize(state),
scope: state.scope,
}),
},
),
);