feat(agents,squads): close the filter/column consistency gaps

Apply the principle "every categorical column is filterable" where it
was missing:
- agents: add a Model filter (model was a categorical column with no
  filter). Distinct non-empty models from the in-scope rows.
- squads: add filters entirely (it had leader/creator columns + a
  column-toggle panel but no Filter button — the only such outlier).
  Leader (agent) + Creator (member) filters, with the result count and
  the same Filter dropdown shape as the other lists. Store gains
  SquadListFilters + toggleFilter/clearFilters + migration-safe
  filters deep-merge.

autopilots creator stays default-hidden per product call (not every
"who made it" must be visible). Filter stores' partialize tests
updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-06-15 13:50:40 +08:00
parent 544d148fec
commit 01aa5848af
15 changed files with 312 additions and 12 deletions

View File

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

View File

@@ -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";

View File

@@ -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",

View File

@@ -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<SquadsViewState>()(
@@ -97,6 +115,15 @@ export const useSquadsViewStore = create<SquadsViewState>()(
? 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<SquadsViewState>()(
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<SquadsViewState>) };
const p = persisted as Partial<SquadsViewState>;
return {
...current,
...p,
filters: { ...EMPTY_SQUAD_FILTERS, ...(p.filters ?? {}) },
};
},
},
),

View File

@@ -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<string, number>();
const modelCounts = new Map<string, number>();
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<AgentsScope, string> = {
@@ -402,6 +406,36 @@ export function AgentListToolbar({
})}
</DropdownMenuSubContent>
</DropdownMenuSub>
{/* Model — runtime-native model id (categorical column → filter) */}
{modelCounts.size > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex-1">
{t(($) => $.toolbar.section_model)}
</span>
{filters.models.length > 0 && (
<span className="text-xs font-medium text-primary">
{filters.models.length}
</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-72 w-auto min-w-44 overflow-y-auto">
{[...modelCounts.entries()].map(([model, count]) => (
<DropdownMenuCheckboxItem
key={model}
checked={filters.models.includes(model)}
onCheckedChange={() => onToggleFilter("models", model)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={filters.models.includes(model)} />
<span className="min-w-0 truncate">{model}</span>
{countBadge(count)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

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

View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -459,7 +459,8 @@
"direction_asc": "昇順",
"direction_desc": "降順",
"section_columns": "列",
"section_owner": "オーナー"
"section_owner": "オーナー",
"section_model": "モデル"
},
"actions": {
"selected_one": "{{count}} 件選択中",

View File

@@ -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": "フィルターをクリア"
}
}

View File

@@ -471,7 +471,8 @@
"direction_asc": "오름차순",
"direction_desc": "내림차순",
"section_columns": "열",
"section_owner": "소유자"
"section_owner": "소유자",
"section_model": "모델"
},
"actions": {
"selected_one": "{{count}}개 선택됨",

View File

@@ -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": "필터 지우기"
}
}

View File

@@ -459,7 +459,8 @@
"direction_asc": "升序",
"direction_desc": "降序",
"section_columns": "列",
"section_owner": "Owner"
"section_owner": "Owner",
"section_model": "模型"
},
"actions": {
"selected_one": "已选 {{count}} 项",

View File

@@ -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": "清除筛选"
}
}

View File

@@ -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<SquadsScope, number>;
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) => (
<span className="ml-auto pl-3 text-xs text-muted-foreground">{n}</span>
);
const SCOPE_LABELS: Record<SquadsScope, string> = {
mine: t(($) => $.scope.mine),
all: t(($) => $.scope.all),
@@ -495,8 +530,111 @@ function SquadListToolbar({
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
{hasActiveFilters && (
<span
title={t(($) => $.toolbar.result_count_title)}
className="hidden shrink-0 text-xs tabular-nums text-muted-foreground md:inline"
>
{visibleCount} / {totalCount}
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Filter */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant={hasActiveFilters ? "default" : "outline"}
size="sm"
className={
hasActiveFilters
? "h-8 w-8 gap-1 bg-brand px-0 text-white hover:bg-brand/90 md:w-auto md:px-2.5"
: "h-8 w-8 gap-1 px-0 text-muted-foreground md:w-auto md:px-2.5"
}
>
<Filter className="size-3.5" />
{hasActiveFilters ? (
<>
<span className="hidden md:inline">
{t(($) => $.toolbar.filter_active_count, { count: activeFilterCount })}
</span>
<span className="tabular-nums md:hidden">{activeFilterCount}</span>
</>
) : (
<span className="hidden md:inline">{t(($) => $.toolbar.filter_label)}</span>
)}
{hasActiveFilters && (
<span
role="button"
tabIndex={-1}
aria-label={t(($) => $.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()}
>
<X className="size-3" />
</span>
)}
</Button>
}
/>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex-1">{t(($) => $.page.table.leader)}</span>
{filters.leaders.length > 0 && (
<span className="text-xs font-medium text-primary">{filters.leaders.length}</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-72 w-auto min-w-48 overflow-y-auto">
{leaderOptions.map((o) => (
<DropdownMenuCheckboxItem
key={o.id}
checked={filters.leaders.includes(o.id)}
onCheckedChange={() => onToggleFilter("leaders", o.id)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={filters.leaders.includes(o.id)} />
<ActorAvatar actorType="agent" actorId={o.id} size={16} />
<span className="min-w-0 truncate">{o.name}</span>
{countBadge(o.count)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<span className="flex-1">{t(($) => $.page.table.creator)}</span>
{filters.creators.length > 0 && (
<span className="text-xs font-medium text-primary">{filters.creators.length}</span>
)}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="max-h-72 w-auto min-w-48 overflow-y-auto">
{creatorOptions.map((o) => (
<DropdownMenuCheckboxItem
key={o.id}
checked={filters.creators.includes(o.id)}
onCheckedChange={() => onToggleFilter("creators", o.id)}
className={FILTER_ITEM_CLASS}
>
<HoverCheck checked={filters.creators.includes(o.id)} />
<ActorAvatar actorType="member" actorId={o.id} size={16} />
<span className="min-w-0 truncate">{o.name}</span>
{countBadge(o.count)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
{/* Display settings */}
<Popover>
<Tooltip>
@@ -602,6 +740,7 @@ function SquadListToolbar({
</div>
</PopoverContent>
</Popover>
</div>
</div>
);
}
@@ -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<Squad[]>(() => {
const inScope = squads.filter((s) => {
// Rows within the current scope, unfiltered — filter option lists + the
// "n / total" denominator derive from this.
const scopeRows = useMemo<Squad[]>(() => {
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<string, { id: string; name: string; count: number }>();
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<string, { id: string; name: string; count: number }>();
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<Squad[]>(() => {
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 (
<div className="flex flex-1 min-h-0 flex-col">
@@ -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}