diff --git a/packages/core/agents/stores/view-store.ts b/packages/core/agents/stores/view-store.ts index 1cf0b0e5a..94e6e8bc0 100644 --- a/packages/core/agents/stores/view-store.ts +++ b/packages/core/agents/stores/view-store.ts @@ -49,12 +49,15 @@ export interface AgentListFilters { * toggleFilter. So owner-as-filter and Mine never coexist, which keeps * the axis orthogonal (no "mine + owner=someone-else = empty" state). */ owners: string[]; + /** Runtime-native model identifiers (e.g. claude / codex / gpt-…). */ + models: string[]; } export const EMPTY_AGENT_FILTERS: AgentListFilters = { availability: [], runtimes: [], owners: [], + models: [], }; // User-hideable columns. Name and the structural columns (checkbox, kebab) diff --git a/packages/core/squads/stores/index.ts b/packages/core/squads/stores/index.ts index b06cb8a26..5ae3becc2 100644 --- a/packages/core/squads/stores/index.ts +++ b/packages/core/squads/stores/index.ts @@ -3,9 +3,11 @@ export { SQUAD_SCOPES, SQUAD_SORT_DEFAULT_DIRECTION, SQUAD_DEFAULT_HIDDEN_COLUMNS, + EMPTY_SQUAD_FILTERS, type SquadsScope, type SquadsViewState, type SquadSortField, type SquadSortDirection, type SquadColumnKey, + type SquadListFilters, } from "./view-store"; diff --git a/packages/core/squads/stores/view-store.test.ts b/packages/core/squads/stores/view-store.test.ts index 4727d8d79..af22d66cc 100644 --- a/packages/core/squads/stores/view-store.test.ts +++ b/packages/core/squads/stores/view-store.test.ts @@ -53,6 +53,7 @@ describe("useSquadsViewStore", () => { expect(raw).not.toBeNull(); const parsed = JSON.parse(raw as string); expect(Object.keys(parsed.state).sort()).toEqual([ + "filters", "hiddenColumns", "scope", "sortDirection", diff --git a/packages/core/squads/stores/view-store.ts b/packages/core/squads/stores/view-store.ts index 10433dde2..f572be04b 100644 --- a/packages/core/squads/stores/view-store.ts +++ b/packages/core/squads/stores/view-store.ts @@ -43,11 +43,26 @@ export type SquadColumnKey = "members" | "creator" | "created"; * workspace-admin only), so labelling it Owner would mislead. */ export const SQUAD_DEFAULT_HIDDEN_COLUMNS: SquadColumnKey[] = ["created"]; +/** Multi-select filters — the categorical columns (leader, creator). Empty + * array per dimension = inactive. */ +export interface SquadListFilters { + /** Leader agent ids. */ + leaders: string[]; + /** Creator member user ids. */ + creators: string[]; +} + +export const EMPTY_SQUAD_FILTERS: SquadListFilters = { + leaders: [], + creators: [], +}; + export interface SquadsViewState { scope: SquadsScope; sortField: SquadSortField; sortDirection: SquadSortDirection; hiddenColumns: SquadColumnKey[]; + filters: SquadListFilters; setScope: (scope: SquadsScope) => void; /** Header click: toggles direction on the active field, otherwise switches * to the field with its default direction. */ @@ -56,6 +71,8 @@ export interface SquadsViewState { setSortField: (field: SquadSortField) => void; setSortDirection: (direction: SquadSortDirection) => void; toggleColumn: (key: SquadColumnKey) => void; + toggleFilter: (key: keyof SquadListFilters, value: string) => void; + clearFilters: () => void; } const DEFAULTS = { @@ -63,6 +80,7 @@ const DEFAULTS = { sortField: "name" as SquadSortField, sortDirection: SQUAD_SORT_DEFAULT_DIRECTION.name, hiddenColumns: SQUAD_DEFAULT_HIDDEN_COLUMNS, + filters: EMPTY_SQUAD_FILTERS, }; export const useSquadsViewStore = create()( @@ -97,6 +115,15 @@ export const useSquadsViewStore = create()( ? state.hiddenColumns.filter((k) => k !== key) : [...state.hiddenColumns, key], })), + toggleFilter: (key, value) => + set((state) => { + const list = state.filters[key] as string[]; + const next = list.includes(value) + ? list.filter((v) => v !== value) + : [...list, value]; + return { filters: { ...state.filters, [key]: next } }; + }), + clearFilters: () => set({ filters: EMPTY_SQUAD_FILTERS }), }), { name: "multica_squads_view", @@ -108,12 +135,19 @@ export const useSquadsViewStore = create()( sortField: state.sortField, sortDirection: state.sortDirection, hiddenColumns: state.hiddenColumns, + filters: state.filters, }), // On rehydrate, if the new workspace has no persisted value, reset to // the defaults instead of leaking the previous workspace's state. + // Deep-merge filters so a pre-filters payload backfills defaults. merge: (persisted, current) => { if (!persisted) return { ...current, ...DEFAULTS }; - return { ...current, ...(persisted as Partial) }; + const p = persisted as Partial; + return { + ...current, + ...p, + filters: { ...EMPTY_SQUAD_FILTERS, ...(p.filters ?? {}) }, + }; }, }, ), diff --git a/packages/views/agents/components/agent-list-toolbar.tsx b/packages/views/agents/components/agent-list-toolbar.tsx index 009962580..c6ac0ba79 100644 --- a/packages/views/agents/components/agent-list-toolbar.tsx +++ b/packages/views/agents/components/agent-list-toolbar.tsx @@ -77,6 +77,7 @@ export function countActiveFilterDimensions( if (filters.availability.length > 0) count++; if (filters.runtimes.length > 0) count++; if (filters.owners.length > 0) count++; + if (filters.models.length > 0) count++; return count; } @@ -144,9 +145,12 @@ export function AgentListToolbar({ // Owner options: members who own at least one agent in the current scope. const memberById = new Map(members.map((m) => [m.user_id, m])); const ownerCounts = new Map(); + const modelCounts = new Map(); for (const row of allRows) { const oid = row.agent.owner_id; if (oid) ownerCounts.set(oid, (ownerCounts.get(oid) ?? 0) + 1); + const model = row.agent.model; + if (model) modelCounts.set(model, (modelCounts.get(model) ?? 0) + 1); } const SCOPE_LABELS: Record = { @@ -402,6 +406,36 @@ export function AgentListToolbar({ })} + + {/* Model — runtime-native model id (categorical column → filter) */} + {modelCounts.size > 0 && ( + + + + {t(($) => $.toolbar.section_model)} + + {filters.models.length > 0 && ( + + {filters.models.length} + + )} + + + {[...modelCounts.entries()].map(([model, count]) => ( + onToggleFilter("models", model)} + className={FILTER_ITEM_CLASS} + > + + {model} + {countBadge(count)} + + ))} + + + )} diff --git a/packages/views/agents/components/agents-page.tsx b/packages/views/agents/components/agents-page.tsx index 76763b7f1..f54a6f194 100644 --- a/packages/views/agents/components/agents-page.tsx +++ b/packages/views/agents/components/agents-page.tsx @@ -947,6 +947,12 @@ export function AgentsPage(_props: AgentsPageProps = {}) { ) { return false; } + if ( + filters.models.length > 0 && + !filters.models.includes(row.agent.model) + ) { + return false; + } return true; }); diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index aed20e0c4..d412fc646 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -471,7 +471,8 @@ "direction_asc": "Ascending", "direction_desc": "Descending", "section_columns": "Columns", - "section_owner": "Owner" + "section_owner": "Owner", + "section_model": "Model" }, "actions": { "selected_one": "{{count}} selected", diff --git a/packages/views/locales/en/squads.json b/packages/views/locales/en/squads.json index a6bcf3671..dfb1f7d0d 100644 --- a/packages/views/locales/en/squads.json +++ b/packages/views/locales/en/squads.json @@ -93,6 +93,11 @@ "sort_by": "Sort by", "direction_asc": "Ascending", "direction_desc": "Descending", - "section_columns": "Columns" + "section_columns": "Columns", + "result_count_title": "Matching squads / all squads", + "filter_label": "Filter", + "filter_active_count_one": "{{count}} filter", + "filter_active_count_other": "{{count}} filters", + "clear_filters": "Clear filters" } } diff --git a/packages/views/locales/ja/agents.json b/packages/views/locales/ja/agents.json index 1c0930931..d6f1adb6f 100644 --- a/packages/views/locales/ja/agents.json +++ b/packages/views/locales/ja/agents.json @@ -459,7 +459,8 @@ "direction_asc": "昇順", "direction_desc": "降順", "section_columns": "列", - "section_owner": "オーナー" + "section_owner": "オーナー", + "section_model": "モデル" }, "actions": { "selected_one": "{{count}} 件選択中", diff --git a/packages/views/locales/ja/squads.json b/packages/views/locales/ja/squads.json index 452301815..e593cf892 100644 --- a/packages/views/locales/ja/squads.json +++ b/packages/views/locales/ja/squads.json @@ -90,6 +90,11 @@ "sort_by": "並び替え", "direction_asc": "昇順", "direction_desc": "降順", - "section_columns": "列" + "section_columns": "列", + "result_count_title": "該当スカッド / すべてのスカッド", + "filter_label": "フィルター", + "filter_active_count_one": "{{count}} 件のフィルター", + "filter_active_count_other": "{{count}} 件のフィルター", + "clear_filters": "フィルターをクリア" } } diff --git a/packages/views/locales/ko/agents.json b/packages/views/locales/ko/agents.json index c6ae548a6..24b38faf6 100644 --- a/packages/views/locales/ko/agents.json +++ b/packages/views/locales/ko/agents.json @@ -471,7 +471,8 @@ "direction_asc": "오름차순", "direction_desc": "내림차순", "section_columns": "열", - "section_owner": "소유자" + "section_owner": "소유자", + "section_model": "모델" }, "actions": { "selected_one": "{{count}}개 선택됨", diff --git a/packages/views/locales/ko/squads.json b/packages/views/locales/ko/squads.json index 2e45cca63..7376c2937 100644 --- a/packages/views/locales/ko/squads.json +++ b/packages/views/locales/ko/squads.json @@ -93,6 +93,11 @@ "sort_by": "정렬", "direction_asc": "오름차순", "direction_desc": "내림차순", - "section_columns": "열" + "section_columns": "열", + "result_count_title": "일치하는 스쿼드 / 전체 스쿼드", + "filter_label": "필터", + "filter_active_count_one": "필터 {{count}}개", + "filter_active_count_other": "필터 {{count}}개", + "clear_filters": "필터 지우기" } } diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 0440dc4ff..cf58409c0 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -459,7 +459,8 @@ "direction_asc": "升序", "direction_desc": "降序", "section_columns": "列", - "section_owner": "Owner" + "section_owner": "Owner", + "section_model": "模型" }, "actions": { "selected_one": "已选 {{count}} 项", diff --git a/packages/views/locales/zh-Hans/squads.json b/packages/views/locales/zh-Hans/squads.json index c1283aa79..f9fb89328 100644 --- a/packages/views/locales/zh-Hans/squads.json +++ b/packages/views/locales/zh-Hans/squads.json @@ -93,6 +93,11 @@ "sort_by": "排序", "direction_asc": "升序", "direction_desc": "降序", - "section_columns": "列" + "section_columns": "列", + "result_count_title": "当前结果 / 全部小队", + "filter_label": "筛选", + "filter_active_count_one": "{{count}} 项筛选", + "filter_active_count_other": "{{count}} 项筛选", + "clear_filters": "清除筛选" } } diff --git a/packages/views/squads/components/squads-page.tsx b/packages/views/squads/components/squads-page.tsx index f60925241..aa550dd92 100644 --- a/packages/views/squads/components/squads-page.tsx +++ b/packages/views/squads/components/squads-page.tsx @@ -5,11 +5,13 @@ import { ArrowDown, ArrowUp, ChevronDown, + Filter, Loader2, MoreHorizontal, Plus, Trash2, Users, + X, } from "lucide-react"; import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; @@ -29,6 +31,7 @@ import { SQUAD_SCOPES, SQUAD_DEFAULT_HIDDEN_COLUMNS, type SquadColumnKey, + type SquadListFilters, type SquadsScope, type SquadSortField, } from "@multica/core/squads/stores"; @@ -44,10 +47,14 @@ import { } from "@multica/ui/components/ui/dialog"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@multica/ui/components/ui/dropdown-menu"; import { @@ -73,6 +80,7 @@ import { } from "@multica/ui/components/ui/tooltip"; import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar"; import { ActorAvatar } from "../../common/actor-avatar"; +import { FILTER_ITEM_CLASS, HoverCheck } from "../../common/hover-check"; import { AppLink } from "../../navigation"; import { PageHeader } from "../../layout/page-header"; import { useT } from "../../i18n"; @@ -405,10 +413,23 @@ function SquadListHeader({ const COLUMN_KEYS: SquadColumnKey[] = ["members", "creator", "created"]; const SORT_FIELDS: SquadSortField[] = ["name", "members", "created"]; +interface ActorOption { + id: string; + name: string; + count: number; +} + function SquadListToolbar({ scope, onScopeChange, scopeCounts, + filters, + onToggleFilter, + onClearFilters, + leaderOptions, + creatorOptions, + visibleCount, + totalCount, sortField, sortDirection, onSortFieldChange, @@ -419,6 +440,13 @@ function SquadListToolbar({ scope: SquadsScope; onScopeChange: (scope: SquadsScope) => void; scopeCounts: Record; + filters: SquadListFilters; + onToggleFilter: (key: keyof SquadListFilters, value: string) => void; + onClearFilters: () => void; + leaderOptions: ActorOption[]; + creatorOptions: ActorOption[]; + visibleCount: number; + totalCount: number; sortField: SquadSortField; sortDirection: ListGridSortDirection; onSortFieldChange: (field: SquadSortField) => void; @@ -427,6 +455,13 @@ function SquadListToolbar({ onToggleColumn: (key: SquadColumnKey) => void; }) { const { t } = useT("squads"); + const activeFilterCount = + (filters.leaders.length > 0 ? 1 : 0) + + (filters.creators.length > 0 ? 1 : 0); + const hasActiveFilters = activeFilterCount > 0; + const countBadge = (n: number) => ( + {n} + ); const SCOPE_LABELS: Record = { mine: t(($) => $.scope.mine), all: t(($) => $.scope.all), @@ -495,8 +530,111 @@ function SquadListToolbar({ + + {hasActiveFilters && ( + $.toolbar.result_count_title)} + className="hidden shrink-0 text-xs tabular-nums text-muted-foreground md:inline" + > + {visibleCount} / {totalCount} + + )} +
+ {/* Filter */} + + + + {hasActiveFilters ? ( + <> + + {t(($) => $.toolbar.filter_active_count, { count: activeFilterCount })} + + {activeFilterCount} + + ) : ( + {t(($) => $.toolbar.filter_label)} + )} + {hasActiveFilters && ( + $.toolbar.clear_filters)} + className="-mr-1 ml-0.5 hidden rounded-sm p-0.5 hover:bg-white/20 md:inline-flex" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + onClearFilters(); + }} + onPointerDown={(e) => e.stopPropagation()} + > + + + )} + + } + /> + + + + {t(($) => $.page.table.leader)} + {filters.leaders.length > 0 && ( + {filters.leaders.length} + )} + + + {leaderOptions.map((o) => ( + onToggleFilter("leaders", o.id)} + className={FILTER_ITEM_CLASS} + > + + + {o.name} + {countBadge(o.count)} + + ))} + + + + + {t(($) => $.page.table.creator)} + {filters.creators.length > 0 && ( + {filters.creators.length} + )} + + + {creatorOptions.map((o) => ( + onToggleFilter("creators", o.id)} + className={FILTER_ITEM_CLASS} + > + + + {o.name} + {countBadge(o.count)} + + ))} + + + + + {/* Display settings */} @@ -602,6 +740,7 @@ function SquadListToolbar({
+ ); } @@ -651,6 +790,9 @@ export function SquadsPage() { const handleSortFieldSelect = useSquadsViewStore((s) => s.setSortField); const setSortDirection = useSquadsViewStore((s) => s.setSortDirection); const toggleColumn = useSquadsViewStore((s) => s.toggleColumn); + const filters = useSquadsViewStore((s) => s.filters); + const toggleFilter = useSquadsViewStore((s) => s.toggleFilter); + const clearFilters = useSquadsViewStore((s) => s.clearFilters); const isColVisible = (key: SquadColumnKey) => !hiddenColumns.includes(key); @@ -662,13 +804,60 @@ export function SquadsPage() { return { mine, all: squads.length }; }, [squads, currentUser]); - const rows = useMemo(() => { - const inScope = squads.filter((s) => { + // Rows within the current scope, unfiltered — filter option lists + the + // "n / total" denominator derive from this. + const scopeRows = useMemo(() => { + return squads.filter((s) => { if (scope === "mine") { return !!currentUser && s.creator_id === currentUser.id; } return true; }); + }, [squads, scope, currentUser]); + + const leaderOptions = useMemo(() => { + const m = new Map(); + for (const s of scopeRows) { + const e = m.get(s.leader_id); + if (e) e.count += 1; + else + m.set(s.leader_id, { + id: s.leader_id, + name: agentsById.get(s.leader_id)?.name ?? s.leader_id.slice(0, 8), + count: 1, + }); + } + return [...m.values()]; + }, [scopeRows, agentsById]); + + const creatorOptions = useMemo(() => { + const m = new Map(); + for (const s of scopeRows) { + const e = m.get(s.creator_id); + if (e) e.count += 1; + else + m.set(s.creator_id, { + id: s.creator_id, + name: membersById.get(s.creator_id)?.name ?? s.creator_id.slice(0, 8), + count: 1, + }); + } + return [...m.values()]; + }, [scopeRows, membersById]); + + const rows = useMemo(() => { + const inScope = scopeRows.filter((s) => { + if (filters.leaders.length > 0 && !filters.leaders.includes(s.leader_id)) { + return false; + } + if ( + filters.creators.length > 0 && + !filters.creators.includes(s.creator_id) + ) { + return false; + } + return true; + }); const dir = sortDirection === "asc" ? 1 : -1; const sorted = [...inScope]; sorted.sort((a, b) => { @@ -685,7 +874,7 @@ export function SquadsPage() { return a.name.localeCompare(b.name) * dir; }); return sorted; - }, [squads, scope, currentUser, sortField, sortDirection]); + }, [scopeRows, filters, sortField, sortDirection]); return (
@@ -735,6 +924,13 @@ export function SquadsPage() { scope={scope} onScopeChange={setScope} scopeCounts={scopeCounts} + filters={filters} + onToggleFilter={toggleFilter} + onClearFilters={clearFilters} + leaderOptions={leaderOptions} + creatorOptions={creatorOptions} + visibleCount={rows.length} + totalCount={scopeRows.length} sortField={sortField} sortDirection={sortDirection} onSortFieldChange={handleSortFieldSelect}