mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 21:39:54 +02:00
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 <github@multica.ai>
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -168,6 +168,7 @@ function ActorSubContent({
|
||||
includeNoAssignee,
|
||||
onToggleNoAssignee,
|
||||
noAssigneeCount,
|
||||
showSquads = true,
|
||||
}: {
|
||||
counts: Map<string, number>;
|
||||
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({
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
|
||||
{filteredSquads.length > 0 && (
|
||||
{showSquads && filteredSquads.length > 0 && (
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>{t(($) => $.filters.squads_group)}</DropdownMenuLabel>
|
||||
{filteredSquads.map((s) => {
|
||||
@@ -316,7 +318,7 @@ function ActorSubContent({
|
||||
</DropdownMenuGroup>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && filteredSquads.length === 0 && search && (
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && (!showSquads || filteredSquads.length === 0) && search && (
|
||||
<div className="px-2 py-3 text-center text-sm text-muted-foreground">
|
||||
{t(($) => $.filters.no_results)}
|
||||
</div>
|
||||
@@ -708,6 +710,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
|
||||
counts={counts.creator}
|
||||
selected={creatorFilters}
|
||||
onToggle={act.toggleCreatorFilter}
|
||||
showSquads={false}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
@@ -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(<IssuesPage />);
|
||||
|
||||
// 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(<IssuesPage />);
|
||||
|
||||
await screen.findByText("Implement auth");
|
||||
expect(screen.queryByText("Squad task")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Design landing page")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
62
packages/views/issues/utils/strip-mention-markdown.test.ts
Normal file
62
packages/views/issues/utils/strip-mention-markdown.test.ts
Normal file
@@ -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("");
|
||||
});
|
||||
});
|
||||
21
packages/views/issues/utils/strip-mention-markdown.ts
Normal file
21
packages/views/issues/utils/strip-mention-markdown.ts
Normal file
@@ -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(
|
||||
/(?<![\\])\[(@?)((?:\\.|[^\]])+)\]\(mention:\/\/\w+\/[^)]+\)/g,
|
||||
(_, prefix: string, rawLabel: string) => {
|
||||
const label = rawLabel.replace(/\\\[/g, "[").replace(/\\\]/g, "]");
|
||||
return `${prefix}${label}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user