mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
2 Commits
fix/skill-
...
agent/agen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23e68de24b | ||
|
|
2e7d79cad3 |
@@ -24,7 +24,7 @@ export interface CardProperties {
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
type: "member" | "agent";
|
||||
type: "member" | "agent" | "squad";
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"section_label": "标签",
|
||||
"members_group": "成员",
|
||||
"agents_group": "智能体",
|
||||
"squads_group": "小队",
|
||||
"issue_count_other": "{{count}} 个 issue",
|
||||
"reset": "重置全部筛选"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user