fix: apply working filter on project issues (#3631)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-02 13:07:12 +08:00
committed by GitHub
parent a6b83fef41
commit a590dd9a22
4 changed files with 111 additions and 14 deletions

View File

@@ -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.

View File

@@ -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<string>();
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({
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" && (
<BoardView
issues={assigneeGroups ? projectIssues : issues}
assigneeGroups={assigneeGroups}
issues={filteredAssigneeGroups ? filteredAssigneeGroups.flatMap((group) => group.issues) : issues}
assigneeGroups={filteredAssigneeGroups}
assigneeGroupQueryKey={assigneeGroupQueryKey}
assigneeGroupFilter={assigneeGroupFilter}
visibleStatuses={visibleStatuses}

View File

@@ -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 },
]);
});
});

View File

@@ -0,0 +1,20 @@
import type { IssueAssigneeGroup } from "@multica/core/types";
export function filterRunningAssigneeGroups(
groups: IssueAssigneeGroup[] | undefined,
agentRunningFilter: boolean,
runningIssueIds: Set<string>,
): 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);
}