mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
fix: apply working filter on project issues (#3631)
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
20
packages/views/projects/components/project-issue-filters.ts
Normal file
20
packages/views/projects/components/project-issue-filters.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user