From 23e68de24bb6e82ce4bbb83e3a8b6db960ecacdd Mon Sep 17 00:00:00 2001 From: yushen Date: Thu, 14 May 2026 13:01:24 +0800 Subject: [PATCH] fix: address PR #2575 review feedback 1. Extract stripMentionMarkdown as reusable helper with proper regex - Handles escaped brackets in names (e.g. David\[TF\]) - Skips backslash-escaped mentions (\[@...]) - Handles issue mentions (no @ prefix) - Does not touch regular markdown links - 10 unit tests added 2. Squad only appears in Assignee filter, not Creator - Added showSquads prop to ActorSubContent (default true) - Creator filter passes showSquads={false} 3. Squad included in Agents scope - issues-page scope filter now includes squad in agents scope - 2 regression tests added for scope coverage Co-authored-by: multica-agent --- .../components/execution-log-section.tsx | 5 +- .../views/issues/components/issues-header.tsx | 7 ++- .../issues/components/issues-page.test.tsx | 61 +++++++++++++++++- .../views/issues/components/issues-page.tsx | 2 +- .../utils/strip-mention-markdown.test.ts | 62 +++++++++++++++++++ .../issues/utils/strip-mention-markdown.ts | 21 +++++++ 6 files changed, 148 insertions(+), 10 deletions(-) create mode 100644 packages/views/issues/utils/strip-mention-markdown.test.ts create mode 100644 packages/views/issues/utils/strip-mention-markdown.ts diff --git a/packages/views/issues/components/execution-log-section.tsx b/packages/views/issues/components/execution-log-section.tsx index b57324eca..879c2dca3 100644 --- a/packages/views/issues/components/execution-log-section.tsx +++ b/packages/views/issues/components/execution-log-section.tsx @@ -221,10 +221,7 @@ function activeTimeText(task: AgentTask): string { // ─── Active row ──────────────────────────────────────────────────────────── -// Strip mention markdown syntax `[@Name](mention://...)` → `@Name` -function stripMentionMarkdown(text: string): string { - return text.replace(/\[@([^\]]+)\]\(mention:\/\/[^)]+\)/g, "@$1"); -} +import { stripMentionMarkdown } from "../utils/strip-mention-markdown"; function useTriggerText(task: AgentTask): string { const { t } = useT("issues"); diff --git a/packages/views/issues/components/issues-header.tsx b/packages/views/issues/components/issues-header.tsx index 5abdb5f6d..da38b2fbf 100644 --- a/packages/views/issues/components/issues-header.tsx +++ b/packages/views/issues/components/issues-header.tsx @@ -168,6 +168,7 @@ function ActorSubContent({ includeNoAssignee, onToggleNoAssignee, noAssigneeCount, + showSquads = true, }: { counts: Map; selected: ActorFilterValue[]; @@ -176,6 +177,7 @@ function ActorSubContent({ includeNoAssignee?: boolean; onToggleNoAssignee?: () => void; noAssigneeCount?: number; + showSquads?: boolean; }) { const { t } = useT("issues"); const [search, setSearch] = useState(""); @@ -287,7 +289,7 @@ function ActorSubContent({ )} - {filteredSquads.length > 0 && ( + {showSquads && filteredSquads.length > 0 && ( {t(($) => $.filters.squads_group)} {filteredSquads.map((s) => { @@ -316,7 +318,7 @@ function ActorSubContent({ )} - {filteredMembers.length === 0 && filteredAgents.length === 0 && filteredSquads.length === 0 && search && ( + {filteredMembers.length === 0 && filteredAgents.length === 0 && (!showSquads || filteredSquads.length === 0) && search && (
{t(($) => $.filters.no_results)}
@@ -708,6 +710,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) { counts={counts.creator} selected={creatorFilters} onToggle={act.toggleCreatorFilter} + showSquads={false} /> diff --git a/packages/views/issues/components/issues-page.test.tsx b/packages/views/issues/components/issues-page.test.tsx index 075739b07..ff8ff547c 100644 --- a/packages/views/issues/components/issues-page.test.tsx +++ b/packages/views/issues/components/issues-page.test.tsx @@ -103,7 +103,7 @@ vi.mock("@multica/core/issues/config", () => ({ // Mock view store const mockViewState = { - viewMode: "board" as const, + viewMode: "board" as "board" | "list", statusFilters: [] as string[], priorityFilters: [] as string[], assigneeFilters: [] as { type: string; id: string }[], @@ -172,13 +172,15 @@ vi.mock("@multica/core/issues/stores/view-store-context", () => ({ useViewStoreApi: () => ({ getState: () => mockViewState, setState: vi.fn(), subscribe: vi.fn() }), })); +let mockScope = "all"; + vi.mock("@multica/core/issues/stores/issues-scope-store", () => ({ useIssuesScopeStore: Object.assign( (selector?: any) => { - const state = { scope: "all", setScope: vi.fn() }; + const state = { scope: mockScope, setScope: vi.fn() }; return selector ? selector(state) : state; }, - { getState: () => ({ scope: "all", setScope: vi.fn() }) }, + { getState: () => ({ scope: mockScope, setScope: vi.fn() }) }, ), })); @@ -330,6 +332,24 @@ const mockIssues: Issue[] = [ created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }, + { + ...issueDefaults, + id: "issue-4", + workspace_id: "ws-1", + number: 4, + identifier: "TES-4", + title: "Squad task", + description: null, + status: "todo", + priority: "medium", + assignee_type: "squad", + assignee_id: "squad-1", + creator_type: "member", + creator_id: "user-1", + due_date: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, ]; // --------------------------------------------------------------------------- @@ -369,6 +389,7 @@ describe("IssuesPage (shared)", () => { mockViewState.viewMode = "board"; mockViewState.statusFilters = []; mockViewState.priorityFilters = []; + mockScope = "all"; }); it("shows loading skeletons initially", () => { @@ -438,4 +459,38 @@ describe("IssuesPage (shared)", () => { expect(screen.getByText("Members")).toBeInTheDocument(); expect(screen.getByText("Agents")).toBeInTheDocument(); }); + + it("agents scope includes squad-assigned issues", async () => { + mockScope = "agents"; + mockViewState.viewMode = "list"; + mockListIssues.mockImplementation((params: any) => + Promise.resolve({ + issues: mockIssues.filter((i) => i.status === params?.status), + total: mockIssues.filter((i) => i.status === params?.status).length, + }), + ); + renderWithQuery(); + + // Squad task and agent task should be visible + await screen.findByText("Design landing page"); + expect(screen.getByText("Squad task")).toBeInTheDocument(); + // Member task should NOT be visible + expect(screen.queryByText("Implement auth")).not.toBeInTheDocument(); + }); + + it("members scope excludes squad-assigned issues", async () => { + mockScope = "members"; + mockViewState.viewMode = "list"; + mockListIssues.mockImplementation((params: any) => + Promise.resolve({ + issues: mockIssues.filter((i) => i.status === params?.status), + total: mockIssues.filter((i) => i.status === params?.status).length, + }), + ); + renderWithQuery(); + + await screen.findByText("Implement auth"); + expect(screen.queryByText("Squad task")).not.toBeInTheDocument(); + expect(screen.queryByText("Design landing page")).not.toBeInTheDocument(); + }); }); diff --git a/packages/views/issues/components/issues-page.tsx b/packages/views/issues/components/issues-page.tsx index 8bc813f1e..0d1f9bd7b 100644 --- a/packages/views/issues/components/issues-page.tsx +++ b/packages/views/issues/components/issues-page.tsx @@ -53,7 +53,7 @@ export function IssuesPage() { if (scope === "members") return allIssues.filter((i) => i.assignee_type === "member"); if (scope === "agents") - return allIssues.filter((i) => i.assignee_type === "agent"); + return allIssues.filter((i) => i.assignee_type === "agent" || i.assignee_type === "squad"); return allIssues; }, [allIssues, scope]); diff --git a/packages/views/issues/utils/strip-mention-markdown.test.ts b/packages/views/issues/utils/strip-mention-markdown.test.ts new file mode 100644 index 000000000..a393ada8d --- /dev/null +++ b/packages/views/issues/utils/strip-mention-markdown.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; +import { stripMentionMarkdown } from "./strip-mention-markdown"; + +describe("stripMentionMarkdown", () => { + it("strips simple agent mention", () => { + expect( + stripMentionMarkdown("[@魏和尚](mention://agent/de8efbcc-eaa1-4605-a6ac-d50cfa88e447)"), + ).toBe("@魏和尚"); + }); + + it("strips simple member mention", () => { + expect( + stripMentionMarkdown("[@Alice](mention://member/abc-123)"), + ).toBe("@Alice"); + }); + + it("strips issue mention (no @ prefix)", () => { + expect( + stripMentionMarkdown("[MUL-123](mention://issue/some-uuid)"), + ).toBe("MUL-123"); + }); + + it("handles escaped brackets in names", () => { + expect( + stripMentionMarkdown("[@David\\[TF\\]](mention://agent/id-123)"), + ).toBe("@David[TF]"); + }); + + it("handles multiple mentions in one string", () => { + expect( + stripMentionMarkdown( + "Triggered by [@Alice](mention://member/a1) and [@Bob](mention://agent/b2)", + ), + ).toBe("Triggered by @Alice and @Bob"); + }); + + it("does NOT strip regular markdown links", () => { + expect( + stripMentionMarkdown("[docs](https://example.com)"), + ).toBe("[docs](https://example.com)"); + }); + + it("does NOT strip non-mention parenthetical links", () => { + expect( + stripMentionMarkdown("[click here](http://foo.bar/baz)"), + ).toBe("[click here](http://foo.bar/baz)"); + }); + + it("handles backslash-escaped content that is NOT a mention", () => { + expect( + stripMentionMarkdown("\\[@Literal](mention://agent/id)"), + ).toBe("\\[@Literal](mention://agent/id)"); + }); + + it("returns plain text unchanged", () => { + expect(stripMentionMarkdown("hello world")).toBe("hello world"); + }); + + it("handles empty string", () => { + expect(stripMentionMarkdown("")).toBe(""); + }); +}); diff --git a/packages/views/issues/utils/strip-mention-markdown.ts b/packages/views/issues/utils/strip-mention-markdown.ts new file mode 100644 index 000000000..c5da0cc1f --- /dev/null +++ b/packages/views/issues/utils/strip-mention-markdown.ts @@ -0,0 +1,21 @@ +/** + * Strip mention markdown syntax to plain text. + * + * Handles: + * - Simple mentions: `[@Name](mention://agent/id)` → `@Name` + * - Escaped brackets in names: `[@David\[TF\]](mention://agent/id)` → `@David[TF]` + * - Issue mentions (no @): `[MUL-123](mention://issue/id)` → `MUL-123` + * - Does NOT touch regular markdown links: `[docs](https://...)` stays unchanged + * - Does NOT touch backslash-escaped mentions: `\[@Name](mention://...)` stays unchanged + * + * The regex mirrors the tokenizer in mention-extension.ts. + */ +export function stripMentionMarkdown(text: string): string { + return text.replace( + /(? { + const label = rawLabel.replace(/\\\[/g, "[").replace(/\\\]/g, "]"); + return `${prefix}${label}`; + }, + ); +}