diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index f96c79fe0..142742513 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -74,7 +74,6 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ import type { Issue } from "@multica/core/types"; import { useT } from "../../i18n"; import { matchesPinyin } from "../../editor/extensions/pinyin-match"; -import { useIssueViewStore } from "@multica/core/issues/stores/view-store"; import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip"; // --------------------------------------------------------------------------- @@ -509,11 +508,12 @@ export function IssuesHeader({ const { t } = useT("issues"); const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); - // Bind the workspace agents-working chip to the global /issues view - // store. Subscribing here keeps the chip presentational and lets - // /my-issues bind its own store via a sibling header. - const agentRunningFilter = useIssueViewStore((s) => s.agentRunningFilter); - const toggleAgentRunningFilter = useIssueViewStore( + // Bind the workspace agents-working chip to the active view store so + // shared IssuesHeader consumers (/issues and project detail) toggle the + // same filter state as the rest of the display controls. /my-issues keeps + // its own sibling header and passes chip state explicitly. + const agentRunningFilter = useViewStore((s) => s.agentRunningFilter); + const toggleAgentRunningFilter = useViewStore( (s) => s.toggleAgentRunningFilter, ); // Scope the chip to whatever issues this page is currently showing. diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index c0b3f5efd..9d43c6f64 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -24,6 +24,7 @@ import { import { useUpdateIssue } from "@multica/core/issues/mutations"; import { useModalStore } from "@multica/core/modals"; import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries"; +import { agentTaskSnapshotOptions } from "@multica/core/agents"; import { useWorkspaceId } from "@multica/core/hooks"; import { useWorkspacePaths } from "@multica/core/paths"; import { useActorName } from "@multica/core/workspace/hooks"; @@ -33,6 +34,7 @@ import { createIssueViewStore } from "@multica/core/issues/stores/view-store"; import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context"; import { filterIssues } from "../../issues/utils/filter"; import { getProjectIssueMetrics } from "./project-issue-metrics"; +import { filterRunningAssigneeGroups } from "./project-issue-filters"; import { ActorAvatar } from "../../common/actor-avatar"; import { useNavigation } from "../../navigation"; import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor"; @@ -139,24 +141,39 @@ function ProjectIssuesContent({ const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); const creatorFilters = useViewStore((s) => s.creatorFilters); const labelFilters = useViewStore((s) => s.labelFilters); + const agentRunningFilter = useViewStore((s) => s.agentRunningFilter); + + const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId)); + const runningIssueIds = useMemo(() => { + const ids = new Set(); + for (const task of snapshot) { + if (task.status === "running" && task.issue_id) ids.add(task.issue_id); + } + return ids; + }, [snapshot]); const issues = useMemo( - () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }), - [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], + () => filterIssues(projectIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters, agentRunningFilter, runningIssueIds }), + [projectIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters, agentRunningFilter, runningIssueIds], ); // Status-unfiltered companion for Swimlane. const swimlaneIssues = useMemo( - () => filterIssues(projectIssues, { statusFilters: [], priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }), - [projectIssues, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], + () => filterIssues(projectIssues, { statusFilters: [], priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters, agentRunningFilter, runningIssueIds }), + [projectIssues, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters, agentRunningFilter, runningIssueIds], ); // Gantt rides its own dedicated query (scheduled-only) so it doesn't have // to wait for every status bucket to paginate in. View-store filters still // apply so toggling priority / assignee / label hides the same bars. const filteredGanttIssues = useMemo( - () => filterIssues(ganttIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters }), - [ganttIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters], + () => filterIssues(ganttIssues, { statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters: [], includeNoProject: false, labelFilters, agentRunningFilter, runningIssueIds }), + [ganttIssues, statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, labelFilters, agentRunningFilter, runningIssueIds], + ); + + const filteredAssigneeGroups = useMemo( + () => filterRunningAssigneeGroups(assigneeGroups, agentRunningFilter, runningIssueIds), + [assigneeGroups, agentRunningFilter, runningIssueIds], ); const { data: childProgressMap = new Map() } = useQuery(childIssueProgressOptions(wsId)); @@ -221,8 +238,8 @@ function ProjectIssuesContent({
{viewMode === "board" && ( group.issues) : issues} + assigneeGroups={filteredAssigneeGroups} assigneeGroupQueryKey={assigneeGroupQueryKey} assigneeGroupFilter={assigneeGroupFilter} visibleStatuses={visibleStatuses} diff --git a/packages/views/projects/components/project-issue-filters.test.ts b/packages/views/projects/components/project-issue-filters.test.ts new file mode 100644 index 000000000..1ca0d399c --- /dev/null +++ b/packages/views/projects/components/project-issue-filters.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type { Issue, IssueAssigneeGroup } from "@multica/core/types"; +import { filterRunningAssigneeGroups } from "./project-issue-filters"; + +function issue(id: string): Issue { + return { + id, + title: `Issue ${id}`, + identifier: id, + number: Number(id.replace(/\D/g, "")) || 0, + description: null, + status: "todo", + priority: "none", + assignee_type: null, + assignee_id: null, + creator_type: "member", + creator_id: "user-1", + workspace_id: "ws-1", + project_id: "project-1", + parent_issue_id: null, + position: 0, + due_date: null, + start_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + } as Issue; +} + +function group(id: string, issues: Issue[]): IssueAssigneeGroup { + return { + id, + assignee_type: id === "none" ? null : "agent", + assignee_id: id === "none" ? null : id, + issues, + total: issues.length, + }; +} + +describe("filterRunningAssigneeGroups", () => { + it("returns the original groups when the agent running filter is off", () => { + const groups = [group("agent-1", [issue("issue-1"), issue("issue-2")])]; + + expect(filterRunningAssigneeGroups(groups, false, new Set(["issue-1"]))).toBe(groups); + }); + + it("keeps only running issues and removes empty assignee groups", () => { + const groups = [ + group("agent-1", [issue("issue-1"), issue("issue-2")]), + group("agent-2", [issue("issue-3")]), + group("none", [issue("issue-4")]), + ]; + + const result = filterRunningAssigneeGroups(groups, true, new Set(["issue-2", "issue-4"])); + + expect(result!.map((g) => ({ id: g.id, issueIds: g.issues.map((i) => i.id), total: g.total }))).toEqual([ + { id: "agent-1", issueIds: ["issue-2"], total: 1 }, + { id: "none", issueIds: ["issue-4"], total: 1 }, + ]); + }); +}); diff --git a/packages/views/projects/components/project-issue-filters.ts b/packages/views/projects/components/project-issue-filters.ts new file mode 100644 index 000000000..021c0c912 --- /dev/null +++ b/packages/views/projects/components/project-issue-filters.ts @@ -0,0 +1,20 @@ +import type { IssueAssigneeGroup } from "@multica/core/types"; + +export function filterRunningAssigneeGroups( + groups: IssueAssigneeGroup[] | undefined, + agentRunningFilter: boolean, + runningIssueIds: Set, +): IssueAssigneeGroup[] | undefined { + if (!groups || !agentRunningFilter) return groups; + + return groups + .map((group) => { + const issues = group.issues.filter((issue) => runningIssueIds.has(issue.id)); + return { + ...group, + issues, + total: issues.length, + }; + }) + .filter((group) => group.issues.length > 0); +}