Compare commits

...

2 Commits

Author SHA1 Message Date
yushen
23e68de24b 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>
2026-05-14 13:01:24 +08:00
yushen
2e7d79cad3 fix: execution log name rendering and squad assignee support
- Strip mention markdown in trigger_summary ([@Name](mention://...) → @Name)
  so execution log rows show clean text instead of raw markdown
- Add squad to ActorFilterValue type so squad assignees are filterable
- Add squad section to assignee filter dropdown in issues-header
- Add i18n keys for squads_group (en/zh-Hans)

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 12:44:51 +08:00
9 changed files with 187 additions and 9 deletions

View File

@@ -24,7 +24,7 @@ export interface CardProperties {
}
export interface ActorFilterValue {
type: "member" | "agent";
type: "member" | "agent" | "squad";
id: string;
}

View File

@@ -221,6 +221,8 @@ function activeTimeText(task: AgentTask): string {
// ─── Active row ────────────────────────────────────────────────────────────
import { stripMentionMarkdown } from "../utils/strip-mention-markdown";
function useTriggerText(task: AgentTask): string {
const { t } = useT("issues");
const isRetry = !!task.parent_task_id;
@@ -230,7 +232,7 @@ function useTriggerText(task: AgentTask): string {
: t(($) => $.execution_log.trigger_retry_prefix)
: "";
if (task.trigger_summary) return retryPrefix + task.trigger_summary;
if (task.trigger_summary) return retryPrefix + stripMentionMarkdown(task.trigger_summary);
if (isRetry) {
return task.attempt && task.attempt > 1
? t(($) => $.execution_log.trigger_retry_attempt, { attempt: task.attempt })

View File

@@ -46,7 +46,7 @@ import {
import { StatusIcon, PriorityIcon } from ".";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { memberListOptions, agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
import { projectListOptions } from "@multica/core/projects/queries";
import { labelListOptions } from "@multica/core/labels/queries";
import { ProjectIcon } from "../../projects/components/project-icon";
@@ -168,6 +168,7 @@ function ActorSubContent({
includeNoAssignee,
onToggleNoAssignee,
noAssigneeCount,
showSquads = true,
}: {
counts: Map<string, number>;
selected: ActorFilterValue[];
@@ -176,12 +177,14 @@ function ActorSubContent({
includeNoAssignee?: boolean;
onToggleNoAssignee?: () => void;
noAssigneeCount?: number;
showSquads?: boolean;
}) {
const { t } = useT("issues");
const [search, setSearch] = useState("");
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const query = search.trim().toLowerCase();
const filteredMembers = members.filter((m) =>
m.name.toLowerCase().includes(query),
@@ -189,8 +192,11 @@ function ActorSubContent({
const filteredAgents = agents.filter((a) =>
!a.archived_at && a.name.toLowerCase().includes(query),
);
const filteredSquads = squads.filter((s) =>
!s.archived_at && s.name.toLowerCase().includes(query),
);
const isSelected = (type: "member" | "agent", id: string) =>
const isSelected = (type: "member" | "agent" | "squad", id: string) =>
selected.some((f) => f.type === type && f.id === id);
return (
@@ -283,7 +289,36 @@ function ActorSubContent({
</DropdownMenuGroup>
)}
{filteredMembers.length === 0 && filteredAgents.length === 0 && search && (
{showSquads && filteredSquads.length > 0 && (
<DropdownMenuGroup>
<DropdownMenuLabel>{t(($) => $.filters.squads_group)}</DropdownMenuLabel>
{filteredSquads.map((s) => {
const checked = isSelected("squad", s.id);
const count = counts.get(`squad:${s.id}`) ?? 0;
return (
<DropdownMenuCheckboxItem
key={s.id}
checked={checked}
onCheckedChange={() =>
onToggle({ type: "squad", id: s.id })
}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={checked} />
<ActorAvatar actorType="squad" actorId={s.id} size={18} />
<span className="truncate">{s.name}</span>
{count > 0 && (
<span className="ml-auto text-xs text-muted-foreground">
{count}
</span>
)}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuGroup>
)}
{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>
@@ -675,6 +710,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
counts={counts.creator}
selected={creatorFilters}
onToggle={act.toggleCreatorFilter}
showSquads={false}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>

View File

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

View File

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

View 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("");
});
});

View 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}`;
},
);
}

View File

@@ -45,6 +45,7 @@
"section_label": "Label",
"members_group": "Members",
"agents_group": "Agents",
"squads_group": "Squads",
"issue_count_one": "{{count}} issue",
"issue_count_other": "{{count}} issues",
"reset": "Reset all filters"

View File

@@ -45,6 +45,7 @@
"section_label": "标签",
"members_group": "成员",
"agents_group": "智能体",
"squads_group": "小队",
"issue_count_other": "{{count}} 个 issue",
"reset": "重置全部筛选"
},